>r3LL-QA%6`he411+q$BwAmY^WM$;?J3TRw`;wGs+RBlK!2 zdDVgaW9G6A`gD7izIJzH2J3N#8@LN3_w$`kqlxkLNOHfC$z8LJ z6`txc02N#+ylgO}`yU|f;D)qHHKq2BW6|}*NLNg>34XJ#uaF=xfpv+Y&@JpTh{)+{ zz5~9bo9mPHcOp-}#JM2l1d3#BI3@;<;u&Z4n(DfC&yN#wG2i^E1neioipXYv=r@=j zTE^G@%y7@ ud+rKO~ z`19G$8}^U+Pm@6fK*86i45UX<{3yE(Q?g|u<^4|5JRqUW1wlq@D^KBuEZBup3k~eo z$gs|o^d>6EO!8=g=hcw$YsdTdI1TLH`}UWu`aI xm}hibF% z;~llo9|Y>2SMo>vuP}X$eZf42X@&|oT!m1GO*Gmt5F82zvxMW#)uZ -@gRt|`6^221Drj%s|UN1dae&L9F%Od4Wm8Qaw(UEsW z@fYG%UD|xSm70*x>-EdB0l*gwyOE@&8+<%#2(ZlbQ0tKx4v)|jVU;tbHvC&Dj+9p+ zEPAA`LoV4ru;+96N`~HQ$1tpz1Ak3FA*X5A=w*!N7 Igip8NhH*P`5PQ&m!lx0yU%V}`wuXDQfP;4&UX|hL?n)~h zAs@?5B(=sJkfT9qJSPH!97^QO5ybxx*Tps8mAak2FMm)Uzp3zDoGuC8&G?N|ajxvn z-MGh6zKSh1Q-^1P!91>Tc#d!!`|U)+__R>RM8LPp-ab53R?SPAmY7_hT#rN7J2(`g zy=mzEW7H>5&sWyI883lJCde^syvSZ%8XFX>tnAo;Rt=Q=YZ&|Ki37GOBZGB&Vz>+_ z8M1{)bHc; $c4(S8*>A*^ zOwMg^lwETT`YEF2VDIVQe!~U`Qx3|-N?eEwWT%)Mt8s++;uC1(lV4k52OEo#fssAY zjn6^Vv@nAx_(jT(*ZqA-rot{<#d5zL^4$POG2pc=nIHAXqp8vuaLHmWXh7^(Ai z8CgH7#3)Yt3_Uy)h>)e7KywJN%0^ZJ3P9kBT3S&{AFOMmR?&nCFBZXhvwY7oa-o6R z!Y*zfSpa~(BGK;@nnB8~B%rfdcnT=C-M@bIxvZTS-|-FovdQKC0*J_UU}h>jVrApo zJ9{yg!a1Ngnb$nufYQG(w^alBiL<7Iqr|)LDo=vLEP)}B&W-q+8< rsx^*-EMfm B-Z9aw< zAw^bC?H)^8dEQyc*X?@7M<&4MacAkzLlbExpW%1Wj~D~muaN9mGHt3hW-vIrd*eku z4)}YC{a;_*i-H0DXn3_cPrqjFE}Y`&=DP(-iN9cv-p+Y<%5)RTZXcdwXew2Jp7FfC z_;@cuztChJs-=Fcz?eZ|>9I}v+a;Xlz-IM0m}x_M5SUwJ2=!RNwS_NhF24i#9Km-Y zebS^}CV{bHZ&id2t19bN6$jg9kO@c32#EPrA2cC(o%FkHPKP0;g+Fu{RX4xTD%~Dx z$AP0G^^L&f^Z>LRdMM8~5w8mtOWRVre37bs+1BW26hP~@b2I(7)PYXl06lYuB_BLN zrYlz_+vrs`FWu!+TT`uKR7P1m7`uSu7CGy1L$2Sik49Md#|eFKWK!mDPJBL{6e5|U zqJaLViNJ?ZZHHA}_2g)Jt#QO8YQ@HQ5YAE)3zdC_o+S)XxKa`EH^DK+UzA|7XCnmt zB@<6WpTHCFl$r)n=mNj|{djC2`wzi!O!rEV<8@jx-He|j`Ck4c02SKSv7KY9VpQWt zVs9y|nZL=S)Al5L7aQ>^mJ#)A_KicufvM70x7LdE=2i+O!EbA_MWqjdeNi^y;L4lw zf5+9e4g2NR$ojaQ^4eOVyM5j)UlWE3L9lWxn;oAxZgRza_Fu1 j5J=U_8Fy}c3#ndj=QI>j=7h#lLG=4yy$ftySL5?2pnI-dRe$ZcMJ0k4H z7sx}@e2cGKwo2=|yxK#7LXgf)xB7>s}g>8X(zvpa{E} zx^v4qI&P?85xff*jW>X{(^=C^o#^hF4PT{77 r0UB66`)ZUKtG`>xq%G4C1H>rCanX7WChE>*9w0J0l<=)EF5WeZ=CPGQJAHNkFs8jrFRnsZZ zzI!s_##>EjzawX*V8rPHcnx#(H#~zaI|(J~co*(o%iN*s5GbrK(v_Df-SfHM4YX|m zRavz*)VUSEu(9nIerxBF#JoU2d3rXfjN2_HS7~;027570tdzaQwFo**=0(SD<#MST z??698Vh_T03()!0K9?LyL!%V_WZ7>Vn~acg<~|k5p%ALp)DS)dcap^R?PU6;!nsCP zUZL3BQi#r&%aSqItmZI|5i^#&yU$Jc?cZR~K8k7<@FSH A>Y|1P(OAb1c*cO|RlL-ulEpfB3GukT>MU-Yd zVot~sE~59Q$Xgh=x~r!VEo#r00w>8fGeFqWE2>uE!+`qlf@T_Cr-eZdDW}sxSr!Z- zNgr5&I=JnVkfJ18s7A=zcn0~jAy+lOikeJ07X}29;-|Ve9bMtCO33PXCeV{Hf?I#% zVtfZj t;x`8|NgFBA;0ia?MQXD2;`Wo# f$XDPtb1Eb!c z^%ZUpUTRL`sAkNd=}pHAyS~Tdx~oYosnBC%&LqAe!}jA8%nNfR6%KQ)2_|KnA7mKu z_Lv~ppHf~kdTD!o3QhA+u~;?kB$3_i{C5G)Iq7XOCEZ4OgRe;gGH~NG;idDw#QcGD z(RCwUO#$oAyUzy03y3F**4QcCWX>hGD<6&$O>kr5EzY_7Z>p7>rOS$dCW;;*&|l5# zbK^3I!EqH+1aUuh)Pg$U4w1s)9d!o-+WLxeq4sn^bctE7R?Zj1=GOD{)~|d7m=v?Y zxoz2IJO1uDsG!^q_%{1w(evfIG6FGwM`n`#jMYGfn}1i0?1p%b*G0&Yf3n4D0q2=O z9Ws$L9T-(EZY)Bo?4eDX2Z_%KA9La8c@O5=o;0|Y LP*kqwp|2Q7OE+XGi14Dh<_DWBhhe1Jp z>fMc*ih0a^Q{i4(H95av%$kA?{zw#B@~WWc1gl2I%*RYzwhpe=m>J0kS+T*-Cg8p( zBdZG>bfGL~ZJK&2N9{>+>#nYFl=m@R2<{fne?+ww^xIt1H#q{a`*MF3Mx< $Ig2 z>LPOSF_>^_;mx(@_?-+XV!$$2q|=3WLPaK!g3cdgrBNAzO}*Uqo$2(1z i40TE> zeU~xc{JvT|_mpHw!Xe48(qC+FhPe ;n^xJzyqZ(6h|Q+k?FFlw?Or$p0e|N zhdq*pc=Nv6!W{2wHYnR_h=3o~to=t%;$oYTmxWLdWTOq%%v(c|wG{~LZMH<-VU(&0 zR>lh5YhW=04sQeU-tO Icuu)nTiguLszeKcRaD|QZwyqiI-c6uL#JfQWhX&;qO5v3MT2=s?OlK zL^{`=mq$^2-zH&}JI2GUg!Zfglt9^QX;(169K%qm>ApZ(ZwOw6`?jO6pDFC_1*&yc z5U$dJIvXk{@yagvd}x4O*d~niT_b9e<8jw}?ohL8!c4n<67sLZMZC#quRmAEzFPSi zTeooMgBF3ffmG>hWgnEn1JmqgbzY!Bo_qhI)=!a6>}fg;(n3IW!tcEmu64XKb_>g& zaC<{2Ax%}Vd+%|++D}`np2_BGpPOHo=9CMdEFs_fcH6c~4!?s$UbJu5M!%p$iMh+j zkr^U#j8h#2=yN?A*1@K#dV<+UiDi#DX?v6lqQc3rpaD~!KH8Vg0pKvp3VWrNTpNhl zjJ^f^d)c3l4u9m-9{fmKRE*NGD2MeQfIWvKn3Q}nioBNaiTWd>akBFW3tTp}``GqE z@3az_R8diL@h1Ivca5U`;9f$g93Rk0VX#d u#DBR1XJ zlzb5|6Cvk@3u%$EN`TfSMRUf7)bE7R9z>6S3y_yz_FV1Koc5(q=RPYe9XER+jg}?% zVK^FI(1H?Uc=9*Vm>2o;fsDuj#X5q#0yMD+sJQnn{k-ka-d@fpTuG_PqIyyE2r?)S zgFuHpDz`1b#MmNR1PT;n{)IaMYVwH!~MXHO@s+LPsR^No~0vM<0h?op=1O^M2&3KIS z*|GfX6F?{DON6(BdY5YAok%MwsG&6 m>Uf+d z;z0Z4>STFl>(jQwR=b#@;0a%})pxF}(a;{XxmXp7XAhE}dmQ2&00>1vdBihB+}Sgs ze|#qe nno& z6FYZ(OSFtFrMAa7+7%}BhKoYzn$BRcRb-b&vHOe8UPdkB7Pi)NL#{g>r|SeU4`xUL z;qmDlq~1)8tE5PuKfH_l!HW5hzl@s{V7iT$%jRI#9niVrIwWnpVI0EKdlg%&wA+ma z6Xx6D0bqE;_3R5j^Bs(Jt06E(nVlWtI;?iWT{5*%EGo<|06a9WU(AYf+sXW)%KF-) zpYHiQEXFJuxdQxDH)fqmBhcM1Y`=Hj@t-dzjc=^6a32hg;i}q%nrT696w?`nOD?ce z)Nc% =TL_TIsC?*_fASn12883K6(g?{& zp45?(!HFlpN{Qc^5pmO>`5cI@d%oPO4#9(m;PV0tKHX{qav|{JEYKPfgZ)=cLL|U? zf1IKxEjUdeNjIu9X+x(O0x7s63A@A~CBRmCkGQqc1^_Ey@-zAC2{Vb=7_a9(g_N3$ zRV#9w^h!gTMMcrWZ&`RTaVht&W7yNyDoM~5tY;&srG5ouzh%KCBvacq`QV1RNJKih zfr{ha&1lI7LG^jLPeZ%Q!hw4+R@GC)h)LA4@P5pP(%B27ZClEiZwJx}Hv!L7a%M*j z&)hXC#_D;Ww~_CSaOK!I{Vepu=Xn|{z*v-yH3Qf3mPhDA*3+6cx#xnaCM3wIeQ&3c zB^E5(+yww=1K*E2d?#_Z`tJ|&QmVUwm$WiB$GMUR&6_V#hfjk^hecVQw%&^wE+TTm zkfM$uW20Pmb udRgKFN&)dkp_A@u{UP)G& &1K%6jA7M8x@UWiUk^}hiCPe|sBZe?BSOp{JF2&m0KhX{+Fz`!*2 zs{9~ @u0Tf$({+LbuRb*kecuHRKp&LZQ6x-b%WH`Gswzkp_>lf|YZM zhN7UipPZ$-vaO;4W`5?HwBaIH?U6q}Zn^ph@9yhZjp-Uvf1OuadR=-x(lF_!6#q!J z+$N~dQH2Kp0F({lOCU}^ Uvpdq<~TGu3$7MbpfZ ziN@N6A8Pqt%#d)o(X8?EtwR6)n5q?prSIxJq%y(D!&TRlgLJ|8*Aa7>p~=I|g-KkC zwmDi}Xr#BVogescui3{LH~2HrRhI&FD9C@0A**ND4MK`NXU*f;Tb`#lz0pM1=NF5- zz@kJ*3(#~bKDZ%M*WmTxk~tdHIk%SZsKTy$w9sMNNIS}9bu75@5<{J^9faCO$PfkK zvN8{+PC#*CA-Fa?*ystD>~H}9uxRI5;8+4WDyI!1<3JzL*>fdBdPj87ovrnh?CmGK zHp!JZRyk6(X&Ayp>{}ddvhZ4K6B7FrrkR6`^G5iIvV|k> PwRnMWw5k|L7OfsmE;xD+#d439<{PRv32|?|%Ah|C6H_8?AG(i9m2qHrDq~*aI z=Vdj87E+0Z7c+tSS`Jhc(GT2w=nco+CN<+i B>sRMUiLNRAfL^&Te$W-eBC}pJ@7*l0*%_Alw8d_e0)X+H*O)2~j z+ nU0szu*b$}B#Fm7(Iz zi;=?T&NKQ8bJGr*Tlm|O$-w@jd}q1mEh|H>V+1e!D^8^|F$SV*Qk-4qTl bNg`xjt$Woy4d;dO%ycw%uWLttt!e zBr`Fa(1RGY=sEF|r~$`5&R{EN$kqsuP16gKHC+XHT9!w4J7nXk5e$rdxsBZ%Xh6q} z2mk;N*HegPcW5@EQ^12W*U<%^jY{yZbL-6mmHp~6k_o15Vgv2S ia`5nNhDZ zCeirBN4!`EE+k;DGKO9JdOv@>#W4uR-Yt`kSwutRdAFAV-lXRYTE>0)eqNy-t|y%J zwLvrf@rKG12_Xq=I1tO0wS)gOknH{FGkUHB9hZ)fFD4n5B46~=)X{>%N1Fn82dUdD zwhfQTeHVZ?t-=hO{&kD!;8$@IR-p%e(o?Xw{OdJttDm>4jl$4mr}pD{T1gG|M+|}G z*9_*G(&vvFcd_vhT$&Pi2bMW~@RY!d5U)#sTE|H$V3l31ZFVIV@28r8)PSm5^F27d zyIe|Nw%qfNE0dH}1X?itOMR%RbTUog9nZo2)C_-?wiWw{rWa*egqTw-uVVXqqxH#G zma^tOfVH$qK`e1Jg-wukVNO8PX9+$5f)r+TfUc!_s7TUw93tMvU%!F)_lpEFoAOX1 zpHL_40%bmZhgW#0@9E0xqHh!?AxDMp_-n(d>8Kd;uLci^WCHP(J@A~dN0j=L=F`%| z1mDH1PjW@WX=B&6d86(Qi5Z-c_XV3N+)~UIfLnf~SSme`BX`|3ga(_n9{-y?g(%zX zd&A9a1fs5jLDAzxk~f+w1tQMr+J$Ml!(D7J$twwZ)uFlx?mK;@#a!_dvjD*=4Reh@ zxotGlyFgSI6y!!%qW=s5c2p7}9>0|lu>{f1L^tW6l^VNrl@*uMyHy|HS?o~4?c`aB zA~;^ztV8F!bvzNJGcKOK2 }53cv+$bKiGz4m8$6^MZUI&se_G13!0 3}JlutRT}HlOXcXw> zGiM>x3YFL4Uo)Wd&p)S1z{rS(>0oQ}t { + return [ + { + // internet explorer + destination: '/ie-incompatible.html', + has: [ + { + type: 'header', + key: 'user-agent', + value: '(.*Trident.*)', // all ie browsers + }, + ], + permanent: false, + source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page + }, + ] +} + +export default redirects diff --git a/examples/localization/src/access/anyone.ts b/examples/localization/src/access/anyone.ts new file mode 100644 index 0000000000..bf37c3a115 --- /dev/null +++ b/examples/localization/src/access/anyone.ts @@ -0,0 +1,3 @@ +import type { Access } from 'payload' + +export const anyone: Access = () => true diff --git a/examples/localization/src/access/authenticated.ts b/examples/localization/src/access/authenticated.ts new file mode 100644 index 0000000000..e2dc34d2a8 --- /dev/null +++ b/examples/localization/src/access/authenticated.ts @@ -0,0 +1,9 @@ +import type { AccessArgs } from 'payload' + +import type { User } from '@/payload-types' + +type isAuthenticated = (args: AccessArgs ) => boolean + +export const authenticated: isAuthenticated = ({ req: { user } }) => { + return Boolean(user) +} diff --git a/examples/localization/src/access/authenticatedOrPublished.ts b/examples/localization/src/access/authenticatedOrPublished.ts new file mode 100644 index 0000000000..e49198fbaa --- /dev/null +++ b/examples/localization/src/access/authenticatedOrPublished.ts @@ -0,0 +1,13 @@ +import type { Access } from 'payload' + +export const authenticatedOrPublished: Access = ({ req: { user } }) => { + if (user) { + return true + } + + return { + _status: { + equals: 'published', + }, + } +} diff --git a/examples/localization/src/app/(frontend)/[locale]/[slug]/page.client.tsx b/examples/localization/src/app/(frontend)/[locale]/[slug]/page.client.tsx new file mode 100644 index 0000000000..2d52669280 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/[slug]/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('light') + }, [setHeaderTheme]) + return +} + +export default PageClient diff --git a/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx new file mode 100644 index 0000000000..41e027a921 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/[slug]/page.tsx @@ -0,0 +1,108 @@ +import type { Metadata } from 'next' + +import { PayloadRedirects } from '@/components/PayloadRedirects' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import { draftMode } from 'next/headers' +import React, { cache } from 'react' +import { homeStatic } from '@/endpoints/seed/home-static' + +import type { Page as PageType } from '@/payload-types' + +import { RenderBlocks } from '@/blocks/RenderBlocks' +import { RenderHero } from '@/heros/RenderHero' +import { generateMeta } from '@/utilities/generateMeta' +import PageClient from './page.client' +import { TypedLocale } from 'payload' + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const pages = await payload.find({ + collection: 'pages', + draft: false, + limit: 1000, + overrideAccess: false, + }) + + const params = pages.docs + ?.filter((doc) => { + return doc.slug !== 'home' + }) + .map(({ slug }) => { + return { slug } + }) + + return params +} + +type Args = { + params: Promise<{ + slug?: string + locale: TypedLocale + }> +} + +export default async function Page({ params: paramsPromise }: Args) { + const { slug = 'home', locale = 'en' } = await paramsPromise + const url = '/' + slug + + let page: PageType | null + + page = await queryPage({ + slug, + locale, + }) + + // Remove this code once your website is seeded + if (!page && slug === 'home') { + page = homeStatic + } + + if (!page) { + return + } + + const { hero, layout } = page + + return ( + + + ) +} + +export async function generateMetadata({ params: paramsPromise }): Promise+ {/* Allows redirects for valid pages too */} + + + + + { + const { slug = 'home', locale = 'en' } = await paramsPromise + const page = await queryPage({ + slug, + locale, + }) + + return generateMeta({ doc: page }) +} + +const queryPage = cache(async ({ slug, locale }: { slug: string; locale: TypedLocale }) => { + const { isEnabled: draft } = await draftMode() + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'pages', + draft, + limit: 1, + locale, + overrideAccess: draft, + where: { + slug: { + equals: slug, + }, + }, + }) + + return result.docs?.[0] || null +}) diff --git a/examples/localization/src/app/(frontend)/[locale]/globals.css b/examples/localization/src/app/(frontend)/[locale]/globals.css new file mode 100644 index 0000000000..13be9b73ce --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/globals.css @@ -0,0 +1,103 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: auto; + font-weight: auto; + } + + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 240 5% 96%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 240 6% 90%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.2rem; + + --success: 196 52% 74%; + --warning: 34 89% 85%; + --error: 10 100% 86%; + } + + [data-theme='dark'] { + --background: 0 0% 0%; + --foreground: 210 40% 98%; + + --card: 240 6% 10%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 240 4% 16%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + --success: 196 100% 14%; + --warning: 34 51% 25%; + --error: 10 39% 43%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +html { + opacity: 0; +} + +html[data-theme='dark'], +html[data-theme='light'] { + opacity: initial; +} diff --git a/examples/localization/src/app/(frontend)/[locale]/layout.tsx b/examples/localization/src/app/(frontend)/[locale]/layout.tsx new file mode 100644 index 0000000000..acff83dfd5 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/layout.tsx @@ -0,0 +1,84 @@ +import type { Metadata } from 'next' + +import { cn } from 'src/utilities/cn' +import { GeistMono } from 'geist/font/mono' +import { GeistSans } from 'geist/font/sans' +import React from 'react' + +import { AdminBar } from '@/components/AdminBar' +import { Footer } from '@/globals/Footer/Component' +import { Header } from '@/globals/Header/Component' +import { LivePreviewListener } from '@/components/LivePreviewListener' +import { Providers } from '@/providers' +import { InitTheme } from '@/providers/Theme/InitTheme' +import { mergeOpenGraph } from '@/utilities/mergeOpenGraph' +import { draftMode } from 'next/headers' +import { TypedLocale } from 'payload' + +import './globals.css' +import { getMessages, setRequestLocale } from 'next-intl/server' +import { NextIntlClientProvider } from 'next-intl' +import { routing } from '@/i18n/routing' +import { notFound } from 'next/navigation' + +type Args = { + children: React.ReactNode + params: Promise<{ + locale: TypedLocale + }> +} + +export default async function RootLayout({ children, params }: Args) { + const { locale } = await params + + if (!routing.locales.includes(locale as any)) { + notFound() + } + setRequestLocale(locale) + + const { isEnabled } = await draftMode() + const messages = await getMessages() + + return ( + + + + + + + + + + + + ) +} + +export const metadata: Metadata = { + metadataBase: new URL(process.env.NEXT_PUBLIC_SERVER_URL || 'https://payloadcms.com'), + openGraph: mergeOpenGraph(), + twitter: { + card: 'summary_large_image', + creator: '@payloadcms', + }, +} + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })) +} diff --git a/examples/localization/src/app/(frontend)/[locale]/not-found.tsx b/examples/localization/src/app/(frontend)/[locale]/not-found.tsx new file mode 100644 index 0000000000..039240437c --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/not-found.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link' +import React from 'react' + +import { Button } from '@/components/ui/button' +import { useTranslations } from 'next-intl' + +export default function NotFound() { + const t = useTranslations() + + return ( ++ ++ + + + {children} + + ++ ) +} diff --git a/examples/localization/src/app/(frontend)/[locale]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/page.tsx new file mode 100644 index 0000000000..e49260fc43 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/page.tsx @@ -0,0 +1,86 @@ +import { Metadata } from 'next' +// import PageTemplate from './[slug]/page' + +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import { draftMode } from 'next/headers' +import React, { cache } from 'react' +import { generateMeta } from '@/utilities/generateMeta' +import { TypedLocale } from 'payload' +import { PayloadRedirects } from '@/components/PayloadRedirects' +import { homeStatic } from '@/endpoints/seed/home-static' +import type { Page as PageType } from '@/payload-types' +import { RenderBlocks } from '@/blocks/RenderBlocks' +import { RenderHero } from '@/heros/RenderHero' +import PageClient from './[slug]/page.client' + +type Args = { + params: Promise<{ + slug?: string + locale: TypedLocale + }> +} + +export default async function Page({ params: paramsPromise }: Args) { + const { slug = 'home', locale = 'en' } = await paramsPromise + const url = '/' + slug + + let page: PageType | null + + page = await queryPage({ + slug, + locale, + }) + + // Remove this code once your website is seeded + if (!page && slug === 'home') { + page = homeStatic + } + + if (!page) { + return++ +404
+{t('page-not-found')}
++ } + + const { hero, layout } = page + + return ( + + + ) +} + +export async function generateMetadata({ params }: Args): Promise+ + + + + { + const { locale = 'en', slug = 'home' } = await params + const page = await queryPage({ + locale, + slug, + }) + + return generateMeta({ doc: page }) +} + +const queryPage = cache(async ({ locale, slug }: { locale: TypedLocale; slug: string }) => { + const { isEnabled: draft } = await draftMode() + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'pages', + draft, + limit: 1, + overrideAccess: draft, + locale: locale, + where: { + slug: { + equals: slug, + }, + }, + }) + + return result.docs?.[0] || null +}) diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.client.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.client.tsx new file mode 100644 index 0000000000..0025b057eb --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('dark') + }, [setHeaderTheme]) + return +} + +export default PageClient diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx new file mode 100644 index 0000000000..b2e4659b51 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/posts/[slug]/page.tsx @@ -0,0 +1,103 @@ +import type { Metadata } from 'next' + +import { RelatedPosts } from '@/blocks/RelatedPosts/Component' +import { PayloadRedirects } from '@/components/PayloadRedirects' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import { draftMode } from 'next/headers' +import React, { cache } from 'react' +import RichText from '@/components/RichText' + +import type { Post } from '@/payload-types' + +import { PostHero } from '@/heros/PostHero' +import { generateMeta } from '@/utilities/generateMeta' +import PageClient from './page.client' +import { TypedLocale } from 'payload' + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const posts = await payload.find({ + collection: 'posts', + draft: false, + limit: 1000, + overrideAccess: false, + }) + + const params = posts.docs.map(({ slug }) => { + return { slug } + }) + + return params +} + +type Args = { + params: Promise<{ + slug?: string + locale?: TypedLocale + }> +} + +export default async function Post({ params: paramsPromise }: Args) { + const { slug = '', locale = 'en' } = await paramsPromise + const url = '/posts/' + slug + const post = await queryPost({ slug, locale }) + + if (!post) return + + return ( + + + ) +} + +export async function generateMetadata({ params: paramsPromise }: Args): Promise+ + {/* Allows redirects for valid pages too */} + + + + + ++++ + {post.relatedPosts && post.relatedPosts.length > 0 && ( ++ typeof post === 'object')} + /> + )} + { + const { slug = '', locale = 'en' } = await paramsPromise + const post = await queryPost({ slug, locale }) + + return generateMeta({ doc: post }) +} + +const queryPost = cache(async ({ slug, locale }: { slug: string; locale: TypedLocale }) => { + const { isEnabled: draft } = await draftMode() + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'posts', + draft, + limit: 1, + overrideAccess: draft, + locale, + where: { + slug: { + equals: slug, + }, + }, + }) + + return result.docs?.[0] || null +}) diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/page.client.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/page.client.tsx new file mode 100644 index 0000000000..2d52669280 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/posts/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('light') + }, [setHeaderTheme]) + return +} + +export default PageClient diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/page.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/page.tsx new file mode 100644 index 0000000000..3040a16d75 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/posts/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from 'next/types' + +import { CollectionArchive } from '@/components/CollectionArchive' +import { PageRange } from '@/components/PageRange' +import { Pagination } from '@/components/Pagination' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import React from 'react' +import PageClient from './page.client' +import { TypedLocale } from 'payload' +import { getTranslations, setRequestLocale } from 'next-intl/server' + +export const revalidate = 600 + +type Args = { + params: Promise<{ + locale: TypedLocale + }> +} + +export default async function Page({ params }: Args) { + const { locale = 'en' } = await params + const t = await getTranslations() + const payload = await getPayload({ config: configPromise }) + + const posts = await payload.find({ + collection: 'posts', + locale, + depth: 1, + limit: 12, + overrideAccess: false, + }) + + return ( + ++ ) +} + +export function generateMetadata(): Metadata { + return { + title: `Payload Website Template Posts`, + } +} diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.client.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.client.tsx new file mode 100644 index 0000000000..2d52669280 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('light') + }, [setHeaderTheme]) + return+ ++ +++{t('posts')}
+++ ++ + + + {posts.totalPages > 1 && posts.page && ( +++ )} + +} + +export default PageClient diff --git a/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx b/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx new file mode 100644 index 0000000000..2cd8a68c54 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/posts/page/[pageNumber]/page.tsx @@ -0,0 +1,94 @@ +import type { Metadata } from 'next/types' + +import { CollectionArchive } from '@/components/CollectionArchive' +import { PageRange } from '@/components/PageRange' +import { Pagination } from '@/components/Pagination' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import React from 'react' +import PageClient from './page.client' +import { notFound } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { TypedLocale } from 'payload' + +export const revalidate = 600 + +type Args = { + params: Promise<{ + pageNumber: string + locale: TypedLocale + }> +} + +export default async function Page({ params: paramsPromise }: Args) { + const { pageNumber, locale } = await paramsPromise + const payload = await getPayload({ config: configPromise }) + const t = await getTranslations() + + const sanitizedPageNumber = Number(pageNumber) + + if (!Number.isInteger(sanitizedPageNumber)) notFound() + + const posts = await payload.find({ + collection: 'posts', + depth: 1, + limit: 12, + locale, + page: sanitizedPageNumber, + overrideAccess: false, + }) + + return ( + ++ ) +} + +export async function generateMetadata({ params: paramsPromise }: Args): Promise+ ++ +++{t('posts')}
+++ ++ + + + {posts.totalPages > 1 && posts.page && ( +++ )} + { + const { pageNumber } = await paramsPromise + return { + title: `Payload Website Template Posts Page ${pageNumber || ''}`, + } +} + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const posts = await payload.find({ + collection: 'posts', + depth: 0, + limit: 10, + draft: false, + overrideAccess: false, + }) + + const pages: { pageNumber: string }[] = [] + + for (let i = 1; i <= posts.totalPages; i++) { + pages.push({ pageNumber: String(i) }) + } + + return pages +} diff --git a/examples/localization/src/app/(frontend)/[locale]/search/page.client.tsx b/examples/localization/src/app/(frontend)/[locale]/search/page.client.tsx new file mode 100644 index 0000000000..2d52669280 --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/search/page.client.tsx @@ -0,0 +1,15 @@ +'use client' +import { useHeaderTheme } from '@/providers/HeaderTheme' +import React, { useEffect } from 'react' + +const PageClient: React.FC = () => { + /* Force the header to be dark mode while we have an image behind it */ + const { setHeaderTheme } = useHeaderTheme() + + useEffect(() => { + setHeaderTheme('light') + }, [setHeaderTheme]) + return +} + +export default PageClient diff --git a/examples/localization/src/app/(frontend)/[locale]/search/page.tsx b/examples/localization/src/app/(frontend)/[locale]/search/page.tsx new file mode 100644 index 0000000000..c7150c5aaf --- /dev/null +++ b/examples/localization/src/app/(frontend)/[locale]/search/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from 'next/types' + +import { CollectionArchive } from '@/components/CollectionArchive' +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import React from 'react' +import { Post } from '@/payload-types' +import { Search } from '@/search/Component' +import PageClient from './page.client' +import { getTranslations } from 'next-intl/server' +import { TypedLocale } from 'payload' + +type Args = { + searchParams: Promise<{ + q: string + }> + params: Promise<{ + locale: TypedLocale + }> +} +export default async function Page({ + searchParams: searchParamsPromise, + params: paramsPromise, +}: Args) { + const { q: query } = await searchParamsPromise + const { locale } = await paramsPromise + const payload = await getPayload({ config: configPromise }) + const t = await getTranslations() + + const posts = await payload.find({ + collection: 'search', + depth: 1, + limit: 12, + locale, + ...(query + ? { + where: { + or: [ + { + title: { + like: query, + }, + }, + { + 'meta.description': { + like: query, + }, + }, + { + 'meta.title': { + like: query, + }, + }, + { + slug: { + like: query, + }, + }, + ], + }, + } + : {}), + }) + + return ( + ++ ) +} + +export function generateMetadata(): Metadata { + return { + title: `Payload Website Template Search`, + } +} diff --git a/examples/localization/src/app/(frontend)/next/exit-preview/GET.ts b/examples/localization/src/app/(frontend)/next/exit-preview/GET.ts new file mode 100644 index 0000000000..a8e3e69b57 --- /dev/null +++ b/examples/localization/src/app/(frontend)/next/exit-preview/GET.ts @@ -0,0 +1,7 @@ +import { draftMode } from 'next/headers' + +export async function GET(): Promise+ ++ + {posts.totalDocs > 0 ? ( +++{t('search')}
++ + ) : ( + No results found.+ )} +{ + const draft = await draftMode() + draft.disable() + return new Response('Draft mode is disabled') +} diff --git a/examples/localization/src/app/(frontend)/next/exit-preview/route.ts b/examples/localization/src/app/(frontend)/next/exit-preview/route.ts new file mode 100644 index 0000000000..a8e3e69b57 --- /dev/null +++ b/examples/localization/src/app/(frontend)/next/exit-preview/route.ts @@ -0,0 +1,7 @@ +import { draftMode } from 'next/headers' + +export async function GET(): Promise { + const draft = await draftMode() + draft.disable() + return new Response('Draft mode is disabled') +} diff --git a/examples/localization/src/app/(frontend)/next/preview/route.ts b/examples/localization/src/app/(frontend)/next/preview/route.ts new file mode 100644 index 0000000000..b1eca1e0d0 --- /dev/null +++ b/examples/localization/src/app/(frontend)/next/preview/route.ts @@ -0,0 +1,91 @@ +import jwt from 'jsonwebtoken' +import { draftMode } from 'next/headers' +import { redirect } from 'next/navigation' +import { getPayload } from 'payload' +import configPromise from '@payload-config' +import { CollectionSlug, TypedLocale } from 'payload' + +const payloadToken = 'payload-token' + +export async function GET( + req: Request & { + cookies: { + get: (name: string) => { + value: string + } + } + }, +): Promise { + const payload = await getPayload({ config: configPromise }) + const token = req.cookies.get(payloadToken)?.value + const { searchParams } = new URL(req.url) + const path = searchParams.get('path') + const collection = searchParams.get('collection') as CollectionSlug + const slug = searchParams.get('slug') + + const previewSecret = searchParams.get('previewSecret') + + if (previewSecret) { + return new Response('You are not allowed to preview this page', { status: 403 }) + } else { + if (!path) { + return new Response('No path provided', { status: 404 }) + } + + if (!collection) { + return new Response('No path provided', { status: 404 }) + } + + if (!slug) { + return new Response('No path provided', { status: 404 }) + } + + if (!token) { + new Response('You are not allowed to preview this page', { status: 403 }) + } + + if (!path.startsWith('/')) { + new Response('This endpoint can only be used for internal previews', { status: 500 }) + } + + let user + + try { + user = jwt.verify(token, payload.secret) + } catch (error) { + payload.logger.error('Error verifying token for live preview:', error) + } + + const draft = await draftMode() + + // You can add additional checks here to see if the user is allowed to preview this page + if (!user) { + draft.disable() + return new Response('You are not allowed to preview this page', { status: 403 }) + } + + // Verify the given slug exists + try { + const docs = await payload.find({ + collection: collection, + draft: true, + locale: path.split('/')[0] as TypedLocale, + where: { + slug: { + equals: slug, + }, + }, + }) + + if (!docs.docs.length) { + return new Response('Document not found', { status: 404 }) + } + } catch (error) { + payload.logger.error('Error verifying token for live preview:', error) + } + + draft.enable() + + redirect(path) + } +} diff --git a/examples/localization/src/app/(payload)/admin/[[...segments]]/not-found.tsx b/examples/localization/src/app/(payload)/admin/[[...segments]]/not-found.tsx new file mode 100644 index 0000000000..64108365fd --- /dev/null +++ b/examples/localization/src/app/(payload)/admin/[[...segments]]/not-found.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' +import { importMap } from '../importMap' + +type Args = { + params: Promise<{ + segments: string[] + }> + searchParams: Promise<{ + [key: string]: string | string[] + }> +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const NotFound = ({ params, searchParams }: Args) => + NotFoundPage({ config, params, searchParams, importMap }) + +export default NotFound diff --git a/examples/localization/src/app/(payload)/admin/[[...segments]]/page.tsx b/examples/localization/src/app/(payload)/admin/[[...segments]]/page.tsx new file mode 100644 index 0000000000..0de685cd62 --- /dev/null +++ b/examples/localization/src/app/(payload)/admin/[[...segments]]/page.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +import { RootPage, generatePageMetadata } from '@payloadcms/next/views' +import { importMap } from '../importMap' + +type Args = { + params: Promise<{ + segments: string[] + }> + searchParams: Promise<{ + [key: string]: string | string[] + }> +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const Page = ({ params, searchParams }: Args) => + RootPage({ config, params, searchParams, importMap }) + +export default Page diff --git a/examples/localization/src/app/(payload)/admin/importMap.js b/examples/localization/src/app/(payload)/admin/importMap.js new file mode 100644 index 0000000000..1ea1c97e01 --- /dev/null +++ b/examples/localization/src/app/(payload)/admin/importMap.js @@ -0,0 +1,61 @@ +import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client' +import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent' +import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' +import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' +import { default as default_035a063f0e4325a280e3cc815d2ec5d7 } from '@/components/AfterDashboard' +import { default as default_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' + +export const importMap = { + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell': + RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/rsc#RscEntryLexicalField': + RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + '@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient': + InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient': + FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#HeadingFeatureClient': + HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#UnderlineFeatureClient': + UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BoldFeatureClient': + BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#ItalicFeatureClient': + ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#LinkFeatureClient': + LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-seo/client#OverviewComponent': + OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaTitleComponent': + MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaImageComponent': + MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#MetaDescriptionComponent': + MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@payloadcms/plugin-seo/client#PreviewComponent': + PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860, + '@/fields/slug/SlugComponent#SlugComponent': SlugComponent_92cc057d0a2abb4f6cf0307edf59f986, + '@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient': + HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/richtext-lexical/client#BlocksFeatureClient': + BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + '@payloadcms/plugin-search/client#LinkToDoc': LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, + '@payloadcms/plugin-search/client#ReindexButton': ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, + '@/components/AfterDashboard#default': default_035a063f0e4325a280e3cc815d2ec5d7, + '@/components/BeforeLogin#default': default_8a7ab0eb7ab5c511aba12e68480bfe5e, +} diff --git a/examples/localization/src/app/(payload)/api/[...slug]/route.ts b/examples/localization/src/app/(payload)/api/[...slug]/route.ts new file mode 100644 index 0000000000..183cf457f6 --- /dev/null +++ b/examples/localization/src/app/(payload)/api/[...slug]/route.ts @@ -0,0 +1,10 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' + +export const GET = REST_GET(config) +export const POST = REST_POST(config) +export const DELETE = REST_DELETE(config) +export const PATCH = REST_PATCH(config) +export const OPTIONS = REST_OPTIONS(config) diff --git a/examples/localization/src/app/(payload)/api/graphql-playground/route.ts b/examples/localization/src/app/(payload)/api/graphql-playground/route.ts new file mode 100644 index 0000000000..3a5eb92625 --- /dev/null +++ b/examples/localization/src/app/(payload)/api/graphql-playground/route.ts @@ -0,0 +1,6 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' + +export const GET = GRAPHQL_PLAYGROUND_GET(config) diff --git a/examples/localization/src/app/(payload)/api/graphql/route.ts b/examples/localization/src/app/(payload)/api/graphql/route.ts new file mode 100644 index 0000000000..9c2ffc39be --- /dev/null +++ b/examples/localization/src/app/(payload)/api/graphql/route.ts @@ -0,0 +1,6 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import { GRAPHQL_POST } from '@payloadcms/next/routes' + +export const POST = GRAPHQL_POST(config) diff --git a/examples/localization/src/app/(payload)/custom.scss b/examples/localization/src/app/(payload)/custom.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/localization/src/app/(payload)/layout.tsx b/examples/localization/src/app/(payload)/layout.tsx new file mode 100644 index 0000000000..ad687820b6 --- /dev/null +++ b/examples/localization/src/app/(payload)/layout.tsx @@ -0,0 +1,32 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import configPromise from '@payload-config' +import '@payloadcms/next/css' +import React from 'react' +import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' +import type { ServerFunctionClient } from 'payload' +import config from '@payload-config' + +import './custom.scss' +import { importMap } from './admin/importMap' + +type Args = { + children: React.ReactNode +} + +const serverFunction: ServerFunctionClient = async function (args) { + 'use server' + return handleServerFunctions({ + ...args, + config, + importMap, + }) +} + +const Layout = ({ children }: Args) => ( + + {children} + +) + +export default Layout diff --git a/examples/localization/src/blocks/ArchiveBlock/Component.tsx b/examples/localization/src/blocks/ArchiveBlock/Component.tsx new file mode 100644 index 0000000000..ebbd24abbf --- /dev/null +++ b/examples/localization/src/blocks/ArchiveBlock/Component.tsx @@ -0,0 +1,76 @@ +import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types' + +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import React from 'react' +import RichText from '@/components/RichText' + +import { CollectionArchive } from '@/components/CollectionArchive' +import { TypedLocale } from 'payload' + +export const ArchiveBlock: React.FC< + ArchiveBlockProps & { + id?: string + locale: TypedLocale + } +> = async (props) => { + const { + id, + categories, + introContent, + limit: limitFromProps, + populateBy, + selectedDocs, + locale, + } = props + + const limit = limitFromProps || 3 + + let posts: Post[] = [] + + if (populateBy === 'collection') { + const payload = await getPayload({ config: configPromise }) + + const flattenedCategories = categories?.map((category) => { + if (typeof category === 'object') return category.id + else return category + }) + + const fetchedPosts = await payload.find({ + collection: 'posts', + depth: 1, + locale, + limit, + ...(flattenedCategories && flattenedCategories.length > 0 + ? { + where: { + categories: { + in: flattenedCategories, + }, + }, + } + : {}), + }) + + posts = fetchedPosts.docs + } else { + if (selectedDocs?.length) { + const filteredSelectedPosts = selectedDocs.map((post) => { + if (typeof post.value === 'object') return post.value + }) as Post[] + + posts = filteredSelectedPosts + } + } + + return ( ++ {introContent && ( ++ ) +} diff --git a/examples/localization/src/blocks/ArchiveBlock/config.ts b/examples/localization/src/blocks/ArchiveBlock/config.ts new file mode 100644 index 0000000000..379c3ecc82 --- /dev/null +++ b/examples/localization/src/blocks/ArchiveBlock/config.ts @@ -0,0 +1,95 @@ +import type { Block } from 'payload' + +import { + FixedToolbarFeature, + HeadingFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +export const Archive: Block = { + slug: 'archive', + interfaceName: 'ArchiveBlock', + fields: [ + { + name: 'introContent', + type: 'richText', + localized: true, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + FixedToolbarFeature(), + InlineToolbarFeature(), + ] + }, + }), + label: 'Intro Content', + }, + { + name: 'populateBy', + type: 'select', + defaultValue: 'collection', + options: [ + { + label: 'Collection', + value: 'collection', + }, + { + label: 'Individual Selection', + value: 'selection', + }, + ], + }, + { + name: 'relationTo', + type: 'select', + admin: { + condition: (_, siblingData) => siblingData.populateBy === 'collection', + }, + defaultValue: 'posts', + label: 'Collections To Show', + options: [ + { + label: 'Posts', + value: 'posts', + }, + ], + }, + { + name: 'categories', + type: 'relationship', + admin: { + condition: (_, siblingData) => siblingData.populateBy === 'collection', + }, + hasMany: true, + label: 'Categories To Show', + relationTo: 'categories', + }, + { + name: 'limit', + type: 'number', + admin: { + condition: (_, siblingData) => siblingData.populateBy === 'collection', + step: 1, + }, + defaultValue: 10, + label: 'Limit', + }, + { + name: 'selectedDocs', + type: 'relationship', + admin: { + condition: (_, siblingData) => siblingData.populateBy === 'selection', + }, + hasMany: true, + label: 'Selection', + relationTo: ['posts'], + }, + ], + labels: { + plural: 'Archives', + singular: 'Archive', + }, +} diff --git a/examples/localization/src/blocks/Banner/Component.tsx b/examples/localization/src/blocks/Banner/Component.tsx new file mode 100644 index 0000000000..143596cb28 --- /dev/null +++ b/examples/localization/src/blocks/Banner/Component.tsx @@ -0,0 +1,26 @@ +import type { BannerBlock as BannerBlockProps } from 'src/payload-types' + +import { cn } from 'src/utilities/cn' +import React from 'react' +import RichText from '@/components/RichText' + +type Props = { + className?: string +} & BannerBlockProps + +export const BannerBlock: React.FC++ )} ++ + = ({ className, content, style }) => { + return ( + ++ ) +} diff --git a/examples/localization/src/blocks/Banner/config.ts b/examples/localization/src/blocks/Banner/config.ts new file mode 100644 index 0000000000..236851a6e1 --- /dev/null +++ b/examples/localization/src/blocks/Banner/config.ts @@ -0,0 +1,38 @@ +import type { Block } from 'payload' + +import { + FixedToolbarFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +export const Banner: Block = { + slug: 'banner', + fields: [ + { + name: 'style', + type: 'select', + defaultValue: 'info', + options: [ + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + { label: 'Success', value: 'success' }, + ], + required: true, + }, + { + name: 'content', + type: 'richText', + localized: true, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()] + }, + }), + label: false, + required: true, + }, + ], + interfaceName: 'BannerBlock', +} diff --git a/examples/localization/src/blocks/CallToAction/Component.tsx b/examples/localization/src/blocks/CallToAction/Component.tsx new file mode 100644 index 0000000000..776ba84b1a --- /dev/null +++ b/examples/localization/src/blocks/CallToAction/Component.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +import type { Page } from '@/payload-types' + +import RichText from '@/components/RichText' +import { CMSLink } from '@/components/Link' + +type Props = Extract+++ + +export const CallToActionBlock: React.FC< + Props & { + id?: string + } +> = ({ links, richText }) => { + return ( + ++ ) +} diff --git a/examples/localization/src/blocks/CallToAction/config.ts b/examples/localization/src/blocks/CallToAction/config.ts new file mode 100644 index 0000000000..acbd2d8147 --- /dev/null +++ b/examples/localization/src/blocks/CallToAction/config.ts @@ -0,0 +1,43 @@ +import type { Block } from 'payload' + +import { + FixedToolbarFeature, + HeadingFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +import { linkGroup } from '../../fields/linkGroup' + +export const CallToAction: Block = { + slug: 'cta', + interfaceName: 'CallToActionBlock', + fields: [ + { + name: 'richText', + type: 'richText', + localized: true, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + FixedToolbarFeature(), + InlineToolbarFeature(), + ] + }, + }), + label: false, + }, + linkGroup({ + appearances: ['default', 'outline'], + overrides: { + maxRows: 2, + }, + }), + ], + labels: { + plural: 'Calls to Action', + singular: 'Call to Action', + }, +} diff --git a/examples/localization/src/blocks/Code/Component.client.tsx b/examples/localization/src/blocks/Code/Component.client.tsx new file mode 100644 index 0000000000..4e9854fb11 --- /dev/null +++ b/examples/localization/src/blocks/Code/Component.client.tsx @@ -0,0 +1,31 @@ +'use client' +import { Highlight, themes } from 'prism-react-renderer' +import React from 'react' + +type Props = { + code: string + language?: string +} + +export const Code: React.FC+++ {richText &&+} + + {(links || []).map(({ link }, i) => { + return++ })} + = ({ code, language = '' }) => { + if (!code) return null + + return ( + + {({ getLineProps, getTokenProps, tokens }) => ( + + ) +} diff --git a/examples/localization/src/blocks/Code/Component.tsx b/examples/localization/src/blocks/Code/Component.tsx new file mode 100644 index 0000000000..7f776d74de --- /dev/null +++ b/examples/localization/src/blocks/Code/Component.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import { Code } from './Component.client' + +export type CodeBlockProps = { + code: string + language?: string + blockType: 'code' +} + +type Props = CodeBlockProps & { + className?: string +} + +export const CodeBlock: React.FC+ {tokens.map((line, i) => ( ++ )} ++ {i + 1} + + {line.map((token, key) => ( + + ))} + ++ ))} += ({ className, code, language }) => { + return ( + ++ ) +} diff --git a/examples/localization/src/blocks/Code/config.ts b/examples/localization/src/blocks/Code/config.ts new file mode 100644 index 0000000000..7b26f805db --- /dev/null +++ b/examples/localization/src/blocks/Code/config.ts @@ -0,0 +1,33 @@ +import type { Block } from 'payload' + +export const Code: Block = { + slug: 'code', + interfaceName: 'CodeBlock', + fields: [ + { + name: 'language', + type: 'select', + defaultValue: 'typescript', + options: [ + { + label: 'Typescript', + value: 'typescript', + }, + { + label: 'Javascript', + value: 'javascript', + }, + { + label: 'CSS', + value: 'css', + }, + ], + }, + { + name: 'code', + type: 'code', + label: false, + required: true, + }, + ], +} diff --git a/examples/localization/src/blocks/Content/Component.tsx b/examples/localization/src/blocks/Content/Component.tsx new file mode 100644 index 0000000000..a09232bee5 --- /dev/null +++ b/examples/localization/src/blocks/Content/Component.tsx @@ -0,0 +1,49 @@ +import { cn } from 'src/utilities/cn' +import React from 'react' +import RichText from '@/components/RichText' + +import type { Page } from '@/payload-types' + +import { CMSLink } from '../../components/Link' + +type Props = Extract++ +export const ContentBlock: React.FC< + { + id?: string + } & Props +> = (props) => { + const { columns } = props + + const colsSpanClasses = { + full: '12', + half: '6', + oneThird: '4', + twoThirds: '8', + } + + return ( + ++ ) +} diff --git a/examples/localization/src/blocks/Content/config.ts b/examples/localization/src/blocks/Content/config.ts new file mode 100644 index 0000000000..3f6280d22f --- /dev/null +++ b/examples/localization/src/blocks/Content/config.ts @@ -0,0 +1,75 @@ +import type { Block, Field } from 'payload' + +import { + FixedToolbarFeature, + HeadingFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +import { link } from '@/fields/link' + +const columnFields: Field[] = [ + { + name: 'size', + type: 'select', + defaultValue: 'oneThird', + options: [ + { + label: 'One Third', + value: 'oneThird', + }, + { + label: 'Half', + value: 'half', + }, + { + label: 'Two Thirds', + value: 'twoThirds', + }, + { + label: 'Full', + value: 'full', + }, + ], + }, + { + name: 'richText', + type: 'richText', + localized: true, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }), + FixedToolbarFeature(), + InlineToolbarFeature(), + ] + }, + }), + label: false, + }, + { + name: 'enableLink', + type: 'checkbox', + }, + link({ + overrides: { + admin: { + condition: (_, { enableLink }) => Boolean(enableLink), + }, + }, + }), +] + +export const Content: Block = { + slug: 'content', + interfaceName: 'ContentBlock', + fields: [ + { + name: 'columns', + type: 'array', + fields: columnFields, + }, + ], +} diff --git a/examples/localization/src/blocks/Form/Checkbox/index.tsx b/examples/localization/src/blocks/Form/Checkbox/index.tsx new file mode 100644 index 0000000000..1eff6940a4 --- /dev/null +++ b/examples/localization/src/blocks/Form/Checkbox/index.tsx @@ -0,0 +1,44 @@ +import type { CheckboxField } from '@payloadcms/plugin-form-builder/types' +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form' + +import { useFormContext } from 'react-hook-form' + +import { Checkbox as CheckboxUi } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import React from 'react' + +import { Error } from '../Error' +import { Width } from '../Width' + +export const Checkbox: React.FC< + CheckboxField & { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + getValues: any + register: UseFormRegister+ {columns && + columns.length > 0 && + columns.map((col, index) => { + const { enableLink, link, richText, size } = col + + return ( +++ {richText &&+ ) + })} +} + + {enableLink && } + + setValue: any + } +> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => { + const props = register(name, { required: requiredFromProps }) + const { setValue } = useFormContext() + + return ( + + + ) +} diff --git a/examples/localization/src/blocks/Form/Component.tsx b/examples/localization/src/blocks/Form/Component.tsx new file mode 100644 index 0000000000..fcd495bd15 --- /dev/null +++ b/examples/localization/src/blocks/Form/Component.tsx @@ -0,0 +1,173 @@ +'use client' +import type { Form as FormType } from '@payloadcms/plugin-form-builder/types' + +import { useRouter } from 'next/navigation' +import React, { useCallback, useState } from 'react' +import { useForm, FormProvider } from 'react-hook-form' +import RichText from '@/components/RichText' +import { Button } from '@/components/ui/button' + +import { buildInitialFormState } from './buildInitialFormState' +import { fields } from './fields' +import { useTranslations } from 'next-intl' + +export type Value = unknown + +export interface Property { + [key: string]: Value +} + +export interface Data { + [key: string]: Property | Property[] +} + +export type FormBlockType = { + blockName?: string + blockType?: 'formBlock' + enableIntro: boolean + form: FormType + introContent?: { + [k: string]: unknown + }[] +} + +export const FormBlock: React.FC< + { + id?: string + } & FormBlockType +> = (props) => { + const { + enableIntro, + form: formFromProps, + form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {}, + introContent, + } = props + + const formMethods = useForm({ + defaultValues: buildInitialFormState(formFromProps.fields), + }) + const { + control, + formState: { errors }, + handleSubmit, + register, + } = formMethods + + const [isLoading, setIsLoading] = useState(false) + const [hasSubmitted, setHasSubmitted] = useState++ {requiredFromProps && errors[name] &&{ + setValue(props.name, checked) + }} + /> + + } + () + const [error, setError] = useState<{ message: string; status?: string } | undefined>() + const router = useRouter() + const t = useTranslations() + + const onSubmit = useCallback( + (data: Data) => { + let loadingTimerID: ReturnType + const submitForm = async () => { + setError(undefined) + + const dataToSend = Object.entries(data).map(([name, value]) => ({ + field: name, + value, + })) + + // delay loading indicator by 1s + loadingTimerID = setTimeout(() => { + setIsLoading(true) + }, 1000) + + try { + const req = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/form-submissions`, { + body: JSON.stringify({ + form: formID, + submissionData: dataToSend, + }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + + const res = await req.json() + + clearTimeout(loadingTimerID) + + if (req.status >= 400) { + setIsLoading(false) + + setError({ + message: res.errors?.[0]?.message || 'Internal Server Error', + status: res.status, + }) + + return + } + + setIsLoading(false) + setHasSubmitted(true) + + if (confirmationType === 'redirect' && redirect) { + const { url } = redirect + + const redirectUrl = url + + if (redirectUrl) router.push(redirectUrl) + } + } catch (err) { + console.warn(err) + setIsLoading(false) + setError({ + message: 'Something went wrong.', + }) + } + } + + void submitForm() + }, + [router, formID, redirect, confirmationType], + ) + + return ( + ++ ) +} diff --git a/examples/localization/src/blocks/Form/Country/index.tsx b/examples/localization/src/blocks/Form/Country/index.tsx new file mode 100644 index 0000000000..e6cfa0844b --- /dev/null +++ b/examples/localization/src/blocks/Form/Country/index.tsx @@ -0,0 +1,63 @@ +import type { CountryField } from '@payloadcms/plugin-form-builder/types' +import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form' + +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import React from 'react' +import { Controller } from 'react-hook-form' + +import { Error } from '../Error' +import { Width } from '../Width' +import { countryOptions } from './options' + +export const Country: React.FC< + CountryField & { + control: Control+ {enableIntro && introContent && !hasSubmitted && ( + ++ )} + {!isLoading && hasSubmitted && confirmationType === 'message' && ( + + )} + {isLoading && !hasSubmitted && {t('loading')}
} + {error &&{`${error.status || '500'}: ${error.message || ''}`}} + {!hasSubmitted && ( + + )} ++ errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + } +> = ({ name, control, errors, label, required, width }) => { + return ( + + + + ) +} diff --git a/examples/localization/src/blocks/Form/Country/options.ts b/examples/localization/src/blocks/Form/Country/options.ts new file mode 100644 index 0000000000..f952c1df89 --- /dev/null +++ b/examples/localization/src/blocks/Form/Country/options.ts @@ -0,0 +1,982 @@ +export const countryOptions = [ + { + label: 'Afghanistan', + value: 'AF', + }, + { + label: 'Åland Islands', + value: 'AX', + }, + { + label: 'Albania', + value: 'AL', + }, + { + label: 'Algeria', + value: 'DZ', + }, + { + label: 'American Samoa', + value: 'AS', + }, + { + label: 'Andorra', + value: 'AD', + }, + { + label: 'Angola', + value: 'AO', + }, + { + label: 'Anguilla', + value: 'AI', + }, + { + label: 'Antarctica', + value: 'AQ', + }, + { + label: 'Antigua and Barbuda', + value: 'AG', + }, + { + label: 'Argentina', + value: 'AR', + }, + { + label: 'Armenia', + value: 'AM', + }, + { + label: 'Aruba', + value: 'AW', + }, + { + label: 'Australia', + value: 'AU', + }, + { + label: 'Austria', + value: 'AT', + }, + { + label: 'Azerbaijan', + value: 'AZ', + }, + { + label: 'Bahamas', + value: 'BS', + }, + { + label: 'Bahrain', + value: 'BH', + }, + { + label: 'Bangladesh', + value: 'BD', + }, + { + label: 'Barbados', + value: 'BB', + }, + { + label: 'Belarus', + value: 'BY', + }, + { + label: 'Belgium', + value: 'BE', + }, + { + label: 'Belize', + value: 'BZ', + }, + { + label: 'Benin', + value: 'BJ', + }, + { + label: 'Bermuda', + value: 'BM', + }, + { + label: 'Bhutan', + value: 'BT', + }, + { + label: 'Bolivia', + value: 'BO', + }, + { + label: 'Bosnia and Herzegovina', + value: 'BA', + }, + { + label: 'Botswana', + value: 'BW', + }, + { + label: 'Bouvet Island', + value: 'BV', + }, + { + label: 'Brazil', + value: 'BR', + }, + { + label: 'British Indian Ocean Territory', + value: 'IO', + }, + { + label: 'Brunei Darussalam', + value: 'BN', + }, + { + label: 'Bulgaria', + value: 'BG', + }, + { + label: 'Burkina Faso', + value: 'BF', + }, + { + label: 'Burundi', + value: 'BI', + }, + { + label: 'Cambodia', + value: 'KH', + }, + { + label: 'Cameroon', + value: 'CM', + }, + { + label: 'Canada', + value: 'CA', + }, + { + label: 'Cape Verde', + value: 'CV', + }, + { + label: 'Cayman Islands', + value: 'KY', + }, + { + label: 'Central African Republic', + value: 'CF', + }, + { + label: 'Chad', + value: 'TD', + }, + { + label: 'Chile', + value: 'CL', + }, + { + label: 'China', + value: 'CN', + }, + { + label: 'Christmas Island', + value: 'CX', + }, + { + label: 'Cocos (Keeling) Islands', + value: 'CC', + }, + { + label: 'Colombia', + value: 'CO', + }, + { + label: 'Comoros', + value: 'KM', + }, + { + label: 'Congo', + value: 'CG', + }, + { + label: 'Congo, The Democratic Republic of the', + value: 'CD', + }, + { + label: 'Cook Islands', + value: 'CK', + }, + { + label: 'Costa Rica', + value: 'CR', + }, + { + label: "Cote D'Ivoire", + value: 'CI', + }, + { + label: 'Croatia', + value: 'HR', + }, + { + label: 'Cuba', + value: 'CU', + }, + { + label: 'Cyprus', + value: 'CY', + }, + { + label: 'Czech Republic', + value: 'CZ', + }, + { + label: 'Denmark', + value: 'DK', + }, + { + label: 'Djibouti', + value: 'DJ', + }, + { + label: 'Dominica', + value: 'DM', + }, + { + label: 'Dominican Republic', + value: 'DO', + }, + { + label: 'Ecuador', + value: 'EC', + }, + { + label: 'Egypt', + value: 'EG', + }, + { + label: 'El Salvador', + value: 'SV', + }, + { + label: 'Equatorial Guinea', + value: 'GQ', + }, + { + label: 'Eritrea', + value: 'ER', + }, + { + label: 'Estonia', + value: 'EE', + }, + { + label: 'Ethiopia', + value: 'ET', + }, + { + label: 'Falkland Islands (Malvinas)', + value: 'FK', + }, + { + label: 'Faroe Islands', + value: 'FO', + }, + { + label: 'Fiji', + value: 'FJ', + }, + { + label: 'Finland', + value: 'FI', + }, + { + label: 'France', + value: 'FR', + }, + { + label: 'French Guiana', + value: 'GF', + }, + { + label: 'French Polynesia', + value: 'PF', + }, + { + label: 'French Southern Territories', + value: 'TF', + }, + { + label: 'Gabon', + value: 'GA', + }, + { + label: 'Gambia', + value: 'GM', + }, + { + label: 'Georgia', + value: 'GE', + }, + { + label: 'Germany', + value: 'DE', + }, + { + label: 'Ghana', + value: 'GH', + }, + { + label: 'Gibraltar', + value: 'GI', + }, + { + label: 'Greece', + value: 'GR', + }, + { + label: 'Greenland', + value: 'GL', + }, + { + label: 'Grenada', + value: 'GD', + }, + { + label: 'Guadeloupe', + value: 'GP', + }, + { + label: 'Guam', + value: 'GU', + }, + { + label: 'Guatemala', + value: 'GT', + }, + { + label: 'Guernsey', + value: 'GG', + }, + { + label: 'Guinea', + value: 'GN', + }, + { + label: 'Guinea-Bissau', + value: 'GW', + }, + { + label: 'Guyana', + value: 'GY', + }, + { + label: 'Haiti', + value: 'HT', + }, + { + label: 'Heard Island and Mcdonald Islands', + value: 'HM', + }, + { + label: 'Holy See (Vatican City State)', + value: 'VA', + }, + { + label: 'Honduras', + value: 'HN', + }, + { + label: 'Hong Kong', + value: 'HK', + }, + { + label: 'Hungary', + value: 'HU', + }, + { + label: 'Iceland', + value: 'IS', + }, + { + label: 'India', + value: 'IN', + }, + { + label: 'Indonesia', + value: 'ID', + }, + { + label: 'Iran, Islamic Republic Of', + value: 'IR', + }, + { + label: 'Iraq', + value: 'IQ', + }, + { + label: 'Ireland', + value: 'IE', + }, + { + label: 'Isle of Man', + value: 'IM', + }, + { + label: 'Israel', + value: 'IL', + }, + { + label: 'Italy', + value: 'IT', + }, + { + label: 'Jamaica', + value: 'JM', + }, + { + label: 'Japan', + value: 'JP', + }, + { + label: 'Jersey', + value: 'JE', + }, + { + label: 'Jordan', + value: 'JO', + }, + { + label: 'Kazakhstan', + value: 'KZ', + }, + { + label: 'Kenya', + value: 'KE', + }, + { + label: 'Kiribati', + value: 'KI', + }, + { + label: "Democratic People's Republic of Korea", + value: 'KP', + }, + { + label: 'Korea, Republic of', + value: 'KR', + }, + { + label: 'Kosovo', + value: 'XK', + }, + { + label: 'Kuwait', + value: 'KW', + }, + { + label: 'Kyrgyzstan', + value: 'KG', + }, + { + label: "Lao People's Democratic Republic", + value: 'LA', + }, + { + label: 'Latvia', + value: 'LV', + }, + { + label: 'Lebanon', + value: 'LB', + }, + { + label: 'Lesotho', + value: 'LS', + }, + { + label: 'Liberia', + value: 'LR', + }, + { + label: 'Libyan Arab Jamahiriya', + value: 'LY', + }, + { + label: 'Liechtenstein', + value: 'LI', + }, + { + label: 'Lithuania', + value: 'LT', + }, + { + label: 'Luxembourg', + value: 'LU', + }, + { + label: 'Macao', + value: 'MO', + }, + { + label: 'Macedonia, The Former Yugoslav Republic of', + value: 'MK', + }, + { + label: 'Madagascar', + value: 'MG', + }, + { + label: 'Malawi', + value: 'MW', + }, + { + label: 'Malaysia', + value: 'MY', + }, + { + label: 'Maldives', + value: 'MV', + }, + { + label: 'Mali', + value: 'ML', + }, + { + label: 'Malta', + value: 'MT', + }, + { + label: 'Marshall Islands', + value: 'MH', + }, + { + label: 'Martinique', + value: 'MQ', + }, + { + label: 'Mauritania', + value: 'MR', + }, + { + label: 'Mauritius', + value: 'MU', + }, + { + label: 'Mayotte', + value: 'YT', + }, + { + label: 'Mexico', + value: 'MX', + }, + { + label: 'Micronesia, Federated States of', + value: 'FM', + }, + { + label: 'Moldova, Republic of', + value: 'MD', + }, + { + label: 'Monaco', + value: 'MC', + }, + { + label: 'Mongolia', + value: 'MN', + }, + { + label: 'Montenegro', + value: 'ME', + }, + { + label: 'Montserrat', + value: 'MS', + }, + { + label: 'Morocco', + value: 'MA', + }, + { + label: 'Mozambique', + value: 'MZ', + }, + { + label: 'Myanmar', + value: 'MM', + }, + { + label: 'Namibia', + value: 'NA', + }, + { + label: 'Nauru', + value: 'NR', + }, + { + label: 'Nepal', + value: 'NP', + }, + { + label: 'Netherlands', + value: 'NL', + }, + { + label: 'Netherlands Antilles', + value: 'AN', + }, + { + label: 'New Caledonia', + value: 'NC', + }, + { + label: 'New Zealand', + value: 'NZ', + }, + { + label: 'Nicaragua', + value: 'NI', + }, + { + label: 'Niger', + value: 'NE', + }, + { + label: 'Nigeria', + value: 'NG', + }, + { + label: 'Niue', + value: 'NU', + }, + { + label: 'Norfolk Island', + value: 'NF', + }, + { + label: 'Northern Mariana Islands', + value: 'MP', + }, + { + label: 'Norway', + value: 'NO', + }, + { + label: 'Oman', + value: 'OM', + }, + { + label: 'Pakistan', + value: 'PK', + }, + { + label: 'Palau', + value: 'PW', + }, + { + label: 'Palestinian Territory, Occupied', + value: 'PS', + }, + { + label: 'Panama', + value: 'PA', + }, + { + label: 'Papua New Guinea', + value: 'PG', + }, + { + label: 'Paraguay', + value: 'PY', + }, + { + label: 'Peru', + value: 'PE', + }, + { + label: 'Philippines', + value: 'PH', + }, + { + label: 'Pitcairn', + value: 'PN', + }, + { + label: 'Poland', + value: 'PL', + }, + { + label: 'Portugal', + value: 'PT', + }, + { + label: 'Puerto Rico', + value: 'PR', + }, + { + label: 'Qatar', + value: 'QA', + }, + { + label: 'Reunion', + value: 'RE', + }, + { + label: 'Romania', + value: 'RO', + }, + { + label: 'Russian Federation', + value: 'RU', + }, + { + label: 'Rwanda', + value: 'RW', + }, + { + label: 'Saint Helena', + value: 'SH', + }, + { + label: 'Saint Kitts and Nevis', + value: 'KN', + }, + { + label: 'Saint Lucia', + value: 'LC', + }, + { + label: 'Saint Pierre and Miquelon', + value: 'PM', + }, + { + label: 'Saint Vincent and the Grenadines', + value: 'VC', + }, + { + label: 'Samoa', + value: 'WS', + }, + { + label: 'San Marino', + value: 'SM', + }, + { + label: 'Sao Tome and Principe', + value: 'ST', + }, + { + label: 'Saudi Arabia', + value: 'SA', + }, + { + label: 'Senegal', + value: 'SN', + }, + { + label: 'Serbia', + value: 'RS', + }, + { + label: 'Seychelles', + value: 'SC', + }, + { + label: 'Sierra Leone', + value: 'SL', + }, + { + label: 'Singapore', + value: 'SG', + }, + { + label: 'Slovakia', + value: 'SK', + }, + { + label: 'Slovenia', + value: 'SI', + }, + { + label: 'Solomon Islands', + value: 'SB', + }, + { + label: 'Somalia', + value: 'SO', + }, + { + label: 'South Africa', + value: 'ZA', + }, + { + label: 'South Georgia and the South Sandwich Islands', + value: 'GS', + }, + { + label: 'Spain', + value: 'ES', + }, + { + label: 'Sri Lanka', + value: 'LK', + }, + { + label: 'Sudan', + value: 'SD', + }, + { + label: 'Suriname', + value: 'SR', + }, + { + label: 'Svalbard and Jan Mayen', + value: 'SJ', + }, + { + label: 'Swaziland', + value: 'SZ', + }, + { + label: 'Sweden', + value: 'SE', + }, + { + label: 'Switzerland', + value: 'CH', + }, + { + label: 'Syrian Arab Republic', + value: 'SY', + }, + { + label: 'Taiwan', + value: 'TW', + }, + { + label: 'Tajikistan', + value: 'TJ', + }, + { + label: 'Tanzania, United Republic of', + value: 'TZ', + }, + { + label: 'Thailand', + value: 'TH', + }, + { + label: 'Timor-Leste', + value: 'TL', + }, + { + label: 'Togo', + value: 'TG', + }, + { + label: 'Tokelau', + value: 'TK', + }, + { + label: 'Tonga', + value: 'TO', + }, + { + label: 'Trinidad and Tobago', + value: 'TT', + }, + { + label: 'Tunisia', + value: 'TN', + }, + { + label: 'Turkey', + value: 'TR', + }, + { + label: 'Turkmenistan', + value: 'TM', + }, + { + label: 'Turks and Caicos Islands', + value: 'TC', + }, + { + label: 'Tuvalu', + value: 'TV', + }, + { + label: 'Uganda', + value: 'UG', + }, + { + label: 'Ukraine', + value: 'UA', + }, + { + label: 'United Arab Emirates', + value: 'AE', + }, + { + label: 'United Kingdom', + value: 'GB', + }, + { + label: 'United States', + value: 'US', + }, + { + label: 'United States Minor Outlying Islands', + value: 'UM', + }, + { + label: 'Uruguay', + value: 'UY', + }, + { + label: 'Uzbekistan', + value: 'UZ', + }, + { + label: 'Vanuatu', + value: 'VU', + }, + { + label: 'Venezuela', + value: 'VE', + }, + { + label: 'Viet Nam', + value: 'VN', + }, + { + label: 'Virgin Islands, British', + value: 'VG', + }, + { + label: 'Virgin Islands, U.S.', + value: 'VI', + }, + { + label: 'Wallis and Futuna', + value: 'WF', + }, + { + label: 'Western Sahara', + value: 'EH', + }, + { + label: 'Yemen', + value: 'YE', + }, + { + label: 'Zambia', + value: 'ZM', + }, + { + label: 'Zimbabwe', + value: 'ZW', + }, +] diff --git a/examples/localization/src/blocks/Form/Email/index.tsx b/examples/localization/src/blocks/Form/Email/index.tsx new file mode 100644 index 0000000000..c015b93ecb --- /dev/null +++ b/examples/localization/src/blocks/Form/Email/index.tsx @@ -0,0 +1,34 @@ +import type { EmailField } from '@payloadcms/plugin-form-builder/types' +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form' + +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import React from 'react' + +import { Error } from '../Error' +import { Width } from '../Width' + +export const Email: React.FC< + EmailField & { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + register: UseFormRegister{ + const controlledValue = countryOptions.find((t) => t.value === value) + + return ( + + ) + }} + rules={{ required }} + /> + {required && errors[name] && } + + } +> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => { + return ( + + + + + {requiredFromProps && errors[name] && + ) +} diff --git a/examples/localization/src/blocks/Form/Error/index.tsx b/examples/localization/src/blocks/Form/Error/index.tsx new file mode 100644 index 0000000000..94aa851343 --- /dev/null +++ b/examples/localization/src/blocks/Form/Error/index.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export const Error: React.FC = () => { + return} + This field is required+} diff --git a/examples/localization/src/blocks/Form/Message/index.tsx b/examples/localization/src/blocks/Form/Message/index.tsx new file mode 100644 index 0000000000..f1d7851867 --- /dev/null +++ b/examples/localization/src/blocks/Form/Message/index.tsx @@ -0,0 +1,12 @@ +import RichText from '@/components/RichText' +import React from 'react' + +import { Width } from '../Width' + +export const Message: React.FC = ({ message }: { message: Record}) => { + return ( + + {message && + ) +} diff --git a/examples/localization/src/blocks/Form/Number/index.tsx b/examples/localization/src/blocks/Form/Number/index.tsx new file mode 100644 index 0000000000..5dedc8a93a --- /dev/null +++ b/examples/localization/src/blocks/Form/Number/index.tsx @@ -0,0 +1,32 @@ +import type { TextField } from '@payloadcms/plugin-form-builder/types' +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form' + +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import React from 'react' + +import { Error } from '../Error' +import { Width } from '../Width' +export const Number: React.FC< + TextField & { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + register: UseFormRegister} + + } +> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => { + return ( + + + + {requiredFromProps && errors[name] && + ) +} diff --git a/examples/localization/src/blocks/Form/Select/index.tsx b/examples/localization/src/blocks/Form/Select/index.tsx new file mode 100644 index 0000000000..c0d1f77b8f --- /dev/null +++ b/examples/localization/src/blocks/Form/Select/index.tsx @@ -0,0 +1,60 @@ +import type { SelectField } from '@payloadcms/plugin-form-builder/types' +import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form' + +import { Label } from '@/components/ui/label' +import { + Select as SelectComponent, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import React from 'react' +import { Controller } from 'react-hook-form' + +import { Error } from '../Error' +import { Width } from '../Width' + +export const Select: React.FC< + SelectField & { + control: Control} + + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + } +> = ({ name, control, errors, label, options, required, width }) => { + return ( + + + + ) +} diff --git a/examples/localization/src/blocks/Form/State/index.tsx b/examples/localization/src/blocks/Form/State/index.tsx new file mode 100644 index 0000000000..db8aa8a704 --- /dev/null +++ b/examples/localization/src/blocks/Form/State/index.tsx @@ -0,0 +1,61 @@ +import type { StateField } from '@payloadcms/plugin-form-builder/types' +import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form' + +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import React from 'react' +import { Controller } from 'react-hook-form' + +import { Error } from '../Error' +import { Width } from '../Width' +import { stateOptions } from './options' + +export const State: React.FC< + StateField & { + control: Control{ + const controlledValue = options.find((t) => t.value === value) + + return ( + onChange(val)} value={controlledValue?.value}> + + ) + }} + rules={{ required }} + /> + {required && errors[name] &&+ ++ + {options.map(({ label, value }) => { + return ( + ++ {label} + + ) + })} +} + + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + } +> = ({ name, control, errors, label, required, width }) => { + return ( + + + + ) +} diff --git a/examples/localization/src/blocks/Form/State/options.ts b/examples/localization/src/blocks/Form/State/options.ts new file mode 100644 index 0000000000..8dff991e7a --- /dev/null +++ b/examples/localization/src/blocks/Form/State/options.ts @@ -0,0 +1,52 @@ +export const stateOptions = [ + { label: 'Alabama', value: 'AL' }, + { label: 'Alaska', value: 'AK' }, + { label: 'Arizona', value: 'AZ' }, + { label: 'Arkansas', value: 'AR' }, + { label: 'California', value: 'CA' }, + { label: 'Colorado', value: 'CO' }, + { label: 'Connecticut', value: 'CT' }, + { label: 'Delaware', value: 'DE' }, + { label: 'Florida', value: 'FL' }, + { label: 'Georgia', value: 'GA' }, + { label: 'Hawaii', value: 'HI' }, + { label: 'Idaho', value: 'ID' }, + { label: 'Illinois', value: 'IL' }, + { label: 'Indiana', value: 'IN' }, + { label: 'Iowa', value: 'IA' }, + { label: 'Kansas', value: 'KS' }, + { label: 'Kentucky', value: 'KY' }, + { label: 'Louisiana', value: 'LA' }, + { label: 'Maine', value: 'ME' }, + { label: 'Maryland', value: 'MD' }, + { label: 'Massachusetts', value: 'MA' }, + { label: 'Michigan', value: 'MI' }, + { label: 'Minnesota', value: 'MN' }, + { label: 'Mississippi', value: 'MS' }, + { label: 'Missouri', value: 'MO' }, + { label: 'Montana', value: 'MT' }, + { label: 'Nebraska', value: 'NE' }, + { label: 'Nevada', value: 'NV' }, + { label: 'New Hampshire', value: 'NH' }, + { label: 'New Jersey', value: 'NJ' }, + { label: 'New Mexico', value: 'NM' }, + { label: 'New York', value: 'NY' }, + { label: 'North Carolina', value: 'NC' }, + { label: 'North Dakota', value: 'ND' }, + { label: 'Ohio', value: 'OH' }, + { label: 'Oklahoma', value: 'OK' }, + { label: 'Oregon', value: 'OR' }, + { label: 'Pennsylvania', value: 'PA' }, + { label: 'Rhode Island', value: 'RI' }, + { label: 'South Carolina', value: 'SC' }, + { label: 'South Dakota', value: 'SD' }, + { label: 'Tennessee', value: 'TN' }, + { label: 'Texas', value: 'TX' }, + { label: 'Utah', value: 'UT' }, + { label: 'Vermont', value: 'VT' }, + { label: 'Virginia', value: 'VA' }, + { label: 'Washington', value: 'WA' }, + { label: 'West Virginia', value: 'WV' }, + { label: 'Wisconsin', value: 'WI' }, + { label: 'Wyoming', value: 'WY' }, +] diff --git a/examples/localization/src/blocks/Form/Text/index.tsx b/examples/localization/src/blocks/Form/Text/index.tsx new file mode 100644 index 0000000000..33778bd76d --- /dev/null +++ b/examples/localization/src/blocks/Form/Text/index.tsx @@ -0,0 +1,33 @@ +import type { TextField } from '@payloadcms/plugin-form-builder/types' +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form' + +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import React from 'react' + +import { Error } from '../Error' +import { Width } from '../Width' + +export const Text: React.FC< + TextField & { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + register: UseFormRegister{ + const controlledValue = stateOptions.find((t) => t.value === value) + + return ( + + ) + }} + rules={{ required }} + /> + {required && errors[name] && } + + } +> = ({ name, defaultValue, errors, label, register, required: requiredFromProps, width }) => { + return ( + + + + {requiredFromProps && errors[name] && + ) +} diff --git a/examples/localization/src/blocks/Form/Textarea/index.tsx b/examples/localization/src/blocks/Form/Textarea/index.tsx new file mode 100644 index 0000000000..ef52d88e77 --- /dev/null +++ b/examples/localization/src/blocks/Form/Textarea/index.tsx @@ -0,0 +1,45 @@ +import type { TextField } from '@payloadcms/plugin-form-builder/types' +import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form' + +import { Label } from '@/components/ui/label' +import { Textarea as TextAreaComponent } from '@/components/ui/textarea' +import React from 'react' + +import { Error } from '../Error' +import { Width } from '../Width' + +export const Textarea: React.FC< + TextField & { + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any + }> + > + register: UseFormRegister} + + rows?: number + } +> = ({ + name, + defaultValue, + errors, + label, + register, + required: requiredFromProps, + rows = 3, + width, +}) => { + return ( + + + + + ) +} diff --git a/examples/localization/src/blocks/Form/Width/index.tsx b/examples/localization/src/blocks/Form/Width/index.tsx new file mode 100644 index 0000000000..bcc51a3333 --- /dev/null +++ b/examples/localization/src/blocks/Form/Width/index.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' + +export const Width: React.FC<{ + children: React.ReactNode + className?: string + width?: number | string +}> = ({ children, className, width }) => { + return ( ++ + {requiredFromProps && errors[name] && } + + {children} ++ ) +} diff --git a/examples/localization/src/blocks/Form/buildInitialFormState.tsx b/examples/localization/src/blocks/Form/buildInitialFormState.tsx new file mode 100644 index 0000000000..fe5d3c0c08 --- /dev/null +++ b/examples/localization/src/blocks/Form/buildInitialFormState.tsx @@ -0,0 +1,42 @@ +import type { FormFieldBlock } from '@payloadcms/plugin-form-builder/types' + +export const buildInitialFormState = (fields: FormFieldBlock[]) => { + return fields?.reduce((initialSchema, field) => { + if (field.blockType === 'checkbox') { + return { + ...initialSchema, + [field.name]: field.defaultValue, + } + } + if (field.blockType === 'country') { + return { + ...initialSchema, + [field.name]: '', + } + } + if (field.blockType === 'email') { + return { + ...initialSchema, + [field.name]: '', + } + } + if (field.blockType === 'text') { + return { + ...initialSchema, + [field.name]: '', + } + } + if (field.blockType === 'select') { + return { + ...initialSchema, + [field.name]: '', + } + } + if (field.blockType === 'state') { + return { + ...initialSchema, + [field.name]: '', + } + } + }, {}) +} diff --git a/examples/localization/src/blocks/Form/config.ts b/examples/localization/src/blocks/Form/config.ts new file mode 100644 index 0000000000..d904da028d --- /dev/null +++ b/examples/localization/src/blocks/Form/config.ts @@ -0,0 +1,52 @@ +import type { Block } from 'payload' + +import { + FixedToolbarFeature, + HeadingFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +export const FormBlock: Block = { + slug: 'formBlock', + interfaceName: 'FormBlock', + fields: [ + { + name: 'form', + type: 'relationship', + relationTo: 'forms', + required: true, + }, + { + name: 'enableIntro', + type: 'checkbox', + label: 'Enable Intro Content', + }, + { + name: 'introContent', + type: 'richText', + localized: true, + admin: { + condition: (_, { enableIntro }) => Boolean(enableIntro), + }, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + FixedToolbarFeature(), + InlineToolbarFeature(), + ] + }, + }), + label: 'Intro Content', + }, + ], + graphQL: { + singularName: 'FormBlock', + }, + labels: { + plural: 'Form Blocks', + singular: 'Form Block', + }, +} diff --git a/examples/localization/src/blocks/Form/fields.tsx b/examples/localization/src/blocks/Form/fields.tsx new file mode 100644 index 0000000000..fa660f7e39 --- /dev/null +++ b/examples/localization/src/blocks/Form/fields.tsx @@ -0,0 +1,21 @@ +import { Checkbox } from './Checkbox' +import { Country } from './Country' +import { Email } from './Email' +import { Message } from './Message' +import { Number } from './Number' +import { Select } from './Select' +import { State } from './State' +import { Text } from './Text' +import { Textarea } from './Textarea' + +export const fields = { + checkbox: Checkbox, + country: Country, + email: Email, + message: Message, + number: Number, + select: Select, + state: State, + text: Text, + textarea: Textarea, +} diff --git a/examples/localization/src/blocks/MediaBlock/Component.tsx b/examples/localization/src/blocks/MediaBlock/Component.tsx new file mode 100644 index 0000000000..f459c0c0ce --- /dev/null +++ b/examples/localization/src/blocks/MediaBlock/Component.tsx @@ -0,0 +1,70 @@ +import type { StaticImageData } from 'next/image' + +import { cn } from 'src/utilities/cn' +import React from 'react' +import RichText from '@/components/RichText' + +import type { Page } from '@/payload-types' + +import { Media } from '../../components/Media' + +type Props = Extract& { + breakout?: boolean + captionClassName?: string + className?: string + enableGutter?: boolean + id?: string + imgClassName?: string + staticImage?: StaticImageData + disableInnerContainer?: boolean +} + +export const MediaBlock: React.FC = (props) => { + const { + captionClassName, + className, + enableGutter = true, + imgClassName, + media, + position = 'default', + staticImage, + disableInnerContainer, + } = props + + let caption + if (media && typeof media === 'object') caption = media.caption + + return ( + + {position === 'fullscreen' && ( ++ ) +} diff --git a/examples/localization/src/blocks/MediaBlock/config.ts b/examples/localization/src/blocks/MediaBlock/config.ts new file mode 100644 index 0000000000..ba84efe4ab --- /dev/null +++ b/examples/localization/src/blocks/MediaBlock/config.ts @@ -0,0 +1,29 @@ +import type { Block } from 'payload' + +export const MediaBlock: Block = { + slug: 'mediaBlock', + interfaceName: 'MediaBlock', + fields: [ + { + name: 'position', + type: 'select', + defaultValue: 'default', + options: [ + { + label: 'Default', + value: 'default', + }, + { + label: 'Fullscreen', + value: 'fullscreen', + }, + ], + }, + { + name: 'media', + type: 'upload', + relationTo: 'media', + required: true, + }, + ], +} diff --git a/examples/localization/src/blocks/RelatedPosts/Component.tsx b/examples/localization/src/blocks/RelatedPosts/Component.tsx new file mode 100644 index 0000000000..3bebc75173 --- /dev/null +++ b/examples/localization/src/blocks/RelatedPosts/Component.tsx @@ -0,0 +1,31 @@ +import clsx from 'clsx' +import React from 'react' +import RichText from '@/components/RichText' + +import type { Post } from '@/payload-types' + +import { Card } from '../../components/Card' + +export type RelatedPostsProps = { + className?: string + docs?: Post[] + introContent?: any +} + +export const RelatedPosts: React.FC++ )} + {position === 'default' && ( ++ + )} + {caption && ( + ++ )} ++ = (props) => { + const { className, docs, introContent } = props + + return ( + + {introContent &&+ ) +} diff --git a/examples/localization/src/blocks/RenderBlocks.tsx b/examples/localization/src/blocks/RenderBlocks.tsx new file mode 100644 index 0000000000..89ee59ab02 --- /dev/null +++ b/examples/localization/src/blocks/RenderBlocks.tsx @@ -0,0 +1,54 @@ +import { cn } from 'src/utilities/cn' +import React, { Fragment } from 'react' + +import type { Page } from '@/payload-types' + +import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component' +import { CallToActionBlock } from '@/blocks/CallToAction/Component' +import { ContentBlock } from '@/blocks/Content/Component' +import { FormBlock } from '@/blocks/Form/Component' +import { MediaBlock } from '@/blocks/MediaBlock/Component' +import { TypedLocale } from 'payload' + +const blockComponents = { + archive: ArchiveBlock, + content: ContentBlock, + cta: CallToActionBlock, + formBlock: FormBlock, + mediaBlock: MediaBlock, +} + +export const RenderBlocks: React.FC<{ + blocks: Page['layout'][0][] + locale: TypedLocale +}> = (props) => { + const { blocks, locale } = props + + const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0 + + if (hasBlocks) { + return ( +} + + + {docs?.map((doc, index) => { + if (typeof doc === 'string') return null + + return++ })} + + {blocks.map((block, index) => { + const { blockType } = block + + if (blockType && blockType in blockComponents) { + const Block = blockComponents[blockType] + + if (Block) { + return ( + + ) + } + + return null +} diff --git a/examples/localization/src/collections/Categories.ts b/examples/localization/src/collections/Categories.ts new file mode 100644 index 0000000000..abf9268c02 --- /dev/null +++ b/examples/localization/src/collections/Categories.ts @@ -0,0 +1,27 @@ +import type { CollectionConfig } from 'payload' + +import { anyone } from '../access/anyone' +import { authenticated } from '../access/authenticated' + +const Categories: CollectionConfig = { + slug: 'categories', + access: { + create: authenticated, + delete: authenticated, + read: anyone, + update: authenticated, + }, + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + ], +} + +export default Categories diff --git a/examples/localization/src/collections/Media.ts b/examples/localization/src/collections/Media.ts new file mode 100644 index 0000000000..50ccc095f2 --- /dev/null +++ b/examples/localization/src/collections/Media.ts @@ -0,0 +1,47 @@ +import type { CollectionConfig } from 'payload' + +import { + FixedToolbarFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' +import path from 'path' +import { fileURLToPath } from 'url' + +import { anyone } from '../access/anyone' +import { authenticated } from '../access/authenticated' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const Media: CollectionConfig = { + slug: 'media', + access: { + create: authenticated, + delete: authenticated, + read: anyone, + update: authenticated, + }, + fields: [ + { + name: 'alt', + type: 'text', + localized: true, + required: true, + }, + { + name: 'caption', + type: 'richText', + localized: true, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()] + }, + }), + }, + ], + upload: { + // Upload to the public/media directory in Next.js making them publicly accessible even outside of Payload + staticDir: path.resolve(dirname, '../../public/media'), + }, +} diff --git a/examples/localization/src/collections/Pages/hooks/revalidatePage.ts b/examples/localization/src/collections/Pages/hooks/revalidatePage.ts new file mode 100644 index 0000000000..d01296a4aa --- /dev/null +++ b/examples/localization/src/collections/Pages/hooks/revalidatePage.ts @@ -0,0 +1,30 @@ +import type { CollectionAfterChangeHook } from 'payload' + +import { revalidatePath } from 'next/cache' + +import type { Page } from '../../../payload-types' + +export const revalidatePage: CollectionAfterChangeHook+ {/* @ts-expect-error */} ++ ) + } + } + return null + })} ++ = ({ + doc, + previousDoc, + req: { payload }, +}) => { + if (doc._status === 'published') { + const path = doc.slug === 'home' ? '/' : `/${doc.slug}` + + payload.logger.info(`Revalidating page at path: ${path}`) + + revalidatePath(path) + } + + // If the page was previously published, we need to revalidate the old path + if (previousDoc?._status === 'published' && doc._status !== 'published') { + const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}` + + payload.logger.info(`Revalidating old page at path: ${oldPath}`) + + revalidatePath(oldPath) + } + + return doc +} diff --git a/examples/localization/src/collections/Pages/index.ts b/examples/localization/src/collections/Pages/index.ts new file mode 100644 index 0000000000..9f940ff61a --- /dev/null +++ b/examples/localization/src/collections/Pages/index.ts @@ -0,0 +1,131 @@ +import type { CollectionConfig, TypedLocale } from 'payload' + +import { authenticated } from '../../access/authenticated' +import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' +import { Archive } from '../../blocks/ArchiveBlock/config' +import { CallToAction } from '../../blocks/CallToAction/config' +import { Content } from '../../blocks/Content/config' +import { FormBlock } from '../../blocks/Form/config' +import { MediaBlock } from '../../blocks/MediaBlock/config' +import { hero } from '@/heros/config' +import { slugField } from '@/fields/slug' +import { populatePublishedAt } from '../../hooks/populatePublishedAt' +import { generatePreviewPath } from '../../utilities/generatePreviewPath' +import { revalidatePage } from './hooks/revalidatePage' + +import { + MetaDescriptionField, + MetaImageField, + MetaTitleField, + OverviewField, + PreviewField, +} from '@payloadcms/plugin-seo/fields' +export const Pages: CollectionConfig = { + slug: 'pages', + access: { + create: authenticated, + delete: authenticated, + read: authenticatedOrPublished, + update: authenticated, + }, + admin: { + defaultColumns: ['title', 'slug', 'updatedAt'], + livePreview: { + url: ({ data, locale }) => { + const path = generatePreviewPath({ + slug: typeof data?.slug === 'string' ? data.slug : '', + collection: 'pages', + locale: locale.code, + }) + + return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}` + }, + }, + preview: (data, { locale }) => { + const path = generatePreviewPath({ + slug: typeof data?.slug === 'string' ? data.slug : '', + collection: 'pages', + locale, + }) + + return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}` + }, + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + localized: true, + type: 'text', + required: true, + }, + { + type: 'tabs', + tabs: [ + { + fields: [hero], + label: 'Hero', + }, + { + fields: [ + { + name: 'layout', + type: 'blocks', + localized: true, + blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock], + required: true, + }, + ], + label: 'Content', + }, + { + name: 'meta', + label: 'SEO', + fields: [ + OverviewField({ + titlePath: 'meta.title', + descriptionPath: 'meta.description', + imagePath: 'meta.image', + }), + MetaTitleField({ + hasGenerateFn: true, + }), + MetaImageField({ + relationTo: 'media', + }), + + MetaDescriptionField({}), + PreviewField({ + // if the `generateUrl` function is configured + hasGenerateFn: true, + + // field paths to match the target field for data + titlePath: 'meta.title', + descriptionPath: 'meta.description', + }), + ], + }, + ], + }, + { + name: 'publishedAt', + type: 'date', + admin: { + position: 'sidebar', + }, + }, + ...slugField(), + ], + hooks: { + afterChange: [revalidatePage], + beforeChange: [populatePublishedAt], + }, + versions: { + drafts: { + autosave: { + interval: 100, // We set this interval for optimal live preview + }, + }, + maxPerDoc: 50, + }, +} diff --git a/examples/localization/src/collections/Posts/hooks/populateAuthors.ts b/examples/localization/src/collections/Posts/hooks/populateAuthors.ts new file mode 100644 index 0000000000..8b812b42d8 --- /dev/null +++ b/examples/localization/src/collections/Posts/hooks/populateAuthors.ts @@ -0,0 +1,30 @@ +import type { CollectionAfterReadHook } from 'payload' +import { User } from 'src/payload-types' + +// The `user` collection has access control locked so that users are not publicly accessible +// This means that we need to populate the authors manually here to protect user privacy +// GraphQL will not return mutated user data that differs from the underlying schema +// So we use an alternative `populatedAuthors` field to populate the user data, hidden from the admin UI +export const populateAuthors: CollectionAfterReadHook = async ({ doc, req, req: { payload } }) => { + if (doc?.authors) { + const authorDocs: User[] = [] + + for (const author of doc.authors) { + const authorDoc = await payload.findByID({ + id: typeof author === 'object' ? author?.id : author, + collection: 'users', + depth: 0, + req, + }) + + authorDocs.push(authorDoc) + } + + doc.populatedAuthors = authorDocs.map((authorDoc) => ({ + id: authorDoc.id, + name: authorDoc.name, + })) + } + + return doc +} diff --git a/examples/localization/src/collections/Posts/hooks/revalidatePost.ts b/examples/localization/src/collections/Posts/hooks/revalidatePost.ts new file mode 100644 index 0000000000..ac55bcf419 --- /dev/null +++ b/examples/localization/src/collections/Posts/hooks/revalidatePost.ts @@ -0,0 +1,30 @@ +import type { CollectionAfterChangeHook } from 'payload' + +import { revalidatePath } from 'next/cache' + +import type { Post } from '../../../payload-types' + +export const revalidatePost: CollectionAfterChangeHook = ({ + doc, + previousDoc, + req: { payload }, +}) => { + if (doc._status === 'published') { + const path = `/posts/${doc.slug}` + + payload.logger.info(`Revalidating post at path: ${path}`) + + revalidatePath(path) + } + + // If the post was previously published, we need to revalidate the old path + if (previousDoc._status === 'published' && doc._status !== 'published') { + const oldPath = `/posts/${previousDoc.slug}` + + payload.logger.info(`Revalidating old post at path: ${oldPath}`) + + revalidatePath(oldPath) + } + + return doc +} diff --git a/examples/localization/src/collections/Posts/index.ts b/examples/localization/src/collections/Posts/index.ts new file mode 100644 index 0000000000..c6069b497d --- /dev/null +++ b/examples/localization/src/collections/Posts/index.ts @@ -0,0 +1,222 @@ +import type { CollectionConfig, TypedLocale } from 'payload' + +import { + BlocksFeature, + FixedToolbarFeature, + HeadingFeature, + HorizontalRuleFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +import { authenticated } from '../../access/authenticated' +import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' +import { Banner } from '../../blocks/Banner/config' +import { Code } from '../../blocks/Code/config' +import { MediaBlock } from '../../blocks/MediaBlock/config' +import { generatePreviewPath } from '../../utilities/generatePreviewPath' +import { populateAuthors } from './hooks/populateAuthors' +import { revalidatePost } from './hooks/revalidatePost' + +import { + MetaDescriptionField, + MetaImageField, + MetaTitleField, + OverviewField, + PreviewField, +} from '@payloadcms/plugin-seo/fields' +import { slugField } from '@/fields/slug' + +export const Posts: CollectionConfig = { + slug: 'posts', + access: { + create: authenticated, + delete: authenticated, + read: authenticatedOrPublished, + update: authenticated, + }, + admin: { + defaultColumns: ['title', 'slug', 'updatedAt'], + livePreview: { + url: ({ data, locale }) => { + const path = generatePreviewPath({ + slug: typeof data?.slug === 'string' ? data.slug : '', + collection: 'posts', + locale: locale.code, + }) + + return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}` + }, + }, + preview: (data, { locale }) => { + const path = generatePreviewPath({ + slug: typeof data?.slug === 'string' ? data.slug : '', + collection: 'posts', + locale, + }) + + return `${process.env.NEXT_PUBLIC_SERVER_URL}${path}` + }, + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + localized: true, + required: true, + }, + { + type: 'tabs', + tabs: [ + { + fields: [ + { + name: 'content', + type: 'richText', + localized: true, + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + BlocksFeature({ blocks: [Banner, Code, MediaBlock] }), + FixedToolbarFeature(), + InlineToolbarFeature(), + HorizontalRuleFeature(), + ] + }, + }), + label: false, + required: true, + }, + ], + label: 'Content', + }, + { + fields: [ + { + name: 'relatedPosts', + type: 'relationship', + admin: { + position: 'sidebar', + }, + filterOptions: ({ id }) => { + return { + id: { + not_in: [id], + }, + } + }, + hasMany: true, + relationTo: 'posts', + }, + { + name: 'categories', + type: 'relationship', + admin: { + position: 'sidebar', + }, + hasMany: true, + relationTo: 'categories', + }, + ], + label: 'Meta', + }, + { + name: 'meta', + label: 'SEO', + fields: [ + OverviewField({ + titlePath: 'meta.title', + descriptionPath: 'meta.description', + imagePath: 'meta.image', + }), + MetaTitleField({ + hasGenerateFn: true, + }), + MetaImageField({ + relationTo: 'media', + }), + + MetaDescriptionField({}), + PreviewField({ + // if the `generateUrl` function is configured + hasGenerateFn: true, + + // field paths to match the target field for data + titlePath: 'meta.title', + descriptionPath: 'meta.description', + }), + ], + }, + ], + }, + { + name: 'publishedAt', + type: 'date', + admin: { + date: { + pickerAppearance: 'dayAndTime', + }, + position: 'sidebar', + }, + hooks: { + beforeChange: [ + ({ siblingData, value }) => { + if (siblingData._status === 'published' && !value) { + return new Date() + } + return value + }, + ], + }, + }, + { + name: 'authors', + type: 'relationship', + admin: { + position: 'sidebar', + }, + hasMany: true, + relationTo: 'users', + }, + // This field is only used to populate the user data via the `populateAuthors` hook + // This is because the `user` collection has access control locked to protect user privacy + // GraphQL will also not return mutated user data that differs from the underlying schema + { + name: 'populatedAuthors', + type: 'array', + access: { + update: () => false, + }, + admin: { + disabled: true, + readOnly: true, + }, + fields: [ + { + name: 'id', + type: 'text', + }, + { + name: 'name', + type: 'text', + }, + ], + }, + ...slugField(), + ], + hooks: { + afterChange: [revalidatePost], + afterRead: [populateAuthors], + }, + versions: { + drafts: { + autosave: { + interval: 100, // We set this interval for optimal live preview + }, + }, + maxPerDoc: 50, + }, +} diff --git a/examples/localization/src/collections/Users/index.ts b/examples/localization/src/collections/Users/index.ts new file mode 100644 index 0000000000..98287c9fd0 --- /dev/null +++ b/examples/localization/src/collections/Users/index.ts @@ -0,0 +1,28 @@ +import type { CollectionConfig } from 'payload' + +import { authenticated } from '../../access/authenticated' + +const Users: CollectionConfig = { + slug: 'users', + access: { + admin: authenticated, + create: authenticated, + delete: authenticated, + read: authenticated, + update: authenticated, + }, + admin: { + defaultColumns: ['name', 'email'], + useAsTitle: 'name', + }, + auth: true, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + timestamps: true, +} + +export default Users diff --git a/examples/localization/src/components/AdminBar/index.scss b/examples/localization/src/components/AdminBar/index.scss new file mode 100644 index 0000000000..54060c5ba4 --- /dev/null +++ b/examples/localization/src/components/AdminBar/index.scss @@ -0,0 +1,7 @@ +@import '~@payloadcms/ui/scss'; + +.admin-bar { + @include small-break { + display: none; + } +} diff --git a/examples/localization/src/components/AdminBar/index.tsx b/examples/localization/src/components/AdminBar/index.tsx new file mode 100644 index 0000000000..d98be47e9d --- /dev/null +++ b/examples/localization/src/components/AdminBar/index.tsx @@ -0,0 +1,85 @@ +'use client' + +import type { PayloadAdminBarProps } from 'payload-admin-bar' + +import { cn } from '@/utilities/cn' +import { useSelectedLayoutSegments } from 'next/navigation' +import { PayloadAdminBar } from 'payload-admin-bar' +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' + +import './index.scss' +import { useTranslations } from 'next-intl' + +const baseClass = 'admin-bar' + +const collectionLabels = { + pages: { + plural: 'Pages', + singular: 'Page', + }, + posts: { + plural: 'Posts', + singular: 'Post', + }, + projects: { + plural: 'Projects', + singular: 'Project', + }, +} + +export const AdminBar: React.FC<{ + adminBarProps?: PayloadAdminBarProps +}> = (props) => { + const { adminBarProps } = props || {} + const segments = useSelectedLayoutSegments() + const [show, setShow] = useState(false) + const collection = collectionLabels?.[segments?.[1]] ? segments?.[1] : 'pages' + const router = useRouter() + const t = useTranslations() + + const onAuthChange = React.useCallback((user) => { + setShow(user?.id) + }, []) + + return ( + ++ ) +} diff --git a/examples/localization/src/components/AfterDashboard/index.tsx b/examples/localization/src/components/AfterDashboard/index.tsx new file mode 100644 index 0000000000..38324ba366 --- /dev/null +++ b/examples/localization/src/components/AfterDashboard/index.tsx @@ -0,0 +1,53 @@ +'use client' + +import React, { Fragment, useCallback, useState } from 'react' +import { toast } from '@payloadcms/ui' + +const SuccessMessage: React.FC = () => ( +++{t('dashboard')}} + onAuthChange={onAuthChange} + onPreviewExit={() => { + fetch('/next/exit-preview').then(() => { + router.push('/') + router.refresh() + }) + }} + style={{ + backgroundColor: 'transparent', + padding: 0, + position: 'relative', + zIndex: 'unset', + }} + /> + + Database seeded! You can now{' '} + + visit your website + ++) + +export const SeedButton: React.FC = () => { + const [loading, setLoading] = useState(false) + const [seeded, setSeeded] = useState(false) + const [error, setError] = useState(null) + + const handleClick = useCallback( + async (e) => { + e.preventDefault() + if (loading || seeded) return + + setLoading(true) + + try { + await fetch('/api/seed') + setSeeded(true) + toast.success(, { duration: 5000 }) + } catch (err) { + setError(err) + } + }, + [loading, seeded], + ) + + let message = '' + if (loading) message = ' (seeding...)' + if (seeded) message = ' (done!)' + if (error) message = ` (error: ${error})` + + return ( + + + Reset / seed database + + {message} + + ) +} + +export default SeedButton diff --git a/examples/localization/src/components/BeforeLogin/index.tsx b/examples/localization/src/components/BeforeLogin/index.tsx new file mode 100644 index 0000000000..8768831c82 --- /dev/null +++ b/examples/localization/src/components/BeforeLogin/index.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +const BeforeLogin: React.FC = () => { + return ( +++ ) +} + +export default BeforeLogin diff --git a/examples/localization/src/components/Card/index.tsx b/examples/localization/src/components/Card/index.tsx new file mode 100644 index 0000000000..6277de2dfd --- /dev/null +++ b/examples/localization/src/components/Card/index.tsx @@ -0,0 +1,82 @@ +'use client' +import { cn } from '@/utilities/cn' +import useClickableCard from '@/utilities/useClickableCard' +import Link from 'next/link' +import React, { Fragment } from 'react' + +import type { Post } from '@/payload-types' + +import { Media } from '@/components/Media' + +export const Card: React.FC<{ + alignItems?: 'center' + className?: string + doc?: Post + relationTo?: 'posts' + showCategories?: boolean + title?: string +}> = (props) => { + const { card, link } = useClickableCard({}) + const { className, doc, relationTo, showCategories, title: titleFromProps } = props + + const { slug, categories, meta, title } = doc || {} + const { description, image: metaImage } = meta || {} + + const hasCategories = categories && Array.isArray(categories) && categories.length > 0 + const titleToUse = titleFromProps || title + const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space + const href = `/${relationTo}/${slug}` + + return ( ++ Welcome to your dashboard! + {' This is where site admins will log in to manage your website.'} +
++ + ) +} diff --git a/examples/localization/src/components/CollectionArchive/index.tsx b/examples/localization/src/components/CollectionArchive/index.tsx new file mode 100644 index 0000000000..b31185f55f --- /dev/null +++ b/examples/localization/src/components/CollectionArchive/index.tsx @@ -0,0 +1,34 @@ +import { cn } from 'src/utilities/cn' +import React from 'react' + +import type { Post } from '@/payload-types' + +import { Card } from '@/components/Card' + +export type Props = { + posts: Post[] +} + +export const CollectionArchive: React.FC+ {!metaImage &&+No image} + {metaImage && typeof metaImage !== 'string' &&} + + {showCategories && hasCategories && ( +++ {showCategories && hasCategories && ( ++ )} + {titleToUse && ( ++ {categories?.map((category, index) => { + if (typeof category === 'object') { + const { title: titleFromCategory } = category + + const categoryTitle = titleFromCategory || 'Untitled category' + + const isLast = index === categories.length - 1 + + return ( ++ )} ++ {categoryTitle} + {!isLast && + ) + } + + return null + })} +, } +++ )} + {description &&+ + {titleToUse} + +
+{description &&} +{sanitizedDescription}
}= (props) => { + const { posts } = props + + return ( + ++ ) +} diff --git a/examples/localization/src/components/Link/index.tsx b/examples/localization/src/components/Link/index.tsx new file mode 100644 index 0000000000..608fb1a5a4 --- /dev/null +++ b/examples/localization/src/components/Link/index.tsx @@ -0,0 +1,70 @@ +import { Button, type ButtonProps } from '@/components/ui/button' +import { cn } from 'src/utilities/cn' +import { Link as i18nLink } from '@/i18n/routing' +import React from 'react' + +import type { Page, Post } from '@/payload-types' +import NextLink from 'next/link' + +type CMSLinkType = { + appearance?: 'inline' | ButtonProps['variant'] + children?: React.ReactNode + className?: string + label?: string | null + newTab?: boolean | null + reference?: { + relationTo: 'pages' | 'posts' + value: Page | Post | string | number + } | null + size?: ButtonProps['size'] | null + type?: 'custom' | 'reference' | null + url?: string | null +} + +export const CMSLink: React.FC+++ {posts?.map((result, index) => { + if (typeof result === 'object' && result !== null) { + return ( ++++ ) + } + + return null + })} ++ = (props) => { + const { + type, + appearance = 'inline', + children, + className, + label, + newTab, + reference, + size: sizeFromProps, + url, + } = props + + const href = + type === 'reference' && typeof reference?.value === 'object' && reference.value.slug + ? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${ + reference.value.slug + }` + : url + + if (!href) return null + + const finalHref = href || url || '' + const Link = finalHref.startsWith('/admin') ? NextLink : i18nLink + + const size = appearance === 'link' ? 'clear' : sizeFromProps + const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {} + + /* Ensure we don't break any styles set by richText */ + if (appearance === 'inline') { + return ( + + {label && label} + {children && children} + + ) + } + + return ( + + ) +} diff --git a/examples/localization/src/components/LivePreviewListener/index.tsx b/examples/localization/src/components/LivePreviewListener/index.tsx new file mode 100644 index 0000000000..2d59078d05 --- /dev/null +++ b/examples/localization/src/components/LivePreviewListener/index.tsx @@ -0,0 +1,11 @@ +'use client' +import { RefreshRouteOnSave as PayloadLivePreview } from '@payloadcms/live-preview-react' +import { useRouter } from 'next/navigation' +import React from 'react' + +export const LivePreviewListener: React.FC = () => { + const router = useRouter() + return ( + + ) +} diff --git a/examples/localization/src/components/Logo/Logo.tsx b/examples/localization/src/components/Logo/Logo.tsx new file mode 100644 index 0000000000..b62b95aaf4 --- /dev/null +++ b/examples/localization/src/components/Logo/Logo.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +export const Logo = () => { + return ( + /* eslint-disable @next/next/no-img-element */ + + ) +} diff --git a/examples/localization/src/components/Media/ImageMedia/index.tsx b/examples/localization/src/components/Media/ImageMedia/index.tsx new file mode 100644 index 0000000000..308cd4b853 --- /dev/null +++ b/examples/localization/src/components/Media/ImageMedia/index.tsx @@ -0,0 +1,78 @@ +'use client' + +import type { StaticImageData } from 'next/image' + +import { cn } from 'src/utilities/cn' +import NextImage from 'next/image' +import React from 'react' + +import type { Props as MediaProps } from '../types' + +import cssVariables from '@/cssVariables' + +const { breakpoints } = cssVariables + +export const ImageMedia: React.FC
= (props) => { + const { + alt: altFromProps, + fill, + imgClassName, + onClick, + onLoad: onLoadFromProps, + priority, + resource, + size: sizeFromProps, + src: srcFromProps, + } = props + + const [isLoading, setIsLoading] = React.useState(true) + + let width: number | undefined + let height: number | undefined + let alt = altFromProps + let src: StaticImageData | string = srcFromProps || '' + + if (!src && resource && typeof resource === 'object') { + const { + alt: altFromResource, + filename: fullFilename, + height: fullHeight, + url, + width: fullWidth, + } = resource + + width = fullWidth! + height = fullHeight! + alt = altFromResource + + src = `${process.env.NEXT_PUBLIC_SERVER_URL}${url}` + } + + // NOTE: this is used by the browser to determine which image to download at different screen sizes + const sizes = sizeFromProps + ? sizeFromProps + : Object.entries(breakpoints) + .map(([, value]) => `(max-width: ${value}px) ${value}px`) + .join(', ') + + return ( + { + setIsLoading(false) + if (typeof onLoadFromProps === 'function') { + onLoadFromProps() + } + }} + priority={priority} + quality={90} + sizes={sizes} + src={src} + width={!fill ? width : undefined} + /> + ) +} diff --git a/examples/localization/src/components/Media/VideoMedia/index.tsx b/examples/localization/src/components/Media/VideoMedia/index.tsx new file mode 100644 index 0000000000..7857de1ca0 --- /dev/null +++ b/examples/localization/src/components/Media/VideoMedia/index.tsx @@ -0,0 +1,44 @@ +'use client' + +import { cn } from 'src/utilities/cn' +import React, { useEffect, useRef } from 'react' + +import type { Props as MediaProps } from '../types' + +export const VideoMedia: React.FC = (props) => { + const { onClick, resource, videoClassName } = props + + const videoRef = useRef (null) + // const [showFallback] = useState () + + useEffect(() => { + const { current: video } = videoRef + if (video) { + video.addEventListener('suspend', () => { + // setShowFallback(true); + // console.warn('Video was suspended, rendering fallback image.') + }) + } + }, []) + + if (resource && typeof resource === 'object') { + const { filename } = resource + + return ( + + ) + } + + return null +} diff --git a/examples/localization/src/components/Media/index.tsx b/examples/localization/src/components/Media/index.tsx new file mode 100644 index 0000000000..2714c8a708 --- /dev/null +++ b/examples/localization/src/components/Media/index.tsx @@ -0,0 +1,25 @@ +import React, { Fragment } from 'react' + +import type { Props } from './types' + +import { ImageMedia } from './ImageMedia' +import { VideoMedia } from './VideoMedia' + +export const Media: React.FC = (props) => { + const { className, htmlElement = 'div', resource } = props + + const isVideo = typeof resource === 'object' && resource?.mimeType?.includes('video') + const Tag = (htmlElement as any) || Fragment + + return ( + + {isVideo ? + ) +} diff --git a/examples/localization/src/components/Media/types.ts b/examples/localization/src/components/Media/types.ts new file mode 100644 index 0000000000..23224c20f1 --- /dev/null +++ b/examples/localization/src/components/Media/types.ts @@ -0,0 +1,20 @@ +import type { StaticImageData } from 'next/image' +import type { ElementType, Ref } from 'react' + +import type { Media as MediaType } from '@/payload-types' + +export interface Props { + alt?: string + className?: string + fill?: boolean // for NextImage only + htmlElement?: ElementType | null + imgClassName?: string + onClick?: () => void + onLoad?: () => void + priority?: boolean // for NextImage only + ref?: Ref: } + + resource?: MediaType | string | number // for Payload media + size?: string // for NextImage only + src?: StaticImageData // for static media + videoClassName?: string +} diff --git a/examples/localization/src/components/PageRange/index.tsx b/examples/localization/src/components/PageRange/index.tsx new file mode 100644 index 0000000000..a93b69c421 --- /dev/null +++ b/examples/localization/src/components/PageRange/index.tsx @@ -0,0 +1,34 @@ +import { useTranslations } from 'next-intl' +import React from 'react' + +export const PageRange: React.FC<{ + className?: string + collection?: string + collectionLabels?: { + plural?: string + singular?: string + } + currentPage?: number + limit?: number + totalDocs?: number +}> = (props) => { + const { className, currentPage, limit, totalDocs } = props + const t = useTranslations() + + let indexStart = (currentPage ? currentPage - 1 : 1) * (limit || 1) + 1 + if (totalDocs && indexStart > totalDocs) indexStart = 0 + + let indexEnd = (currentPage || 1) * (limit || 1) + if (totalDocs && indexEnd > totalDocs) indexEnd = totalDocs + + return ( + + {(typeof totalDocs === 'undefined' || totalDocs === 0) && 'Search produced no results.'} + {typeof totalDocs !== 'undefined' && + totalDocs > 0 && + `${t('showing')} ${indexStart}${indexStart > 0 ? ` - ${indexEnd}` : ''} ${t('of')} ${totalDocs} ${ + totalDocs > 1 ? t('posts').toLowerCase() : t('post').toLowerCase() + }`} ++ ) +} diff --git a/examples/localization/src/components/Pagination/index.tsx b/examples/localization/src/components/Pagination/index.tsx new file mode 100644 index 0000000000..d6d6fda194 --- /dev/null +++ b/examples/localization/src/components/Pagination/index.tsx @@ -0,0 +1,101 @@ +'use client' +import { + Pagination as PaginationComponent, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination' +import { cn } from '@/utilities/cn' +import { useRouter } from 'next/navigation' +import React from 'react' + +export const Pagination: React.FC<{ + className?: string + page: number + totalPages: number +}> = (props) => { + const router = useRouter() + + const { className, page, totalPages } = props + const hasNextPage = page < totalPages + const hasPrevPage = page > 1 + + const hasExtraPrevPages = page - 1 > 1 + const hasExtraNextPages = page + 1 < totalPages + + return ( +++ ) +} diff --git a/examples/localization/src/components/PayloadRedirects/index.tsx b/examples/localization/src/components/PayloadRedirects/index.tsx new file mode 100644 index 0000000000..2e94fdde88 --- /dev/null +++ b/examples/localization/src/components/PayloadRedirects/index.tsx @@ -0,0 +1,50 @@ +import type React from 'react' +import type { Page, Post } from '@/payload-types' + +import { getCachedDocument } from '@/utilities/getDocument' +import { getCachedRedirects } from '@/utilities/getRedirects' +import { notFound, redirect } from 'next/navigation' + +interface Props { + disableNotFound?: boolean + url: string +} + +/* This component helps us with SSR based dynamic redirects */ +export const PayloadRedirects: React.FC+ ++ ++ + + {hasExtraPrevPages && ( +{ + router.push(`/posts/page/${page - 1}`) + }} + /> + + + )} + + {hasPrevPage && ( ++ + + )} + +{ + router.push(`/posts/page/${page - 1}`) + }} + > + {page - 1} + ++ + + {hasNextPage && ( +{ + router.push(`/posts/page/${page}`) + }} + > + {page} + ++ + )} + + {hasExtraNextPages && ( +{ + router.push(`/posts/page/${page + 1}`) + }} + > + {page + 1} + ++ + )} + ++ + +{ + router.push(`/posts/page/${page + 1}`) + }} + /> + = async ({ disableNotFound, url }) => { + const slug = url.startsWith('/') ? url : `${url}` + + const redirects = await getCachedRedirects()() + + const redirectItem = redirects.find((redirect) => redirect.from === slug) + + if (redirectItem) { + if (redirectItem.to?.url) { + redirect(redirectItem.to.url) + } + + let redirectUrl: string + + if (typeof redirectItem.to?.reference?.value === 'string') { + const collection = redirectItem.to?.reference?.relationTo + const id = redirectItem.to?.reference?.value + + const document = (await getCachedDocument(collection, id)()) as Page | Post + redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${ + document?.slug + }` + } else { + redirectUrl = `${redirectItem.to?.reference?.relationTo !== 'pages' ? `/${redirectItem.to?.reference?.relationTo}` : ''}/${ + typeof redirectItem.to?.reference?.value === 'object' + ? redirectItem.to?.reference?.value?.slug + : '' + }` + } + + if (redirectUrl) redirect(redirectUrl) + } + + if (disableNotFound) return null + + notFound() +} diff --git a/examples/localization/src/components/RichText/index.tsx b/examples/localization/src/components/RichText/index.tsx new file mode 100644 index 0000000000..39b5528a19 --- /dev/null +++ b/examples/localization/src/components/RichText/index.tsx @@ -0,0 +1,43 @@ +import { cn } from '@/utilities/cn' +import React from 'react' + +import { serializeLexical } from './serialize' + +type Props = { + className?: string + content: Record + enableGutter?: boolean + enableProse?: boolean +} + +const RichText: React.FC = ({ + className, + content, + enableGutter = true, + enableProse = true, +}) => { + if (!content) { + return null + } + + return ( + + {content && + !Array.isArray(content) && + typeof content === 'object' && + 'root' in content && + serializeLexical({ nodes: content?.root?.children })} ++ ) +} + +export default RichText diff --git a/examples/localization/src/components/RichText/nodeFormat.tsx b/examples/localization/src/components/RichText/nodeFormat.tsx new file mode 100644 index 0000000000..5d5ff59172 --- /dev/null +++ b/examples/localization/src/components/RichText/nodeFormat.tsx @@ -0,0 +1,125 @@ +// @ts-nocheck +//This copy-and-pasted from lexical here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts + +import type { ElementFormatType, TextFormatType } from 'lexical' +import type { TextDetailType, TextModeType } from 'lexical/nodes/LexicalTextNode' + +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// DOM +export const DOM_ELEMENT_TYPE = 1 +export const DOM_TEXT_TYPE = 3 + +// Reconciling +export const NO_DIRTY_NODES = 0 +export const HAS_DIRTY_NODES = 1 +export const FULL_RECONCILE = 2 + +// Text node modes +export const IS_NORMAL = 0 +export const IS_TOKEN = 1 +export const IS_SEGMENTED = 2 +// IS_INERT = 3 + +// Text node formatting +export const IS_BOLD = 1 +export const IS_ITALIC = 1 << 1 +export const IS_STRIKETHROUGH = 1 << 2 +export const IS_UNDERLINE = 1 << 3 +export const IS_CODE = 1 << 4 +export const IS_SUBSCRIPT = 1 << 5 +export const IS_SUPERSCRIPT = 1 << 6 +export const IS_HIGHLIGHT = 1 << 7 + +export const IS_ALL_FORMATTING = + IS_BOLD | + IS_ITALIC | + IS_STRIKETHROUGH | + IS_UNDERLINE | + IS_CODE | + IS_SUBSCRIPT | + IS_SUPERSCRIPT | + IS_HIGHLIGHT + +// Text node details +export const IS_DIRECTIONLESS = 1 +export const IS_UNMERGEABLE = 1 << 1 + +// Element node formatting +export const IS_ALIGN_LEFT = 1 +export const IS_ALIGN_CENTER = 2 +export const IS_ALIGN_RIGHT = 3 +export const IS_ALIGN_JUSTIFY = 4 +export const IS_ALIGN_START = 5 +export const IS_ALIGN_END = 6 + +// Reconciliation +export const NON_BREAKING_SPACE = '\u00A0' +const ZERO_WIDTH_SPACE = '\u200b' + +export const DOUBLE_LINE_BREAK = '\n\n' + +// For FF, we need to use a non-breaking space, or it gets composition +// in a stuck state. + +const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC' +const LTR = + 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + + '\uFE00-\uFE6F\uFEFD-\uFFFF' + +export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']') + +export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']') + +export const TEXT_TYPE_TO_FORMAT: Record= { + bold: IS_BOLD, + code: IS_CODE, + highlight: IS_HIGHLIGHT, + italic: IS_ITALIC, + strikethrough: IS_STRIKETHROUGH, + subscript: IS_SUBSCRIPT, + superscript: IS_SUPERSCRIPT, + underline: IS_UNDERLINE, +} + +export const DETAIL_TYPE_TO_DETAIL: Record = { + directionless: IS_DIRECTIONLESS, + unmergeable: IS_UNMERGEABLE, +} + +export const ELEMENT_TYPE_TO_FORMAT: Record , number> = { + center: IS_ALIGN_CENTER, + end: IS_ALIGN_END, + justify: IS_ALIGN_JUSTIFY, + left: IS_ALIGN_LEFT, + right: IS_ALIGN_RIGHT, + start: IS_ALIGN_START, +} + +export const ELEMENT_FORMAT_TO_TYPE: Record = { + [IS_ALIGN_CENTER]: 'center', + [IS_ALIGN_END]: 'end', + [IS_ALIGN_JUSTIFY]: 'justify', + [IS_ALIGN_LEFT]: 'left', + [IS_ALIGN_RIGHT]: 'right', + [IS_ALIGN_START]: 'start', +} + +export const TEXT_MODE_TO_TYPE: Record = { + normal: IS_NORMAL, + segmented: IS_SEGMENTED, + token: IS_TOKEN, +} + +export const TEXT_TYPE_TO_MODE: Record = { + [IS_NORMAL]: 'normal', + [IS_SEGMENTED]: 'segmented', + [IS_TOKEN]: 'token', +} diff --git a/examples/localization/src/components/RichText/serialize.tsx b/examples/localization/src/components/RichText/serialize.tsx new file mode 100644 index 0000000000..69bf3bd721 --- /dev/null +++ b/examples/localization/src/components/RichText/serialize.tsx @@ -0,0 +1,211 @@ +import { BannerBlock } from '@/blocks/Banner/Component' +import { CallToActionBlock } from '@/blocks/CallToAction/Component' +import { CodeBlock, CodeBlockProps } from '@/blocks/Code/Component' +import { MediaBlock } from '@/blocks/MediaBlock/Component' +import React, { Fragment, JSX } from 'react' +import { CMSLink } from '@/components/Link' +import { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical' +import type { BannerBlock as BannerBlockProps } from '@/payload-types' + +import { + IS_BOLD, + IS_CODE, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, +} from './nodeFormat' +import type { Page } from '@/payload-types' + +export type NodeTypes = + | DefaultNodeTypes + | SerializedBlockNode< + | Extract + | Extract + | BannerBlockProps + | CodeBlockProps + > + +type Props = { + nodes: NodeTypes[] +} + +export function serializeLexical({ nodes }: Props): JSX.Element { + return ( + + {nodes?.map((node, index): JSX.Element | null => { + if (node == null) { + return null + } + + if (node.type === 'text') { + let text = + ) +} diff --git a/examples/localization/src/components/ui/button.tsx b/examples/localization/src/components/ui/button.tsx new file mode 100644 index 0000000000..5b7a65282e --- /dev/null +++ b/examples/localization/src/components/ui/button.tsx @@ -0,0 +1,49 @@ +import { cn } from 'src/utilities/cn' +import { Slot } from '@radix-ui/react-slot' +import { type VariantProps, cva } from 'class-variance-authority' +import * as React from 'react' + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + defaultVariants: { + size: 'default', + variant: 'default', + }, + variants: { + size: { + clear: '', + default: 'h-10 px-4 py-2', + icon: 'h-10 w-10', + lg: 'h-11 rounded px-8', + sm: 'h-9 rounded px-3', + }, + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + ghost: 'hover:bg-card hover:text-accent-foreground', + link: 'text-primary items-start justify-start underline-offset-4 hover:underline', + outline: 'border border-border bg-background hover:bg-card hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + }, + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes{node.text} + if (node.format & IS_BOLD) { + text = {text} + } + if (node.format & IS_ITALIC) { + text = {text} + } + if (node.format & IS_STRIKETHROUGH) { + text = ( + + {text} + + ) + } + if (node.format & IS_UNDERLINE) { + text = ( + + {text} + + ) + } + if (node.format & IS_CODE) { + text ={node.text}+ } + if (node.format & IS_SUBSCRIPT) { + text = {text} + } + if (node.format & IS_SUPERSCRIPT) { + text = {text} + } + + return text + } + + // NOTE: Hacky fix for + // https://github.com/facebook/lexical/blob/d10c4e6e55261b2fdd7d1845aed46151d0f06a8c/packages/lexical-list/src/LexicalListItemNode.ts#L133 + // which does not return checked: false (only true - i.e. there is no prop for false) + const serializedChildrenFn = (node: NodeTypes): JSX.Element | null => { + if (node.children == null) { + return null + } else { + if (node?.type === 'list' && node?.listType === 'check') { + for (const item of node.children) { + if ('checked' in item) { + if (!item?.checked) { + item.checked = false + } + } + } + } + return serializeLexical({ nodes: node.children as NodeTypes[] }) + } + } + + const serializedChildren = 'children' in node ? serializedChildrenFn(node) : '' + + if (node.type === 'block') { + const block = node.fields + + const blockType = block?.blockType + + if (!block || !blockType) { + return null + } + + switch (blockType) { + case 'cta': + return+ case 'mediaBlock': + return ( + + ) + case 'banner': + return + case 'code': + return + default: + return null + } + } else { + switch (node.type) { + case 'linebreak': { + return
+ } + case 'paragraph': { + return ( ++ {serializedChildren} +
+ ) + } + case 'heading': { + const Tag = node?.tag + return ( ++ {serializedChildren} + + ) + } + case 'list': { + const Tag = node?.tag + return ( ++ {serializedChildren} + + ) + } + case 'listitem': { + if (node?.checked != null) { + return ( ++ {serializedChildren} + + ) + } else { + return ( ++ {serializedChildren} + + ) + } + } + case 'quote': { + return ( ++ {serializedChildren} ++ ) + } + case 'link': { + const fields = node.fields + + return ( ++ {serializedChildren} + + ) + } + + default: + return null + } + } + })} +, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef ( + ({ asChild = false, className, size, variant, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + }, +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/examples/localization/src/components/ui/card.tsx b/examples/localization/src/components/ui/card.tsx new file mode 100644 index 0000000000..5d7b80ed67 --- /dev/null +++ b/examples/localization/src/components/ui/card.tsx @@ -0,0 +1,56 @@ +/* eslint-disable jsx-a11y/heading-has-content */ +import { cn } from 'src/utilities/cn' +import * as React from 'react' + +const Card = React.forwardRef >( + ({ className, ...props }, ref) => ( + + ), +) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef >( + ({ className, ...props }, ref) => ( + + ), +) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef >( + ({ className, ...props }, ref) => ( + + ), +) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef >( + ({ className, ...props }, ref) => ( + + ), +) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef >( + ({ className, ...props }, ref) => ( + + ), +) +CardFooter.displayName = 'CardFooter' + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } diff --git a/examples/localization/src/components/ui/checkbox.tsx b/examples/localization/src/components/ui/checkbox.tsx new file mode 100644 index 0000000000..fe23eed55e --- /dev/null +++ b/examples/localization/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +'use client' + +import { cn } from 'src/utilities/cn' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' +import * as React from 'react' + +const Checkbox = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/examples/localization/src/components/ui/input.tsx b/examples/localization/src/components/ui/input.tsx new file mode 100644 index 0000000000..eb103a810b --- /dev/null +++ b/examples/localization/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import { cn } from 'src/utilities/cn' +import * as React from 'react' + +export interface InputProps extends React.InputHTMLAttributes+ ++ {} + +const Input = React.forwardRef ( + ({ type, className, ...props }, ref) => { + return ( + + ) + }, +) +Input.displayName = 'Input' + +export { Input } diff --git a/examples/localization/src/components/ui/label.tsx b/examples/localization/src/components/ui/label.tsx new file mode 100644 index 0000000000..da8a964c4e --- /dev/null +++ b/examples/localization/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +'use client' + +import { cn } from 'src/utilities/cn' +import * as LabelPrimitive from '@radix-ui/react-label' +import { type VariantProps, cva } from 'class-variance-authority' +import * as React from 'react' + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', +) + +const Label = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/examples/localization/src/components/ui/pagination.tsx b/examples/localization/src/components/ui/pagination.tsx new file mode 100644 index 0000000000..d2e01e28f6 --- /dev/null +++ b/examples/localization/src/components/ui/pagination.tsx @@ -0,0 +1,114 @@ +/* eslint-disable react/button-has-type */ +import type { ButtonProps } from '@/components/ui/button' + +import { buttonVariants } from '@/components/ui/button' +import { cn } from 'src/utilities/cn' +import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react' +import * as React from 'react' +import { useTranslations } from 'next-intl' + +const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( + +) +Pagination.displayName = 'Pagination' + +const PaginationContent = React.forwardRef >( + ({ className, ...props }, ref) => ( + + ), +) +PaginationContent.displayName = 'PaginationContent' + +const PaginationItem = React.forwardRef
>( + ({ className, ...props }, ref) => , +) +PaginationItem.displayName = 'PaginationItem' + +type PaginationLinkProps = { + isActive?: boolean +} & Pick & + React.ComponentProps<'button'> + +const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => ( + +) +PaginationLink.displayName = 'PaginationLink' + +const PaginationPrevious = ({ + className, + ...props +}: React.ComponentProps ) => { + const t = useTranslations() + + return ( + + + ) +} +PaginationPrevious.displayName = 'PaginationPrevious' + +const PaginationNext = ({ className, ...props }: React.ComponentProps+ {t('previous')} + ) => { + const t = useTranslations() + + return ( + + {t('next')} + + ) +} +PaginationNext.displayName = 'PaginationNext' + +const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => { + const t = useTranslations() + + return ( + ++ + {t('more-pages')} + + ) +} + +PaginationEllipsis.displayName = 'PaginationEllipsis' + +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} diff --git a/examples/localization/src/components/ui/select.tsx b/examples/localization/src/components/ui/select.tsx new file mode 100644 index 0000000000..d9da66223f --- /dev/null +++ b/examples/localization/src/components/ui/select.tsx @@ -0,0 +1,152 @@ +'use client' + +import { cn } from 'src/utilities/cn' +import * as SelectPrimitive from '@radix-ui/react-select' +import { Check, ChevronDown, ChevronUp } from 'lucide-react' +import * as React from 'react' + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef +>(({ children, className, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + ref={ref} + {...props} + > + {children} + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef+ ++ , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef+ , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + +)) +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef+ , + React.ComponentPropsWithoutRef +>(({ children, className, position = 'popper', ...props }, ref) => ( + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef+ ++ + {children} + ++ , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef , + React.ComponentPropsWithoutRef +>(({ children, className, ...props }, ref) => ( + + + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef+ + + ++ {children} +, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/examples/localization/src/components/ui/textarea.tsx b/examples/localization/src/components/ui/textarea.tsx new file mode 100644 index 0000000000..3dd3492011 --- /dev/null +++ b/examples/localization/src/components/ui/textarea.tsx @@ -0,0 +1,22 @@ +import { cn } from 'src/utilities/cn' +import * as React from 'react' + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef ( + ({ className, ...props }, ref) => { + return ( + + ) + }, +) +Textarea.displayName = 'Textarea' + +export { Textarea } diff --git a/examples/localization/src/cssVariables.js b/examples/localization/src/cssVariables.js new file mode 100644 index 0000000000..55c9f8f03a --- /dev/null +++ b/examples/localization/src/cssVariables.js @@ -0,0 +1,19 @@ +// Keep these in sync with the CSS variables in the `_css` directory + +const cssVariables = { + breakpoints: { + l: 1440, + m: 1024, + s: 768, + }, + colors: { + base0: 'rgb(255, 255, 255)', + base100: 'rgb(235, 235, 235)', + base500: 'rgb(128, 128, 128)', + base850: 'rgb(34, 34, 34)', + base1000: 'rgb(0, 0, 0)', + error500: 'rgb(255, 111, 118)', + }, +} + +export default cssVariables diff --git a/examples/localization/src/endpoints/seed/contact-form.ts b/examples/localization/src/endpoints/seed/contact-form.ts new file mode 100644 index 0000000000..91012b9201 --- /dev/null +++ b/examples/localization/src/endpoints/seed/contact-form.ts @@ -0,0 +1,117 @@ +import type { Form } from '@/payload-types' + +export const contactForm = (locale: 'en' | 'es'): Partial