Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a695ec7319 | ||
|
|
c6730335ef | ||
|
|
247b849759 | ||
|
|
0dee178ded | ||
|
|
9359f26788 | ||
|
|
5630765215 | ||
|
|
ed4a78b109 | ||
|
|
9acb22e2f9 | ||
|
|
6a8361d710 | ||
|
|
502ffee31f | ||
|
|
5a322230ae | ||
|
|
f919c288b2 | ||
|
|
21b948a4f8 | ||
|
|
d04271f942 | ||
|
|
4f08e9ea4d | ||
|
|
67347f1e34 | ||
|
|
75f2b6400d | ||
|
|
26aec1c5af | ||
|
|
0fe7ca0575 | ||
|
|
d745a764fe | ||
|
|
6c273118a2 | ||
|
|
a010a7368a | ||
|
|
08bd00f01f |
Vendored
+2
@@ -12,3 +12,5 @@ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentCont
|
||||
export type { CookieBannerClassNames } from '../modules/consent/client.js';
|
||||
export { Turnstile } from '../modules/turnstile/client.js';
|
||||
export type { TurnstileProps } from '../modules/turnstile/client.js';
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||
export type { FormNotificationTexts } from '../modules/notifications/types.js';
|
||||
|
||||
Vendored
+1
@@ -10,5 +10,6 @@ export { Analytics } from '../modules/analytics/client.js';
|
||||
* client-only code.
|
||||
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
|
||||
export { Turnstile } from '../modules/turnstile/client.js';
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
|
||||
|
||||
//# sourceMappingURL=client.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC"}
|
||||
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"}
|
||||
+17
-8
@@ -1,17 +1,26 @@
|
||||
'use client';
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useField } from '@payloadcms/ui';
|
||||
import { useState } from 'react';
|
||||
export const MaskedField = ({ field, path })=>{
|
||||
const { setValue, value } = useField({
|
||||
path
|
||||
});
|
||||
export const MaskedField = (props)=>{
|
||||
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {};
|
||||
const [internalValue, setInternalValue] = useState(propValue ?? '');
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const label = typeof field?.label === 'string' ? field.label : field?.name ?? path;
|
||||
const handleChange = (e)=>{
|
||||
const newVal = e.target.value;
|
||||
setInternalValue(newVal);
|
||||
if (typeof propSetValue === 'function') {
|
||||
propSetValue(newVal);
|
||||
}
|
||||
if (typeof propOnChange === 'function') {
|
||||
propOnChange(e);
|
||||
}
|
||||
};
|
||||
const currentValue = propValue !== undefined ? propValue : internalValue;
|
||||
return /*#__PURE__*/ _jsxs("div", {
|
||||
className: "field-type text",
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("label", {
|
||||
label && /*#__PURE__*/ _jsx("label", {
|
||||
className: "field-label",
|
||||
children: label
|
||||
}),
|
||||
@@ -23,12 +32,12 @@ export const MaskedField = ({ field, path })=>{
|
||||
children: [
|
||||
/*#__PURE__*/ _jsx("input", {
|
||||
autoComplete: "off",
|
||||
onChange: (e)=>setValue(e.target.value),
|
||||
onChange: handleChange,
|
||||
style: {
|
||||
flex: 1
|
||||
},
|
||||
type: revealed ? 'text' : 'password',
|
||||
value: value ?? ''
|
||||
value: currentValue ?? ''
|
||||
}),
|
||||
/*#__PURE__*/ _jsx("button", {
|
||||
onClick: ()=>setRevealed((r)=>!r),
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\n\nimport { useField } from '@payloadcms/ui'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = ({ field, path }) => {\n const { setValue, value } = useField<string>({ path })\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n return (\n <div className=\"field-type text\">\n <label className=\"field-label\">{label}</label>\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={(e) => setValue(e.target.value)}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={value ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\nexport default MaskedField\n"],"names":["useField","useState","MaskedField","field","path","setValue","value","revealed","setRevealed","label","name","div","className","style","display","gap","input","autoComplete","onChange","e","target","flex","type","button","onClick","r"],"mappings":"AAAA;;AAGA,SAASA,QAAQ,QAAQ,iBAAgB;AACzC,SAASC,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAAC,EAAEC,KAAK,EAAEC,IAAI,EAAE;IACnE,MAAM,EAAEC,QAAQ,EAAEC,KAAK,EAAE,GAAGN,SAAiB;QAAEI;IAAK;IACpD,MAAM,CAACG,UAAUC,YAAY,GAAGP,SAAS;IACzC,MAAMQ,QAAQ,OAAON,OAAOM,UAAU,WAAWN,MAAMM,KAAK,GAAIN,OAAOO,QAAQN;IAE/E,qBACE,MAACO;QAAIC,WAAU;;0BACb,KAACH;gBAAMG,WAAU;0BAAeH;;0BAChC,MAACE;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbC,UAAU,CAACC,IAAMd,SAASc,EAAEC,MAAM,CAACd,KAAK;wBACxCO,OAAO;4BAAEQ,MAAM;wBAAE;wBACjBC,MAAMf,WAAW,SAAS;wBAC1BD,OAAOA,SAAS;;kCAElB,KAACiB;wBAAOC,SAAS,IAAMhB,YAAY,CAACiB,IAAM,CAACA;wBAAIH,MAAK;kCACjDf,WAAW,SAAS;;;;;;AAK/B,EAAC;AACD,eAAeL,YAAW"}
|
||||
{"version":3,"sources":["../../../../src/globals/SiteIntegrations/components/MaskedField.tsx"],"sourcesContent":["'use client'\nimport type { TextFieldClientComponent } from 'payload'\nimport { useState } from 'react'\n\nexport const MaskedField: TextFieldClientComponent = (props: any) => {\n const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}\n const [internalValue, setInternalValue] = useState(propValue ?? '')\n const [revealed, setRevealed] = useState(false)\n const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const newVal = e.target.value\n setInternalValue(newVal)\n if (typeof propSetValue === 'function') {\n propSetValue(newVal)\n }\n if (typeof propOnChange === 'function') {\n propOnChange(e)\n }\n }\n\n const currentValue = propValue !== undefined ? propValue : internalValue\n\n return (\n <div className=\"field-type text\">\n {label && <label className=\"field-label\">{label}</label>}\n <div style={{ display: 'flex', gap: '.5rem' }}>\n <input\n autoComplete=\"off\"\n onChange={handleChange}\n style={{ flex: 1 }}\n type={revealed ? 'text' : 'password'}\n value={currentValue ?? ''}\n />\n <button onClick={() => setRevealed((r) => !r)} type=\"button\">\n {revealed ? 'Hide' : 'Reveal'}\n </button>\n </div>\n </div>\n )\n}\n\nexport default MaskedField\n"],"names":["useState","MaskedField","props","field","path","value","propValue","setValue","propSetValue","onChange","propOnChange","internalValue","setInternalValue","revealed","setRevealed","label","name","handleChange","e","newVal","target","currentValue","undefined","div","className","style","display","gap","input","autoComplete","flex","type","button","onClick","r"],"mappings":"AAAA;;AAEA,SAASA,QAAQ,QAAQ,QAAO;AAEhC,OAAO,MAAMC,cAAwC,CAACC;IACpD,MAAM,EAAEC,KAAK,EAAEC,IAAI,EAAEC,OAAOC,SAAS,EAAEC,UAAUC,YAAY,EAAEC,UAAUC,YAAY,EAAE,GAAGR,SAAS,CAAC;IACpG,MAAM,CAACS,eAAeC,iBAAiB,GAAGZ,SAASM,aAAa;IAChE,MAAM,CAACO,UAAUC,YAAY,GAAGd,SAAS;IACzC,MAAMe,QAAQ,OAAOZ,OAAOY,UAAU,WAAWZ,MAAMY,KAAK,GAAIZ,OAAOa,QAAQZ;IAE/E,MAAMa,eAAe,CAACC;QACpB,MAAMC,SAASD,EAAEE,MAAM,CAACf,KAAK;QAC7BO,iBAAiBO;QACjB,IAAI,OAAOX,iBAAiB,YAAY;YACtCA,aAAaW;QACf;QACA,IAAI,OAAOT,iBAAiB,YAAY;YACtCA,aAAaQ;QACf;IACF;IAEA,MAAMG,eAAef,cAAcgB,YAAYhB,YAAYK;IAE3D,qBACE,MAACY;QAAIC,WAAU;;YACZT,uBAAS,KAACA;gBAAMS,WAAU;0BAAeT;;0BAC1C,MAACQ;gBAAIE,OAAO;oBAAEC,SAAS;oBAAQC,KAAK;gBAAQ;;kCAC1C,KAACC;wBACCC,cAAa;wBACbpB,UAAUQ;wBACVQ,OAAO;4BAAEK,MAAM;wBAAE;wBACjBC,MAAMlB,WAAW,SAAS;wBAC1BR,OAAOgB,gBAAgB;;kCAEzB,KAACW;wBAAOC,SAAS,IAAMnB,YAAY,CAACoB,IAAM,CAACA;wBAAIH,MAAK;kCACjDlB,WAAW,SAAS;;;;;;AAK/B,EAAC;AAED,eAAeZ,YAAW"}
|
||||
+4
@@ -14,6 +14,10 @@ type BuildSiteIntegrationsArgs = {
|
||||
* impossible to enter.)
|
||||
*
|
||||
* 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 declare function buildSiteIntegrations({ additionalFields, }?: BuildSiteIntegrationsArgs): GlobalConfig;
|
||||
export {};
|
||||
|
||||
+4
-5
@@ -1,7 +1,6 @@
|
||||
import { isAdmin } from '../../modules/access/index.js';
|
||||
import { analyticsFields } from './fields/analytics.js';
|
||||
import { smtpFields } from './fields/smtp.js';
|
||||
import { storageFields } from './fields/storage.js';
|
||||
import { turnstileFields } from './fields/turnstile.js';
|
||||
/**
|
||||
* Builds the SiteIntegrations global.
|
||||
@@ -14,6 +13,10 @@ import { turnstileFields } from './fields/turnstile.js';
|
||||
* impossible to enter.)
|
||||
*
|
||||
* 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 } = {}) {
|
||||
return {
|
||||
slug: 'site-integrations',
|
||||
@@ -42,10 +45,6 @@ import { turnstileFields } from './fields/turnstile.js';
|
||||
fields: smtpFields,
|
||||
label: 'SMTP'
|
||||
},
|
||||
{
|
||||
fields: storageFields,
|
||||
label: 'Storage'
|
||||
},
|
||||
...additionalFields?.length ? [
|
||||
{
|
||||
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
+4
@@ -20,6 +20,9 @@ export type { I18nConfig, LocaleDefinition, LocalizedSlugs } from './modules/i18
|
||||
export { buildLocalizedPath, getDefaultLocale, getLocaleCodes, getLocaleDefinition, getLocalizedSlugs, isValidLocale, LOCALE_COOKIE_NAME, matchAcceptLanguage, negotiateLocale, switchLocalePath, } from './modules/i18n/index.js';
|
||||
export type { LocaleMiddlewareResult } from './modules/i18n/index.js';
|
||||
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js';
|
||||
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js';
|
||||
export { getNotificationTexts, NOTIFICATION_FALLBACK, resolveFormMessage, } from './modules/notifications/index.js';
|
||||
export type { FormNotificationTexts, NotificationsData, NotificationTexts, } from './modules/notifications/index.js';
|
||||
export type { PagesOption, SystemPageRole } from './modules/pages/index.js';
|
||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js';
|
||||
export type { GlobalQueryOptions } from './modules/payload/index.js';
|
||||
@@ -31,5 +34,6 @@ export { buildHreflangAlternates, buildMetadata, composeTitle } from './modules/
|
||||
export type { AutoFillMapping, RobotsRules, SitemapEntry } 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 { buildR2Storage } from './modules/storage/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
export type { IpalOptions } from './types.js';
|
||||
|
||||
Vendored
+5
@@ -12,12 +12,17 @@ export { buildFormsPlugin } from './modules/forms/formsPluginConfig.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 { 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 { getGlobal, getSiteIntegrations, getSiteSettings, SITE_INTEGRATIONS_SLUG, SITE_SETTINGS_SLUG } from './modules/payload/index.js';
|
||||
export { buildSecurityHeaders } from './modules/security/index.js';
|
||||
export { buildHreflangAlternates, buildMetadata, composeTitle } 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';
|
||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||
export { buildR2Storage } from './modules/storage/index.js';
|
||||
export { ipalKit } from './plugin.js';
|
||||
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+28
-13
@@ -89,10 +89,19 @@ import { getSiteIntegrations } from '../payload/index.js';
|
||||
sent: false
|
||||
};
|
||||
}
|
||||
// From-display comes from the panel; falls back to the caller's from.
|
||||
// Display name on the From, WITHOUT triggering Send-As.
|
||||
//
|
||||
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||
// same-address / custom-name From is allowed and gives each project its
|
||||
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||
//
|
||||
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||
// and the panel's from-name becomes the sender display name.
|
||||
const panel = await getSiteIntegrations(payload);
|
||||
const fromAddress = panel.smtpFromAddress || undefined;
|
||||
const fromName = panel.smtpFromName || undefined;
|
||||
const replyToAddress = panel.smtpFromAddress || undefined;
|
||||
const senderName = panel.smtpFromName || undefined;
|
||||
const to = toRecipients(message.to);
|
||||
if (to.length === 0) {
|
||||
payload.logger.error('[ipal] Email not sent: no valid recipient.');
|
||||
@@ -104,6 +113,14 @@ import { getSiteIntegrations } from '../payload/index.js';
|
||||
// Graph accepts either HTML or Text; Payload gives us html and/or text.
|
||||
const isHtml = typeof message.html === 'string' && message.html.length > 0;
|
||||
const content = isHtml ? String(message.html) : String(message.text ?? '');
|
||||
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||
const replyTo = message.replyTo ? toRecipients(message.replyTo) : replyToAddress ? [
|
||||
{
|
||||
emailAddress: {
|
||||
address: replyToAddress
|
||||
}
|
||||
}
|
||||
] : [];
|
||||
const graphMessage = {
|
||||
body: {
|
||||
content,
|
||||
@@ -117,21 +134,19 @@ import { getSiteIntegrations } from '../payload/index.js';
|
||||
...message.bcc ? {
|
||||
bccRecipients: toRecipients(message.bcc)
|
||||
} : {},
|
||||
// from is only honoured if the app has Send-As for that address; when
|
||||
// it's the shared mailbox itself, omit it and Graph uses the sender.
|
||||
...fromAddress ? {
|
||||
// From with the sender's OWN address (no Send-As) plus an optional
|
||||
// display name from the panel. Omit entirely when no name is set —
|
||||
// Graph then uses the mailbox's default name.
|
||||
...senderName ? {
|
||||
from: {
|
||||
emailAddress: {
|
||||
address: fromAddress,
|
||||
...fromName ? {
|
||||
name: fromName
|
||||
} : {}
|
||||
name: senderName,
|
||||
address: env.sender
|
||||
}
|
||||
}
|
||||
} : {},
|
||||
// replyTo lets the recipient reply to the real submitter if the caller set it.
|
||||
...message.replyTo ? {
|
||||
replyTo: toRecipients(message.replyTo)
|
||||
...replyTo.length > 0 ? {
|
||||
replyTo
|
||||
} : {}
|
||||
};
|
||||
try {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+8
-1
@@ -1,6 +1,13 @@
|
||||
import type { I18nConfig } from './types.js';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */
|
||||
export declare const LOCALE_COOKIE_NAME = "ipal-locale";
|
||||
/**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/
|
||||
export declare const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
|
||||
type NegotiateLocaleArgs = {
|
||||
/** Raw Accept-Language header value */
|
||||
acceptLanguage?: null | string;
|
||||
|
||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
||||
import { getLocaleCodes, isValidLocale } from './helpers.js';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */ export const LOCALE_COOKIE_NAME = 'ipal-locale';
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */ /**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/ export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
|
||||
/**
|
||||
* Resolves which locale to serve, in priority order:
|
||||
* 1. Cookie (explicit prior choice)
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js';
|
||||
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"}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import type { CollectionBeforeOperationHook } from 'payload';
|
||||
/**
|
||||
* 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 declare function normalizeFilename(filename: string): string;
|
||||
/**
|
||||
* 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 declare const normalizeFilenameHook: CollectionBeforeOperationHook;
|
||||
+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"}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type { Plugin } from 'payload';
|
||||
/**
|
||||
* 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 declare const buildR2Storage: (collections?: string[]) => Plugin;
|
||||
+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
+1
@@ -0,0 +1 @@
|
||||
export { buildR2Storage } from './buildR2Storage.js';
|
||||
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"}
|
||||
@@ -0,0 +1,254 @@
|
||||
# Playbook wdrożenia — ipal-kit
|
||||
|
||||
Sztywna procedura dla pracownika albo AI (Antigravity). Mówi CO robić, W JAKIEJ
|
||||
KOLEJNOŚCI, i CZYM SIĘ KIEROWAĆ. Zasady są twarde, przykłady realne — wzięte z
|
||||
faktycznych błędów, które się zdarzyły. Odstępstwa tylko za świadomą decyzją.
|
||||
|
||||
Powiązane: [standardy-kodu.md](./standardy-kodu.md) (dobre praktyki senior),
|
||||
[publishing.md](./publishing.md) (cykl publikacji), [getting-started.md](./getting-started.md)
|
||||
(nowy projekt), ../ANTIGRAVITY-ZASADY-AGENT.md (zasady dla AI).
|
||||
|
||||
---
|
||||
|
||||
## ZŁOTE ZASADY (łam tylko świadomie)
|
||||
|
||||
1. **Nic na sztywno.** Tekst, obraz, link, dane firmy → panel/baza, nie kod.
|
||||
2. **Logika w pluginie, projekt podłącza.** Jeśli piszesz w projekcie coś, co
|
||||
robi już plugin — zatrzymaj się, użyj pluginu.
|
||||
3. **Next 16 = proxy.ts.** NIGDY middleware.ts. Jeśli istnieje — usuń.
|
||||
4. **Weryfikuj każdy etap grepem.** Nie zakładaj, że zadziałało. Sprawdź.
|
||||
5. **Napraw u źródła, nie łataj.** Bez `as any`, `@ts-ignore`, kopii logiki.
|
||||
6. **Zmiana w pluginie nie działa, dopóki nie: build → publish → wciągnięcie.**
|
||||
7. **Zmieniłeś API → zaktualizuj docs w tym samym commicie.** Docs jadą w
|
||||
pakiecie; rozjazd kod↔docs = agent dostaje złą mapę.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ A — ŁAŃCUCH ZMIANY W PLUGINIE (najważniejsze)
|
||||
|
||||
Najczęstsze źródło frustracji tej sesji: „zmieniłem kod, a nie działa". Prawie
|
||||
zawsze przyczyna: **przerwany łańcuch**. Zmiana w pluginie przechodzi przez
|
||||
PIĘĆ etapów. Pominięcie któregokolwiek = stara wersja w projekcie.
|
||||
|
||||
```
|
||||
źródła (src) → build (dist) → publish (rejestr) → wciągnięcie (node_modules) → restart
|
||||
```
|
||||
|
||||
### Sztywna procedura zmiany w pluginie
|
||||
|
||||
```bash
|
||||
cd ~/payload-cms/ipal-kit
|
||||
|
||||
# 1. ŹRÓDŁA — nanieś zmianę, ZWERYFIKUJ że jest
|
||||
grep -c "<symbol-zmiany>" src/<ścieżka> # MUSI być >0
|
||||
|
||||
# 1b. DOCS — jeśli zmiana dotyka API/zachowania, ZAKTUALIZUJ docs/
|
||||
# (nowa funkcja, zmiana sygnatury, nowe pole panelu, nowy adapter...).
|
||||
# Docs jadą w pakiecie (files: dist, docs) — nieaktualne docs = agent
|
||||
# dostaje złą mapę. Kod i docs publikuj RAZEM.
|
||||
|
||||
# 2. BUILD — zbuduj, ZWERYFIKUJ że dist ma zmianę
|
||||
pnpm build
|
||||
grep -c "<symbol-zmiany>" dist/<ścieżka> # MUSI być >0
|
||||
|
||||
# 3. COMMIT (PRZED version — inaczej "working directory not clean")
|
||||
git add -A && git commit -m "opis"
|
||||
|
||||
# 4. VERSION + PUBLISH
|
||||
npm version patch # czyste repo wymagane
|
||||
npm publish
|
||||
|
||||
# 5. PUSH
|
||||
git push && git push --tags
|
||||
|
||||
# 6. PROJEKT — wciągnij, ZWERYFIKUJ że node_modules ma zmianę
|
||||
cd ~/<projekt>
|
||||
pnpm add @intecion/ipal-kit@<nowa-wersja>
|
||||
grep -c "<symbol-zmiany>" node_modules/@intecion/ipal-kit/dist/<ścieżka> # MUSI być >0
|
||||
|
||||
# 7. RESTART dev (Payload buduje adaptery/config przy starcie!)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### TRZY punkty kontrolne grep (nie pomijaj żadnego)
|
||||
|
||||
| Etap | Grep | Jeśli 0 |
|
||||
|---|---|---|
|
||||
| po edycji | `src/...` | zmiana nie zapisana / zły plik |
|
||||
| po build | `dist/...` | build nie złapał / błąd typów |
|
||||
| po pnpm add | `node_modules/...` | projekt ma starą wersję |
|
||||
|
||||
**Realny przykład (z tej sesji):** `buildSecurityHeaders is not a function`.
|
||||
Przyczyna: moduł istniał w `src`, ale NIE był wyeksportowany w `src/index.ts`
|
||||
→ `dist` go nie miał → import w projekcie = undefined. Grep `dist/index.js`
|
||||
pokazał 0. Naprawa: dodać eksport, przejść łańcuch od nowa.
|
||||
|
||||
### Pułapki kolejności (realne błędy sesji)
|
||||
|
||||
- **`npm version` przed commitem** → "Git working directory not clean". ZAWSZE
|
||||
commit przed version.
|
||||
- **`npm publish` bez `pnpm build`** → publikujesz STARY dist. ZAWSZE build przed
|
||||
publish, grep dist po buildzie.
|
||||
- **`pnpm add` przy działającym dev** → proces ma stary adapter w pamięci.
|
||||
Payload czyta email/config przy starcie. ZAWSZE restart po wciągnięciu.
|
||||
- **Publikacja bez aktualizacji docs** → agent (Antigravity) po `pnpm add`
|
||||
czyta `node_modules/@intecion/ipal-kit/docs/` z NIEAKTUALNĄ mapą. Jeśli
|
||||
zmieniłeś API — docs w tym samym commicie.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ B — GREP JAKO NARZĘDZIE (jak weryfikować dobrze)
|
||||
|
||||
Grep był w tej sesji głównym narzędziem diagnozy. Ale trzeba go używać mądrze.
|
||||
|
||||
### Reguła: grepuj TOKENY, nie całe frazy z kolejnością
|
||||
|
||||
**Realny błąd:** grep `"env.sender, name: senderName"` dał 0, choć kod był OK —
|
||||
bo plik miał odwróconą kolejność kluczy (`name: senderName, address: env.sender`).
|
||||
Obiekt JS ignoruje kolejność, ale grep nie.
|
||||
|
||||
```bash
|
||||
# ŹLE — zależny od kolejności/formatowania:
|
||||
grep -c "env.sender, name: senderName" plik.ts # 0 mimo poprawnego kodu
|
||||
|
||||
# DOBRZE — pojedynczy token, odporny:
|
||||
grep -c "senderName" plik.ts # 3 ✓
|
||||
```
|
||||
|
||||
Grepuj **nazwę symbolu** (funkcja, zmienna, eksport), nie całą linię z interpunkcją.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ C — DIAGNOSTYKA „KOD DOBRY, ZACHOWANIE ZŁE"
|
||||
|
||||
Gdy grep potwierdza kod, wersja nowa, a zachowanie stare — przejdź listę:
|
||||
|
||||
1. **Dev nie zrestartowany?** Payload buduje adaptery/config przy starcie.
|
||||
Ctrl+C + `pnpm dev`. (Najczęstsza przyczyna.)
|
||||
2. **Zmiana zapisana w panelu?** Endpointy czytają z BAZY, nie z pola na ekranie.
|
||||
Kliknij Save.
|
||||
3. **Zdublowana zależność?** `@payloadcms/ui` w node_modules pluginu = dwie
|
||||
instancje = hooki bez kontekstu. Sprawdź:
|
||||
`ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui`
|
||||
Jest? → peerDependency problem (patrz Część D).
|
||||
4. **Cache klienta?** Np. klient pocztowy pokazuje zapamiętaną nazwę nadawcy
|
||||
mimo poprawnych nagłówków. Sprawdź surowe źródło (View Source), wyślij na
|
||||
inny adres.
|
||||
5. **Import map nieaktualny?** Custom komponenty Payload:
|
||||
`npx payload generate:importmap`.
|
||||
|
||||
**Realny przykład:** MaskedField rzucał "Cannot destructure property 'config'".
|
||||
Kod OK. Przyczyna: dublet `@payloadcms/ui` (plugin miał własną kopię) →
|
||||
`useField` z jednej instancji nie widział kontekstu z drugiej. Naprawa w Część D.
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ D — peerDependencies (dublety zależności)
|
||||
|
||||
**Zasada:** wszystko, co dostarcza PROJEKT, jest `peerDependency` w pluginie,
|
||||
NIE `dependency`. Inaczej menedżer instaluje własną kopię dla pluginu → dublet
|
||||
→ React/Payload context się rozjeżdża (dwie instancje nie widzą się nawzajem).
|
||||
|
||||
Peer (projekt dostarcza): `payload`, `@payloadcms/ui`, `@payloadcms/next`,
|
||||
`@payloadcms/plugin-*`, `react`, `react-dom`, `next`.
|
||||
|
||||
**Realny błąd:** `@payloadcms/ui` był tylko w devDependencies (brak w peer) →
|
||||
pnpm dołożył kopię pluginowi → MaskedField/TestEmailButton/CookieBanner
|
||||
wszystkie się psuły (hooki bez kontekstu). Naprawa: dodać do peerDependencies,
|
||||
opublikować, w projekcie `rm -rf node_modules/@intecion/ipal-kit && pnpm add`.
|
||||
|
||||
Weryfikacja braku dubletu:
|
||||
```bash
|
||||
ls node_modules/@intecion/ipal-kit/node_modules/@payloadcms/ui 2>/dev/null \
|
||||
&& echo "DUBLET ✗" || echo "OK ✓"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ E — NOWY PROJEKT KLIENCKI (kolejność)
|
||||
|
||||
Pełne szczegóły: [getting-started.md](./getting-started.md). Tu skrót kolejności.
|
||||
|
||||
1. **Szkielet** Payload 3 + Next 16, pnpm, Node 22
|
||||
2. **`.npmrc`** — `legacy-peer-deps=true` + rejestr `@intecion`
|
||||
3. **`pnpm add @intecion/ipal-kit`** + zależności peer
|
||||
4. **build script z `--webpack`** (Next 16 + Payload; Turbopack konfliktuje)
|
||||
5. **i18n.config.ts** — jedno źródło locale
|
||||
6. **payload.config.ts** — ipalKit({...}), `email: mailAdapter()`
|
||||
7. **Kolekcje/globale** — wszystko localized/upload (nic na sztywno)
|
||||
8. **lib/content.ts + lib/payload.ts** — helpery, jedno źródło getCachedPayload
|
||||
9. **proxy.ts** (NIE middleware.ts) — routing locale, obsługa roota
|
||||
10. **Bloki** — dane przez enhanceProps, nie import lib (cykl)
|
||||
11. **buildSlugField** zamiast ręcznego slug
|
||||
12. **getLocalizedSlugs** zamiast zaszytej mapy ścieżek
|
||||
13. **buildSecurityHeaders** w next.config
|
||||
14. **Test:** root `/` przekierowuje, formularz wysyła, panel działa
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ F — EMAIL (SMTP vs Graph)
|
||||
|
||||
Pełne szczegóły: [email.md](./email.md). Decyzja transportu:
|
||||
|
||||
- **Klient na M365/Exchange** → Graph (SMTP AUTH na M365 często wyłączony)
|
||||
- **Klient z własnym SMTP / Gmail** → SMTP
|
||||
- **Przełącznik:** panel → Site Integrations → SMTP → Email Transport
|
||||
- **Dyspozytor:** `email: mailAdapter()` czyta wybór przy każdej wysyłce
|
||||
|
||||
### Graph — checklist wdrożenia (Wasza strona, jednorazowo)
|
||||
|
||||
1. Azure: App registration → tenantId, clientId, clientSecret
|
||||
2. Azure: Mail.Send APPLICATION permission + **Grant admin consent**
|
||||
3. `.env` projektu: GRAPH_TENANT_ID, GRAPH_CLIENT_ID, GRAPH_CLIENT_SECRET, GRAPH_SENDER
|
||||
4. Panel: From Name (nazwa nadawcy), From Address (→ reply-to)
|
||||
|
||||
### Realne pułapki Graph (wszystkie zdarzyły się w sesji)
|
||||
|
||||
| Błąd | Przyczyna | Naprawa |
|
||||
|---|---|---|
|
||||
| `ErrorSendAsDenied` | `from` ≠ sender | from.address = GRAPH_SENDER, klient w replyTo |
|
||||
| nazwa „Noreply" mimo panelu | Exchange nadpisuje / cache klienta | display name skrzynki / sprawdź nagłówki |
|
||||
| `Insufficient privileges` | brak admin consent | Grant admin consent w Azure |
|
||||
| `AADSTS1002012` | zły scope | scope = `.../.default`, nie Mail.Send |
|
||||
|
||||
**Zasada from/replyTo:** `from.address` ZAWSZE = GRAPH_SENDER (wspólna skrzynka,
|
||||
zero Send-As). Nazwa (`from.name`) z panelu — różna per projekt. Adres klienta
|
||||
→ replyTo (odpowiedzi trafiają do klienta).
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ G — CO NALEŻY DO PLUGINU, A CO DO PROJEKTU
|
||||
|
||||
Powtarzalne pytanie. Reguła: **jeśli zależy od danych/domen konkretnego projektu
|
||||
→ projekt. Jeśli identyczne wszędzie → plugin.**
|
||||
|
||||
| Rzecz | Gdzie | Dlaczego |
|
||||
|---|---|---|
|
||||
| i18n, SEO meta, forms, consent, blog | plugin | uniwersalne |
|
||||
| Powiadomienia (teksty wyników) | plugin | uniwersalne, per język z panelu |
|
||||
| Zgoda RODO (enforcement) | plugin | uniwersalne, server-side |
|
||||
| Nagłówki bezpieczeństwa (HSTS...) | plugin | identyczne wszędzie |
|
||||
| Email (SMTP + Graph) | plugin | uniwersalne, konfiguracja z panelu/env |
|
||||
| **CSP** | **projekt** | zależy od domen projektu |
|
||||
| **schema.org / JSON-LD** | **projekt** | zależy od danych firmy |
|
||||
| **Breadcrumbs** | **projekt** | render z danych routingu projektu |
|
||||
| **Dane rejestrowe firmy** | **projekt** | różne per typ firmy |
|
||||
|
||||
---
|
||||
|
||||
## CZĘŚĆ H — CHECKLIST PRZED „GOTOWE"
|
||||
|
||||
Nie mów „działa", dopóki:
|
||||
|
||||
- [ ] `pnpm build --webpack` przechodzi lokalnie (nie tylko dev)
|
||||
- [ ] root `/` przekierowuje na locale (bez middleware.ts)
|
||||
- [ ] formularz wysyła (test przez panel: Send test)
|
||||
- [ ] panel: wszystkie teksty/obrazy edytowalne (nic na sztywno)
|
||||
- [ ] brak dubletu @payloadcms/ui (Część D)
|
||||
- [ ] grep potwierdza wersję pluginu w node_modules
|
||||
- [ ] sekrety w .env (nie w repo), maskowane w panelu
|
||||
- [ ] brak plików middleware.ts, brak zaszytej mapy slugów
|
||||
- [ ] strona 404 (not-found.tsx) — edytowalna, per język, link powrotu
|
||||
- [ ] formularze z buildera w panelu (NIE własne hardkodowane)
|
||||
- [ ] compliance: polityki, baner cookies, zgoda RODO w formularzach
|
||||
(patrz [wymagania-prawne.md](./wymagania-prawne.md))
|
||||
+4
-1
@@ -1,5 +1,7 @@
|
||||
# IPAL — Dokumentacja modułów
|
||||
|
||||
> **Zaczynasz wdrożenie?** Przeczytaj najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md) — sztywna procedura, kolejność, realne przykłady błędów.
|
||||
|
||||
**Instalacja pakietu** (token Gitea, rejestr vs repozytorium) → główny
|
||||
[README](../README.md).
|
||||
**Nowy projekt krok po kroku** → [getting-started.md](./getting-started.md).
|
||||
@@ -106,6 +108,7 @@ export default buildConfig({
|
||||
| access | Role admin > editor > user, kontrola dostępu | [access.md](./access.md) |
|
||||
| payload-helpers | getSiteSettings / getSiteIntegrations | [payload-helpers.md](./payload-helpers.md) |
|
||||
| seo | Metadata, hreflang, auto-fill, plugin-seo | [seo.md](./seo.md) |
|
||||
| architektura-tresci | **Jak budować, żeby klient wszystko edytował** (filozofia CMS) | [architektura-tresci.md](./architektura-tresci.md) |
|
||||
| blocks | RenderBlocks — silnik renderowania bloków | [blocks.md](./blocks.md) |
|
||||
| consent | Banner cookies GDPR, Google Consent Mode | [consent.md](./consent.md) |
|
||||
| turnstile | Cloudflare Turnstile (widget + verify) | [turnstile.md](./turnstile.md) |
|
||||
@@ -118,7 +121,7 @@ export default buildConfig({
|
||||
| content | Blog/archiwa: kolekcje pod stroną-archiwum, listing, paginacja | [content.md](./content.md) |
|
||||
|
||||
Nowy projekt krok po kroku: [getting-started.md](./getting-started.md)
|
||||
Referencja wdrożenia frontu: [frontend-setup.md](./frontend-setup.md)
|
||||
Referencja wdrożenia frontu: [getting-started.md](./getting-started.md)
|
||||
Wydawanie nowych wersji wtyczki: [publishing.md](./publishing.md)
|
||||
Jak komendy łączą się z Gitea (dla instalujących): [gitea-commands.md](./gitea-commands.md)
|
||||
Working with a project repo on Gitea (clone/pull/push): [gitea-workflow.md](./gitea-workflow.md) · [🇵🇱 PL](./gitea-workflow.pl.md)
|
||||
|
||||
+37
-2
@@ -109,7 +109,7 @@ Dostępne tokeny (każdy ma odpowiednik `-dark` używany pod `dark:`):
|
||||
| `--ipal-hover` | `#f5f5f5` | hover przycisków drugorzędnych |
|
||||
| `--ipal-radius` | `0.375rem` | zaokrąglenie przycisków |
|
||||
|
||||
Wymaga `@source` skanującego pakiet (patrz frontend-setup.md) — inaczej Tailwind
|
||||
Wymaga `@source` skanującego pakiet (patrz getting-started.md) — inaczej Tailwind
|
||||
nie wygeneruje tych klas.
|
||||
|
||||
### Gdy tokeny nie wystarczą
|
||||
@@ -131,4 +131,39 @@ Sloty: `root`, `primaryButton`, `secondaryButton`. Podany className zastępuje
|
||||
domyślny (nie dokleja się).
|
||||
|
||||
Elementy mają też `data-ipal="banner"` i `data-ipal="cookie-button"` — stabilne
|
||||
uchwyty do CSS albo testów e2e.
|
||||
uchwyty do CSS albo testów e2e.
|
||||
|
||||
## Locale jako cookie functional (wbudowane)
|
||||
|
||||
Plugin sam zarządza jedną cookie functional: **`NEXT_LOCALE`** (wybór języka).
|
||||
Nie musisz nic konfigurować — działa out of the box:
|
||||
|
||||
- **Zapis za zgodą.** Middleware zapisuje `NEXT_LOCALE` tylko, gdy użytkownik
|
||||
zaakceptował kategorię **functional**. Bez zgody język działa (negocjacja per
|
||||
żądanie), ale nie jest utrwalany w cookie.
|
||||
- **Sprzątanie po cofnięciu.** Gdy użytkownik cofnie zgodę na functional, hook
|
||||
consent usuwa `NEXT_LOCALE` automatycznie. Odpowiada za to `DEFAULT_COOKIE_MAP`:
|
||||
|
||||
```ts
|
||||
const DEFAULT_COOKIE_MAP = {
|
||||
functional: [LOCALE_COOKIE_NAME], // 'NEXT_LOCALE' — plugin zna własną cookie
|
||||
}
|
||||
```
|
||||
|
||||
### Twoje własne cookie functional/analytics
|
||||
|
||||
Jeśli ustawiasz własne cookie podlegające zgodzie, rozszerz mapę — hook wtedy
|
||||
sprzątnie też Twoje po cofnięciu zgody:
|
||||
|
||||
```ts
|
||||
useConsent({
|
||||
functional: ['NEXT_LOCALE', 'moje-ustawienie'],
|
||||
analytics: ['_ga', '_gid'],
|
||||
})
|
||||
```
|
||||
|
||||
Przekazana mapa zastępuje domyślną — pamiętaj dołączyć `NEXT_LOCALE`, jeśli
|
||||
chcesz zachować sprzątanie locale (albo zaimportuj `LOCALE_COOKIE_NAME` i dodaj).
|
||||
|
||||
> Mechanizm zgody dla locale jest opisany też od strony i18n:
|
||||
> [i18n.md](./i18n.md#cookie-locale-a-zgoda-rodo).
|
||||
+28
-1
@@ -149,4 +149,31 @@ Jeśli `from` w panelu = cudza domena (np. `[email protected]`), a sender =
|
||||
`[email protected]` — Exchange zablokuje, chyba że aplikacja ma Send-As na tę
|
||||
domenę. Najbezpieczniej: `from` = `GRAPH_SENDER` (Wasza skrzynka), a adres
|
||||
klienta w `replyTo` (odpowiedzi trafią do klienta). Wtedy Send-As na cudze
|
||||
domeny nie jest potrzebny.
|
||||
domeny nie jest potrzebny.
|
||||
|
||||
## Przełącznik transportu — mailAdapter
|
||||
|
||||
`mailAdapter()` to dyspozytor: jeden adapter wpięty w config, wybiera transport
|
||||
(SMTP/Graph) przy KAŻDEJ wysyłce, czytając ustawienie z panelu. Dzięki temu
|
||||
przełącznik działa w panelu (Payload buduje adapter raz przy starcie, więc nie
|
||||
da się podmieniać osobnych adapterów w runtime — dyspozytor deleguje wewnątrz).
|
||||
|
||||
```ts
|
||||
// payload.config.ts — JEDEN adapter, wybór wewnątrz
|
||||
import { mailAdapter } from '@intecion/ipal-kit'
|
||||
email: mailAdapter()
|
||||
```
|
||||
|
||||
Panel → Site Integrations → SMTP → **Email Transport** (SMTP / Microsoft Graph).
|
||||
Dyspozytor czyta ten wybór per wysyłka. Guard: jeśli wybrano Graph, ale brak
|
||||
sekretów w .env → log + fallback na SMTP (nie cicha awaria).
|
||||
|
||||
## Test wysyłki — przycisk w panelu
|
||||
|
||||
W tabie SMTP jest przycisk **Send test**: podaj adres, kliknij, wyślij testowy
|
||||
mail przez AKTUALNY transport. Pokazuje wynik (✓/✗ z błędem). Endpoint
|
||||
`POST /api/ipal/test-email` (admin-only). Zapisz zmiany przed testem — endpoint
|
||||
czyta z bazy, nie z pola na ekranie.
|
||||
|
||||
> Bezcenne przy diagnozie Graph — od razu widzisz `ErrorSendAsDenied`,
|
||||
> `Insufficient privileges` itp. zamiast zgadywać.
|
||||
@@ -4,6 +4,31 @@ Wpina `@payloadcms/plugin-form-builder` (kolekcje forms + form-submissions) i
|
||||
dostarcza `submitForm` — wywoływalną z frontu funkcję, która spina: weryfikację
|
||||
Turnstile → zapis zgłoszenia → wysyłkę maili (naszym senderem).
|
||||
|
||||
## ⚠️ ZASADA: formularz POCHODZI z buildera w panelu (obowiązkowe)
|
||||
|
||||
**Formularze buduje redaktor w panelu** (kolekcja Forms), NIE deweloper w kodzie.
|
||||
To jest CMS — klient sam definiuje pola, etykiety, komunikaty, odbiorcę. Front
|
||||
tylko RENDERUJE formularz z panelu i wysyła przez `submitForm`.
|
||||
|
||||
**NIGDY nie twórz własnego, hardkodowanego formularza** — z ręcznie wpisanymi
|
||||
polami, etykietami w JSX, własną walidacją. To łamie „nic na sztywno" (klient nie
|
||||
zmieni pól ani tekstów) i omija cały mechanizm pluginu (Turnstile, rate-limit,
|
||||
consent RODO, powiadomienia).
|
||||
|
||||
| ŹLE (własny formularz) | DOBRZE (builder pluginu) |
|
||||
|---|---|
|
||||
| `<input name="email" placeholder="Email" />` w JSX | pola z kolekcji Forms (panel) |
|
||||
| etykiety/komunikaty w kodzie | etykiety per język w panelu |
|
||||
| własna walidacja/wysyłka | `submitForm` (Turnstile+consent+mail) |
|
||||
| klient nie zmieni formularza | klient edytuje pola w panelu |
|
||||
|
||||
**Jak poprawnie:** redaktor tworzy formularz w kolekcji Forms → front pobiera
|
||||
jego definicję → renderuje pola dynamicznie → wysyła przez `submitForm`. Pola,
|
||||
etykiety, komunikaty, odbiorca — wszystko z panelu.
|
||||
|
||||
Jeśli formularz wymaga pola, którego builder nie ma — dodaj je przez konfigurację
|
||||
`fields` (patrz niżej) albo rozbuduj plugin. NIE hardkoduj własnego formularza.
|
||||
|
||||
## Zależność
|
||||
|
||||
```json
|
||||
|
||||
+207
-221
@@ -1,14 +1,18 @@
|
||||
# Nowy projekt — krok po kroku
|
||||
# Setup projektu — od zera do wdrożenia
|
||||
|
||||
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest opisana w głównym
|
||||
Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z
|
||||
blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność
|
||||
kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).
|
||||
|
||||
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym
|
||||
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
|
||||
> już zainstalowany, i przeprowadza przez **konfigurację** projektu.
|
||||
> zainstalowany, i przeprowadza przez konfigurację.
|
||||
>
|
||||
> **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md)
|
||||
> — zasady, procedura, pułapki.
|
||||
|
||||
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i
|
||||
formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat
|
||||
bazy, importMap, kolejność wpięcia).
|
||||
|
||||
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
||||
Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy,
|
||||
importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,21 +20,22 @@ Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
|
||||
|
||||
```bash
|
||||
npx create-payload-app@latest moj-projekt
|
||||
# → Blank, SQLite
|
||||
# → Blank, SQLite (dev) / Postgres (prod)
|
||||
cd moj-projekt
|
||||
```
|
||||
|
||||
## 2. Plugin i zależności
|
||||
|
||||
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md) (rejestr Gitea
|
||||
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności
|
||||
współdzielone z Payloadem, których plugin nie zaciąga sam:
|
||||
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj
|
||||
zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/plugin-seo@3.84.1 @payloadcms/plugin-form-builder@3.84.1 \
|
||||
pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \
|
||||
nodemailer lucide-react slugify server-only
|
||||
```
|
||||
|
||||
### Spójność wersji @payloadcms/* (KRYTYCZNE)
|
||||
|
||||
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
|
||||
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
|
||||
projekt się wywala. Wymuś w `package.json`:
|
||||
@@ -38,13 +43,13 @@ projekt się wywala. Wymuś w `package.json`:
|
||||
```json
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"payload": "3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1"
|
||||
"payload": "3.88.0",
|
||||
"@payloadcms/ui": "3.88.0",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"@payloadcms/plugin-seo": "3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "3.88.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -53,11 +58,17 @@ projekt się wywala. Wymuś w `package.json`:
|
||||
rm -rf node_modules pnpm-lock.yaml && pnpm install
|
||||
```
|
||||
|
||||
### Build script z --webpack (Next 16)
|
||||
|
||||
Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`:
|
||||
```json
|
||||
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
|
||||
```
|
||||
|
||||
## 3. Konfiguracja locale — jedno źródło
|
||||
|
||||
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc
|
||||
nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu
|
||||
miejscach:
|
||||
Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie
|
||||
może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:
|
||||
|
||||
```ts
|
||||
// src/i18n.config.ts
|
||||
@@ -67,25 +78,24 @@ export const i18nConfig = {
|
||||
{ code: 'pl', label: 'Polski' },
|
||||
{ code: 'en', label: 'English' },
|
||||
],
|
||||
} as const
|
||||
} as const // as const — inaczej TS nie uzna locales za niepustą tuple
|
||||
```
|
||||
|
||||
`as const` jest konieczne — bez niego TS nie uzna `locales` za niepustą listę.
|
||||
Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`.
|
||||
|
||||
## 4. payload.config.ts
|
||||
|
||||
```ts
|
||||
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit'
|
||||
import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { Pages } from '@/collections/Pages'
|
||||
|
||||
export default buildConfig({
|
||||
// …reszta z template'u
|
||||
collections: [Users, Media, Pages],
|
||||
|
||||
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu.
|
||||
// Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce.
|
||||
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
|
||||
email: panelSmtpAdapter(),
|
||||
email: mailAdapter(),
|
||||
|
||||
plugins: [
|
||||
ipalKit({
|
||||
@@ -113,11 +123,11 @@ export const Pages: CollectionConfig = {
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true, localized: true },
|
||||
buildSlugField({ from: 'title' }),
|
||||
buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma
|
||||
{
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala
|
||||
blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -158,15 +168,31 @@ import type { BlockComponentMap } from '@intecion/ipal-kit/rsc'
|
||||
import { ContentBlockComponent } from '@/blocks/Content/Component'
|
||||
|
||||
export const blockRegistry: BlockComponentMap = {
|
||||
content: ContentBlockComponent,
|
||||
content: ContentBlockComponent, // klucz = slug bloku
|
||||
}
|
||||
```
|
||||
|
||||
Klucz w rejestrze = `slug` bloku.
|
||||
> **Jak budować treść, żeby klient mógł wszystko edytować** (filozofia
|
||||
> CMS, kolejność komponent→blok→strona): [architektura-tresci.md](./architektura-tresci.md).
|
||||
|
||||
## 7. Tailwind
|
||||
**Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok.
|
||||
|
||||
Blank template go nie ma, a komponenty pluginu (banner cookies) są w Tailwindzie.
|
||||
### enhanceProps — wstrzykiwanie danych server-side do bloków
|
||||
|
||||
Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey,
|
||||
odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu:
|
||||
|
||||
```ts
|
||||
const enhanceProps = ({ block }) => {
|
||||
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
|
||||
return {}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Tailwind (WYMÓG)
|
||||
|
||||
Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w
|
||||
czystym Tailwindzie.
|
||||
|
||||
```bash
|
||||
pnpm add tailwindcss @tailwindcss/postcss
|
||||
@@ -183,16 +209,30 @@ export default { plugins: { '@tailwindcss/postcss': {} } }
|
||||
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
|
||||
```
|
||||
|
||||
`@source` jest **konieczny** — Tailwind nie skanuje `node_modules`, więc bez
|
||||
niego klasy komponentów pluginu nie powstaną i banner wyrenderuje się goły.
|
||||
Ścieżka jest relatywna do pliku CSS.
|
||||
**`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez
|
||||
niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły).
|
||||
Ścieżka relatywna do pliku CSS.
|
||||
|
||||
## 8. Proxy (dawniej middleware)
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
> **Next 16:** konwencja `middleware.ts` jest przestarzała — nazwa pliku to teraz
|
||||
> `proxy.ts`, a funkcja `proxy` zamiast `middleware`. Logika pluginu bez zmian:
|
||||
> `createLocaleMiddleware` działa tak samo. Migracja jednej komendy:
|
||||
> `npx @next/codemod@canary middleware-to-proxy .`
|
||||
Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom
|
||||
properties (fallbacki wbudowane):
|
||||
```css
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
Pełna lista tokenów + opcja classNames: [consent.md](./consent.md).
|
||||
|
||||
## 8. Proxy (routing locale) — NIGDY middleware.ts
|
||||
|
||||
> **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja
|
||||
> `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy
|
||||
> obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .`
|
||||
>
|
||||
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
||||
> subpath eksportu, NIE nazwa pliku. Nie myl ich.
|
||||
|
||||
```ts
|
||||
// src/proxy.ts
|
||||
@@ -205,104 +245,89 @@ const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const result = localeMiddleware(request)
|
||||
if (result.type === 'next') return NextResponse.next()
|
||||
|
||||
const response = NextResponse.redirect(result.location)
|
||||
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined
|
||||
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value)
|
||||
// Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
|
||||
// języka), TYLKO gdy jest zgoda na functional.
|
||||
const response =
|
||||
result.type === 'next'
|
||||
? NextResponse.next()
|
||||
: NextResponse.redirect(result.location)
|
||||
|
||||
if (result.cookie) {
|
||||
response.cookies.set(result.cookie.name, result.cookie.value)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje
|
||||
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin
|
||||
// i /_next, i wszystko zwróci 500.
|
||||
// Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
|
||||
// Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
|
||||
// Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
|
||||
export const config = {
|
||||
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
|
||||
}
|
||||
```
|
||||
|
||||
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
|
||||
> subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa się
|
||||
> `middleware.ts` czy `proxy.ts`.
|
||||
### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)
|
||||
|
||||
## 9. Warstwa dostępu do danych
|
||||
|
||||
Next uruchamia `generateMetadata` i komponent strony niezależnie — `cache()`
|
||||
sprawia, że nie pytają bazy dwa razy o to samo.
|
||||
Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów.
|
||||
Slugi są w bazie (pole `slug` localized):
|
||||
|
||||
```ts
|
||||
// src/lib/payload.ts
|
||||
import { cache } from 'react'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'
|
||||
|
||||
export const getCachedPayload = cache(async () => getPayload({ config: await config }))
|
||||
|
||||
export const getSettings = cache(async (locale: string) =>
|
||||
(await getCachedPayload()).findGlobal({
|
||||
slug: 'site-settings',
|
||||
locale: locale as 'pl' | 'en',
|
||||
depth: 2,
|
||||
}),
|
||||
)
|
||||
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
|
||||
const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig })
|
||||
switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about'
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/lib/locales.ts
|
||||
import { cache } from 'react'
|
||||
import config from '@/payload.config'
|
||||
## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
|
||||
|
||||
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
|
||||
const payloadConfig = await config
|
||||
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
|
||||
```ts
|
||||
// src/lib/content.ts — JEDYNE źródło helperów pluginu
|
||||
import { createContentHelpers } from '@intecion/ipal-kit'
|
||||
import payloadConfig from '@/payload.config'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
|
||||
export const {
|
||||
getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots,
|
||||
} = createContentHelpers({
|
||||
config: payloadConfig, // PAYLOAD config (nie i18n!)
|
||||
content: { collections: [] },
|
||||
i18n: i18nConfig, // i18n OSOBNO
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/lib/pages.ts
|
||||
// src/lib/payload.ts — funkcje projektu, typowane
|
||||
import { cache } from 'react'
|
||||
import type { Page, SiteSetting } from '@/payload-types'
|
||||
import { getCachedPayload, getSettings } from './payload'
|
||||
import { getSiteSettings } from '@intecion/ipal-kit'
|
||||
import { getCachedPayload } from './content' // z content, nie osobny getPayload
|
||||
import type { SiteSetting } from '@/payload-types'
|
||||
|
||||
export const resolvePage = cache(
|
||||
async (locale: string, slugPath: string | null): Promise<Page | null> => {
|
||||
if (!slugPath) {
|
||||
// Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu.
|
||||
const settings = (await getSettings(locale)) as SiteSetting
|
||||
const homepage = settings.homepage
|
||||
return homepage && typeof homepage === 'object' ? homepage : null
|
||||
}
|
||||
|
||||
const payload = await getCachedPayload()
|
||||
const result = await payload.find({
|
||||
collection: 'pages',
|
||||
where: { slug: { equals: slugPath } },
|
||||
locale: locale as 'pl' | 'en',
|
||||
depth: 2,
|
||||
limit: 1,
|
||||
})
|
||||
return result.docs[0] ?? null
|
||||
},
|
||||
export const getSettings = cache(async (locale: string) =>
|
||||
getSiteSettings<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
|
||||
)
|
||||
```
|
||||
|
||||
> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
|
||||
> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
|
||||
|
||||
## 10. Trasy
|
||||
|
||||
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
|
||||
layout locale, bo `<html lang>` musi znać język, a `(frontend)` jest ponad
|
||||
segmentem `[locale]`. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
|
||||
layout locale (bo `<html lang>` musi znać język).
|
||||
|
||||
```
|
||||
src/app/(frontend)/
|
||||
styles.css
|
||||
[locale]/
|
||||
layout.tsx
|
||||
layout.tsx # walidacja locale + ConsentProvider + Analytics
|
||||
[[...slug]]/
|
||||
page.tsx
|
||||
page.tsx # render bloków
|
||||
```
|
||||
|
||||
`[[...slug]]` — **podwójne** nawiasy. Pojedyncze `[slug]` dają string zamiast
|
||||
tablicy (`slug.join is not a function`) i nie łapią samego `/pl`.
|
||||
**`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]`
|
||||
dają string (`slug.join is not a function`) i nie łapią samego `/pl`.
|
||||
|
||||
```tsx
|
||||
// src/app/(frontend)/[locale]/layout.tsx
|
||||
@@ -310,8 +335,8 @@ import { notFound } from 'next/navigation'
|
||||
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
|
||||
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { getCachedPayload, getSettings } from '@/lib/payload'
|
||||
import { getConfiguredLocales } from '@/lib/locales'
|
||||
import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
|
||||
import { getSettings } from '@/lib/payload'
|
||||
import '../styles.css'
|
||||
|
||||
export default async function LocaleLayout({ children, params }) {
|
||||
@@ -325,13 +350,9 @@ export default async function LocaleLayout({ children, params }) {
|
||||
|
||||
const [texts, analytics] = await Promise.all([
|
||||
getConsentTexts({
|
||||
config: i18nConfig,
|
||||
locale,
|
||||
payload,
|
||||
privacyPolicy:
|
||||
privacyPage && typeof privacyPage === 'object'
|
||||
? { page: privacyPage, label: 'Polityka prywatności' }
|
||||
: undefined,
|
||||
config: i18nConfig, locale, payload,
|
||||
privacyPolicy: privacyPage && typeof privacyPage === 'object'
|
||||
? { page: privacyPage, label: 'Polityka prywatności' } : undefined,
|
||||
}),
|
||||
getAnalyticsConfig(payload),
|
||||
])
|
||||
@@ -343,7 +364,7 @@ export default async function LocaleLayout({ children, params }) {
|
||||
<main>{children}</main>
|
||||
<CookieBanner />
|
||||
<CookieButton />
|
||||
<Analytics {...analytics} />
|
||||
<Analytics {...analytics} /> {/* WEWNĄTRZ ConsentProvider */}
|
||||
</ConsentProvider>
|
||||
</body>
|
||||
</html>
|
||||
@@ -364,8 +385,7 @@ import { RenderBlocks } from '@intecion/ipal-kit/rsc'
|
||||
import { createPageMetadata } from '@intecion/ipal-kit'
|
||||
import { i18nConfig } from '@/i18n.config'
|
||||
import { blockRegistry } from '@/blocks/registry'
|
||||
import { getCachedPayload } from '@/lib/payload'
|
||||
import { resolvePage } from '@/lib/pages'
|
||||
import { getCachedPayload, resolveRoute } from '@/lib/content'
|
||||
|
||||
const pageMetadata = createPageMetadata({
|
||||
config: i18nConfig,
|
||||
@@ -377,104 +397,92 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
||||
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
|
||||
}
|
||||
|
||||
export default async function Page({ params }) {
|
||||
export default async function Page({ params, searchParams }) {
|
||||
const { locale, slug } = await params
|
||||
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null)
|
||||
if (!page) notFound()
|
||||
|
||||
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} />
|
||||
const { page } = await searchParams
|
||||
const route = await resolveRoute(locale, slug ?? [], page) // 3 args
|
||||
if (!route) notFound()
|
||||
return <RenderBlocks blocks={route.doc.layout as never} components={blockRegistry} />
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Środowisko
|
||||
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
|
||||
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
|
||||
- `depth: 2` → relacje w blokach (form) się populują
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DATABASE_URL=file:./moj-projekt.db
|
||||
PAYLOAD_SECRET=<losowy-ciąg>
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
```
|
||||
## 11. Metadata / SEO (szczegóły)
|
||||
|
||||
`createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera
|
||||
dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której
|
||||
budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string,
|
||||
hreflang nie powstanie.
|
||||
|
||||
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
|
||||
|
||||
## 12. Generowanie i start
|
||||
## 12. Nagłówki bezpieczeństwa
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
import { buildSecurityHeaders } from '@intecion/ipal-kit'
|
||||
const securityHeaders = buildSecurityHeaders({
|
||||
hsts: process.env.NODE_ENV === 'production', // off w dev (http)
|
||||
additional: [ /* CSP projektu — zna swoje domeny */ ],
|
||||
})
|
||||
// async headers() { return [{ source: '/:path*', headers: securityHeaders }] }
|
||||
```
|
||||
Szczegóły: [security.md](./security.md).
|
||||
|
||||
## 13. Środowisko
|
||||
|
||||
```bash
|
||||
# .env
|
||||
DATABASE_URI=<postgres albo file:./dev.db>
|
||||
PAYLOAD_SECRET=<losowy-ciąg>
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
# Email przez Graph (opcjonalnie — sekrety agencyjne):
|
||||
# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=...
|
||||
```
|
||||
|
||||
## 14. Generowanie i start
|
||||
|
||||
```bash
|
||||
pnpm generate:types
|
||||
pnpm payload generate:importmap # pola SEO to komponenty admina
|
||||
pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
`generate:importmap` powtarzaj po każdej zmianie, która dokłada komponenty
|
||||
admina.
|
||||
`generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina.
|
||||
|
||||
## 13. Konfiguracja w panelu
|
||||
## 15. Konfiguracja w panelu
|
||||
|
||||
`http://localhost:3000/admin`
|
||||
|
||||
1. **Utwórz pierwszego użytkownika** (dostanie rolę admin).
|
||||
2. **Site Settings → General** — nazwa witryny, kolejność i separator tytułu.
|
||||
3. **Pages** — utwórz stronę główną. Wypełnij tytuł **w każdym locale**
|
||||
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza
|
||||
404 na `/en/…`.
|
||||
4. **Site Settings → System Pages** — wskaż Homepage. Bez tego `/pl` da 404.
|
||||
5. **Cookie Settings** — treść bannera (bez tego lecą angielskie domyślne).
|
||||
1. **Utwórz pierwszego użytkownika** (rola admin).
|
||||
2. **Site Settings → General** — nazwa witryny, tytuł.
|
||||
3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty
|
||||
slug EN = 404 na `/en/…`).
|
||||
4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404).
|
||||
5. **Cookie Settings** — treść bannera per język.
|
||||
6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback).
|
||||
7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address.
|
||||
|
||||
Wejdź na `/` — powinno przekierować na `/pl` i pokazać stronę.
|
||||
Wejdź na `/` — powinno przekierować na `/pl`.
|
||||
|
||||
---
|
||||
|
||||
## Rzeczy opcjonalne
|
||||
## Opcjonalne
|
||||
|
||||
### Formularz z Turnstile
|
||||
Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations →
|
||||
Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret
|
||||
`1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez
|
||||
mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`.
|
||||
|
||||
Wymaga bloku formularza w projekcie (patrz forms.md) oraz:
|
||||
|
||||
- **Site Integrations → Turnstile** — site key i secret. Klucze testowe
|
||||
Cloudflare (zawsze przechodzą): site `1x00000000000000000000AA`, secret
|
||||
`1x0000000000000000000000000000000AA`.
|
||||
- **Site Integrations → SMTP** — host, port, user, hasło, adres nadawcy.
|
||||
- **Forms → dany formularz → Emails** — odbiorca, temat, treść (`{{*:table}}`
|
||||
wypisze wszystkie pola tabelką). Maile wysyła form-builder przez
|
||||
`panelSmtpAdapter` — nie pisze się ich w kodzie.
|
||||
|
||||
|
||||
### Blog / archiwum (kolekcja pod stroną-archiwum)
|
||||
|
||||
Pełny opis: content.md. W skrócie:
|
||||
|
||||
1. **Kolekcja** `src/collections/Posts.ts` — tytuł (localized), `buildSlugField`,
|
||||
pola, bloki. Dodaj ją do `collections` w payload.config.
|
||||
|
||||
2. **content.config.ts** obok i18n.config.ts:
|
||||
```ts
|
||||
import type { ContentOption } from '@intecion/ipal-kit'
|
||||
export const contentConfig: ContentOption = {
|
||||
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
|
||||
}
|
||||
```
|
||||
|
||||
3. **payload.config** — `content: contentConfig`, plus `posts` w `seo.collections`.
|
||||
|
||||
4. **Front** — `createContentHelpers` w `src/lib/content.ts`, `resolveRoute`
|
||||
w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).
|
||||
|
||||
5. **Baza + typy** — nowa kolekcja to nowy schemat:
|
||||
```bash
|
||||
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
|
||||
```
|
||||
|
||||
6. **W panelu** — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok
|
||||
listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
|
||||
|
||||
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny
|
||||
typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
|
||||
|
||||
### Sitemapa i robots.txt
|
||||
|
||||
`createContentHelpers` oddaje gotowe handlery — dodaj `i18n` i `baseUrl` do jego
|
||||
argumentów (patrz seo.md), potem dwa pliki po jednej linii:
|
||||
### Blog / archiwum
|
||||
Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts +
|
||||
przypisanie strony-archiwum w System Pages.
|
||||
|
||||
### Sitemapa i robots
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
@@ -482,44 +490,22 @@ export { sitemap as default } from '@/lib/content'
|
||||
export { robots as default } from '@/lib/content'
|
||||
```
|
||||
|
||||
Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex.
|
||||
|
||||
### Analytics
|
||||
|
||||
**Site Integrations** → GA4 Measurement ID albo GTM Container ID. Tagi ładują
|
||||
się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie
|
||||
zaakceptuje kategorii Analytics.
|
||||
|
||||
### Przestylowanie pod klienta
|
||||
|
||||
```css
|
||||
/* styles.css */
|
||||
:root {
|
||||
--ipal-primary: #16a34a;
|
||||
--ipal-radius: 1rem;
|
||||
}
|
||||
```
|
||||
|
||||
Pełna lista tokenów: consent.md.
|
||||
|
||||
---
|
||||
|
||||
## Kiedy coś nie działa
|
||||
|
||||
| Objaw | Przyczyna |
|
||||
|---|---|
|
||||
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
||||
| Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
|
||||
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
|
||||
| Banner bez stylów | brak `@source` na `node_modules/@intecion/ipal-kit` albo brak Tailwinda |
|
||||
| `/admin` i `/_next` zwracają 500 | matcher w middleware nie jest inline |
|
||||
| `slug.join is not a function` | katalog `[slug]` zamiast `[[...slug]]` |
|
||||
| `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) |
|
||||
| Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda |
|
||||
| `/admin` i `/_next` → 500 | matcher w proxy nie jest inline |
|
||||
| `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` |
|
||||
| `/pl` → 404 | Homepage nieustawiony w System Pages |
|
||||
| `/en/cokolwiek` → 404, `/pl/cokolwiek` działa | pusty tytuł (a więc i slug) w locale EN |
|
||||
| `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN |
|
||||
| `Missing <html> and <body>` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
|
||||
| `SQLITE_ERROR: index … already exists` | zmiana schematu — usuń `*.db *.db-shm *.db-wal` |
|
||||
| Zmiany w pluginie nie widać | Turbopack cache — `rm -rf .next` |
|
||||
| Maile nie wychodzą | brak `email: panelSmtpAdapter()` w configu albo pusty SMTP w panelu |
|
||||
| GTM ładuje się, brak `_ga` | pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 |
|
||||
| `/pl/artykuly` → 404 | strona nieprzypisana jako archiwum w System Pages |
|
||||
| brak pola „archive page" w panelu | brak `content` w configu albo `generate:importmap` po dodaniu |
|
||||
| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |
|
||||
| Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) |
|
||||
| Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph |
|
||||
| istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` |
|
||||
| zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |
|
||||
+33
-1
@@ -107,4 +107,36 @@ Zachowanie:
|
||||
locale z: cookie → Accept-Language → default
|
||||
- wybrany locale zapisany w cookie (`LOCALE_COOKIE_NAME`)
|
||||
|
||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||
`DEFAULT_MIDDLEWARE_MATCHER` wyklucza `api`, `admin`, `_next`, pliki statyczne.
|
||||
|
||||
## Cookie locale a zgoda (RODO)
|
||||
|
||||
Wybór języka zapisywany jest w cookie **`NEXT_LOCALE`** (konwencja Next.js —
|
||||
kompatybilna z innymi bibliotekami i18n, które czytają aktywny locale). Ale
|
||||
zapis podlega zgodzie: to cookie kategorii **functional**, więc:
|
||||
|
||||
- **Zapis TYLKO za zgodą.** Middleware zapisuje `NEXT_LOCALE` jedynie, gdy
|
||||
użytkownik zgodził się na kategorię functional (`mayPersistLocale` sprawdza
|
||||
zgodę). Bez zgody język działa per żądanie (negocjacja z Accept-Language),
|
||||
ale nie jest utrwalany.
|
||||
- **Sprzątanie po cofnięciu zgody.** Gdy użytkownik cofnie zgodę na functional,
|
||||
cookie `NEXT_LOCALE` jest usuwane automatycznie (consent zna tę cookie przez
|
||||
`DEFAULT_COOKIE_MAP` — patrz [consent.md](./consent.md)).
|
||||
|
||||
Nazwa cookie to jedna stała `LOCALE_COOKIE_NAME` (`modules/i18n/negotiateLocale`),
|
||||
propagująca do middleware i sprzątania consent. Można nadpisać w
|
||||
`createLocaleMiddleware({ cookieName })`, ale domyślnie `NEXT_LOCALE` jest
|
||||
zalecane (interop).
|
||||
|
||||
### Kolejność negocjacji locale
|
||||
|
||||
1. Cookie `NEXT_LOCALE` (jeśli jest — czyli był wybór za zgodą)
|
||||
2. Nagłówek `Accept-Language` (preferencje przeglądarki)
|
||||
3. `defaultLocale` z konfiguracji
|
||||
|
||||
Wejście na `/` → negocjacja → redirect na `/pl` (albo wynik negocjacji).
|
||||
Zmiana języka (URL `/en` różny od cookie) → zapis nowego wyboru (za zgodą).
|
||||
|
||||
> **Migracja ze starej nazwy:** wcześniej cookie nazywało się `ipal-locale`.
|
||||
> Po zmianie na `NEXT_LOCALE` użytkownicy ze starą cookie przejdą raz ponowną
|
||||
> negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# storage — media na Cloudflare R2
|
||||
|
||||
Offload mediów (obrazy, pliki) do Cloudflare R2 zamiast lokalnego dysku. R2 jest
|
||||
S3-kompatybilny; plugin dostarcza `buildR2Storage`, który czyta dane z `.env`
|
||||
i konfiguruje adapter.
|
||||
|
||||
> **Storage to infrastruktura, nie treść.** Dane R2 (klucze, bucket) idą do
|
||||
> `.env` — jak DATABASE_URI, PAYLOAD_SECRET, GRAPH_*. NIE do panelu (to sekrety
|
||||
> agencyjne, wiążą się przy starcie, nie zmienia ich redaktor).
|
||||
|
||||
## Zależność
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/storage-s3
|
||||
```
|
||||
|
||||
## Zmienne .env
|
||||
|
||||
Patrz [R2-ENV-przyklad](../R2-ENV-przyklad.md) po pełną instrukcję skąd wziąć wartości.
|
||||
|
||||
```bash
|
||||
R2_BUCKET=nazwa-bucketa
|
||||
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=<access-key-id>
|
||||
R2_SECRET_ACCESS_KEY=<secret-access-key>
|
||||
```
|
||||
|
||||
## Wpięcie (payload.config.ts)
|
||||
|
||||
```ts
|
||||
import { buildR2Storage } from '@intecion/ipal-kit'
|
||||
|
||||
export default buildConfig({
|
||||
// ...
|
||||
plugins: [
|
||||
ipalKit({ /* ... */ }),
|
||||
buildR2Storage(['media']), // slugi kolekcji upload do offloadu
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
`buildR2Storage` przyjmuje listę kolekcji upload (domyślnie `['media']`). Jeśli
|
||||
masz więcej kolekcji plików: `buildR2Storage(['media', 'documents'])`.
|
||||
|
||||
## Zachowanie (fallback)
|
||||
|
||||
- **Wszystkie 4 zmienne** → media w R2.
|
||||
- **Brak zmiennych** → fallback na lokalny dysk (dev działa bez R2, zero konfiguracji).
|
||||
- **Część zmiennych** → ostrzeżenie w logu + fallback (częściowa konfiguracja =
|
||||
pewnie pomyłka).
|
||||
|
||||
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.
|
||||
|
||||
## 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 nie skonfigurujesz publicznego odczytu przez custom domenę. To proces
|
||||
w Cloudflare (nie w kodzie), wieloetapowy — poniżej dokładnie.
|
||||
|
||||
### Dlaczego custom domena, nie „r2.dev"
|
||||
|
||||
R2 oferuje szybki publiczny URL `*.r2.dev`, ALE:
|
||||
- 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
|
||||
|
||||
Jeśli projekt miał media lokalnie i przełączasz na R2 — nowe uploady idą do R2,
|
||||
ale STARE zostają na dysku (i znikną przy redeployu bez wolumenu). Przed
|
||||
przełączeniem na produkcji przenieś istniejące pliki do bucketa (np. `rclone`
|
||||
albo ręcznie przez R2 dashboard), inaczej stare obrazy znikną.
|
||||
|
||||
## Weryfikacja
|
||||
|
||||
```bash
|
||||
# po wpięciu i ustawieniu .env:
|
||||
pnpm dev
|
||||
# wgraj obraz w panelu (Media) → sprawdź w Cloudflare R2, czy plik się pojawił
|
||||
```
|
||||
|
||||
## Dev na lokalnym I na R2 (seedowanie podczas developmentu)
|
||||
|
||||
Fallback (brak zmiennych → lokalny dysk) oznacza, że **dev działa w obu trybach**:
|
||||
|
||||
- **Dev bez R2 w .env** → media na lokalnym dysku. Szybki start, zero konfiguracji.
|
||||
- **Dev z R2 w .env** → media w R2 już podczas developmentu. Przydatne, gdy
|
||||
seedujesz treść w devie i chcesz, żeby od razu lądowała w buckecie (np. wspólny
|
||||
bucket dev, albo test realnego flow przed produkcją).
|
||||
|
||||
Przełączasz trybem po prostu obecnością zmiennych R2 w `.env`. Ten sam kod,
|
||||
`buildR2Storage` sam wykrywa. Nie musisz nic zmieniać w configu między trybami.
|
||||
|
||||
> Jeśli seedujesz w devie do R2 — pamiętaj, że to realny bucket. Używaj osobnego
|
||||
> bucketa dev (nie produkcyjnego), żeby nie mieszać danych testowych z realnymi.
|
||||
|
||||
## Normalizacja nazw plików (automatyczna)
|
||||
|
||||
Plik `normalizeFilenameHook` czyści nazwy wgrywanych plików — slugifikuje nazwę,
|
||||
zachowuje rozszerzenie:
|
||||
|
||||
```
|
||||
"Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
|
||||
"Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
|
||||
```
|
||||
|
||||
Wpięcie w kolekcję Media (projekt):
|
||||
|
||||
```ts
|
||||
import { normalizeFilenameHook } from '@intecion/ipal-kit'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: { staticDir: 'media' /* ... */ },
|
||||
hooks: {
|
||||
beforeOperation: [normalizeFilenameHook], // czyści nazwę przed zapisem
|
||||
},
|
||||
fields: [ /* alt itd. */ ],
|
||||
}
|
||||
```
|
||||
|
||||
Działa z lokalnym dyskiem i z R2/S3 (hook biegnie PRZED warstwą storage, więc
|
||||
czysta nazwa trafia i do bazy, i do bucketa). Dlaczego to ważne:
|
||||
|
||||
- **URL-e mediów są czyste** — `/media/zdjecie-nad-morzem.jpg`, nie
|
||||
`/media/Zdjęcie%20jeden%20nad%20morzem.jpg` (spacje/diakrytyki w URL = problemy).
|
||||
- **Przenośność** — nazwa bez polskich znaków/spacji działa wszędzie (CDN, S3, systemy plików).
|
||||
- **Bez kolizji kodowania** — spacje i `#`, `()` w nazwach plików potrafią psuć
|
||||
ścieżki i cache.
|
||||
|
||||
Sama funkcja `normalizeFilename(name)` też jest wyeksportowana, gdybyś potrzebował
|
||||
jej poza hookiem.
|
||||
+19
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@intecion/ipal-kit",
|
||||
"version": "1.0.10",
|
||||
"version": "1.0.22",
|
||||
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -38,7 +38,8 @@
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"docs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
@@ -59,28 +60,32 @@
|
||||
"test:int": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/storage-s3": "^3.88.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"server-only": "^0.0.1",
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/plugin-form-builder": "^3.84.1",
|
||||
"@payloadcms/plugin-seo": "^3.84.1",
|
||||
"@payloadcms/next": "^3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "^3.88.0",
|
||||
"@payloadcms/plugin-seo": "^3.88.0",
|
||||
"@payloadcms/ui": "^3.88.0",
|
||||
"next": ">=15",
|
||||
"payload": "^3.84.1",
|
||||
"react": "^19.0.0"
|
||||
"payload": "^3.88.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@payloadcms/db-postgres": "3.84.1",
|
||||
"@payloadcms/db-sqlite": "3.84.1",
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/db-sqlite": "3.88.0",
|
||||
"@payloadcms/eslint-config": "3.28.0",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/plugin-form-builder": "3.84.1",
|
||||
"@payloadcms/plugin-seo": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "3.88.0",
|
||||
"@payloadcms/plugin-seo": "3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"@payloadcms/ui": "3.88.0",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.6.0",
|
||||
@@ -96,7 +101,7 @@
|
||||
"mongodb-memory-server": "10.1.4",
|
||||
"next": "16.2.6",
|
||||
"open": "^10.1.0",
|
||||
"payload": "3.84.1",
|
||||
"payload": "3.88.0",
|
||||
"prettier": "^3.4.2",
|
||||
"qs-esm": "8.0.1",
|
||||
"react": "19.2.6",
|
||||
|
||||
Generated
+632
-417
File diff suppressed because it is too large
Load Diff
@@ -19,3 +19,6 @@ export {
|
||||
export type { CookieBannerClassNames } from '../modules/consent/client.js'
|
||||
export { Turnstile } from '../modules/turnstile/client.js'
|
||||
export type { TurnstileProps } from '../modules/turnstile/client.js'
|
||||
|
||||
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'
|
||||
export type { FormNotificationTexts } from '../modules/notifications/types.js'
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
'use client'
|
||||
import type { TextFieldClientComponent } from 'payload'
|
||||
|
||||
import { useField } from '@payloadcms/ui'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
||||
const { setValue, value } = useField<string>({ path })
|
||||
export const MaskedField: TextFieldClientComponent = (props: any) => {
|
||||
const { field, path, value: propValue, setValue: propSetValue, onChange: propOnChange } = props || {}
|
||||
const [internalValue, setInternalValue] = useState(propValue ?? '')
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const label = typeof field?.label === 'string' ? field.label : (field?.name ?? path)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVal = e.target.value
|
||||
setInternalValue(newVal)
|
||||
if (typeof propSetValue === 'function') {
|
||||
propSetValue(newVal)
|
||||
}
|
||||
if (typeof propOnChange === 'function') {
|
||||
propOnChange(e)
|
||||
}
|
||||
}
|
||||
|
||||
const currentValue = propValue !== undefined ? propValue : internalValue
|
||||
|
||||
return (
|
||||
<div className="field-type text">
|
||||
<label className="field-label">{label}</label>
|
||||
{label && <label className="field-label">{label}</label>}
|
||||
<div style={{ display: 'flex', gap: '.5rem' }}>
|
||||
<input
|
||||
autoComplete="off"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onChange={handleChange}
|
||||
style={{ flex: 1 }}
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
value={currentValue ?? ''}
|
||||
/>
|
||||
<button onClick={() => setRevealed((r) => !r)} type="button">
|
||||
{revealed ? 'Hide' : 'Reveal'}
|
||||
@@ -27,4 +39,5 @@ export const MaskedField: TextFieldClientComponent = ({ field, path }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MaskedField
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
/**
|
||||
* Cloudflare R2 storage credentials.
|
||||
* Reserved for future use — media offloading to R2.
|
||||
*
|
||||
* Protected at the global level (SiteIntegrations requires an authenticated
|
||||
* user), so the access keys stay editable in the admin panel while remaining
|
||||
* inaccessible to anonymous API requests.
|
||||
*/
|
||||
export const storageFields: Field[] = [
|
||||
{
|
||||
name: 'r2Bucket',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 bucket name.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'r2Endpoint',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 S3-compatible endpoint URL.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'r2AccessKeyId',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 access key ID.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'r2SecretAccessKey',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'R2 secret access key.',
|
||||
// Masked in the UI (••••) — stored plaintext, readable for R2 auth.
|
||||
components: {
|
||||
Field: '@intecion/ipal-kit/client#MaskedField',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -3,7 +3,6 @@ import type { Field, GlobalConfig } from 'payload'
|
||||
import { isAdmin } from '../../modules/access/index.js'
|
||||
import { analyticsFields } from './fields/analytics.js'
|
||||
import { smtpFields } from './fields/smtp.js'
|
||||
import { storageFields } from './fields/storage.js'
|
||||
import { turnstileFields } from './fields/turnstile.js'
|
||||
|
||||
type BuildSiteIntegrationsArgs = {
|
||||
@@ -22,6 +21,10 @@ type BuildSiteIntegrationsArgs = {
|
||||
* impossible to enter.)
|
||||
*
|
||||
* 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,
|
||||
@@ -44,7 +47,6 @@ export function buildSiteIntegrations({
|
||||
{ fields: analyticsFields, label: 'Analytics' },
|
||||
{ fields: turnstileFields, label: 'Turnstile' },
|
||||
{ fields: smtpFields, label: 'SMTP' },
|
||||
{ fields: storageFields, label: 'Storage' },
|
||||
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
+14
-2
@@ -75,6 +75,18 @@ export {
|
||||
} from './modules/i18n/index.js'
|
||||
export type { LocaleMiddlewareResult } 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 type {
|
||||
FormNotificationTexts,
|
||||
NotificationsData,
|
||||
NotificationTexts,
|
||||
} from './modules/notifications/index.js'
|
||||
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
|
||||
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
|
||||
export type { GlobalQueryOptions } from './modules/payload/index.js'
|
||||
@@ -99,8 +111,8 @@ export {
|
||||
injectAutoFillMeta,
|
||||
} from './modules/seo/index.js'
|
||||
export { buildSlugField, toSlug } from './modules/slug/index.js'
|
||||
// Storage — Cloudflare R2 media offload, configured from .env.
|
||||
export { buildR2Storage } from './modules/storage/index.js'
|
||||
|
||||
export { ipalKit } from './plugin.js'
|
||||
export type { IpalOptions } from './types.js'
|
||||
|
||||
|
||||
|
||||
@@ -115,10 +115,19 @@ export const graphAdapter =
|
||||
return { error: 'Graph is not configured (missing env vars).', sent: false }
|
||||
}
|
||||
|
||||
// From-display comes from the panel; falls back to the caller's from.
|
||||
// Display name on the From, WITHOUT triggering Send-As.
|
||||
//
|
||||
// The trick: we may set a `from` as long as its ADDRESS stays the sender
|
||||
// mailbox (GRAPH_SENDER) — only the display NAME changes. Exchange only
|
||||
// demands Send-As when the from ADDRESS differs from the mailbox, so a
|
||||
// same-address / custom-name From is allowed and gives each project its
|
||||
// own sender label (e.g. "Kancelaria Kędzierski") over the shared mailbox.
|
||||
//
|
||||
// The panel's from-address becomes Reply-To (so replies reach the client),
|
||||
// and the panel's from-name becomes the sender display name.
|
||||
const panel = await getSiteIntegrations<GraphIntegrations>(payload)
|
||||
const fromAddress = panel.smtpFromAddress || undefined
|
||||
const fromName = panel.smtpFromName || undefined
|
||||
const replyToAddress = panel.smtpFromAddress || undefined
|
||||
const senderName = panel.smtpFromName || undefined
|
||||
|
||||
const to = toRecipients(message.to)
|
||||
if (to.length === 0) {
|
||||
@@ -130,25 +139,26 @@ export const graphAdapter =
|
||||
const isHtml = typeof message.html === 'string' && message.html.length > 0
|
||||
const content = isHtml ? String(message.html) : String(message.text ?? '')
|
||||
|
||||
// Reply-To: prefer whatever the caller set; otherwise the panel address.
|
||||
const replyTo = message.replyTo
|
||||
? toRecipients(message.replyTo as SendEmailOptions['to'])
|
||||
: replyToAddress
|
||||
? [{ emailAddress: { address: replyToAddress } }]
|
||||
: []
|
||||
|
||||
const graphMessage: Record<string, unknown> = {
|
||||
body: { content, contentType: isHtml ? 'HTML' : 'Text' },
|
||||
subject: message.subject ?? '',
|
||||
toRecipients: to,
|
||||
...(message.cc ? { ccRecipients: toRecipients(message.cc) } : {}),
|
||||
...(message.bcc ? { bccRecipients: toRecipients(message.bcc) } : {}),
|
||||
// from is only honoured if the app has Send-As for that address; when
|
||||
// it's the shared mailbox itself, omit it and Graph uses the sender.
|
||||
...(fromAddress
|
||||
? {
|
||||
from: {
|
||||
emailAddress: { address: fromAddress, ...(fromName ? { name: fromName } : {}) },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// replyTo lets the recipient reply to the real submitter if the caller set it.
|
||||
...(message.replyTo
|
||||
? { replyTo: toRecipients(message.replyTo as SendEmailOptions['to']) }
|
||||
// From with the sender's OWN address (no Send-As) plus an optional
|
||||
// display name from the panel. Omit entirely when no name is set —
|
||||
// Graph then uses the mailbox's default name.
|
||||
...(senderName
|
||||
? { from: { emailAddress: { name: senderName, address: env.sender } } }
|
||||
: {}),
|
||||
...(replyTo.length > 0 ? { replyTo } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { I18nConfig } from './types.js'
|
||||
import { getLocaleCodes, isValidLocale } from './helpers.js'
|
||||
|
||||
/** Cookie name the template uses to persist a visitor's locale choice. */
|
||||
export const LOCALE_COOKIE_NAME = 'ipal-locale'
|
||||
/**
|
||||
* Cookie name for the persisted locale choice. Uses NEXT_LOCALE — the convention
|
||||
* Next.js and its i18n ecosystem expect — so the cookie is interoperable with
|
||||
* other libraries that read the active locale (instead of a plugin-specific
|
||||
* name). Written only under functional consent; cleared when that consent is
|
||||
* withdrawn (see consent cookieMap).
|
||||
*/
|
||||
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'
|
||||
|
||||
type NegotiateLocaleArgs = {
|
||||
/** Raw Accept-Language header value */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CollectionBeforeOperationHook } from 'payload'
|
||||
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: string): string {
|
||||
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: CollectionBeforeOperationHook = ({ req, operation }) => {
|
||||
if (operation !== 'create' && operation !== 'update') return
|
||||
const file = req.file
|
||||
if (file?.name) {
|
||||
file.name = normalizeFilename(file.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Plugin } from 'payload'
|
||||
|
||||
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: string[] = ['media']): Plugin => {
|
||||
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 }: { 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({
|
||||
bucket,
|
||||
collections: collectionsConfig,
|
||||
config: {
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint,
|
||||
region: 'auto', // R2 uses 'auto'
|
||||
// R2 requires path-style addressing for S3 compatibility.
|
||||
forcePathStyle: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildR2Storage } from './buildR2Storage.js'
|
||||
Reference in New Issue
Block a user