Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1f808d079 | ||
|
|
a8a632e1b0 | ||
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded | ||
|
|
9359f26788 | ||
|
|
5630765215 |
Vendored
+1
@@ -7,3 +7,4 @@
|
|||||||
*/
|
*/
|
||||||
export { RenderBlocks } from '../modules/blocks/index.js';
|
export { RenderBlocks } from '../modules/blocks/index.js';
|
||||||
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
|
export type { BlockComponentMap, BlockData, EnhanceProps, RenderBlocksProps, } from '../modules/blocks/index.js';
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||||
|
|||||||
Vendored
+1
@@ -5,5 +5,6 @@
|
|||||||
* lives here rather than in the main package entry to keep React out of the
|
* lives here rather than in the main package entry to keep React out of the
|
||||||
* server-config bundle.
|
* server-config bundle.
|
||||||
*/ export { RenderBlocks } from '../modules/blocks/index.js';
|
*/ export { RenderBlocks } from '../modules/blocks/index.js';
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js';
|
||||||
|
|
||||||
//# sourceMappingURL=rsc.js.map
|
//# sourceMappingURL=rsc.js.map
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\n"],"names":["RenderBlocks"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B"}
|
{"version":3,"sources":["../../src/exports/rsc.ts"],"sourcesContent":["/**\n * Entry point: ipal-kit/rsc\n *\n * Server-component exports. RenderBlocks is a React Server Component, so it\n * lives here rather than in the main package entry to keep React out of the\n * server-config bundle.\n */\nexport { RenderBlocks } from '../modules/blocks/index.js'\nexport type {\n BlockComponentMap,\n BlockData,\n EnhanceProps,\n RenderBlocksProps,\n} from '../modules/blocks/index.js'\nexport { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'\n"],"names":["RenderBlocks","MediaPreconnect"],"mappings":"AAAA;;;;;;CAMC,GACD,SAASA,YAAY,QAAQ,6BAA4B;AAOzD,SAASC,eAAe,QAAQ,wCAAuC"}
|
||||||
+4
-5
@@ -1,7 +1,6 @@
|
|||||||
import { isAdmin } from '../../modules/access/index.js';
|
import { isAdmin } from '../../modules/access/index.js';
|
||||||
import { analyticsFields } from './fields/analytics.js';
|
import { analyticsFields } from './fields/analytics.js';
|
||||||
import { smtpFields } from './fields/smtp.js';
|
import { smtpFields } from './fields/smtp.js';
|
||||||
import { storageFields } from './fields/storage.js';
|
|
||||||
import { turnstileFields } from './fields/turnstile.js';
|
import { turnstileFields } from './fields/turnstile.js';
|
||||||
/**
|
/**
|
||||||
* Builds the SiteIntegrations global.
|
* Builds the SiteIntegrations global.
|
||||||
@@ -14,6 +13,10 @@ import { turnstileFields } from './fields/turnstile.js';
|
|||||||
* impossible to enter.)
|
* impossible to enter.)
|
||||||
*
|
*
|
||||||
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
|
||||||
|
*
|
||||||
|
* Note: R2 storage credentials are NOT here — storage is infrastructure and
|
||||||
|
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
|
||||||
|
* consumed by buildR2Storage. See docs/storage.md.
|
||||||
*/ export function buildSiteIntegrations({ additionalFields } = {}) {
|
*/ export function buildSiteIntegrations({ additionalFields } = {}) {
|
||||||
return {
|
return {
|
||||||
slug: 'site-integrations',
|
slug: 'site-integrations',
|
||||||
@@ -42,10 +45,6 @@ import { turnstileFields } from './fields/turnstile.js';
|
|||||||
fields: smtpFields,
|
fields: smtpFields,
|
||||||
label: 'SMTP'
|
label: 'SMTP'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
fields: storageFields,
|
|
||||||
label: 'Storage'
|
|
||||||
},
|
|
||||||
...additionalFields?.length ? [
|
...additionalFields?.length ? [
|
||||||
{
|
{
|
||||||
fields: additionalFields,
|
fields: additionalFields,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { storageFields } from './fields/storage.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n { fields: storageFields, label: 'Storage' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","storageFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,aAAa,QAAQ,sBAAqB;AACnD,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKX,QAAQW;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQd;wBAAiBiB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQb;wBAAYgB,OAAO;oBAAO;oBACpC;wBAAEH,QAAQZ;wBAAee,OAAO;oBAAU;uBACtCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
{"version":3,"sources":["../../../src/globals/SiteIntegrations/index.ts"],"sourcesContent":["import type { Field, GlobalConfig } from 'payload'\n\nimport { isAdmin } from '../../modules/access/index.js'\nimport { analyticsFields } from './fields/analytics.js'\nimport { smtpFields } from './fields/smtp.js'\nimport { turnstileFields } from './fields/turnstile.js'\n\ntype BuildSiteIntegrationsArgs = {\n /** Extra fields injected by the client project */\n additionalFields?: Field[]\n}\n\n/**\n * Builds the SiteIntegrations global.\n *\n * Holds third-party service credentials. Access is enforced at the global\n * level — the whole global requires an authenticated user — so secrets stay\n * out of anonymous API responses while remaining editable in the admin panel\n * and readable via the server-side Local API. (Field-level read:false was\n * avoided because it also hides fields from the admin UI, making them\n * impossible to enter.)\n *\n * Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).\n *\n * Note: R2 storage credentials are NOT here — storage is infrastructure and\n * binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),\n * consumed by buildR2Storage. See docs/storage.md.\n */\nexport function buildSiteIntegrations({\n additionalFields,\n}: BuildSiteIntegrationsArgs = {}): GlobalConfig {\n return {\n slug: 'site-integrations',\n access: {\n // Admin-only — secrets live here. Anonymous and non-admin users get\n // nothing through the API; admins read/edit in the panel and via Local API.\n read: ({ req: { user } }) => isAdmin(user),\n update: ({ req: { user } }) => isAdmin(user),\n },\n admin: {\n group: 'Settings',\n },\n fields: [\n {\n type: 'tabs',\n tabs: [\n { fields: analyticsFields, label: 'Analytics' },\n { fields: turnstileFields, label: 'Turnstile' },\n { fields: smtpFields, label: 'SMTP' },\n ...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),\n ],\n },\n ],\n label: 'Site Integrations',\n }\n}\n"],"names":["isAdmin","analyticsFields","smtpFields","turnstileFields","buildSiteIntegrations","additionalFields","slug","access","read","req","user","update","admin","group","fields","type","tabs","label","length"],"mappings":"AAEA,SAASA,OAAO,QAAQ,gCAA+B;AACvD,SAASC,eAAe,QAAQ,wBAAuB;AACvD,SAASC,UAAU,QAAQ,mBAAkB;AAC7C,SAASC,eAAe,QAAQ,wBAAuB;AAOvD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,sBAAsB,EACpCC,gBAAgB,EACU,GAAG,CAAC,CAAC;IAC/B,OAAO;QACLC,MAAM;QACNC,QAAQ;YACN,oEAAoE;YACpE,4EAA4E;YAC5EC,MAAM,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;YACrCC,QAAQ,CAAC,EAAEF,KAAK,EAAEC,IAAI,EAAE,EAAE,GAAKV,QAAQU;QACzC;QACAE,OAAO;YACLC,OAAO;QACT;QACAC,QAAQ;YACN;gBACEC,MAAM;gBACNC,MAAM;oBACJ;wBAAEF,QAAQb;wBAAiBgB,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQX;wBAAiBc,OAAO;oBAAY;oBAC9C;wBAAEH,QAAQZ;wBAAYe,OAAO;oBAAO;uBAChCZ,kBAAkBa,SAAS;wBAAC;4BAAEJ,QAAQT;4BAAkBY,OAAO;wBAAS;qBAAE,GAAG,EAAE;iBACpF;YACH;SACD;QACDA,OAAO;IACT;AACF"}
|
||||||
Vendored
+5
-1
@@ -12,13 +12,17 @@ export { buildFormsPlugin } from './modules/forms/formsPluginConfig.js';
|
|||||||
export { createContentHelpers } from './modules/frontend/index.js';
|
export { createContentHelpers } from './modules/frontend/index.js';
|
||||||
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
|
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath } from './modules/i18n/index.js';
|
||||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||||
|
// Media — filename normalization hook for upload collections (Media).
|
||||||
|
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||||
|
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage } from './modules/notifications/index.js';
|
||||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||||
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
export { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||||
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/seo/index.js';
|
||||||
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
export { buildAutoFillMetaHook, buildRobots, buildSitemapEntries, createMetadataGenerator, createPageMetadata, injectAutoFillMeta } from './modules/seo/index.js';
|
||||||
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
export { buildSlugField, toSlug } from './modules/slug/index.js';
|
||||||
export { NOTIFICATION_FALLBACK, getNotificationTexts, resolveFormMessage } from './modules/notifications/index.js';
|
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||||
|
export { buildR2Storage } from './modules/storage/index.js';
|
||||||
export { ipalKit } from './plugin.js';
|
export { ipalKit } from './plugin.js';
|
||||||
|
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/media/index.ts"],"sourcesContent":["export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'\n"],"names":["normalizeFilename","normalizeFilenameHook"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,qBAAqB,QAAQ,yBAAwB"}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
import slugify from 'slugify';
|
||||||
|
/**
|
||||||
|
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
|
||||||
|
* while preserving the extension. Keeps uploaded media URLs clean and portable.
|
||||||
|
*
|
||||||
|
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||||
|
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||||
|
* "already-clean.webp" → "already-clean.webp"
|
||||||
|
*
|
||||||
|
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
|
||||||
|
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
|
||||||
|
* the extension, and rejoin.
|
||||||
|
*/ export function normalizeFilename(filename) {
|
||||||
|
const lastDot = filename.lastIndexOf('.');
|
||||||
|
// No extension (or leading-dot dotfile) → slug the whole thing.
|
||||||
|
if (lastDot <= 0) {
|
||||||
|
return slugify(filename, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stem = filename.slice(0, lastDot);
|
||||||
|
const ext = filename.slice(lastDot + 1).toLowerCase();
|
||||||
|
const cleanStem = slugify(stem, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true
|
||||||
|
});
|
||||||
|
const cleanExt = slugify(ext, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true
|
||||||
|
});
|
||||||
|
// Stem could slug to empty (e.g. filename was all symbols) — fall back so we
|
||||||
|
// never produce a nameless file.
|
||||||
|
const safeStem = cleanStem || 'plik';
|
||||||
|
return cleanExt ? `${safeStem}.${cleanExt}` : safeStem;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
|
||||||
|
* incoming file's name to its normalized form before Payload stores it, so both
|
||||||
|
* the stored file and its DB filename are clean. Works with local disk and with
|
||||||
|
* cloud storage adapters (R2/S3) — it runs before the storage layer.
|
||||||
|
*
|
||||||
|
* Wire into your Media collection:
|
||||||
|
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||||
|
* hooks: { beforeOperation: [normalizeFilenameHook] }
|
||||||
|
*/ export const normalizeFilenameHook = ({ req, operation })=>{
|
||||||
|
if (operation !== 'create' && operation !== 'update') return;
|
||||||
|
const file = req.file;
|
||||||
|
if (file?.name) {
|
||||||
|
file.name = normalizeFilename(file.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=normalizeFilename.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/media/normalizeFilename.ts"],"sourcesContent":["import type { CollectionBeforeOperationHook } from 'payload'\nimport slugify from 'slugify'\n\n/**\n * Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)\n * while preserving the extension. Keeps uploaded media URLs clean and portable.\n *\n * \"Zdjęcie jeden nad morzem.jpg\" → \"zdjecie-jeden-nad-morzem.jpg\"\n * \"Faktura #12 (2024).PDF\" → \"faktura-12-2024.pdf\"\n * \"already-clean.webp\" → \"already-clean.webp\"\n *\n * Why not toSlug(): toSlug uses strict:true, which would strip the dot and\n * merge name+extension. Here we split on the LAST dot, slug the stem, lowercase\n * the extension, and rejoin.\n */\nexport function normalizeFilename(filename: string): string {\n const lastDot = filename.lastIndexOf('.')\n\n // No extension (or leading-dot dotfile) → slug the whole thing.\n if (lastDot <= 0) {\n return slugify(filename, { lower: true, strict: true, trim: true })\n }\n\n const stem = filename.slice(0, lastDot)\n const ext = filename.slice(lastDot + 1).toLowerCase()\n\n const cleanStem = slugify(stem, { lower: true, strict: true, trim: true })\n const cleanExt = slugify(ext, { lower: true, strict: true, trim: true })\n\n // Stem could slug to empty (e.g. filename was all symbols) — fall back so we\n // never produce a nameless file.\n const safeStem = cleanStem || 'plik'\n\n return cleanExt ? `${safeStem}.${cleanExt}` : safeStem\n}\n\n/**\n * beforeOperation hook for an upload collection (e.g. Media). Rewrites the\n * incoming file's name to its normalized form before Payload stores it, so both\n * the stored file and its DB filename are clean. Works with local disk and with\n * cloud storage adapters (R2/S3) — it runs before the storage layer.\n *\n * Wire into your Media collection:\n * import { normalizeFilenameHook } from '@intecion/ipal-kit'\n * hooks: { beforeOperation: [normalizeFilenameHook] }\n */\nexport const normalizeFilenameHook: CollectionBeforeOperationHook = ({ req, operation }) => {\n if (operation !== 'create' && operation !== 'update') return\n const file = req.file\n if (file?.name) {\n file.name = normalizeFilename(file.name)\n }\n}\n"],"names":["slugify","normalizeFilename","filename","lastDot","lastIndexOf","lower","strict","trim","stem","slice","ext","toLowerCase","cleanStem","cleanExt","safeStem","normalizeFilenameHook","req","operation","file","name"],"mappings":"AACA,OAAOA,aAAa,UAAS;AAE7B;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBC,QAAgB;IAChD,MAAMC,UAAUD,SAASE,WAAW,CAAC;IAErC,gEAAgE;IAChE,IAAID,WAAW,GAAG;QAChB,OAAOH,QAAQE,UAAU;YAAEG,OAAO;YAAMC,QAAQ;YAAMC,MAAM;QAAK;IACnE;IAEA,MAAMC,OAAON,SAASO,KAAK,CAAC,GAAGN;IAC/B,MAAMO,MAAMR,SAASO,KAAK,CAACN,UAAU,GAAGQ,WAAW;IAEnD,MAAMC,YAAYZ,QAAQQ,MAAM;QAAEH,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IACxE,MAAMM,WAAWb,QAAQU,KAAK;QAAEL,OAAO;QAAMC,QAAQ;QAAMC,MAAM;IAAK;IAEtE,6EAA6E;IAC7E,iCAAiC;IACjC,MAAMO,WAAWF,aAAa;IAE9B,OAAOC,WAAW,GAAGC,SAAS,CAAC,EAAED,UAAU,GAAGC;AAChD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMC,wBAAuD,CAAC,EAAEC,GAAG,EAAEC,SAAS,EAAE;IACrF,IAAIA,cAAc,YAAYA,cAAc,UAAU;IACtD,MAAMC,OAAOF,IAAIE,IAAI;IACrB,IAAIA,MAAMC,MAAM;QACdD,KAAKC,IAAI,GAAGlB,kBAAkBiB,KAAKC,IAAI;IACzC;AACF,EAAC"}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/
|
||||||
|
export declare function MediaPreconnect(): import("react/jsx-runtime").JSX.Element | null;
|
||||||
|
export default MediaPreconnect;
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/ export function MediaPreconnect() {
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '');
|
||||||
|
if (!publicUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||||
|
let origin;
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
;
|
||||||
|
}
|
||||||
|
return /*#__PURE__*/ _jsxs(_Fragment, {
|
||||||
|
children: [
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
href: origin,
|
||||||
|
rel: "preconnect"
|
||||||
|
}),
|
||||||
|
/*#__PURE__*/ _jsx("link", {
|
||||||
|
href: origin,
|
||||||
|
rel: "dns-prefetch"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export default MediaPreconnect;
|
||||||
|
|
||||||
|
//# sourceMappingURL=MediaPreconnect.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/MediaPreconnect.tsx"],"sourcesContent":["/**\n * Emits <link rel=\"preconnect\"> + <link rel=\"dns-prefetch\"> for the media CDN\n * domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the\n * media host early — before it hits the first <img>. Saves ~150–300ms on the\n * first image load.\n *\n * Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),\n * so there's ONE source of truth — no per-project hardcoded domain. Renders\n * nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to\n * preconnect).\n *\n * Server Component — drop it in the <head> of your locale layout:\n *\n * import { MediaPreconnect } from '@intecion/ipal-kit/rsc'\n * // in <head> (or top of <body> — Next hoists link tags):\n * <MediaPreconnect />\n */\nexport function MediaPreconnect() {\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '')\n if (!publicUrl) {return null}\n\n // Origin only (scheme + host) — preconnect targets an origin, not a path.\n let origin: string\n try {\n origin = new URL(publicUrl).origin\n } catch {\n return null // malformed URL → skip rather than emit a broken tag\n }\n\n return (\n <>\n <link crossOrigin=\"anonymous\" href={origin} rel=\"preconnect\" />\n <link href={origin} rel=\"dns-prefetch\" />\n </>\n )\n}\n\nexport default MediaPreconnect\n"],"names":["MediaPreconnect","publicUrl","process","env","R2_PUBLIC_URL","replace","origin","URL","link","crossOrigin","href","rel"],"mappings":";AAAA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA;IACd,MAAMC,YAAYC,QAAQC,GAAG,CAACC,aAAa,EAAEC,QAAQ,OAAO;IAC5D,IAAI,CAACJ,WAAW;QAAC,OAAO;IAAI;IAE5B,0EAA0E;IAC1E,IAAIK;IACJ,IAAI;QACFA,SAAS,IAAIC,IAAIN,WAAWK,MAAM;IACpC,EAAE,OAAM;QACN,OAAO,KAAK,qDAAqD;;IACnE;IAEA,qBACE;;0BACE,KAACE;gBAAKC,aAAY;gBAAYC,MAAMJ;gBAAQK,KAAI;;0BAChD,KAACH;gBAAKE,MAAMJ;gBAAQK,KAAI;;;;AAG9B;AAEA,eAAeX,gBAAe"}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
import { s3Storage } from '@payloadcms/storage-s3';
|
||||||
|
/**
|
||||||
|
* Cloudflare R2 media storage — configured from environment variables (agency
|
||||||
|
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
|
||||||
|
* @payloadcms/storage-s3 pointed at the R2 endpoint.
|
||||||
|
*
|
||||||
|
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
|
||||||
|
* boot, and its credentials are agency-owned — so it lives in .env, not the
|
||||||
|
* panel. See docs/storage.md for the required variables.
|
||||||
|
*
|
||||||
|
* Returns the storage plugin when all R2 vars are present; otherwise returns a
|
||||||
|
* no-op passthrough so the project falls back to Payload's default local disk
|
||||||
|
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
|
||||||
|
* gracefully when a transport isn't configured.
|
||||||
|
*
|
||||||
|
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
|
||||||
|
*/ export const buildR2Storage = (collections = [
|
||||||
|
'media'
|
||||||
|
])=>{
|
||||||
|
const bucket = process.env.R2_BUCKET;
|
||||||
|
const endpoint = process.env.R2_ENDPOINT;
|
||||||
|
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
||||||
|
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
|
||||||
|
// Any missing → skip R2, fall back to local disk. Warn so it's not silent.
|
||||||
|
if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {
|
||||||
|
return (config)=>{
|
||||||
|
// Only warn when SOME vars are set (partial config = likely a mistake).
|
||||||
|
if (bucket || endpoint || accessKeyId || secretAccessKey) {
|
||||||
|
console.warn('[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' + 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.');
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||||
|
// accepts uploads and won't serve files (403). With a custom domain
|
||||||
|
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||||
|
// pointing there. Without it, uploads work but images don't display publicly.
|
||||||
|
// See docs/storage.md.
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||||
|
;
|
||||||
|
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||||
|
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||||
|
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||||
|
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||||
|
const generateFileURL = publicUrl ? ({ filename, prefix })=>[
|
||||||
|
publicUrl,
|
||||||
|
prefix,
|
||||||
|
filename
|
||||||
|
].filter(Boolean).join('/') : undefined;
|
||||||
|
const collectionsConfig = {};
|
||||||
|
for (const slug of collections){
|
||||||
|
collectionsConfig[slug] = generateFileURL ? {
|
||||||
|
generateFileURL
|
||||||
|
} : true;
|
||||||
|
}
|
||||||
|
return s3Storage({
|
||||||
|
bucket,
|
||||||
|
collections: collectionsConfig,
|
||||||
|
config: {
|
||||||
|
credentials: {
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey
|
||||||
|
},
|
||||||
|
endpoint,
|
||||||
|
region: 'auto',
|
||||||
|
// R2 requires path-style addressing for S3 compatibility.
|
||||||
|
forcePathStyle: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//# sourceMappingURL=buildR2Storage.js.map
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/buildR2Storage.ts"],"sourcesContent":["import type { Plugin } from 'payload'\n\nimport { s3Storage } from '@payloadcms/storage-s3'\n\n/**\n * Cloudflare R2 media storage — configured from environment variables (agency\n * infrastructure, not per-project panel data). R2 is S3-compatible, so we use\n * @payloadcms/storage-s3 pointed at the R2 endpoint.\n *\n * Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at\n * boot, and its credentials are agency-owned — so it lives in .env, not the\n * panel. See docs/storage.md for the required variables.\n *\n * Returns the storage plugin when all R2 vars are present; otherwise returns a\n * no-op passthrough so the project falls back to Payload's default local disk\n * storage (useful in dev without R2). This mirrors how mailAdapter degrades\n * gracefully when a transport isn't configured.\n *\n * @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])\n */\nexport const buildR2Storage = (collections: string[] = ['media']): Plugin => {\n const bucket = process.env.R2_BUCKET\n const endpoint = process.env.R2_ENDPOINT\n const accessKeyId = process.env.R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY\n\n // Any missing → skip R2, fall back to local disk. Warn so it's not silent.\n if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {\n return (config) => {\n // Only warn when SOME vars are set (partial config = likely a mistake).\n if (bucket || endpoint || accessKeyId || secretAccessKey) {\n console.warn(\n '[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +\n 'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',\n )\n }\n return config\n }\n }\n\n // Public URL for served media. R2 is private by default; its S3 endpoint only\n // accepts uploads and won't serve files (403). With a custom domain\n // (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs\n // pointing there. Without it, uploads work but images don't display publicly.\n // See docs/storage.md.\n const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\\/$/, '') // strip trailing slash\n\n // generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a\n // top-level one) — R2 needs it to point served URLs at the custom domain\n // instead of the private S3 endpoint. Each collection gets either `true`\n // (plain offload) or an object carrying generateFileURL when a public URL is set.\n const generateFileURL = publicUrl\n ? ({ filename, prefix }: { filename: string; prefix?: string }) =>\n [publicUrl, prefix, filename].filter(Boolean).join('/')\n : undefined\n\n const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}\n for (const slug of collections) {\n collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true\n }\n\n return s3Storage({\n bucket,\n collections: collectionsConfig,\n config: {\n credentials: { accessKeyId, secretAccessKey },\n endpoint,\n region: 'auto', // R2 uses 'auto'\n // R2 requires path-style addressing for S3 compatibility.\n forcePathStyle: true,\n },\n })\n}\n"],"names":["s3Storage","buildR2Storage","collections","bucket","process","env","R2_BUCKET","endpoint","R2_ENDPOINT","accessKeyId","R2_ACCESS_KEY_ID","secretAccessKey","R2_SECRET_ACCESS_KEY","config","console","warn","publicUrl","R2_PUBLIC_URL","replace","generateFileURL","filename","prefix","filter","Boolean","join","undefined","collectionsConfig","slug","credentials","region","forcePathStyle"],"mappings":"AAEA,SAASA,SAAS,QAAQ,yBAAwB;AAElD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,MAAMC,iBAAiB,CAACC,cAAwB;IAAC;CAAQ;IAC9D,MAAMC,SAASC,QAAQC,GAAG,CAACC,SAAS;IACpC,MAAMC,WAAWH,QAAQC,GAAG,CAACG,WAAW;IACxC,MAAMC,cAAcL,QAAQC,GAAG,CAACK,gBAAgB;IAChD,MAAMC,kBAAkBP,QAAQC,GAAG,CAACO,oBAAoB;IAExD,2EAA2E;IAC3E,IAAI,CAACT,UAAU,CAACI,YAAY,CAACE,eAAe,CAACE,iBAAiB;QAC5D,OAAO,CAACE;YACN,wEAAwE;YACxE,IAAIV,UAAUI,YAAYE,eAAeE,iBAAiB;gBACxDG,QAAQC,IAAI,CACV,qEACE;YAEN;YACA,OAAOF;QACT;IACF;IAEA,8EAA8E;IAC9E,oEAAoE;IACpE,yEAAyE;IACzE,8EAA8E;IAC9E,uBAAuB;IACvB,MAAMG,YAAYZ,QAAQC,GAAG,CAACY,aAAa,EAAEC,QAAQ,OAAO,IAAI,uBAAuB;;IAEvF,8EAA8E;IAC9E,yEAAyE;IACzE,yEAAyE;IACzE,kFAAkF;IAClF,MAAMC,kBAAkBH,YACpB,CAAC,EAAEI,QAAQ,EAAEC,MAAM,EAAyC,GAC1D;YAACL;YAAWK;YAAQD;SAAS,CAACE,MAAM,CAACC,SAASC,IAAI,CAAC,OACrDC;IAEJ,MAAMC,oBAAwF,CAAC;IAC/F,KAAK,MAAMC,QAAQzB,YAAa;QAC9BwB,iBAAiB,CAACC,KAAK,GAAGR,kBAAkB;YAAEA;QAAgB,IAAI;IACpE;IAEA,OAAOnB,UAAU;QACfG;QACAD,aAAawB;QACbb,QAAQ;YACNe,aAAa;gBAAEnB;gBAAaE;YAAgB;YAC5CJ;YACAsB,QAAQ;YACR,0DAA0D;YAC1DC,gBAAgB;QAClB;IACF;AACF,EAAC"}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export { buildR2Storage } from './buildR2Storage.js';
|
||||||
|
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../../../src/modules/storage/index.ts"],"sourcesContent":["export { buildR2Storage } from './buildR2Storage.js'\n"],"names":["buildR2Storage"],"mappings":"AAAA,SAASA,cAAc,QAAQ,sBAAqB"}
|
||||||
+88
-8
@@ -52,16 +52,71 @@ masz więcej kolekcji plików: `buildR2Storage(['media', 'documents'])`.
|
|||||||
To wzorzec „degrade gracefully" — jak mailAdapter, który wraca do SMTP, gdy brak
|
To wzorzec „degrade gracefully" — jak mailAdapter, który wraca do SMTP, gdy brak
|
||||||
Graph. Projekt działa niezależnie od tego, czy R2 jest skonfigurowany.
|
Graph. Projekt działa niezależnie od tego, czy R2 jest skonfigurowany.
|
||||||
|
|
||||||
## Publiczny dostęp (WAŻNE)
|
## Publiczny dostęp + custom domena (WAŻNE — krok po kroku)
|
||||||
|
|
||||||
R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403), dopóki
|
R2 domyślnie prywatny. Upload zadziała, ale obrazy się NIE wyświetlą (403),
|
||||||
nie skonfigurujesz publicznego odczytu:
|
dopóki nie skonfigurujesz publicznego odczytu przez custom domenę. To proces
|
||||||
|
w Cloudflare (nie w kodzie), wieloetapowy — poniżej dokładnie.
|
||||||
|
|
||||||
1. Cloudflare → R2 → bucket → Settings → **Public access** → podłącz custom domain
|
### Dlaczego custom domena, nie „r2.dev"
|
||||||
2. Albo serwuj przez Cloudflare CDN / własną domenę
|
|
||||||
|
|
||||||
Bez tego media wgrają się do R2, ale front nie pokaże obrazów. Konfiguracja domeny
|
R2 oferuje szybki publiczny URL `*.r2.dev`, ALE:
|
||||||
jest po stronie Cloudflare, nie kodu.
|
- jest rate-limitowany (nie do produkcji)
|
||||||
|
- nie przechodzi przez cache Cloudflare (brak CDN, wolniej, drożej)
|
||||||
|
- brzydki URL (nie Twoja domena)
|
||||||
|
|
||||||
|
Dla produkcji ZAWSZE custom domena (np. `media.klient.pl`) — daje CDN, cache,
|
||||||
|
własny URL. r2.dev tylko do szybkiego testu.
|
||||||
|
|
||||||
|
### Warunek wstępny: domena w Cloudflare
|
||||||
|
|
||||||
|
Custom domena dla R2 wymaga, żeby domena (albo subdomena) była zarządzana przez
|
||||||
|
Cloudflare (nameservery klienta wskazują na Cloudflare). Jeśli domena klienta
|
||||||
|
jest u innego rejestratora — trzeba ją najpierw dodać do Cloudflare (Add Site)
|
||||||
|
i przełączyć nameservery. Sama subdomena `media.klient.pl` wystarczy, jeśli
|
||||||
|
główna domena jest już w Cloudflare.
|
||||||
|
|
||||||
|
### Krok po kroku — podpięcie custom domeny
|
||||||
|
|
||||||
|
1. **Cloudflare Dashboard → R2 → wybierz bucket**
|
||||||
|
2. Zakładka **Settings** → sekcja **Public access** → **Custom Domains**
|
||||||
|
3. **Connect Domain** → wpisz subdomenę, np. `media.klient.pl`
|
||||||
|
4. Cloudflare automatycznie doda rekord CNAME (bo domena jest w Cloudflare) i
|
||||||
|
wystawi certyfikat SSL. Poczekaj, aż status = **Active** (kilka minut).
|
||||||
|
5. Od tej chwili pliki są publiczne pod `https://media.klient.pl/<klucz-pliku>`.
|
||||||
|
|
||||||
|
### Krok: ustaw publiczny URL w projekcie
|
||||||
|
|
||||||
|
Payload musi generować URL-e mediów wskazujące na custom domenę, nie na endpoint
|
||||||
|
S3. Dodaj zmienną i przekaż ją do adaptera:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
R2_PUBLIC_URL=https://media.klient.pl
|
||||||
|
```
|
||||||
|
|
||||||
|
Adapter `buildR2Storage` czyta ją i ustawia jako bazowy URL mediów (jeśli
|
||||||
|
ustawiona). Bez niej Payload zwróci URL wskazujący na prywatny endpoint S3 →
|
||||||
|
403 na froncie. (Patrz aktualizacja buildR2Storage niżej.)
|
||||||
|
|
||||||
|
### Weryfikacja
|
||||||
|
|
||||||
|
1. Wgraj obraz w panelu (Media).
|
||||||
|
2. Sprawdź URL obrazu w panelu — powinien być `https://media.klient.pl/...`,
|
||||||
|
NIE `https://<account>.r2.cloudflarestorage.com/...`.
|
||||||
|
3. Otwórz URL w przeglądarce — obraz się pokazuje (nie 403).
|
||||||
|
4. Na froncie `<img src>` działa.
|
||||||
|
|
||||||
|
### Częsty błąd: 403 mimo custom domeny
|
||||||
|
|
||||||
|
- **URL wskazuje na endpoint S3, nie custom domenę** → brakuje `R2_PUBLIC_URL`
|
||||||
|
albo adapter jej nie używa. Sprawdź URL w panelu.
|
||||||
|
- **Custom domena nie Active** → poczekaj na SSL/CNAME w Cloudflare.
|
||||||
|
- **Public access wyłączony** → w bucket Settings sprawdź, czy custom domena jest
|
||||||
|
podpięta (nie tylko utworzona).
|
||||||
|
|
||||||
|
Bez tego media wgrają się do R2, ale front pokaże 403. Konfiguracja domeny jest
|
||||||
|
po stronie Cloudflare, publiczny URL po stronie projektu (.env).
|
||||||
|
|
||||||
## Migracja istniejących mediów
|
## Migracja istniejących mediów
|
||||||
|
|
||||||
@@ -128,4 +183,29 @@ czysta nazwa trafia i do bazy, i do bucketa). Dlaczego to ważne:
|
|||||||
ścieżki i cache.
|
ścieżki i cache.
|
||||||
|
|
||||||
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
||||||
jej poza hookiem.
|
jej poza hookiem.
|
||||||
|
|
||||||
|
## Preconnect do domeny mediów (wydajność)
|
||||||
|
|
||||||
|
Komponent `MediaPreconnect` generuje `<link rel="preconnect">` + `dns-prefetch`
|
||||||
|
dla domeny mediów (R2_PUBLIC_URL) — przeglądarka nawiązuje połączenie TLS/DNS
|
||||||
|
z CDN zawczasu, zanim napotka pierwszy `<img>`. Zysk ~150–300 ms na pierwszym
|
||||||
|
obrazie.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// layout.tsx — w <head> (albo górze <body>, Next hoistuje link tagi)
|
||||||
|
import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<MediaPreconnect />
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
Czyta domenę z **R2_PUBLIC_URL** (to samo źródło co buildR2Storage) — zero
|
||||||
|
hardkodu, jedno źródło prawdy. Gdy R2_PUBLIC_URL nie ustawione (lokalny dysk,
|
||||||
|
brak CDN) → nie renderuje nic. Zmiana domeny mediów = zmiana jednej zmiennej
|
||||||
|
env, komponent podąża automatycznie.
|
||||||
|
|
||||||
|
> NIE hardkoduj `<link rel="preconnect" href="https://media.klient.pl">` ręcznie
|
||||||
|
> w layoutcie — to zaszywa domenę klienta w kodzie. Użyj MediaPreconnect, który
|
||||||
|
> bierze ją z env.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@intecion/ipal-kit",
|
"name": "@intecion/ipal-kit",
|
||||||
"version": "1.0.19",
|
"version": "1.1.0",
|
||||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ export type {
|
|||||||
EnhanceProps,
|
EnhanceProps,
|
||||||
RenderBlocksProps,
|
RenderBlocksProps,
|
||||||
} from '../modules/blocks/index.js'
|
} from '../modules/blocks/index.js'
|
||||||
|
export { MediaPreconnect } from '../modules/storage/MediaPreconnect.js'
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Emits <link rel="preconnect"> + <link rel="dns-prefetch"> for the media CDN
|
||||||
|
* domain (R2_PUBLIC_URL), so the browser opens the TLS/DNS connection to the
|
||||||
|
* media host early — before it hits the first <img>. Saves ~150–300ms on the
|
||||||
|
* first image load.
|
||||||
|
*
|
||||||
|
* Reads the domain from R2_PUBLIC_URL (the same env var buildR2Storage uses),
|
||||||
|
* so there's ONE source of truth — no per-project hardcoded domain. Renders
|
||||||
|
* nothing when R2_PUBLIC_URL isn't set (local disk / no CDN → nothing to
|
||||||
|
* preconnect).
|
||||||
|
*
|
||||||
|
* Server Component — drop it in the <head> of your locale layout:
|
||||||
|
*
|
||||||
|
* import { MediaPreconnect } from '@intecion/ipal-kit/rsc'
|
||||||
|
* // in <head> (or top of <body> — Next hoists link tags):
|
||||||
|
* <MediaPreconnect />
|
||||||
|
*/
|
||||||
|
export function MediaPreconnect() {
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '')
|
||||||
|
if (!publicUrl) {return null}
|
||||||
|
|
||||||
|
// Origin only (scheme + host) — preconnect targets an origin, not a path.
|
||||||
|
let origin: string
|
||||||
|
try {
|
||||||
|
origin = new URL(publicUrl).origin
|
||||||
|
} catch {
|
||||||
|
return null // malformed URL → skip rather than emit a broken tag
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<link crossOrigin="anonymous" href={origin} rel="preconnect" />
|
||||||
|
<link href={origin} rel="dns-prefetch" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaPreconnect
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Plugin } from 'payload'
|
import type { Plugin } from 'payload'
|
||||||
|
|
||||||
import { s3Storage } from '@payloadcms/storage-s3'
|
import { s3Storage } from '@payloadcms/storage-s3'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,15 +38,34 @@ export const buildR2Storage = (collections: string[] = ['media']): Plugin => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectionsConfig = Object.fromEntries(collections.map((slug) => [slug, true]))
|
// Public URL for served media. R2 is private by default; its S3 endpoint only
|
||||||
|
// accepts uploads and won't serve files (403). With a custom domain
|
||||||
|
// (media.klient.pl → bucket) set R2_PUBLIC_URL so Payload generates URLs
|
||||||
|
// pointing there. Without it, uploads work but images don't display publicly.
|
||||||
|
// See docs/storage.md.
|
||||||
|
const publicUrl = process.env.R2_PUBLIC_URL?.replace(/\/$/, '') // strip trailing slash
|
||||||
|
|
||||||
|
// generateFileURL is a PER-COLLECTION option in @payloadcms/storage-s3 (not a
|
||||||
|
// top-level one) — R2 needs it to point served URLs at the custom domain
|
||||||
|
// instead of the private S3 endpoint. Each collection gets either `true`
|
||||||
|
// (plain offload) or an object carrying generateFileURL when a public URL is set.
|
||||||
|
const generateFileURL = publicUrl
|
||||||
|
? ({ filename, prefix }: { filename: string; prefix?: string }) =>
|
||||||
|
[publicUrl, prefix, filename].filter(Boolean).join('/')
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const collectionsConfig: Record<string, { generateFileURL: typeof generateFileURL } | true> = {}
|
||||||
|
for (const slug of collections) {
|
||||||
|
collectionsConfig[slug] = generateFileURL ? { generateFileURL } : true
|
||||||
|
}
|
||||||
|
|
||||||
return s3Storage({
|
return s3Storage({
|
||||||
collections: collectionsConfig,
|
|
||||||
bucket,
|
bucket,
|
||||||
|
collections: collectionsConfig,
|
||||||
config: {
|
config: {
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
endpoint,
|
endpoint,
|
||||||
region: 'auto', // R2 uses 'auto'
|
region: 'auto', // R2 uses 'auto'
|
||||||
credentials: { accessKeyId, secretAccessKey },
|
|
||||||
// R2 requires path-style addressing for S3 compatibility.
|
// R2 requires path-style addressing for S3 compatibility.
|
||||||
forcePathStyle: true,
|
forcePathStyle: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user