Compare commits

..

135 Commits

Author SHA1 Message Date
Jarrod Flesch
8256b3adfe chore: WIP, improved but still broken changes 2024-05-29 08:29:12 -04:00
Elliot DeNolf
b2662eeb1f ci: update app-build-with-packed job (#6541)
Add `--ignore-workspace` and `--no-frozen-lockfile` where necessary
2024-05-28 14:35:18 -04:00
Elliot DeNolf
0b274dd67e chore: adjust email-nodemailer workspace dep pattern (#6539)
Adjust dep pattern for email-nodemailer reference from plugin-cloud
2024-05-28 14:19:21 -04:00
Elliot DeNolf
2ddd50edc4 fix(deps): proper location for scheduler peer dep (#6537)
Properly put `scheduler` dep under `ui` instead of `payload`.
2024-05-28 14:15:56 -04:00
Elliot DeNolf
0287acb8f0 chore(templates): update dockerfile and docker-compose for blank template (#6536)
Update Dockerfile and docker-compose.yml for blank template.
2024-05-28 12:35:05 -04:00
Elliot DeNolf
10c94b3665 feat(cpa): update existing payload installation (#6193)
Updates create-payload-app to update an existing payload installation

- Detects existing Payload installation. Fixes #6517 
- If not latest, will install latest and grab the `(payload)` directory
structure (ripped from `templates/blank-3.0`
2024-05-28 11:38:33 -04:00
Alessio Gravili
ea48ca377e chore: move lexical package from workspace-root to test package (#6533) 2024-05-28 15:01:30 +00:00
zvizvi
6f5d86ed84 fix: Add missing He lang export in payload/i18n (#6484)
## Description
Fixed missing Hebrew language export in payload/i18n module.
The import statement import { he } from 'payload/i18n/he' was not
functioning due to he not being exported correctly.


<!-- Please include a summary of the pull request and any related issues
it fixes. Please also include relevant motivation and context. -->

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->

- [x] Chore (non-breaking change which does not add functionality)
2024-05-28 10:52:20 -03:00
Alessio Gravili
c383f391e3 feat(richtext-lexical): i18n support (#6524) 2024-05-28 09:10:39 -04:00
Paul
8a91a7adbb feat(richtext-lexical): update validation of custom URLs to include relative and anchor links (#6525)
Updates the regex to allow relative and anchor links as well. Manually
tested all common variations of absolute, relative and anchor links with
a combination

## Type of change

- [x] New feature (non-breaking change which adds functionality)
2024-05-28 00:55:00 -03:00
Alessio Gravili
96181d91a6 chore(ui): add ability to compile using react compiler (#6483)
This does not enable the react compiler by default
2024-05-27 22:54:36 -04:00
Alessio Gravili
eff5129a5f fix(next): unable to pass custom view client components (#6513) 2024-05-27 22:48:41 -04:00
Dan Ribbens
38e5adc462 chore: fix seed file path windows (#6512) 2024-05-26 15:39:16 +00:00
Dan Ribbens
ff4ea1eecc chore: getPayloadHMR conditionally call db.connect (#6510) 2024-05-25 22:17:27 +00:00
Dan Ribbens
dbfd1beed5 chore: gitignore static files uploads tests (#6509) 2024-05-25 22:06:43 +00:00
Dan Ribbens
4b6774463e chore: loader tests error on windows (#6508) 2024-05-25 21:57:32 +00:00
Dan Ribbens
cb14b97a6e chore: swcrc syntax fix (#6505) 2024-05-25 15:45:05 +00:00
Jarrod Flesch
18bc4b708c fix: separate collection docs with same ids were excluded in selectable (#6499) 2024-05-24 15:20:07 -04:00
Paul
6d951e6987 chore: add lexical as a direct dependency to the website template (#6496) 2024-05-24 16:33:36 +00:00
Elliot DeNolf
365660764d chore(templates): enable next lint on blank (#6494)
Enables next linting on blank template

Closes #6481
2024-05-24 11:36:50 -04:00
Elliot DeNolf
8b91af8a5b chore(cpa): adjust unit test template (#6490)
Adjust template used in unit tests.
2024-05-24 10:12:19 -04:00
Paul
b4092f59ae chore: fix seed data validation in website template (#6491)
Fixes an issue with data validation in lexical for the seed script
2024-05-24 14:10:29 +00:00
Alessio Gravili
7a768144ea fix(richtext-lexical): localized sub-fields were omitted from the API output (#6489)
Closes #6455. Proper localization support will be worked on later, this
just resolves the issue where having it enabled not only doesn't
localize those fields, it also omits them from the API response. Now,
they are not omitted, and localization is simply skipped.
2024-05-24 10:01:04 -04:00
Elliot DeNolf
3839eb5ab0 chore(templates): remove blank v2 template (#6488)
New v3 is `blank-3.0`. Will rename that one in future PR.
2024-05-24 09:36:57 -04:00
Paul
fd02bee0fe chore: website template updates (#6480)
Just style updates
2024-05-23 20:38:25 +00:00
Patrik
42222cd2f6 fix(ui): where builder issues (#6478)
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2024-05-23 16:01:13 -04:00
Elliot DeNolf
e3222f2ac3 chore(release): v3.0.0-beta.36 [skip ci] 2024-05-23 13:35:19 -04:00
Alessio Gravili
35f961fecb feat!: next.js 15, react 19, react compiler support (#6429)
**BREAKING:**
- bumps minimum required next.js version from `14.3.0-canary.68` to
`15.0.0-rc.0`
- bumps minimum required react and react-dom versions to `19.0.0
`(`19.0.0-rc-f994737d14-20240522` should be used)
- `@types/react` and `@types/react-dom` have to be bumped to
`npm:types-react@19.0.0-beta.2` using overrides and pnpm overrides, if
you want correct types. You can find an example of this here:
https://github.com/payloadcms/payload/pull/6429/files#diff-10cb9e57a77733f174ee2888587281e94c31f79e434aa3f932a8ec72fa7a5121L32

## Issues

- Bunch of todos for our react-select package which is having type
issues. Works fine, just type issues. Their type defs are importing JSX
in a weird way, we likely just have to wait until they fix them in a
future update.
2024-05-23 13:30:12 -04:00
Paul
85bfca79ef feat: add website template (#6470)
Adds the new website template for 3.0
2024-05-23 16:48:41 +00:00
Alessio Gravili
661a4a099d feat(ui): split up Select component into Select and SelectInput (#6471) 2024-05-23 11:36:57 -04:00
Jarrod Flesch
72c0534008 fix: adds host to initPage req creation (#6476) 2024-05-23 11:04:35 -04:00
Alessio Gravili
78579ed2bd feat(richtext-lexical): various UX and performance improvements (#6467) 2024-05-22 14:42:17 -04:00
Alessio Gravili
7bcb4ba1cc chore(email-*): remove excess backtick in readme install commands 2024-05-22 13:59:33 -04:00
Alessio Gravili
6b45cf3197 feat(richtext-lexical): improve block dragging UX 2024-05-22 13:55:44 -04:00
Elliot DeNolf
73d0b209d7 fix: isHotkey webpack error (#6466)
Fixes webpack issue with isHotkey: `TypeError:
is_hotkey__WEBPACK_IMPORTED_MODULE_9__ is not a function`

Changing this from a default import to a named export, and it appears to
resolve the issue.

Fixes #6421
2024-05-22 17:41:15 +00:00
Alessio Gravili
c93752bdbb fix(richtext-lexical): order of add/drag handles was inconsistent between gutter and no-gutter mode 2024-05-22 10:49:11 -04:00
Alessio Gravili
7a4dd5890e fix(ui): field errors aren't red in light mode 2024-05-22 10:29:41 -04:00
Alessio Gravili
60ee55fcaa chore(richtext-lexical): do not show red border & background for erroring field without gutter 2024-05-22 10:22:06 -04:00
Jacob Fletcher
1fe9790d0d feat(next): server-side theme detection (#6452) 2024-05-22 10:19:38 -04:00
zvizvi
3c0853a675 feat(translations): add Hebrew translation (#6428)
Hebrew translation added.
2024-05-22 14:15:10 +00:00
Jacob Fletcher
2b941b7e2c fix(next,ui): fixes global doc permissions and optimizes publish access data loading (#6451) 2024-05-22 10:03:12 -04:00
Elliot DeNolf
db772a058c chore: add label-author.yml 2024-05-22 09:09:52 -04:00
Alessio Gravili
0bfbf9c750 fix(richtext-lexical): link drawer sending too many form state requests for actions unrelated to links 2024-05-21 22:34:41 -04:00
Alessio Gravili
5c7647f45b ci: split up test suites (#6415) 2024-05-21 17:11:55 -04:00
Alessio Gravili
6c952875e8 feat(richtext-lexical): various gutter, error states & add/drag handle improvements (#6448)
## Gutter

Adds gutter by default:

![CleanShot 2024-05-21 at 16 24
13](https://github.com/payloadcms/payload/assets/70709113/09c59b6f-bd4a-4e81-bfdd-731d1cbbe075)


![CleanShot 2024-05-21 at 16 20
23](https://github.com/payloadcms/payload/assets/70709113/94df3e8c-663e-4b08-90cb-a24b2a788ff6)

can be disabled with admin.hideGutter

## Error states
![CleanShot 2024-05-21 at 16 21
18](https://github.com/payloadcms/payload/assets/70709113/06754d8f-c674-4aaa-a4e5-47e284970776)

Finally, proper error states display. Cleaner, and previously fields
were shown as erroring even though they weren't. No more!

## Drag & Block handles
Improved performance, and cleaned up code. Drag handle positions are now
only calculated for one editor rather than all editors on the page. Add
block handle calculation now uses a better algorithm to minimize the
amount of nodes which are iterated.

Additionally, have you noticed how sometimes the add button jumps to the
next node while the drag button is still at the previous node?


https://github.com/payloadcms/payload/assets/70709113/8dff3081-1de0-4902-8229-62f178f23549

No more! Now they behave the same. Feels a lot cleaner now.
2024-05-21 20:55:06 +00:00
Jacob Fletcher
af7e12aa2f chore(ui)!: uses consistent button naming conventions (#6444)
## Description

Renames the `Save` to `SaveButton`, etc. to match the already
established convention of the `PreviewButton`, etc. This matches the
imports with their respective component and type names, and also gives
these components more context to the developer whenever they're
rendered, i.e. its clearly just a button and not an entire block or
complex component.

**BREAKING**:

Import paths for these components have changed, if you were previously
importing these components into your own projects to customize, change
the import paths accordingly:

Old:
```ts
import { PublishButton } from '@payloadcms/ui/elements/Publish'
import { SaveButton } from '@payloadcms/ui/elements/Save'
import { SaveDraftButton } from '@payloadcms/ui/elements/SaveDraft'
```

New:
```ts
import { PublishButton } from '@payloadcms/ui/elements/PublishButton'
import { SaveButton } from '@payloadcms/ui/elements/SaveButton'
import { SaveDraftButton } from '@payloadcms/ui/elements/SaveDraftButton'
```

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.
2024-05-21 14:52:53 -04:00
Patrik
bcc506b423 fix(ui): disableListColumn fields not hidden in table columns (#6445)
## Description

Setting `disableListColumn` to `true` on a field would hide the field
from the column selector but not from the table columns.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-05-21 13:33:28 -04:00
Elliot DeNolf
3d7c8277d7 chore(release): v3.0.0-beta.35 [skip ci] 2024-05-21 10:51:19 -04:00
Paul
a8a2dc2347 chore!: export DefaultListView as ListView (#6432)
Change the exports of DefaultListView and DefaultEditView to be renamed
without "Default" as ListView

```ts
// before
import { DefaultEditView } from '@payloadcms/next/views'
import { DefaultListView } from '@payloadcms/next/views'

// after 
import { EditView } from '@payloadcms/next/views'
import { ListView } from '@payloadcms/next/views'
```
2024-05-21 10:22:44 -04:00
Alessio Gravili
6c74b0326b chore(richtext-lexical): improve node validation messages (#6443) 2024-05-21 10:19:24 -04:00
Paul
f51af92491 chore(translations): ai translation script should use formal language (#6433)
Added additional prompt to make sure the translation we receive is using
formal language where it makes sense.

In the context of latin languages for example:
- Spanish: "tu" should be using "vos"
- French: "tu" should be using "votre"

These differences can affect verb conjugations and in these languages it
comes across as less professional if informal language is used.
2024-05-21 10:15:01 -04:00
Alessio Gravili
77528a1e7d chore(richtext-slate): fix richtext container elements direction 2024-05-21 09:40:17 -04:00
Alessio Gravili
ba8b8e8330 chore(richtext-lexical): improve node validation messages 2024-05-21 09:36:58 -04:00
Jessica Chowdhury
23f9a32a99 fix: user verification email broken (#6442)
## Description

Closes
[#225](https://github.com/payloadcms/payload-3.0-demo/issues/225).

The user verification emails are not being sent and this error is shown:
```ts
APIError: Error sending email: 422 validation_error - Invalid `from` field. The email address needs to follow the `email@example.com` or `Name <email@example.com>` format.
```

The issue is resolved by updating the `from` property on the outgoing
verification email:
```ts
from: `"${email.defaultFromName}" <${email.defaultFromName}>`,
// to
from: `"${email.defaultFromName}" <${email. defaultFromAddress}>`,
```

**NOTE:** This was not broken in 2.0, see correct outgoing email
[here](https://github.com/payloadcms/payload/blob/main/packages/payload/src/auth/sendVerificationEmail.ts#L69).

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [X] Existing test suite passes locally with my changes
2024-05-21 13:25:59 +00:00
Jessica Chowdhury
0190eb8b28 fix(ui): blocks browser save dialog from opening when hotkey used with no changes (#6366) 2024-05-21 09:00:34 -04:00
Anders Semb Hermansen
f482fdcfd5 fix: separate sort and search fields when looking up relationship. (#6440)
## Description

Default sort is used as searching field which is causing unexpected
behaviour described in https://github.com/payloadcms/payload/issues/4815
and https://github.com/payloadcms/payload/issues/5222 This bugfix
separates which field is used for sorting and which is used for
searching.

Fixes: https://github.com/payloadcms/payload/issues/4815
https://github.com/payloadcms/payload/issues/5222

@denolfe This fix is a port of the fix in
[#5964](https://github.com/payloadcms/payload/pull/5964) to beta branch.

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] Existing test suite passes locally with my changes
- [ ] I have made corresponding changes to the documentation
2024-05-20 16:57:49 -04:00
Alessio Gravili
ed4766188d fix(ui): tooltip positioning issues (#6439) 2024-05-20 20:37:53 +00:00
Ritsu
e682cb1b04 fix(ui): update relationship cell formatted value when when search changes (#6208)
## Description

Fixes https://github.com/payloadcms/payload-3.0-demo/issues/181
Although issue is about page changing, it happens as well when you
change sort / limit / where filter (and probably locale)
<!-- Please include a summary of the pull request and any related issues
it fixes. Please also include relevant motivation and context. -->

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

<!-- Please delete options that are not relevant. -->


- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
2024-05-20 16:03:04 -04:00
Elliot DeNolf
36fda30c61 feat: store focal point on uploads (#6436)
Store focal point data on uploads as `focalX` and `focalY`

Addresses https://github.com/payloadcms/payload/discussions/4082

Mirrors #6364 for beta branch.
2024-05-20 15:57:52 -04:00
Alessio Gravili
fa7cc376d1 fix(richtext-lexical): field required validation not working if content was removed manually (#6435) 2024-05-20 17:17:54 +00:00
Paul
3fc2ff1ef9 chore: export DefaultListView for reuse (#6422)
Exports `DefaultListView` so other plugins or custom implementations can
re-use it
2024-05-20 11:53:36 -03:00
Jarrod Flesch
1d81eef805 fix: attributes graphql packages, adds esm import path (#6431) 2024-05-20 10:48:41 -04:00
Paul
8fcfac61b5 fix(plugin-seo): white screen of death on choosing an existing media for meta image (#6424)
Closes https://github.com/payloadcms/payload/issues/6423

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2024-05-19 04:51:19 +00:00
Elliot DeNolf
0d544dacdb chore(release): v3.0.0-beta.34 [skip ci] 2024-05-17 16:12:46 -04:00
Alessio Gravili
147b50e719 fix: page metadata generation not working in turbopack (#6417)
In turbo, payloadFaviconDark is a string, not an object with src
2024-05-17 15:44:12 -04:00
Elliot DeNolf
eeb689dd55 chore(release): v3.0.0-beta.33 [skip ci] 2024-05-17 14:59:23 -04:00
James Mikrut
c2571cfdb6 fix(db-postgres): uuid custom db name (#6408)
## Description

Fixes an issue with creating versions when using custom DB names,
`uuid`, and drafts.

---------

Co-authored-by: PatrikKozak <patrik@payloadcms.com>
2024-05-17 14:11:14 -04:00
Dan Ribbens
12c812d379 fix(db-postgres): query with like on id columns (#6414)
When typing into the search input on the list view of a collection, the
`like` operator is used for id which causes an error for postgres. To
fix this we are sanitizing the `like` for number or uuid fields to
instead be an `equals` operator. An alternate solution would have been
to cast the ids to text `id::text` but this would have performence
implications on larger data sets.

---------

Co-authored-by: James <james@trbl.design>
2024-05-17 14:09:44 -04:00
Alessio Gravili
bf106db6e2 feat(richtext-lexical): new aboveContainer and belowContainer plugin positioning options, fix incorrect placeholder positioning (#6410) 2024-05-17 17:51:51 +00:00
Jacob Fletcher
18009349c0 fix(ui): properly sets hasSavePermission on nested documents (#6394) 2024-05-17 13:41:38 -04:00
Alessio Gravili
89b6055d61 fix: component is undefined error within isReactServerComponentOrFunction (#6411) 2024-05-17 17:24:51 +00:00
Francis Turmel
d9a8869132 chore(translations): French translation improvements (beta branch) (#6406)
## Description

* The apostrophe character `’` should be used instead of the single
quote `'`
* Gender corrections: "L’adresse e-mail fourni**e**", "Vérification
échoué**e**"
* Lowercase: "Supprimer le **té**léversement"
* Dark and light theme: I think it makes more sense to use "Sombre" and
"Clair" here to identify the theme. Day/Night modes imply a hue/warmth
correction and are different features altogether. Reference:
https://fr.wikipedia.org/wiki/Mode_sombre#Mode_sombre_et_mode_nuit_ou_chaud
* Fix accent: "Mis à jour avec succ**è**s"
* "Bienvenue" I think would be the correct standalone greeting form.
Reference:
https://www.projet-voltaire.fr/question-orthographe/orthographe-bienvenu-bienvenue-chez-moi/
* "Recadrer" is the correct word for "crop". "Récolte" means "crop" in
the sense of "harvest", so this was probably a bad literal Google
Translate that slipped through.
* Correct all "Es-tu sûr ?" to the proper formal "Êtes-vous sûr ?" for
consistency
* Use _article défini_ since we will enumerate the values: "Ce champ
contient **les** sélections invalides suivantes :"
* Space before question marks

---

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.
2024-05-17 13:52:17 -03:00
Alessio Gravili
9d5c0d350c feat!: upgrade minimum next version to 14.3.0-canary.68 & upgrade react packages, react-toastify (#6387)
**BREAKING:**
- The minimum required next version is now 14.3.0-canary.68. This is
because we are migrating away from the deprecated
experimental.serverComponentsExternalPackages next config key to
experimental.serverExternalPackages, which is not available in older
next canaries
- The minimum `react` and `react-dom` versions have been bumped to
^18.2.0 or ^19.0.0. This matches the minimum react version recommended
by next
2024-05-17 12:48:37 -04:00
Alessio Gravili
4dedd6e267 fix: turbopack RSC detection (#6405) 2024-05-17 11:40:10 -04:00
Alessio Gravili
276213193b feat: allow client components and extra rsc props for custom edit and list views (#6395) 2024-05-17 11:25:33 -04:00
Jacob Fletcher
553bb4b530 fix(next)!: removes initPage export from barrel file (#6403) 2024-05-17 09:37:54 -04:00
James Mikrut
5083525189 fix: loader support for server-only (#6383)
## Description

Allows `server-only` to work within the Payload loader.

Fixes https://github.com/payloadcms/payload-3.0-demo/issues/218
2024-05-17 09:07:18 -04:00
Elliot DeNolf
e4185259b4 ci: properly prefix proposed release in release script with 'v' 2024-05-16 22:34:21 -04:00
Alessio Gravili
5323d76a5b fix: react-select menu is hidden behind lexical fixed toolbar (#6396) 2024-05-16 21:09:41 +00:00
Alessio Gravili
608387084c fix(richtext-lexical): upload, relationship and block node insertion fails sometimes 2024-05-16 16:02:53 -04:00
Jacob Fletcher
9556d1bd42 feat!: replaces admin.meta.ogImage with admin.meta.openGraph.images (#6227) 2024-05-16 12:40:15 -04:00
Paul
a6bf05815c chore!: remove unused staticOptions config on uploads (#6378)
Removes the unused `staticOptions` on upload config, it was previously
typed to express configuration and is unused anywhere in the codebase
2024-05-16 15:15:35 +00:00
Patrik
a4deaf07d6 fix(next): incorrect stepnav breadcrumbs after selecting existing upload (#6372)
## Description

Fixes [this](https://github.com/payloadcms/payload-3.0-demo/issues/202)
v3 demo issue

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-05-16 10:23:32 -04:00
Jacob Fletcher
4adf01ab04 fix(next): does not wrap custom views with template by default (#6379) 2024-05-16 09:10:27 -04:00
Alessio Gravili
1abcdf96fa fix(translations): type StripCountVariants not working in TS strict mode (#6374)
This also removes the unnecessary StripCountVariants utility use for
when NestedKeysStripped<T[K]> is an object
2024-05-15 21:30:57 -04:00
Paul
3456b5f6a7 chore: eslint updates to the tailwind example (#6377)
minor updates to eslint rules in tailwind example
2024-05-16 00:56:19 +00:00
Paul
d053778bf2 chore: update form builder example (#6376)
Updates the form builder example
2024-05-15 21:22:54 -03:00
Patrik
fbad39a120 fix: safely access cookie header for uploads (#6373)
## Description

Issue with editing and changing the crop or focal point of an image

`fix`: adds optional chaining to safely access cookie header when
fetching image

v2 PR [here](https://github.com/payloadcms/payload/pull/6367)

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-05-15 21:04:21 +00:00
Patrik
e8d1d369cf fix(db-postgres): filter with ID not_in AND queries (#6359)
## Description

v2 PR [here](https://github.com/payloadcms/payload/pull/6358)

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] Existing test suite passes locally with my changes
2024-05-15 15:10:51 -04:00
Alessio Gravili
fbea52416c feat(richtext-lexical)!: upgrade lexical from 0.14.5 to 0.15.0 (#6371)
**BREAKING:** This upgrades all lexical packages from 0.14.5 to 0.15.0.
If there are any breaking changes within lexical, this could break your
project if you use lexical APIs directly (e.g. in custom features). We
have not noticed any breaking changes within core. Please consult their
changelog: https://github.com/facebook/lexical/releases/tag/v0.15.0
2024-05-15 13:44:51 -04:00
Alessio Gravili
cb9a20fefa fix: loader throwing errors for client files imported using TS paths (#6369)
## Description

Fixes https://github.com/payloadcms/payload-3.0-demo/issues/215

imports like import LogoSVG from '@/components/logo.svg' did not make it
to the TS module resolution, due to the early isClient check.

And the isClient check only uses node module resolution (using
nextResolves) which throws an error here.

This removes module resolution completely, as we "ignore" client files
anyways. Should also help improve performance, and we do not have to
fall back to ts module resolution for client files that way, which would
be unnecessary
2024-05-15 12:53:07 -04:00
Alessio Gravili
8db9664700 fix(richtext-lexical): autoLink node styles not inherited from original text node on creation
Backports https://github.com/facebook/lexical/pull/6069
2024-05-15 12:50:30 -04:00
Alessio Gravili
08add653c7 chore(richtext-lexical): replace deprecated event.keyCode with event.code 2024-05-15 12:42:14 -04:00
Alessio Gravili
eed9676536 chore(richtext-lexical): add @lexical/eslint-plugin eslint plugin and fix all eslint errors & warnings 2024-05-15 12:41:10 -04:00
Alessio Gravili
22480a7648 feat(richtext-lexical)!: upgrade lexical from 0.14.5 to 0.15.0 and ensure peerDependencies force correct lexical version 2024-05-15 12:22:26 -04:00
Jarrod Flesch
aa2073f9e9 chore: adjusts how file uploads are handled, consolidates reading to busboy (#6346) 2024-05-15 11:42:04 -04:00
Alessio Gravili
e0618f81a5 chore: loosen type for ClientTranslationsObject to improve TS performance (#6368) 2024-05-15 15:18:43 +00:00
Alessio Gravili
ea90018979 chore: auto-translation script for v3, and translate all missing translation keys (#6361) 2024-05-15 13:54:25 +00:00
Jessica Chowdhury
5a4074e90a fix: multiselect relationship bug and improve accessibility (#6286)
## Description

Closes [#117](https://github.com/payloadcms/payload-3.0-demo/issues/177)
- hitting the space key while the `ReactSelect` is in focus crashes the
page.

This PR makes the following changes:
- Multivalue select component updated to only use `id`, drag feature
does not work when using `uuid()`
- Ensures relationship field (multi and single value) can be accessed
via the keyboard

- [X] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [X] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [X] Existing test suite passes locally with my changes
2024-05-15 09:33:47 -04:00
Elliot DeNolf
0e9bbecbee chore(release): v3.0.0-beta.32 [skip ci] 2024-05-14 17:33:28 -04:00
Alessio Gravili
c8a1ccaf4b feat(richtext-lexical): add rootFeatures prop to lexicalEditor (#6360) 2024-05-14 17:29:21 -04:00
Alessio Gravili
79f4907cb3 feat!: add missing server-only props to custom RSCs, improve req.user type, clean-up ui imports (#6355) 2024-05-14 17:27:15 -04:00
Jacob Fletcher
6a0fffe002 feat!: consolidates admin.logoutRoute and admin.inactivityRoute into admin.routes (#6354) 2024-05-14 21:18:19 +00:00
Jarrod Flesch
6e116a76fd fix(graphql): threads through correct draft value for upload relations (#6235) 2024-05-14 14:05:58 -04:00
Elliot DeNolf
f52607f3b5 chore(release): v3.0.0-beta.31 [skip ci] 2024-05-14 12:37:38 -04:00
Alessio Gravili
f716122eab feat!: typed i18n (#6343) 2024-05-14 11:10:31 -04:00
Patrik
353c2b0be2 fix(ui): step-nav breadcrumbs ellipsis (#6344) 2024-05-14 11:08:34 -04:00
Jessica Chowdhury
58bbbbd395 fix: collection labels with multiple locales showing incorrectly (#5998) 2024-05-14 15:05:36 +00:00
Jessica Chowdhury
57b072edfc chore: fix indentation in API tab json for empty nested objects/arrays (#6150) 2024-05-14 14:44:10 +00:00
Jacob Fletcher
f6039246c6 feat!: replaces admin.favicon with admin.icons (#6347) 2024-05-14 09:56:07 -04:00
Jessica Chowdhury
fcee13b017 fix: depth field in api view throws error when no value present (#6106) 2024-05-14 13:46:16 +00:00
Jacob Fletcher
ef5197a514 Merge branch 'beta' into feat/next-icons 2024-05-14 09:30:01 -04:00
Jacob Fletcher
7438812db3 feat!: replaces admin.favicon with admin.icons 2024-05-14 08:56:21 -04:00
Elliot DeNolf
48af78278d ci: run protected branch actions to completion 2024-05-13 19:30:02 -04:00
Jarrod Flesch
3abc2e8328 fix: implements graphql schema generation (#6254)
Co-authored-by: Elliot DeNolf <denolfe@gmail.com>
2024-05-13 16:46:43 -04:00
Jacob Fletcher
a48043c2aa fix(next): replaces default svg favicons with png 2024-05-13 16:33:50 -04:00
Jacob Fletcher
7e4f50a01c feat(ui): exports png favicons 2024-05-13 16:27:53 -04:00
Elliot DeNolf
40d3078bf2 feat(cpa): initialize git repo on project creation (#6342) 2024-05-13 14:59:39 -04:00
Elliot DeNolf
662334abfb ci: bump playwright actions/cache usage to v4 2024-05-13 14:01:32 -04:00
Paul
aa5ad47177 fix: named tab being required in generated types without any required fields (#6324) 2024-05-13 13:55:48 -04:00
James Mikrut
890c21dda4 fix(payload): loader alias issues (#6341) 2024-05-13 13:49:16 -04:00
Patrik
c7635b2783 fix(ui): properly adds readOnly styles to disabled radio fields (#6240) 2024-05-13 12:18:36 -04:00
James Mikrut
47cd5f4d01 fix(db-postgres): too many clients already, cannot read properties 'transaction' of undefined (#6338) 2024-05-13 12:13:30 -04:00
Alessio Gravili
0d98b4b96f fix!: some custom components were not handled properly if they are RSCs (#6315)
**Breaking:** The following, exported components now need the `payload` object as a prop rather than the `config` object:
- `RenderCustomComponent` (optional)
- `Logo`
- `DefaultTemplate`
- `DefaultNav`
2024-05-13 12:05:13 -04:00
Elliot DeNolf
23c7ab2bc4 ci: add plugin-relationship-object-ids to publish list (#6335) 2024-05-13 10:14:10 -04:00
Fredrik
0680e0c58b chore(plugin-nested-docs): export the getParents utility (#6325) 2024-05-13 13:39:35 +00:00
Jessica Chowdhury
b3df253880 feat: adds translations for API view and select component (#6143) 2024-05-13 13:03:26 +00:00
Elliot DeNolf
a78b44d0c1 docs: add storage adapter docs (#6334) 2024-05-13 08:46:05 -04:00
Elliot DeNolf
d272a1fd22 docs: update email docs for new adapters (#6332) 2024-05-13 08:44:59 -04:00
Elliot DeNolf
095e4402ac test: type fixes (#6331) 2024-05-13 01:37:52 +00:00
Elliot DeNolf
60d2def428 chore: create file that imports all 2.x exports (#6224) 2024-05-12 21:28:19 -04:00
Paul
2f02b3a6e1 fix: wrong translation key being used as upload:Sizes instead of upload:sizes (#6323) 2024-05-11 15:09:38 +00:00
Jarrod Flesch
4f0ddcf632 chore: improves lexical fixed toolbar styles (#6317) 2024-05-10 17:38:24 -04:00
Jarrod Flesch
693621a6e3 chore: improves types for lexical client features (#6318) 2024-05-10 17:38:10 -04:00
Elliot DeNolf
5b201392cc ci: add storage-uploadthing 2024-05-10 17:15:22 -04:00
1161 changed files with 79018 additions and 79307 deletions

View File

@@ -11,3 +11,4 @@
playwright.config.ts
jest.config.js
test/live-preview/next-app
tsconfig.tsbuildinfo

50
.github/workflows/label-author.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: label-author
on:
pull_request:
types: [opened]
issues:
types: [opened]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
debug-context:
runs-on: ubuntu-latest
steps:
- name: View context attributes
uses: actions/github-script@v7
with:
script: console.log(context)
label-created-by:
name: Label pr/issue on opening
runs-on: ubuntu-latest
steps:
- name: Tag with 'created-by'
uses: actions/github-script@v7
if: github.event.action == 'opened'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const type = context.payload.pull_request ? 'pull_request' : 'issue';
const association = context.payload[type].author_association;
let label = ''
if (association === 'MEMBER' || association === 'OWNER') {
label = 'created-by: Payload team';
} else if (association === 'CONTRIBUTOR') {
label = 'created-by: Contributor';
}
if (!label) return;
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: [label],
});
console.log('Added created-by: Payload team label');

View File

@@ -12,7 +12,8 @@ on:
- beta
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# <workflow_name>-<branch_name>-<true || commit_sha if branch is protected>
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.ref_protected && github.sha || ''}}
cancel-in-progress: true
env:
@@ -288,7 +289,8 @@ jobs:
suite:
- _community
- access-control
- admin
- admin__e2e__1
- admin__e2e__2
- auth
- field-error-states
- fields-relationship
@@ -297,9 +299,17 @@ jobs:
- fields__collections__Array
- fields__collections__Relationship
- fields__collections__RichText
- fields__collections__Lexical
- fields__collections__Lexical__e2e__main
- fields__collections__Lexical__e2e__blocks
- fields__collections__Date
- fields__collections__Number
- fields__collections__Point
- fields__collections__Tabs
- fields__collections__Text
- fields__collections__Upload
- live-preview
- localization
- i18n
- plugin-cloud-storage
- plugin-form-builder
- plugin-nested-docs
@@ -344,7 +354,7 @@ jobs:
- name: Cache Playwright Browsers for Playwright's Version
id: cache-playwright-browsers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
@@ -417,13 +427,12 @@ jobs:
cd templates/blank-3.0
cp .env.example .env
ls -la
pnpm add ./*.tgz
pnpm install --ignore-workspace
pnpm add ./*.tgz --ignore-workspace
pnpm install --ignore-workspace --no-frozen-lockfile
cat package.json
pnpm run build
tests-type-generation:
if: false # This should be replaced with gen on a real Payload project
runs-on: ubuntu-latest
needs: build

27
.idea/payload.iml generated
View File

@@ -47,6 +47,33 @@
<excludeFolder url="file://$MODULE_DIR$/templates" />
<excludeFolder url="file://$MODULE_DIR$/test/.swc" />
<excludeFolder url="file://$MODULE_DIR$/versions" />
<excludeFolder url="file://$MODULE_DIR$/packages/richtext-slate/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/richtext-slate/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/email-nodemailer/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/email-nodemailer/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/email-resend/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/email-resend/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/live-preview-vue/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/live-preview-vue/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/payload/.swc" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-form-builder/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-relationship-object-ids/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-relationship-object-ids/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-stripe/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-azure/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-azure/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-gcs/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-gcs/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-s3/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-s3/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-uploadthing/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-uploadthing/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-vercel-blob/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/storage-vercel-blob/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/translations/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/translations/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/ui/.swc" />
<excludeFolder url="file://$MODULE_DIR$/packages/ui/.turbo" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -13,3 +13,4 @@ tsconfig.json
packages/payload/*.js
packages/payload/*.d.ts
payload-types.ts
tsconfig.tsbuildinfo

9
.swcrc
View File

@@ -7,6 +7,15 @@
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {

View File

@@ -565,10 +565,11 @@ These are the props that will be passed to your custom Label.
#### Example
```tsx
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { getTranslation } from '@payloadcms/translations'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { getTranslation } from 'payload/utilities/getTranslation'
type Props = {
htmlFor?: string
@@ -680,21 +681,21 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo
### Getting the current language
When developing custom components you can support multiple languages to be consistent with Payload's i18n support. The best way to do this is to add your translation resources to the [i18n configuration](https://payloadcms.com/docs/configuration/i18n) and import `useTranslation` from `react-i18next` in your components.
When developing custom components you can support multiple languages to be consistent with Payload's i18n support. The best way to do this is to add your translation resources to the [i18n configuration](https://payloadcms.com/docs/configuration/i18n) and import `useTranslation` from `@payloadcms/ui/providers/Translation` in your components.
For example:
```tsx
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
const CustomComponent: React.FC = () => {
// highlight-start
const { t, i18n } = useTranslation('namespace1')
const { t, i18n } = useTranslation()
// highlight-end
return (
<ul>
<li>{t('key', { variable: 'value' })}</li>
<li>{t('namespace1:key', { variable: 'value' })}</li>
<li>{t('namespace2:key', { variable: 'value' })}</li>
<li>{i18n.language}</li>
</ul>

View File

@@ -33,7 +33,7 @@ All options for the Admin panel are defined in your base Payload config file.
| `bundler` | The bundler that you would like to use to bundle the admin panel. Officially supported bundlers: [Webpack](/docs/admin/webpack) and [Vite](/docs/admin/vite). |
| `user` | The `slug` of a Collection that you want be used to log in to the Admin dashboard. [More](/docs/admin/overview#the-admin-user-collection) |
| `buildPath` | Specify an absolute path for where to store the built Admin panel bundle used in production. Defaults to `path.resolve(process.cwd(), 'build')`. |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `ogImage`, and `favicon`. |
| `meta` | Base meta data to use for the Admin panel. Included properties are `titleSuffix`, `icons`, and `openGraph`. Can be overridden on a per collection or per global basis. |
| `disable` | If set to `true`, the entire Admin panel will be disabled. |
| `indexHTML` | Optionally replace the entirety of the `index.html` file used by the Admin panel. Reference the [base index.html file](https://github.com/payloadcms/payload/blob/main/packages/payload/src/admin/index.html) to ensure your replacement has the appropriate HTML elements. |
| `css` | Absolute path to a stylesheet that you can use to override / customize the Admin panel styling. [More](/docs/admin/customizing-css). |
@@ -45,8 +45,7 @@ All options for the Admin panel are defined in your base Payload config file.
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) |
| `vite` | Customize the Vite config that's used to generate the Admin panel. [More](/docs/admin/vite) |
| `logoutRoute` | The route for the `logout` page. |
| `inactivityRoute` | The route for the `logout` inactivity page. |
| `routes` | Replace built-in Admin Panel routes with your own custom routes. I.e. `{ logout: '/custom-logout', inactivity: 'custom-inactivity' }` |
### The Admin User Collection

View File

@@ -80,6 +80,7 @@ property on a collection's config.
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this collection. |
| `enableRichTextLink` | The [Rich Text](/docs/fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `meta` | Metadata overrides to apply to the [Admin panel](../admin/overview). Included properties are `description` and `openGraph`. |
| `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More](/docs/live-preview/overview). |
| `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) |

View File

@@ -74,6 +74,7 @@ You can customize the way that the Admin panel behaves on a Global-by-Global bas
| `preview` | Function to generate a preview URL within the Admin panel for this global that can point to your app. [More](#preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More](/docs/live-preview/overview). |
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this collection. |
| `meta` | Metadata overrides to apply to the [Admin panel](../admin/overview). Included properties are `description` and `openGraph`. |
### Preview

View File

@@ -6,7 +6,7 @@ desc: Manage and customize internationalization support in your CMS editor exper
keywords: internationalization, i18n, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
Not only does Payload support managing localized content, it also has internationalization support so that admin users can work in their preferred language. Payload's i18n support is built on top of [i18next](https://www.i18next.com). It comes included by default and can be extended in your config.
Not only does Payload support managing localized content, it also has internationalization support so that admin users can work in their preferred language. It comes included by default and can be extended in your config.
While Payload's built-in features come translated, you may want to also translate parts of your project's configuration too. This is possible in places like collections and globals labels and groups, field labels, descriptions and input placeholder text. The admin UI will display all the correct translations you provide based on the user's language.
@@ -72,9 +72,7 @@ After a user logs in, they can change their language selection in the `/account`
Payload's backend uses express middleware to set the language on incoming requests before they are handled. This allows backend validation to return error messages in the user's own language or system generated emails to be sent using the correct translation. You can make HTTP requests with the `accept-language` header and Payload will use that language.
Anywhere in your Payload app that you have access to the `req` object, you can access i18next's extensive internationalization features assigned to `req.i18n`. To access text translations you can use `req.t('namespace:key')`.
Read the i18next [API documentation](https://www.i18next.com/overview/api) to learn more.
Anywhere in your Payload app that you have access to the `req` object, you can access payload's extensive internationalization features assigned to `req.i18n`. To access text translations you can use `req.t('namespace:key')`.
### Configuration Options
@@ -88,9 +86,8 @@ import { buildConfig } from 'payload/config'
export default buildConfig({
//...
i18n: {
fallbackLng: 'en', // default
debug: false, // default
resources: {
fallbackLanguage: 'en', // default
translations: {
en: {
custom: {
// namespace can be anything you want
@@ -107,4 +104,63 @@ export default buildConfig({
})
```
See the i18next [configuration options](https://www.i18next.com/overview/configuration-options) to learn more.
## Types for custom translations
In order to use custom translations in your project, you need to provide the types for the translations. Here is an example of how you can define the types for the custom translations in a custom react component:
```ts
'use client'
import type { NestedKeysStripped } from '@payloadcms/translations'
import type React from 'react'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
const customTranslations = {
en: {
general: {
test: 'Custom Translation',
},
},
}
type CustomTranslationObject = typeof customTranslations.en
type CustomTranslationKeys = NestedKeysStripped<CustomTranslationObject>
export const MyComponent: React.FC = () => {
const { i18n, t } = useTranslation<CustomTranslationObject, CustomTranslationKeys>() // These generics merge your custom translations with the default client translations
return t('general:test')
}
```
Additionally, payload exposes the `t` function in various places, for example in labels. Here is how you would type those:
```ts
import type {
DefaultTranslationKeys,
NestedKeysStripped,
TFunction,
} from '@payloadcms/translations'
import type { Field } from 'payload/types'
const customTranslations = {
en: {
general: {
test: 'Custom Translation',
},
},
}
type CustomTranslationObject = typeof customTranslations.en
type CustomTranslationKeys = NestedKeysStripped<CustomTranslationObject>
const field: Field = {
name: 'myField',
type: 'text',
label: (
{ t }: { t: TFunction<CustomTranslationKeys | DefaultTranslationKeys> }, // The generic passed to TFunction does not automatically merge the custom translations with the default translations. We need to merge them ourselves here
) => t('fields:addLabel'),
}
```

View File

@@ -2,166 +2,165 @@
title: Email Functionality
label: Overview
order: 10
desc: Payload uses NodeMailer to allow you to send emails smoothly from your app. Set up email functions such as password resets, order confirmations and more.
desc: Payload uses an adapter pattern to enable email functionality. Set up email functions such as password resets, order confirmations and more.
keywords: email, overview, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---
### Introduction
Payload comes ready to send your application's email. Whether you simply need built-in password reset
email to work or you want customers to get an order confirmation email, you're almost there. Payload makes use of
[NodeMailer](https://nodemailer.com) for email and won't get in your way for those already familiar.
Payload has a few email adapters that can be imported to enable email functionality. The [@payloadcms/email-nodemailer](https://www.npmjs.com/package/@payloadcms/email-nodemailer) package will be the package most will want to install. This package provides an easy way to use [Nodemailer](https://nodemailer.com) for email and won't get in your way for those already familiar.
For email to send from your Payload server, some configuration is required. The settings you provide will be set
in the `email` property object of your payload init call. Payload will make use of the transport that you have configured for it for things like reset password or verifying new user accounts and email send methods are available to you as well on your payload instance.
The email adapter should be passed into the `email` property of the Payload config. This will allow Payload to send emails for things like password resets, new user verification, and any other email sending needs you may have.
### Configuration
**Three ways to set it up**
#### Default Configuration
1. **Default**: When email is not needed, a mock email handler will be created and used when nothing is provided. This is ideal for development environments and can be changed later when ready to [go to production](/docs/production/deployment).
1. **Recommended**: Set the `transportOptions` and Payload will do the set up for you.
1. **Advanced**: The `transport` object can be assigned a nodemailer transport object set up in your server scripts and given for Payload to use.
When email is not needed or desired, Payload will log a warning on startup notifying that email is not configured. A warning message will also be logged on any attempt to send an email.
The following options are configurable in the `email` property object as part of the options object when calling payload.init().
#### Email Adapter
| Option | Description |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`fromName`** \* | The name part of the From field that will be seen on the delivered email |
| **`fromAddress`** \* | The email address part of the From field that will be used when delivering email |
| **`transport`** | The NodeMailer transport object for when you want to do it yourself, not needed when transportOptions is set |
| **`transportOptions`** | An object that configures the transporter that Payload will create. For all the available options see the [NodeMailer documentation](https://nodemailer.com) or see the examples below |
| **`logMockCredentials`** | If set to true and no transport/transportOptions, ethereal credentials will be logged to console on startup |
An email adapter will require at least the following fields:
_\* An asterisk denotes that a property is required._
| Option | Description |
| --------------------------- | -------------------------------------------------------------------------------- |
| **`defaultFromName`** \* | The name part of the From field that will be seen on the delivered email |
| **`defaultFromAddress`** \* | The email address part of the From field that will be used when delivering email |
#### Officlal Email Adapters
| Name | Package | Description |
| ---------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nodemailer | [@payloadcms/email-nodemailer](https://www.npmjs.com/package/@payloadcms/email-nodemailer) | Use any [Nodemailer transport](https://nodemailer.com/transports), including SMTP, Resend, SendGrid, and more. This was provided by default in Payload 2.x. This is the easiest migration path. |
| Resend | [@payloadcms/email-resend](https://www.npmjs.com/package/@payloadcms/email-resend) | Resend email via their REST API. This is preferred for serverless platforms such as Vercel because it is much more lightweight than the nodemailer adapter. |
### Nodemailer Configuration
| Option | Description |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`transport`** | The Nodemailer transport object for when you want to do it yourself, not needed when transportOptions is set |
| **`transportOptions`** | An object that configures the transporter that Payload will create. For all the available options see the [Nodemailer documentation](https://nodemailer.com) or see the examples below |
### Use SMTP
Simple Mail Transfer Protocol (SMTP) options can be passed in using the `transportOptions` object on the `email` options. See the [NodeMailer SMTP documentation](https://nodemailer.com/smtp/) for more information, including details on when `secure` should and should not be set to `true`.
Simple Mail Transfer Protocol (SMTP) options can be passed in using the `transportOptions` object on the `email` options. See the [Nodemailer SMTP documentation](https://nodemailer.com/smtp/) for more information, including details on when `secure` should and should not be set to `true`.
**Example email options using SMTP:**
```ts
payload.init({
email: {
import { buildConfig } from 'payload/config'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
email: nodemailerAdapter({
defaultFromAddress: 'info@payloadcms.com',
defaultFromName: 'Payload',
// Nodemailer transportOptions
transportOptions: {
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
port: Number(process.env.SMTP_HOST),
secure: Number(process.env.SMTP_PORT) === 465, // true for port 465, false (the default) for 587 and others
requireTLS: true,
},
fromName: 'hello',
fromAddress: 'hello@example.com',
},
// ...
}),
})
```
<Banner type="warning">
It is best practice to avoid saving credentials or API keys directly in your code, use
[environment variables](/docs/configuration/overview#using-environment-variables-in-your-config).
</Banner>
### Use an email service
Many third party mail providers are available and offer benefits beyond basic SMTP. As an example, your payload init could look like this if you wanted to use SendGrid.com, though the same approach would work for any other [NodeMailer transports](https://nodemailer.com/transports/) shown here or provided by another third party.
**Example email options using nodemailer.createTransport:**
```ts
import payload from 'payload'
import nodemailerSendgrid from 'nodemailer-sendgrid'
const sendGridAPIKey = process.env.SENDGRID_API_KEY
payload.init({
...(sendGridAPIKey
? {
email: {
transportOptions: nodemailerSendgrid({
apiKey: sendGridAPIKey,
}),
fromName: 'Admin',
fromAddress: 'admin@example.com',
},
}
: {}),
})
```
### Use a custom NodeMailer transport
To take full control of the mail transport you may wish to use `nodemailer.createTransport()` on your server and provide it to Payload init.
```ts
import payload from 'payload'
import { buildConfig } from 'payload/config'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import nodemailer from 'nodemailer'
const payload = require('payload')
const nodemailer = require('nodemailer')
const transport = await nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
export default buildConfig({
email: nodemailerAdapter({
defaultFromAddress: 'info@payloadcms.com',
defaultFromName: 'Payload',
// Any Nodemailer transport can be used
transport: nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
}),
}),
})
```
payload.init({
email: {
fromName: 'Admin',
fromAddress: 'admin@example.com',
transport,
},
// ...
**Custom Transport:**
You also have the ability to bring your own nodemailer transport. This is an example of using the SendGrid nodemailer transport.
```ts
import { buildConfig } from 'payload/config'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import nodemailerSendgrid from 'nodemailer-sendgrid'
export default buildConfig({
email: nodemailerAdapter({
defaultFromAddress: 'info@payloadcms.com',
defaultFromName: 'Payload',
transportOptions: nodemailerSendgrid({
apiKey: process.env.SENDGRID_API_KEY,
}),
}),
})
```
During development, if you pass nothing to `nodemailerAdapter`, it will use the [ethereal.email](https://ethereal.email) service.
This will log the ethereal.email details to console on startup.
```ts
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
export default buildConfig({
email: nodemailerAdapter(),
})
```
### Resend Configuration
The Resend adapter requires an API key to be passed in the options. This can be found in the Resend dashboard. This is the preferred package if you are deploying on Vercel because this is much more lightweight than the Nodemailer adapter.
| Option | Description |
| ------ | ----------------------------------- |
| apiKey | The API key for the Resend service. |
```ts
import { buildConfig } from 'payload/config'
import { resendAdapter } from '@payloadcms/email-resend'
export default buildConfig({
email: resendAdapter({
defaultFromAddress: 'dev@payloadcms.com',
defaultFromName: 'Payload CMS',
apiKey: process.env.RESEND_API_KEY || '',
}),
})
```
### Sending Mail
With a working transport you can call it anywhere you have access to payload by calling `payload.sendEmail(message)`. The `message` will contain the `to`, `subject` and `email` or `text` for the email being sent. To see all available message configuration options see [NodeMailer](https://nodemailer.com/message).
### Mock transport
By default, Payload uses a mock implementation that only sends mail to the [ethereal](https://ethereal.email) capture service that will never reach a user's inbox. While in development you may wish to make use of the captured messages which is why the payload output during server output helpfully logs this out on the server console.
To see ethereal credentials, add `logMockCredentials: true` to the email options. This will cause them to be logged to console on startup.
With a working transport you can call it anywhere you have access to payload by calling `payload.sendEmail(message)`. The `message` will contain the `to`, `subject` and `html` or `text` for the email being sent. Other options are also available and can be seen in the sendEmail args. Support for these will depend on the adapter being used.
```ts
payload.init({
email: {
fromName: 'Admin',
fromAddress: 'admin@example.com',
logMockCredentials: true, // Optional
},
// ...
// Example of sending an email
const email = await payload.sendEmail({
to: 'test@example.com',
subject: 'This is a test email',
text: 'This is my message body',
})
```
**Console output when starting payload with a mock email instance and logMockCredentials: true**
```
[06:37:21] INFO (payload): Starting Payload...
[06:37:22] INFO (payload): Payload Demo Initialized
[06:37:22] INFO (payload): listening on 3000...
[06:37:22] INFO (payload): Connected to MongoDB server successfully!
[06:37:23] INFO (payload): E-mail configured with mock configuration
[06:37:23] INFO (payload): Log into mock email provider at https://ethereal.email
[06:37:23] INFO (payload): Mock email account username: hhav5jw7doo4euev@ethereal.email
[06:37:23] INFO (payload): Mock email account password: VNdGcvDZeyEhtuPBqf
```
The mock email handler is used when payload is started with neither `transport` or `transportOptions` to know how to deliver email.
<Banner type="warning">
The randomly generated email account username and password will be different each time the Payload
server starts.
</Banner>
### Using multiple mail providers
Payload supports the use of a single transporter of email, but there is nothing stopping you from having more. Consider a use case where sending bulk email is handled differently than transactional email and could be done using a [hook](/docs/hooks/overview).

View File

@@ -52,8 +52,7 @@ Every Payload Collection can opt-in to supporting Uploads by specifying the `upl
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
| **`handlers`** | Array of Express request handlers to execute before the built-in Payload static middleware executes. |
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
| **`staticOptions`** | Set options for `express.static` to use while serving your static files. [More](http://expressjs.com/en/resources/middleware/serve-static.html) |
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) | |
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |

View File

@@ -0,0 +1,343 @@
---
title: Storage Adapters
label: Overview
order: 20
desc: Payload provides additional storage adapters to handle file uploads. These adapters allow you to store files in different locations, such as Amazon S3, Vercel Blob Storage, Google Cloud Storage, Uploadthing, and more.
keywords: uploads, images, media, storage, adapters, s3, vercel, google cloud, azure
---
Payload offers additional storage adapters to handle file uploads. These adapters allow you to store files in different locations, such as Amazon S3, Vercel Blob Storage, Google Cloud Storage, and more.
| Service | Package |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Vercel Blob | [`@payloadcms/storage-vercel-blob`](https://github.com/payloadcms/payload/tree/beta/packages/storage-vercel-blob) |
| AWS S3 | [`@payloadcms/storage-s3`](https://github.com/payloadcms/payload/tree/beta/packages/storage-s3) |
| Azure | [`@payloadcms/storage-azure`](https://github.com/payloadcms/payload/tree/beta/packages/storage-azure) |
| Google Cloud Storage | [`@payloadcms/storage-gcs`](https://github.com/payloadcms/payload/tree/beta/packages/storage-gcs) |
### Vercel Blob Storage [`@payloadcms/storage-vercel-blob`](https://www.npmjs.com/package/@payloadcms/storage-vercel-blob)
#### Installation
```sh
pnpm add @payloadcms/storage-vercel-blob
```
#### Usage
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
- Ensure you have `BLOB_READ_WRITE_TOKEN` set in your Vercel environment variables. This is usually set by Vercel automatically after adding blob storage to your project.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
```ts
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
import { Media } from './collections/Media'
import { MediaWithPrefix } from './collections/MediaWithPrefix'
export default buildConfig({
collections: [Media, MediaWithPrefix],
plugins: [
vercelBlobStorage({
enabled: true, // Optional, defaults to true
// Specify which collections should use Vercel Blob
collections: {
[Media.slug]: true,
[MediaWithPrefix.slug]: {
prefix: 'my-prefix',
},
},
// Token provided by Vercel once Blob storage is added to your Vercel project
token: process.env.BLOB_READ_WRITE_TOKEN,
}),
],
})
```
#### Configuration Options
| Option | Description | Default |
| -------------------- | -------------------------------------------------------------------- | ----------------------------- |
| `enabled` | Whether or not to enable the plugin | `true` |
| `collections` | Collections to apply the Vercel Blob adapter to | |
| `addRandomSuffix` | Add a random suffix to the uploaded file name in Vercel Blob storage | `false` |
| `cacheControlMaxAge` | Cache-Control max-age in seconds | `365 * 24 * 60 * 60` (1 Year) |
| `token` | Vercel Blob storage read/write token | `''` |
### S3 Storage [`@payloadcms/storage-s3`](https://www.npmjs.com/package/@payloadcms/storage-s3)
#### Installation
```sh
pnpm add @payloadcms/storage-s3
```
#### Usage
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
- The `config` object can be any [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object (from [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3)). _This is highly dependent on your AWS setup_. Check the AWS documentation for more information.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
```ts
import { s3Storage } from '@payloadcms/storage-s3'
import { Media } from './collections/Media'
import { MediaWithPrefix } from './collections/MediaWithPrefix'
export default buildConfig({
collections: [Media, MediaWithPrefix],
plugins: [
s3Storage({
collections: {
[mediaSlug]: true,
[mediaWithPrefixSlug]: {
prefix,
},
},
bucket: process.env.S3_BUCKET,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
region: process.env.S3_REGION,
// ... Other S3 configuration
},
}),
],
})
```
##### Configuration Options
See the the [AWS SDK Package](https://github.com/aws/aws-sdk-js-v3) and [`S3ClientConfig`](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/s3) object for guidance on AWS S3 configuration.
### Azure Blob Storage - [`@payloadcms/storage-azure`](https://www.npmjs.com/package/@payloadcms/storage-azure)
#### Installation
```sh
pnpm add @payloadcms/storage-azure
```
#### Usage
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
```ts
import { azureStorage } from '@payloadcms/storage-azure'
import { Media } from './collections/Media'
import { MediaWithPrefix } from './collections/MediaWithPrefix'
export default buildConfig({
collections: [Media, MediaWithPrefix],
plugins: [
azureStorage({
collections: {
[mediaSlug]: true,
[mediaWithPrefixSlug]: {
prefix,
},
},
allowContainerCreate: process.env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
baseURL: process.env.AZURE_STORAGE_ACCOUNT_BASEURL,
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
}),
],
})
```
#### Configuration Options
| Option | Description | Default |
| ---------------------- | ------------------------------------------------------------------------ | ------- |
| `enabled` | Whether or not to enable the plugin | `true` |
| `collections` | Collections to apply the Azure Blob adapter to | |
| `allowContainerCreate` | Whether or not to allow the container to be created if it does not exist | `false` |
| `baseURL` | Base URL for the Azure Blob storage account | |
| `connectionString` | Azure Blob storage connection string | |
| `containerName` | Azure Blob storage container name | |
### Google Cloud Storage [`@payloadcms/storage-gcs`](https://www.npmjs.com/package/@payloadcms/storage-gcs)
#### Installation
```sh
pnpm add @payloadcms/storage-gcs
```
#### Usage
- Configure the `collections` object to specify which collections should use the Vercel Blob adapter. The slug _must_ match one of your existing collection slugs.
- When enabled, this package will automatically set `disableLocalStorage` to `true` for each collection.
```ts
import { gcsStorage } from '@payloadcms/storage-gcs'
import { Media } from './collections/Media'
import { MediaWithPrefix } from './collections/MediaWithPrefix'
export default buildConfig({
collections: [Media, MediaWithPrefix],
plugins: [
gcsStorage({
collections: {
[mediaSlug]: true,
[mediaWithPrefixSlug]: {
prefix,
},
},
bucket: process.env.GCS_BUCKET,
options: {
apiEndpoint: process.env.GCS_ENDPOINT,
projectId: process.env.GCS_PROJECT_ID,
},
}),
],
})
```
#### Configuration Options
| Option | Description | Default |
| ------------- | --------------------------------------------------------------------------------------------------- | --------- |
| `enabled` | Whether or not to enable the plugin | `true` |
| `collections` | Collections to apply the storage to | |
| `bucket` | The name of the bucket to use | |
| `options` | Google Cloud Storage client configuration. See [Docs](https://github.com/googleapis/nodejs-storage) | |
| `acl` | Access control list for files that are uploaded | `Private` |
### Uploadthing Storage [`@payloadcms/storage-uploadthing`](https://www.npmjs.com/package/@payloadcms/storage-uploadthing)
#### Installation
```sh
pnpm add @paylaodcms/storage-uploadthing
```
#### Usage
- Configure the `collections` object to specify which collections should use uploadthing. The slug _must_ match one of your existing collection slugs and be an `upload` type.
- Get an API key from Uploadthing and set it as `apiKey` in the `options` object.
- `acl` is optional and defaults to `public-read`.
```ts
export default buildConfig({
collections: [Media],
plugins: [
uploadthingStorage({
collections: {
[mediaSlug]: true,
},
options: {
apiKey: process.env.UPLOADTHING_SECRET,
acl: 'public-read',
},
}),
],
})
```
#### Configuration Options
| Option | Description | Default |
| ---------------- | ----------------------------------------------- | ------------- |
| `apiKey` | API key from Uploadthing. Required. | |
| `acl` | Access control list for files that are uploaded | `public-read` |
| `logLevel` | Log level for Uploadthing | `info` |
| `fetch` | Custom fetch function | `fetch` |
| `defaultKeyType` | Default key type for file operations | `fileKey` |
### Custom Storage Adapters
If you need to create a custom storage adapter, you can use the [`@payloadcms/plugin-cloud-storage`](https://www.npmjs.com/package/@payloadcms/plugin-cloud-storage) package. This package is used internally by the storage adapters mentioned above.
#### Installation
`pnpm add @payloadcms/plugin-cloud-storage`
#### Usage
Reference any of the existing storage adapters for guidance on how this should be structured. Create an adapter following the `GeneratedAdapter` interface. Then, pass the adapter to the `cloudStorage` plugin.
```ts
export interface GeneratedAdapter {
/**
* Additional fields to be injected into the base collection and image sizes
*/
fields?: Field[]
/**
* Generates the public URL for a file
*/
generateURL?: GenerateURL
handleDelete: HandleDelete
handleUpload: HandleUpload
name: string
onInit?: () => void
staticHandler: StaticHandler
}
```
```ts
import { buildConfig } from 'payload/config'
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
export default buildConfig({
plugins: [
cloudStorage({
collections: {
'my-collection-slug': {
adapter: theAdapterToUse, // see docs for the adapter you want to use
},
},
}),
],
// The rest of your config goes here
})
```
### Plugin options
This plugin is configurable to work across many different Payload collections. A `*` denotes that the property is required.
| Option | Type | Description |
| ---------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `collections` \* | `Record<string, CollectionOptions>` | Object with keys set to the slug of collections you want to enable the plugin for, and values set to collection-specific options. |
| `enabled` | | `boolean` to conditionally enable/disable plugin. Default: true. |
### Collection-specific options
| Option | Type | Description |
| ----------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adapter` \* | [Adapter](https://github.com/payloadcms/plugin-cloud-storage/blob/master/src/types.ts#L51) | Pass in the adapter that you'd like to use for this collection. You can also set this field to `null` for local development if you'd like to bypass cloud storage in certain scenarios and use local storage. |
| `disableLocalStorage` | `boolean` | Choose to disable local storage on this collection. Defaults to `true`. |
| `disablePayloadAccessControl` | `true` | Set to `true` to disable Payload's access control. [More](#payload-access-control) |
| `prefix` | `string` | Set to `media/images` to upload files inside `media/images` folder in the bucket. |
| `generateFileURL` | [GenerateFileURL](https://github.com/payloadcms/plugin-cloud-storage/blob/master/src/types.ts#L53) | Override the generated file URL with one that you create. |
### Payload Access Control
Payload ships with access control that runs _even on statically served files_. The same `read` access control property on your `upload`-enabled collections is used, and it allows you to restrict who can request your uploaded files.
To preserve this feature, by default, this plugin _keeps all file URLs exactly the same_. Your file URLs won't be updated to point directly to your cloud storage source, as in that case, Payload's access control will be completely bypassed and you would need public readability on your cloud-hosted files.
Instead, all uploads will still be reached from the default `/collectionSlug/staticURL/filename` path. This plugin will "pass through" all files that are hosted on your third-party cloud service—with the added benefit of keeping your existing access control in place.
If this does not apply to you (your upload collection has `read: () => true` or similar) you can disable this functionality by setting `disablePayloadAccessControl` to `true`. When this setting is in place, this plugin will update your file URLs to point directly to your cloud host.
### Conditionally Enabling/Disabling
The proper way to conditionally enable/disable this plugin is to use the `enabled` property.
```ts
cloudStoragePlugin({
enabled: process.env.MY_CONDITION === 'true',
collections: {
'my-collection-slug': {
adapter: theAdapterToUse, // see docs for the adapter you want to use
},
},
}),
```

View File

@@ -21,7 +21,7 @@
"@payloadcms/richtext-slate": "3.0.0-beta.24",
"@payloadcms/ui": "3.0.0-beta.24",
"cross-env": "^7.0.3",
"next": "14.3.0-canary.7",
"next": "14.3.0-canary.68",
"payload": "3.0.0-beta.24",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = null

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_PAYLOAD_URL=http://localhost:3000

View File

@@ -0,0 +1,21 @@
module.exports = {
extends: ['plugin:@next/next/core-web-vitals', '@payloadcms'],
ignorePatterns: ['**/payload-types.ts'],
overrides: [
{
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
{
files: ['./app/**/*.ts', './app/**/*.tsx'],
rules: {
'no-restricted-exports': 'off',
},
},
],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
root: true,
}

View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env

View File

@@ -0,0 +1,38 @@
# Form Builder Example Front-End
This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages). It was made explicitly for Payload's [Form Builder Example](https://github.com/payloadcms/payload/tree/main/examples/form-builder/payload).
> This example uses the Pages Router, the legacy API of Next.js. If your app is using the latest [App Router](https://nextjs.org/docs/app), we will add an example for that soon.
## Getting Started
### Payload
First you'll need a running Payload app. There is one made explicitly for this example and [can be found here](https://github.com/payloadcms/payload/tree/main/examples/form-builder/payload). If you have not done so already, clone it down and follow the setup instructions there.
### Next.js App
1. Clone this repo
2. `cd` into this directory and run `yarn` or `npm install`
3. `cp .env.example .env` to copy the example environment variables
4. `yarn dev` or `npm run dev` to start the server
5. `open http://localhost:3001` to see the result
Once running you will find a couple seeded pages on your local environment with some basic instructions. You can also start editing the pages by modifying the documents within Payload. See the [Form Builder Example](https://github.com/payloadcms/payload/tree/main/examples/form-builder/payload) for full details.
## Learn More
To learn more about Payload and Next.js, take a look at the following resources:
- [Payload Documentation](https://payloadcms.com/docs) - learn about Payload features and API.
- [Form Builder Plugin Documentation](https://github.com/payloadcms/plugin-form-builder) - learn about the plugin's features.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Payload GitHub repository](https://github.com/payloadcms/payload/) as well as [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Payload deployment documentation](https://payloadcms.com/docs/production/deployment) or the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@@ -0,0 +1,40 @@
import type { GetStaticPaths } from 'next'
import React from 'react'
import type { Page } from '../../payload-types'
import Blocks from '../../components/Blocks'
export default async function Page({ params: { slug = 'home' } }) {
const page = await getPage(slug)
return (
<React.Fragment>
<Blocks blocks={page.layout} />
</React.Fragment>
)
}
export const getPage = async (slug: string) => {
const pageQuery = await fetch(
`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages?where[slug][equals]=${slug}`,
).then((res) => res.json())
return pageQuery.docs[0]
}
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
const pagesQuery: { docs: Page[] } = await fetch(
`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/pages?limit=100`,
).then((res) => res.json())
return {
fallback: 'blocking',
paths: pagesQuery.docs.map((page) => ({
params: {
slug: page.slug,
},
})),
}
}

View File

@@ -0,0 +1,53 @@
import { ModalContainer, ModalProvider } from '@faceless-ui/modal'
import React from 'react'
import type { MainMenu } from '../payload-types'
import { CloseModalOnRouteChange } from '../components/CloseModalOnRouteChange'
import { Header } from '../components/Header'
import '../css/app.scss'
import { GlobalsProvider } from '../providers/Globals'
export interface IGlobals {
mainMenu: MainMenu
}
export const metadata = {
description: 'An example of how to authenticate with Payload from a Next.js app.',
title: 'Payload Auth + Next.js App Router Example',
}
export const getAllGlobals = async (): Promise<IGlobals> => {
const [mainMenu] = await Promise.all([
fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/globals/main-menu?depth=1`).then((res) =>
res.json(),
),
])
return {
mainMenu,
}
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
const globals = await getAllGlobals()
return (
<html lang="en">
<body>
<React.Fragment>
<GlobalsProvider {...globals}>
<ModalProvider classPrefix="form" transTime={0} zIndex="var(--modal-z-index)">
<CloseModalOnRouteChange />
<Header />
{/* <Component {...pageProps} /> */}
{children}
<ModalContainer />
</ModalProvider>
</GlobalsProvider>
</React.Fragment>
</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
import Page from './[slug]/page'
export default Page

View File

@@ -0,0 +1,69 @@
@use "../shared.scss";
.checkbox {
position: relative;
margin-bottom: var(--base);
:global {
button {
border: 0;
background: none;
box-shadow: none;
border-radius: 0;
padding: 0;
display: flex;
align-items: center;
cursor: pointer;
font-size: 1rem;
&:focus,
&:active {
outline: none;
}
&:hover {
svg {
opacity: 0.2;
}
}
}
input {
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
}
}
.container {
display: flex;
}
.input {
@include shared.formInput;
padding: 0;
line-height: 0;
position: relative;
width: var(--base);
height: var(--base);
margin-right: calc(var(--base) * 0.5);
margin-bottom: 0;
svg {
opacity: 0;
}
}
.checked {
:global {
svg {
opacity: 1 !important;
}
}
}
.label {
display: block;
}

View File

@@ -0,0 +1,62 @@
import type { CheckboxField } from 'payload-plugin-form-builder/dist/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import React, { useState } from 'react'
import { Check } from '../../../icons/Check'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Checkbox: React.FC<
CheckboxField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
getValues: any
register: UseFormRegister<FieldValues & any>
setValue: any
}
> = ({
name,
errors,
getValues,
label,
register,
required: requiredFromProps,
setValue,
width,
}) => {
const [checked, setChecked] = useState(false)
const isCheckboxChecked = getValues(name)
return (
<Width width={width}>
<div className={[classes.checkbox, checked && classes.checked].filter(Boolean).join(' ')}>
<div className={classes.container}>
<input
type="checkbox"
{...register(name, { required: requiredFromProps })}
checked={isCheckboxChecked}
/>
<button
onClick={() => {
setValue(name, !checked)
setChecked(!checked)
}}
type="button"
>
<span className={classes.input}>
<Check />
</span>
</button>
<span className={classes.label}>{label}</span>
</div>
{requiredFromProps && errors[name] && checked === false && <Error />}
</div>
</Width>
)
}

View File

@@ -0,0 +1,123 @@
@use "../shared.scss";
.select {
position: relative;
margin-bottom: var(--base);
}
.label {
margin-bottom: 10px;
display: block;
}
.reactSelect {
display: flex;
:global {
div.rs__control {
@include shared.formInput;
height: auto;
}
.rs__input-container {
color: var(--color-black);
}
.rs__value-container {
padding: 0;
> * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.rs__single-value {
color: var(--color-black);
}
.rs__indicators {
position: absolute;
top: calc(var(--base) * 0.9);
right: calc(var(--base) * 0.9);
.arrow {
transform: rotate(90deg);
}
}
.rs__indicator {
padding: 0px 4px;
cursor: pointer;
svg path {
fill: var(--color-dark-gray);
}
&:hover {
svg path {
fill: var(--color-dark-gray);
}
}
}
.rs__indicator-separator {
display: none;
}
.rs__menu {
color: var(--color-black);
background-color: var(--color-white);
z-index: 2;
border-radius: 0;
box-shadow: 0 4px 11px hsl(0deg 0% 0% / 10%);
}
.rs__menu-list {
padding: calc(var(--base) / 4) 0;
}
.rs__group-heading {
margin-bottom: calc(var(--base) / 2);
}
.rs__option {
font-size: 1rem;
padding: calc(var(--base) / 2) var(--base);
&--is-focused {
background-color: var(--color-light-gray);
color: var(--color-black);
}
&--is-selected {
background-color: var(--color-light-gray);
color: var(--color-black);
}
}
.rs__multi-value {
padding: 0;
background: var(--color-light-gray);
}
.rs__multi-value__label {
max-width: 150px;
color: var(--color-black);
padding: calc(var(--base) / 8) calc(var(--base) / 4);
}
.rs__multi-value__remove {
cursor: pointer;
&:hover {
color: var(--color-black);
background: var(--color-light-gray);
}
}
.rs__clear-indicator {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,50 @@
import type { CountryField } from 'payload-plugin-form-builder/dist/types'
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form';
import React from 'react'
import { Controller } from 'react-hook-form'
import ReactSelect from 'react-select'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
import { countryOptions } from './options'
export const Country: React.FC<
CountryField & {
control: Control<FieldValues, any>
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<div className={classes.select}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => (
<ReactSelect
className={classes.reactSelect}
classNamePrefix="rs"
inputId={name}
instanceId={name}
onChange={(val) => onChange(val.value)}
options={countryOptions}
value={countryOptions.find((c) => c.value === value)}
/>
)}
rules={{ required }}
/>
{required && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -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',
},
]

View File

@@ -0,0 +1,15 @@
@use "../shared.scss";
.wrap {
position: relative;
margin-bottom: var(--base);
}
.input {
@include shared.formInput;
}
.label {
margin-bottom: 10px;
display: block;
}

View File

@@ -0,0 +1,37 @@
import type { EmailField } from 'payload-plugin-form-builder/dist/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Email: React.FC<
EmailField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues & any>
}
> = ({ name, errors, label, register, required: requiredFromProps, width }) => {
return (
<Width width={width}>
<div className={classes.wrap}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<input
className={classes.input}
id={name}
placeholder="Email"
type="text"
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -0,0 +1,4 @@
.error {
margin-top: 5px;
color: var(--color-red);
}

View File

@@ -0,0 +1,7 @@
import * as React from 'react'
import classes from './index.module.scss'
export const Error: React.FC = () => {
return <div className={classes.error}>This field is required</div>
}

View File

@@ -0,0 +1,9 @@
@use '../.../../../../../css/queries.scss' as *;
.message {
margin: var(--base) 0 var(--base) 0;
@include mid-break {
margin: calc(var(--base) * 0.5) 0 calc(var(--base) * 0.5) 0;
}
}

View File

@@ -0,0 +1,15 @@
import type { MessageField } from 'payload-plugin-form-builder/dist/types'
import React from 'react'
import RichText from '../../../RichText'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Message: React.FC<MessageField> = ({ message }) => {
return (
<Width width="100">
<RichText className={classes.message} content={message} />
</Width>
)
}

View File

@@ -0,0 +1,15 @@
@use "../shared.scss";
.wrap {
position: relative;
margin-bottom: var(--base);
}
.input {
@include shared.formInput;
}
.label {
margin-bottom: 10px;
display: block;
}

View File

@@ -0,0 +1,36 @@
import type { TextField } from 'payload-plugin-form-builder/dist/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Number: React.FC<
TextField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues & any>
}
> = ({ name, errors, label, register, required: requiredFromProps, width }) => {
return (
<Width width={width}>
<div className={classes.wrap}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<input
className={classes.input}
id={name}
type="number"
{...register(name, { required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -0,0 +1,123 @@
@use "../shared.scss";
.select {
position: relative;
margin-bottom: var(--base);
}
.label {
margin-bottom: 10px;
display: block;
}
.reactSelect {
display: flex;
:global {
div.rs__control {
@include shared.formInput;
height: auto;
}
.rs__input-container {
color: var(--color-black);
}
.rs__value-container {
padding: 0;
> * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.rs__single-value {
color: var(--color-black);
}
.rs__indicators {
position: absolute;
top: calc(var(--base) * 0.9);
right: calc(var(--base) * 0.9);
.arrow {
transform: rotate(90deg);
}
}
.rs__indicator {
padding: 0px 4px;
cursor: pointer;
svg path {
fill: var(--color-dark-gray);
}
&:hover {
svg path {
fill: var(--color-dark-gray);
}
}
}
.rs__indicator-separator {
display: none;
}
.rs__menu {
color: var(--color-black);
background-color: var(--color-white);
z-index: 2;
border-radius: 0;
box-shadow: 0 4px 11px hsl(0deg 0% 0% / 10%);
}
.rs__menu-list {
padding: calc(var(--base) / 4) 0;
}
.rs__group-heading {
margin-bottom: calc(var(--base) / 2);
}
.rs__option {
font-size: 1rem;
padding: calc(var(--base) / 2) var(--base);
&--is-focused {
background-color: var(--color-light-gray);
color: var(--color-black);
}
&--is-selected {
background-color: var(--color-light-gray);
color: var(--color-black);
}
}
.rs__multi-value {
padding: 0;
background: var(--color-light-gray);
}
.rs__multi-value__label {
max-width: 150px;
color: var(--color-black);
padding: calc(var(--base) / 8) calc(var(--base) / 4);
}
.rs__multi-value__remove {
cursor: pointer;
&:hover {
color: var(--color-black);
background: var(--color-light-gray);
}
}
.rs__clear-indicator {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,49 @@
import type { SelectField } from 'payload-plugin-form-builder/dist/types'
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form';
import React from 'react'
import { Controller } from 'react-hook-form'
import ReactSelect from 'react-select'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Select: React.FC<
SelectField & {
control: Control<FieldValues, any>
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
}
> = ({ name, control, errors, label, options, required, width }) => {
return (
<Width width={width}>
<div className={classes.select}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => (
<ReactSelect
className={classes.reactSelect}
classNamePrefix="rs"
inputId={name}
instanceId={name}
onChange={(val) => onChange(val.value)}
options={options}
value={options.find((s) => s.value === value)}
/>
)}
rules={{ required }}
/>
{required && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -0,0 +1,123 @@
@use "../shared.scss";
.select {
position: relative;
margin-bottom: var(--base);
}
.label {
margin-bottom: 10px;
display: block;
}
.reactSelect {
display: flex;
:global {
div.rs__control {
@include shared.formInput;
height: auto;
}
.rs__input-container {
color: var(--color-black);
}
.rs__value-container {
padding: 0;
> * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
}
}
.rs__single-value {
color: var(--color-black);
}
.rs__indicators {
position: absolute;
top: calc(var(--base) * 0.9);
right: calc(var(--base) * 0.9);
.arrow {
transform: rotate(90deg);
}
}
.rs__indicator {
padding: 0px 4px;
cursor: pointer;
svg path {
fill: var(--color-dark-gray);
}
&:hover {
svg path {
fill: var(--color-dark-gray);
}
}
}
.rs__indicator-separator {
display: none;
}
.rs__menu {
color: var(--color-black);
background-color: var(--color-white);
z-index: 2;
border-radius: 0;
box-shadow: 0 4px 11px hsl(0deg 0% 0% / 10%);
}
.rs__menu-list {
padding: calc(var(--base) / 4) 0;
}
.rs__group-heading {
margin-bottom: calc(var(--base) / 2);
}
.rs__option {
font-size: 1rem;
padding: calc(var(--base) / 2) var(--base);
&--is-focused {
background-color: var(--color-light-gray);
color: var(--color-black);
}
&--is-selected {
background-color: var(--color-light-gray);
color: var(--color-black);
}
}
.rs__multi-value {
padding: 0;
background: var(--color-light-gray);
}
.rs__multi-value__label {
max-width: 150px;
color: var(--color-black);
padding: calc(var(--base) / 8) calc(var(--base) / 4);
}
.rs__multi-value__remove {
cursor: pointer;
&:hover {
color: var(--color-black);
background: var(--color-light-gray);
}
}
.rs__clear-indicator {
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,50 @@
import type { StateField } from 'payload-plugin-form-builder/dist/types'
import type { Control, FieldErrorsImpl, FieldValues } from 'react-hook-form';
import React from 'react'
import { Controller } from 'react-hook-form'
import ReactSelect from 'react-select'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
import { stateOptions } from './options'
export const State: React.FC<
StateField & {
control: Control<FieldValues, any>
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
}
> = ({ name, control, errors, label, required, width }) => {
return (
<Width width={width}>
<div className={classes.select}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<Controller
control={control}
defaultValue=""
name={name}
render={({ field: { onChange, value } }) => (
<ReactSelect
className={classes.reactSelect}
classNamePrefix="rs"
id={name}
instanceId={name}
onChange={(val) => onChange(val.value)}
options={stateOptions}
value={stateOptions.find((t) => t.value === value)}
/>
)}
rules={{ required }}
/>
{required && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -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' },
]

View File

@@ -0,0 +1,15 @@
@use "../shared.scss";
.wrap {
position: relative;
margin-bottom: var(--base);
}
.input {
@include shared.formInput;
}
.label {
margin-bottom: 10px;
display: block;
}

View File

@@ -0,0 +1,36 @@
import type { TextField } from 'payload-plugin-form-builder/dist/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Text: React.FC<
TextField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues & any>
}
> = ({ name, errors, label, register, required: requiredFromProps, width }) => {
return (
<Width width={width}>
<div className={classes.wrap}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<input
className={classes.input}
id={name}
type="text"
{...register(name, { required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -0,0 +1,16 @@
@use "../shared.scss";
.wrap {
position: relative;
margin-bottom: var(--base);
}
.textarea {
@include shared.formInput;
height: unset;
}
.label {
margin-bottom: 10px;
display: block;
}

View File

@@ -0,0 +1,37 @@
import type { TextField } from 'payload-plugin-form-builder/dist/types'
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
import React from 'react'
import { Error } from '../Error'
import { Width } from '../Width'
import classes from './index.module.scss'
export const Textarea: React.FC<
TextField & {
errors: Partial<
FieldErrorsImpl<{
[x: string]: any
}>
>
register: UseFormRegister<FieldValues & any>
rows?: number
}
> = ({ name, errors, label, register, required: requiredFromProps, rows = 3, width }) => {
return (
<Width width={width}>
<div className={classes.wrap}>
<label className={classes.label} htmlFor={name}>
{label}
</label>
<textarea
className={classes.textarea}
id={name}
rows={rows}
{...register(name, { required: requiredFromProps })}
/>
{requiredFromProps && errors[name] && <Error />}
</div>
</Width>
)
}

View File

@@ -0,0 +1,10 @@
@use '../../../../css/queries.scss' as *;
.width {
padding-left: calc(var(--base) * 0.5);
padding-right: calc(var(--base) * 0.5);
@include mid-break {
width: 100% !important;
}
}

View File

@@ -0,0 +1,14 @@
import * as React from 'react'
import classes from './index.module.scss'
export const Width: React.FC<{
children: React.ReactNode
width?: string
}> = ({ children, width }) => {
return (
<div className={classes.width} style={{ width: width ? `${width}%` : undefined }}>
{children}
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { FormFieldBlock } from 'payload-plugin-form-builder/dist/types'
export const buildInitialFormState = (fields: FormFieldBlock[]) => {
return fields.reduce((initialSchema, field) => {
if (field.blockType === 'checkbox') {
return {
...initialSchema,
[field.name]: false,
}
}
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]: '',
}
}
}, {})
}

View File

@@ -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,
}

View File

@@ -0,0 +1,69 @@
@use '../.../../../../css/queries.scss' as *;
.form {
display: flex;
flex-direction: column;
}
.hasSubmitted {
align-items: center;
justify-content: center;
height: 80vh;
@include small-break {
height: 100%;
}
}
.intro {
margin-bottom: var(--base);
@include mid-break {
margin-bottom: var(--base);
}
}
.confirmationMessage {
max-width: 800px;
text-align: center;
* {
margin: 0;
}
}
.fieldWrap {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
margin-left: calc(var(--base) * -0.5);
margin-right: calc(var(--base) * -0.5);
width: calc(100% + #{var(--base)});
>* {
width: 100%;
}
}
.Select {
& label {
position: absolute;
top: calc(var(--base) * -0.5);
}
}
.sidebarContent {
@include mid-break {
display: none;
}
}
.mobileSidebar {
display: none;
@include mid-break {
display: block;
margin-bottom: calc(var(--base) * 2);
}
}

View File

@@ -0,0 +1,174 @@
'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 } from 'react-hook-form'
import { Button } from '../../Button'
import { Gutter } from '../../Gutter'
import RichText from '../../RichText'
import { buildInitialFormState } from './buildInitialFormState'
import { fields } from './fields'
import classes from './index.module.scss'
export type Value = unknown
export interface Property {
[key: string]: Value
}
export interface Data {
[key: string]: Property | Property[] | Value
}
export type FormBlockType = {
blockName?: string
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: {
[k: string]: unknown
}[]
}
export const FormBlock: React.FC<
FormBlockType & {
id?: string
}
> = (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 },
getValues,
handleSubmit,
register,
setValue,
} = formMethods
const [isLoading, setIsLoading] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
const router = useRouter()
const onSubmit = useCallback(
(data: Data) => {
let loadingTimerID: ReturnType<typeof setTimeout>
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_PAYLOAD_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 (
<Gutter>
<div
className={[classes.form, hasSubmitted && classes.hasSubmitted].filter(Boolean).join(' ')}
>
{enableIntro && introContent && !hasSubmitted && (
<RichText className={classes.intro} content={introContent} />
)}
{!isLoading && hasSubmitted && confirmationType === 'message' && (
<RichText className={classes.confirmationMessage} content={confirmationMessage} />
)}
{isLoading && !hasSubmitted && <p>Loading, please wait...</p>}
{error && <div>{`${error.status || '500'}: ${error.message || ''}`}</div>}
{!hasSubmitted && (
<form id={formID} onSubmit={handleSubmit(onSubmit)}>
<div className={classes.fieldWrap}>
{formFromProps &&
formFromProps.fields &&
formFromProps.fields.map((field, index) => {
const Field: React.FC<any> = fields?.[field.blockType]
if (Field) {
return (
<React.Fragment key={index}>
<Field
form={formFromProps}
{...field}
{...formMethods}
control={control}
errors={errors}
register={register}
/>
</React.Fragment>
)
}
return null
})}
</div>
<Button appearance="primary" el="button" form={formID} label={submitButtonLabel} />
</form>
)}
</div>
</Gutter>
)
}

View File

@@ -0,0 +1,42 @@
@use "../../../css/common.scss" as *;
@mixin formInput() {
all: unset;
-webkit-appearance: none;
box-sizing: border-box;
font-family: var(--font-body);
width: 100%;
border: 1px solid var(--color-black);
background: var(--color-white);
color: var(--color-black);
font-size: 1rem;
height: calc(var(--base) * 2.5);
line-height: var(--base);
padding: calc(var(--base) * 0.75);
&::-moz-placeholder,
&::-webkit-input-placeholder {
color: var(--color-mid-gray);
font-weight: normal;
font-size: 1rem;
}
&:hover {
border-color: var(--color-mid-gray);
}
&:focus,
&:active {
border-color: var(--color-gray);
outline: 0;
}
&:disabled {
background: var(--color-light-gray);
color: var(--color-gray);
&:hover {
border-color: var(--color-light-gray);
}
}
}

View File

@@ -0,0 +1,51 @@
import React, { Fragment } from 'react'
import type { Page } from '../../payload-types'
import { toKebabCase } from '../../utilities/toKebabCase'
import { VerticalPadding } from '../VerticalPadding'
import { FormBlock } from './Form'
const blockComponents = {
formBlock: FormBlock,
}
const Blocks: React.FC<{
blocks: Page['layout']
}> = (props) => {
const { blocks } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
if (hasBlocks) {
return (
<Fragment>
{blocks.map((block, index) => {
const { blockName, blockType, form } = block
const isFormBlock = blockType === 'formBlock'
{
/*@ts-expect-error*/
}
const formID: string = isFormBlock && form && (typeof form === 'string' ? form : form.id)
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType]
return (
<VerticalPadding bottom="small" key={isFormBlock ? formID : index} top="small">
{/*@ts-expect-error*/}
<Block id={toKebabCase(blockName)} {...block} />
</VerticalPadding>
)
}
return null
})}
</Fragment>
)
}
return null
}
export default Blocks

View File

@@ -0,0 +1,59 @@
@import '../../css/type.scss';
.content {
display: flex;
align-items: center;
justify-content: center;
svg {
margin-right: calc(var(--base) / 2);
width: var(--base);
height: var(--base);
}
}
.label {
@extend %label;
display: flex;
align-items: center;
}
.button {
all: unset;
cursor: pointer;
box-sizing: border-box;
display: inline-flex;
position: relative;
text-decoration: none;
padding: 12px 18px;
margin-bottom: var(--base);
align-items: center;
justify-content: center;
}
.appearance--primary {
background-color: var(--color-black);
color: var(--color-white);
&:hover, &:focus {
background-color: var(--color-white);
color: var(--color-black);
box-shadow: inset 0 0 0 1px var(--color-black);
}
}
.appearance--secondary {
background-color: var(--color-white);
box-shadow: inset 0 0 0 1px var(--color-black);
&:hover, &:focus {
background-color: var(--color-black);
color: var(--color-white);
box-shadow: inset 0 0 0 1px var(--color-black);
}
}
.appearance--default {
padding: 0;
margin-left: -8px;
}

View File

@@ -0,0 +1,63 @@
import Link from 'next/link'
import React from 'react'
import classes from './index.module.scss'
export type Props = {
appearance?: 'default' | 'primary' | 'secondary'
className?: string
el?: 'a' | 'button' | 'link'
form?: string
href?: string
label: string
newTab?: boolean
onClick?: () => void
}
const elements = {
a: 'a',
button: 'button',
link: Link,
}
export const Button: React.FC<Props> = ({
appearance,
className: classNameFromProps,
el = 'button',
form,
href,
label,
newTab,
}) => {
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
const Element = elements[el]
const className = [classNameFromProps, classes[`appearance--${appearance}`], classes.button]
.filter(Boolean)
.join(' ')
const elementProps = {
...newTabProps,
className,
form,
href,
}
const content = (
<div className={classes.content}>
<span className={classes.label}>{label}</span>
</div>
)
return (
<Element {...elementProps}>
<React.Fragment>
{el === 'link' && (
<Link {...newTabProps} className={elementProps.className} href={href}>
{content}
</Link>
)}
{el !== 'link' && <React.Fragment>{content}</React.Fragment>}
</React.Fragment>
</Element>
)
}

View File

@@ -0,0 +1,18 @@
'use client'
import type React from 'react';
import { useModal } from '@faceless-ui/modal'
import { usePathname } from 'next/navigation'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export const CloseModalOnRouteChange: React.FC = () => {
const { closeAllModals } = useModal()
const pathname = usePathname()
useEffect(() => {
closeAllModals()
}, [pathname, closeAllModals])
return null
}

View File

@@ -1,9 +1,3 @@
.gutter {
max-width: 1920px;
margin-left: auto;
margin-right: auto;
}
.gutterLeft {
padding-left: var(--gutter-h);
}

View File

@@ -0,0 +1,30 @@
import type { Ref } from 'react';
import React, { forwardRef } from 'react'
import classes from './index.module.scss'
type Props = {
children: React.ReactNode
className?: string
left?: boolean
ref?: Ref<HTMLDivElement>
right?: boolean
}
export const Gutter: React.FC<Props> = forwardRef<HTMLDivElement, Props>((props, ref) => {
const { children, className, left = true, right = true } = props
return (
<div
className={[left && classes.gutterLeft, right && classes.gutterRight, className]
.filter(Boolean)
.join(' ')}
ref={ref}
>
{children}
</div>
)
})
Gutter.displayName = 'Gutter'

View File

@@ -0,0 +1,32 @@
'use client'
import { Modal } from '@faceless-ui/modal'
import React from 'react'
import type { MainMenu } from '../../payload-types'
import { HeaderBar } from '.'
import { Gutter } from '../Gutter'
import { CMSLink } from '../Link'
import classes from './mobileMenuModal.module.scss'
type Props = {
navItems: MainMenu['navItems']
}
export const slug = 'menu-modal'
export const MobileMenuModal: React.FC<Props> = ({ navItems }) => {
return (
<Modal className={classes.mobileMenuModal} slug={slug}>
<HeaderBar />
<Gutter>
<div className={classes.mobileMenuItems}>
{navItems.map(({ link }, i) => {
return <CMSLink className={classes.menuItem} key={i} {...link} />
})}
</div>
</Gutter>
</Modal>
)
}

View File

@@ -0,0 +1,36 @@
@use '../../css/queries.scss' as *;
.header {
padding: var(--base) 0;
z-index: var(--header-z-index);
}
.wrap {
display: flex;
justify-content: space-between;
}
.nav {
a {
text-decoration: none;
margin-left: var(--base);
}
@include mid-break {
display: none;
}
}
.mobileMenuToggler {
all: unset;
cursor: pointer;
display: none;
&[aria-expanded="true"] {
transform: rotate(-25deg);
}
@include mid-break {
display: block;
}
}

View File

@@ -0,0 +1,54 @@
'use client'
import { ModalToggler } from '@faceless-ui/modal'
import Link from 'next/link'
import React from 'react'
import { useGlobals } from '../../providers/Globals'
import { Gutter } from '../Gutter'
import { CMSLink } from '../Link'
import { Logo } from '../Logo'
import { MenuIcon } from '../icons/Menu'
import { MobileMenuModal, slug as menuModalSlug } from './MobileMenuModal'
import classes from './index.module.scss'
type HeaderBarProps = {
children?: React.ReactNode
}
export const HeaderBar: React.FC<HeaderBarProps> = ({ children }) => {
return (
<header className={classes.header}>
<Gutter className={classes.wrap}>
<Link href="/">
<Logo />
</Link>
{children}
<ModalToggler className={classes.mobileMenuToggler} slug={menuModalSlug}>
<MenuIcon />
</ModalToggler>
</Gutter>
</header>
)
}
export const Header: React.FC = () => {
const {
mainMenu: { navItems },
} = useGlobals()
return (
<React.Fragment>
<HeaderBar>
<nav className={classes.nav}>
{navItems.map(({ link }, i) => {
return <CMSLink key={i} {...link} />
})}
</nav>
</HeaderBar>
<MobileMenuModal navItems={navItems} />
</React.Fragment>
)
}

View File

@@ -0,0 +1,31 @@
@use '../../css/common.scss' as *;
.mobileMenuModal {
position: relative;
width: 100%;
height: 100%;
border: none;
padding: 0;
opacity: 1;
display: none;
@include mid-break {
display: block;
}
}
.contentContainer {
padding: 20px;
}
.mobileMenuItems {
display: flex;
flex-direction: column;
height: 100%;
margin-top: 30px;
}
.menuItem {
@extend %h4;
margin-top: 0;
}

View File

@@ -1,16 +1,14 @@
import Link from 'next/link'
import React from 'react'
import type { Page } from '../../../payload/payload-types'
import type { Props as ButtonProps } from '../Button'
import type { Page } from '../../payload-types'
import { Button } from '../Button'
type CMSLinkType = {
appearance?: ButtonProps['appearance']
appearance?: 'default' | 'primary' | 'secondary'
children?: React.ReactNode
className?: string
invert?: ButtonProps['invert']
label?: string
newTab?: boolean
reference?: {
@@ -26,7 +24,6 @@ export const CMSLink: React.FC<CMSLinkType> = ({
appearance,
children,
className,
invert,
label,
newTab,
reference,
@@ -34,19 +31,24 @@ export const CMSLink: React.FC<CMSLinkType> = ({
}) => {
const href =
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
? `${reference?.relationTo !== 'pages' ? `/${reference?.relationTo}` : ''}/${
reference.value.slug
}`
? `/${reference.value.slug}`
: url
if (!href) return null
if (!appearance) {
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
if (href || url) {
if (type === 'custom') {
return (
<Link {...newTabProps} className={className} href={href || url}>
<Link href={url} {...newTabProps} className={className}>
{label && label}
{children && children}
</Link>
)
}
if (href) {
return (
<Link href={href} {...newTabProps} className={className}>
{label && label}
{children && children}
</Link>
@@ -54,14 +56,12 @@ export const CMSLink: React.FC<CMSLinkType> = ({
}
}
return (
<Button
appearance={appearance}
className={className}
href={href}
invert={invert}
label={label}
newTab={newTab}
/>
)
const buttonProps = {
appearance,
href,
label,
newTab,
}
return <Button className={className} {...buttonProps} el="link" />
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
export const Logo: React.FC = () => {
return (
<svg
fill="none"
height="29"
viewBox="0 0 123 29"
width="123"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M34.7441 22.9997H37.2741V16.3297H41.5981C44.7031 16.3297 46.9801 14.9037 46.9801 11.4537C46.9801 8.00369 44.7031 6.55469 41.5981 6.55469H34.7441V22.9997ZM37.2741 14.1447V8.73969H41.4831C43.3921 8.73969 44.3581 9.59069 44.3581 11.4537C44.3581 13.2937 43.3921 14.1447 41.4831 14.1447H37.2741Z"
fill="black"
/>
<path
d="M51.3652 23.3217C53.2742 23.3217 54.6082 22.5627 55.3672 21.3437H55.4132C55.5512 22.6777 56.1492 23.1147 57.2762 23.1147C57.6442 23.1147 58.0352 23.0687 58.4262 22.9767V21.5967C58.2882 21.6197 58.2192 21.6197 58.1502 21.6197C57.7132 21.6197 57.5982 21.1827 57.5982 20.3317V14.9497C57.5982 11.9137 55.6662 10.9017 53.2512 10.9017C49.6632 10.9017 48.1912 12.6727 48.0762 14.9267H50.3762C50.4912 13.3627 51.1122 12.7187 53.1592 12.7187C54.8842 12.7187 55.3902 13.4317 55.3902 14.2827C55.3902 15.4327 54.2632 15.6627 52.4232 16.0077C49.5022 16.5597 47.5242 17.3417 47.5242 19.9637C47.5242 21.9647 49.0192 23.3217 51.3652 23.3217ZM49.8702 19.8027C49.8702 18.5837 50.7442 18.0087 52.8142 17.5947C54.0102 17.3417 55.0222 17.0887 55.3902 16.7437V18.4227C55.3902 20.4697 53.8952 21.5047 51.8712 21.5047C50.4682 21.5047 49.8702 20.9067 49.8702 19.8027Z"
fill="black"
/>
<path
d="M61.4996 27.1167C63.3166 27.1167 64.4436 26.1737 65.5706 23.2757L70.2166 11.2697H67.8476L64.6276 20.2397H64.5816L61.1546 11.2697H58.6936L63.4316 22.8847C62.9716 24.7247 61.9136 25.1847 61.0166 25.1847C60.6486 25.1847 60.4416 25.1617 60.0506 25.1157V26.9557C60.6486 27.0707 60.9936 27.1167 61.4996 27.1167Z"
fill="black"
/>
<path d="M71.5939 22.9997H73.8479V6.55469H71.5939V22.9997Z" fill="black" />
<path
d="M81.6221 23.3447C85.2791 23.3447 87.4871 20.7917 87.4871 17.1117C87.4871 13.4547 85.2791 10.9017 81.6451 10.9017C77.9651 10.9017 75.7571 13.4777 75.7571 17.1347C75.7571 20.8147 77.9651 23.3447 81.6221 23.3447ZM78.1031 17.1347C78.1031 14.6737 79.2071 12.7877 81.6451 12.7877C84.0371 12.7877 85.1411 14.6737 85.1411 17.1347C85.1411 19.5727 84.0371 21.4817 81.6451 21.4817C79.2071 21.4817 78.1031 19.5727 78.1031 17.1347Z"
fill="black"
/>
<path
d="M92.6484 23.3217C94.5574 23.3217 95.8914 22.5627 96.6504 21.3437H96.6964C96.8344 22.6777 97.4324 23.1147 98.5594 23.1147C98.9274 23.1147 99.3184 23.0687 99.7094 22.9767V21.5967C99.5714 21.6197 99.5024 21.6197 99.4334 21.6197C98.9964 21.6197 98.8814 21.1827 98.8814 20.3317V14.9497C98.8814 11.9137 96.9494 10.9017 94.5344 10.9017C90.9464 10.9017 89.4744 12.6727 89.3594 14.9267H91.6594C91.7744 13.3627 92.3954 12.7187 94.4424 12.7187C96.1674 12.7187 96.6734 13.4317 96.6734 14.2827C96.6734 15.4327 95.5464 15.6627 93.7064 16.0077C90.7854 16.5597 88.8074 17.3417 88.8074 19.9637C88.8074 21.9647 90.3024 23.3217 92.6484 23.3217ZM91.1534 19.8027C91.1534 18.5837 92.0274 18.0087 94.0974 17.5947C95.2934 17.3417 96.3054 17.0887 96.6734 16.7437V18.4227C96.6734 20.4697 95.1784 21.5047 93.1544 21.5047C91.7514 21.5047 91.1534 20.9067 91.1534 19.8027Z"
fill="black"
/>
<path
d="M106.181 23.3217C108.021 23.3217 109.148 22.4477 109.792 21.6197H109.838V22.9997H112.092V6.55469H109.838V12.6957H109.792C109.148 11.7757 108.021 10.9247 106.181 10.9247C103.191 10.9247 100.914 13.2707 100.914 17.1347C100.914 20.9987 103.191 23.3217 106.181 23.3217ZM103.26 17.1347C103.26 14.8347 104.341 12.8107 106.549 12.8107C108.573 12.8107 109.815 14.4667 109.815 17.1347C109.815 19.7797 108.573 21.4587 106.549 21.4587C104.341 21.4587 103.26 19.4347 103.26 17.1347Z"
fill="black"
/>
<path
d="M12.2464 2.33838L22.2871 8.83812V21.1752L14.7265 25.8854V13.5484L4.67383 7.05725L12.2464 2.33838Z"
fill="black"
/>
<path d="M11.477 25.2017V15.5747L3.90039 20.2936L11.477 25.2017Z" fill="black" />
<path
d="M120.442 6.30273C119.086 6.30273 117.998 7.29978 117.998 8.75952C117.998 10.2062 119.086 11.1968 120.442 11.1968C121.791 11.1968 122.879 10.2062 122.879 8.75952C122.879 7.29978 121.791 6.30273 120.442 6.30273ZM120.442 10.7601C119.34 10.7601 118.48 9.95207 118.48 8.75952C118.48 7.54742 119.34 6.73935 120.442 6.73935C121.563 6.73935 122.397 7.54742 122.397 8.75952C122.397 9.95207 121.563 10.7601 120.442 10.7601ZM120.52 8.97457L121.048 9.9651H121.641L121.041 8.86378C121.367 8.72042 121.511 8.45975 121.511 8.17302C121.511 7.49528 121.054 7.36495 120.285 7.36495H119.49V9.9651H120.025V8.97457H120.52ZM120.37 7.78853C120.729 7.78853 120.976 7.86673 120.976 8.17953C120.976 8.43368 120.807 8.56402 120.403 8.56402H120.025V7.78853H120.37Z"
fill="black"
/>
</svg>
)
}

View File

@@ -0,0 +1,3 @@
.container {
max-width: 480px;
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import styles from './index.module.scss'
import { serializeLexical } from './serialize'
const RichText: React.FC<{ className?: string; content: any; enableGutter?: boolean }> = ({
className,
content,
enableGutter = true,
}) => {
if (!content) {
return null
}
return (
<div className={[className].filter(Boolean).join(' ')}>
{content &&
!Array.isArray(content) &&
typeof content === 'object' &&
'root' in content &&
serializeLexical({ nodes: content?.root?.children })}
</div>
)
}
export default RichText

View File

@@ -0,0 +1,128 @@
/* eslint-disable regexp/no-obscure-range */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
// @ts-nocheck
//This copy-and-pasted from lexical here 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'
// eslint-disable-next-line
export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']')
// eslint-disable-next-line
export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']')
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
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<TextDetailType | string, number> = {
directionless: IS_DIRECTIONLESS,
unmergeable: IS_UNMERGEABLE,
}
export const ELEMENT_TYPE_TO_FORMAT: Record<Exclude<ElementFormatType, ''>, 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<number, ElementFormatType> = {
[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<TextModeType, 0 | 1 | 2> = {
normal: IS_NORMAL,
segmented: IS_SEGMENTED,
token: IS_TOKEN,
}
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
[IS_NORMAL]: 'normal',
[IS_SEGMENTED]: 'segmented',
[IS_TOKEN]: 'token',
}

View File

@@ -0,0 +1,200 @@
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list'
import type { SerializedHeadingNode } from '@lexical/rich-text'
import type { LinkFields, SerializedLinkNode } from '@payloadcms/richtext-lexical'
import type { SerializedElementNode, SerializedLexicalNode, SerializedTextNode } from 'lexical'
import React, { Fragment } from 'react'
import { CMSLink } from '../Link'
import {
IS_BOLD,
IS_CODE,
IS_ITALIC,
IS_STRIKETHROUGH,
IS_SUBSCRIPT,
IS_SUPERSCRIPT,
IS_UNDERLINE,
} from './nodeFormat'
interface Props {
nodes: SerializedLexicalNode[]
}
export function serializeLexical({ nodes }: Props): JSX.Element {
return (
<Fragment>
{nodes?.map((_node, index): JSX.Element | null => {
if (_node.type === 'text') {
const node = _node as SerializedTextNode
let text = <React.Fragment key={index}>{node.text}</React.Fragment>
if (node.format & IS_BOLD) {
text = <strong key={index}>{text}</strong>
}
if (node.format & IS_ITALIC) {
text = <em key={index}>{text}</em>
}
if (node.format & IS_STRIKETHROUGH) {
text = (
<span key={index} style={{ textDecoration: 'line-through' }}>
{text}
</span>
)
}
if (node.format & IS_UNDERLINE) {
text = (
<span key={index} style={{ textDecoration: 'underline' }}>
{text}
</span>
)
}
if (node.format & IS_CODE) {
text = <code key={index}>{node.text}</code>
}
if (node.format & IS_SUBSCRIPT) {
text = <sub key={index}>{text}</sub>
}
if (node.format & IS_SUPERSCRIPT) {
text = <sup key={index}>{text}</sup>
}
return text
}
if (_node == null) {
return null
}
// 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: SerializedElementNode): JSX.Element | null => {
if (node.children == null) {
return null
} else {
if (node?.type === 'list' && (node as SerializedListNode)?.listType === 'check') {
for (const item of node.children) {
if ('checked' in item) {
if (!item?.checked) {
item.checked = false
}
}
}
return serializeLexical({ nodes: node.children })
} else {
return serializeLexical({ nodes: node.children })
}
}
}
const serializedChildren =
'children' in _node ? serializedChildrenFn(_node as SerializedElementNode) : ''
switch (_node.type) {
case 'linebreak': {
return <br key={index} />
}
case 'paragraph': {
return <p key={index}>{serializedChildren}</p>
}
case 'heading': {
const node = _node as SerializedHeadingNode
type Heading = Extract<keyof JSX.IntrinsicElements, 'h1' | 'h2' | 'h3' | 'h4' | 'h5'>
const Tag = node?.tag as Heading
return <Tag key={index}>{serializedChildren}</Tag>
}
case 'list': {
const node = _node as SerializedListNode
type List = Extract<keyof JSX.IntrinsicElements, 'ol' | 'ul'>
const Tag = node?.tag as List
return (
<Tag className="list" key={index}>
{serializedChildren}
</Tag>
)
}
case 'listitem': {
const node = _node as SerializedListItemNode
if (node?.checked != null) {
return (
<li
aria-checked={node.checked ? 'true' : 'false'}
className={` ${node.checked ? '' : ''}`}
key={index}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="checkbox"
tabIndex={-1}
value={node?.value}
>
{serializedChildren}
</li>
)
} else {
return (
<li key={index} value={node?.value}>
{serializedChildren}
</li>
)
}
}
case 'quote': {
return <blockquote key={index}>{serializedChildren}</blockquote>
}
case 'link': {
const node = _node as SerializedLinkNode
const fields: LinkFields = node.fields
return (
<CMSLink
key={index}
newTab={Boolean(fields?.newTab)}
reference={fields.doc as any}
type={fields.linkType === 'internal' ? 'reference' : 'custom'}
url={fields.url}
>
{serializedChildren}
</CMSLink>
)
}
/* case 'block': {
// todo: fix types
//@ts-expect-error
const block = _node.fields
//@ts-expect-error
const blockType = _node.fields?.blockType
if (!block || !blockType) {
return null
}
switch (blockType) {
case 'content':
return <ContentBlock {...block} />
case 'cta':
return <CallToActionBlock {...block} />
case 'archive':
return <ArchiveBlock {...block} />
case 'mediaBlock':
return <MediaBlock {...block} />
case 'banner':
return <BannerBlock {...block} />
case 'code':
return <CodeBlock {...block} />
default:
return null
}
} */
default:
return null
}
})}
</Fragment>
)
}

View File

@@ -6,6 +6,10 @@
padding-top: calc(var(--block-padding) / 2);
}
.top-small {
padding-top: calc(var(--block-padding) / 3);
}
.bottom-large {
padding-bottom: var(--block-padding);
}
@@ -13,3 +17,7 @@
.bottom-medium {
padding-bottom: calc(var(--block-padding) / 2);
}
.bottom-small {
padding-bottom: calc(var(--block-padding) / 3);
}

View File

@@ -2,7 +2,7 @@ import React from 'react'
import classes from './index.module.scss'
export type VerticalPaddingOptions = 'large' | 'medium' | 'none'
export type VerticalPaddingOptions = 'large' | 'medium' | 'none' | 'small'
type Props = {
bottom?: VerticalPaddingOptions

View File

@@ -0,0 +1,16 @@
.checkBox {
height: var(--base);
width: var(--base);
.stroke {
stroke-width: 1px;
fill: none;
stroke: currentColor;
}
&:local() {
.stroke {
stroke-width: 2px;
}
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import classes from './index.module.scss'
export const Check: React.FC = () => {
return (
<svg className={classes.checkBox} height="100%" viewBox="0 0 25 25" width="100%">
<path
className={classes.stroke}
d="M10.6092 16.0192L17.6477 8.98076"
strokeLinecap="square"
strokeLinejoin="bevel"
/>
<path
className={classes.stroke}
d="M7.35229 12.7623L10.6092 16.0192"
strokeLinecap="square"
strokeLinejoin="bevel"
/>
</svg>
)
}

View File

@@ -1,9 +1,14 @@
@use './queries.scss' as *;
@use './colors.scss' as *;
@use './type.scss' as *;
@import './theme.scss';
:root {
--breakpoint-xs-width : #{$breakpoint-xs-width};
--breakpoint-s-width : #{$breakpoint-s-width};
--breakpoint-m-width : #{$breakpoint-m-width};
--breakpoint-l-width : #{$breakpoint-l-width};
--scrollbar-width: 17px;
--base: 24px;
--font-body: system-ui;
--font-mono: 'Roboto Mono', monospace;
@@ -11,6 +16,9 @@
--gutter-h: 180px;
--block-padding: 120px;
--header-z-index: 100;
--modal-z-index: 90;
@include large-break {
--gutter-h: 144px;
--block-padding: 96px;
@@ -22,20 +30,18 @@
}
}
/////////////////////////////
// GLOBAL STYLES
/////////////////////////////
* {
box-sizing: border-box;
}
html {
@extend %body;
background: var(--theme-bg);
background: var(--color-white);
-webkit-font-smoothing: antialiased;
opacity: 0;
&[data-theme='dark'],
&[data-theme='light'] {
opacity: initial;
}
}
html,
@@ -46,18 +52,18 @@ body,
body {
font-family: var(--font-body);
color: var(--color-black);
margin: 0;
color: var(--theme-text);
}
::selection {
background: var(--theme-success-500);
color: var(--color-base-800);
background: var(--color-green);
color: var(--color-black);
}
::-moz-selection {
background: var(--theme-success-500);
color: var(--color-base-800);
background: var(--color-green);
color: var(--color-black);
}
img {
@@ -94,7 +100,7 @@ p {
margin: var(--base) 0;
@include mid-break {
margin: calc(var(--base) * 0.75) 0;
margin: calc(var(--base) * .75) 0;
}
}
@@ -108,12 +114,12 @@ a {
color: currentColor;
&:focus {
opacity: 0.8;
opacity: .8;
outline: none;
}
&:active {
opacity: 0.7;
opacity: .7;
outline: none;
}
}

View File

@@ -0,0 +1,10 @@
:root {
--color-red: rgb(255,0,0);
--color-green: rgb(178, 255, 214);
--color-white: rgb(255, 255, 255);
--color-dark-gray: rgb(51,52,52);
--color-mid-gray: rgb(196,196,196);
--color-gray: rgb(212,212,212);
--color-light-gray: rgb(244,244,244);
--color-black: rgb(0, 0, 0);
}

View File

@@ -1,2 +1,2 @@
@forward './queries.scss';
@forward './type.scss';
@forward './type.scss';

View File

@@ -1,10 +1,12 @@
// Keep these in sync with the breakpoints exported in '../cssVariables.js'
$breakpoint-xs-width: 400px;
$breakpoint-s-width: 768px;
$breakpoint-m-width: 1024px;
$breakpoint-l-width: 1440px;
////////////////////////////
// MEDIA QUERIES
/////////////////////////////
@mixin extra-small-break {
@media (max-width: #{$breakpoint-xs-width}) {
@content;
@@ -27,4 +29,4 @@ $breakpoint-l-width: 1440px;
@media (max-width: #{$breakpoint-l-width}) {
@content;
}
}
}

View File

@@ -0,0 +1,168 @@
@use 'queries' as *;
/////////////////////////////
// HEADINGS
/////////////////////////////
%h1,
%h2,
%h3,
%h4,
%h5,
%h6 {
font-weight: 700;
}
%h1 {
margin: 50px 0;
font-size: 84px;
line-height: 1;
@include mid-break {
font-size: 70px;
}
@include small-break {
margin: 24px 0;
font-size: 36px;
line-height: 42px;
}
}
%h2 {
margin: 32px 0;
font-size: 56px;
line-height: 1;
@include mid-break {
margin: 36px 0;
font-size: 48px;
}
@include small-break {
margin: 24px 0;
font-size: 28px;
line-height: 32px;
}
}
%h3 {
margin: 28px 0;
font-size: 48px;
line-height: 56px;
@include mid-break {
font-size: 40px;
line-height: 48px;
}
@include small-break {
margin: 24px 0;
font-size: 24px;
line-height: 30px;
}
}
%h4 {
margin: 24px 0;
font-size: 40px;
line-height: 48px;
@include mid-break {
font-size: 33px;
line-height: 36px;
}
@include small-break {
margin: 20px 0;
font-size: 20px;
line-height: 24px;
}
}
%h5 {
margin: 20px 0;
font-size: 32px;
line-height: 42px;
@include mid-break {
font-size: 26px;
line-height: 32px;
}
@include small-break {
margin: 16px 0;
font-size: 18px;
line-height: 24px;
}
}
%h6 {
margin: 20px 0;
font-size: 24px;
line-height: 28px;
@include mid-break {
font-size: 20px;
line-height: 30px;
}
@include small-break {
margin: 16px 0;
font-size: 16px;
line-height: 22px;
}
}
/////////////////////////////
// TYPE STYLES
/////////////////////////////
%body {
font-size: 18px;
line-height: 32px;
@include mid-break {
font-size: 15px;
line-height: 24px;
}
@include small-break {
font-size: 13px;
line-height: 24px;
}
}
%large-body {
font-size: 25px;
line-height: 32px;
@include mid-break {
font-size: 22px;
line-height: 30px;
}
@include small-break {
font-size: 17px;
line-height: 24px;
}
}
%label {
font-size: 16px;
line-height: 24px;
letter-spacing: 3px;
text-transform: uppercase;
@include mid-break {
font-size: 13px;
letter-spacing: 2.75px;
}
@include small-break {
font-size: 12px;
line-height: 18px;
letter-spacing: 2.625px;
}
}

View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ['localhost', process.env.NEXT_PUBLIC_PAYLOAD_URL || ''].filter(Boolean),
},
reactStrictMode: true,
swcMinify: true,
}
module.exports = nextConfig

View File

@@ -0,0 +1,45 @@
{
"name": "payload-example-form-builder-website",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev -p 3001",
"lint": "next lint",
"start": "next start"
},
"dependencies": {
"@apollo/client": "^3.7.0",
"@faceless-ui/css-grid": "^1.2.0",
"@faceless-ui/modal": "^2.0.2",
"@lexical/list": "^0.14.5",
"@lexical/react": "0.14.5",
"@lexical/rich-text": "^0.14.5",
"@lexical/utils": "0.14.5",
"@payloadcms/plugin-form-builder": "beta",
"@payloadcms/richtext-lexical": "beta",
"escape-html": "^1.0.3",
"graphql": "^16.8.1",
"next": "^14.3.0-canary.68",
"react": "18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.41.0",
"react-select": "^5.7.0",
"sass": "^1.55.0",
"slate": "^0.84.0"
},
"devDependencies": {
"@next/eslint-plugin-next": "^13.5.6",
"@types/node": "^20",
"@types/react": "^18.0.21",
"eslint": "8.57.0",
"eslint-config-next": "12.3.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"typescript": "^5.4"
}
}

View File

@@ -0,0 +1,241 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
pages: Page
users: User
forms: Form
'form-submissions': FormSubmission
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
globals: {
'main-menu': MainMenu
}
}
export interface Page {
id: string
title: string
layout: {
form: string | Form
enableIntro?: boolean
introContent: {
[k: string]: unknown
}[]
id?: string
blockName?: string
blockType: 'formBlock'
}[]
slug?: string
updatedAt: string
createdAt: string
_status?: 'draft' | 'published'
}
export interface Form {
id: string
title: string
fields?: (
| {
name: string
label?: string
width?: number
defaultValue?: string
required?: boolean
id?: string
blockName?: string
blockType: 'text'
}
| {
name: string
label?: string
width?: number
defaultValue?: string
required?: boolean
id?: string
blockName?: string
blockType: 'textarea'
}
| {
name: string
label?: string
width?: number
defaultValue?: string
options?: {
label: string
value: string
id?: string
}[]
required?: boolean
id?: string
blockName?: string
blockType: 'select'
}
| {
name: string
label?: string
width?: number
required?: boolean
id?: string
blockName?: string
blockType: 'email'
}
| {
name: string
label?: string
width?: number
required?: boolean
id?: string
blockName?: string
blockType: 'state'
}
| {
name: string
label?: string
width?: number
required?: boolean
id?: string
blockName?: string
blockType: 'country'
}
| {
name: string
label?: string
width?: number
defaultValue?: number
required?: boolean
id?: string
blockName?: string
blockType: 'number'
}
| {
name: string
label?: string
width?: number
required?: boolean
defaultValue?: boolean
id?: string
blockName?: string
blockType: 'checkbox'
}
| {
message?: {
[k: string]: unknown
}[]
id?: string
blockName?: string
blockType: 'message'
}
)[]
submitButtonLabel?: string
confirmationType?: 'message' | 'redirect'
confirmationMessage: {
[k: string]: unknown
}[]
redirect?: {
url: string
}
emails?: {
emailTo?: string
cc?: string
bcc?: string
replyTo?: string
emailFrom?: string
subject: string
message?: {
[k: string]: unknown
}[]
id?: string
}[]
updatedAt: string
createdAt: string
}
export interface User {
id: string
updatedAt: string
createdAt: string
email: string
resetPasswordToken?: string
resetPasswordExpiration?: string
salt?: string
hash?: string
loginAttempts?: number
lockUntil?: string
password?: string
}
export interface FormSubmission {
id: string
form: string | Form
submissionData?: {
field: string
value: string
id?: string
}[]
updatedAt: string
createdAt: string
}
export interface PayloadPreference {
id: string
user: {
relationTo: 'users'
value: string | User
}
key?: string
value?:
| {
[k: string]: unknown
}
| unknown[]
| string
| number
| boolean
| null
updatedAt: string
createdAt: string
}
export interface PayloadMigration {
id: string
name?: string
batch?: number
updatedAt: string
createdAt: string
}
export interface MainMenu {
id: string
navItems?: {
link: {
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
relationTo: 'pages'
value: string | Page
}
url: string
label: string
}
id?: string
}[]
updatedAt?: string
createdAt?: string
}
declare module 'payload' {
export interface GeneratedTypes {
collections: {
pages: Page
users: User
forms: Form
'form-submissions': FormSubmission
'payload-preferences': PayloadPreference
'payload-migrations': PayloadMigration
}
globals: {
'main-menu': MainMenu
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
'use client'
import React, { createContext, useContext } from 'react'
import type { MainMenu } from '../../payload-types'
export type MainMenuType = MainMenu
export interface IGlobals {
mainMenu: MainMenuType
}
export const GlobalsContext = createContext<IGlobals>({} as IGlobals)
export const useGlobals = (): IGlobals => useContext(GlobalsContext)
export const GlobalsProvider: React.FC<
IGlobals & {
children: React.ReactNode
}
> = (props) => {
const { children, mainMenu } = props
return (
<GlobalsContext.Provider
value={{
mainMenu,
}}
>
{children}
</GlobalsContext.Provider>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,29 @@
module.exports.formatSlug = (reference) => {
let slug = ''
const { relationTo, value } = reference
if (typeof value === 'object' && value !== null) {
const { slug: referenceSlug, breadcrumbs } = value
// pages could be nested, so use breadcrumbs
if (relationTo === 'pages') {
if (breadcrumbs) {
const { url: lastCrumbURL = '' } = breadcrumbs?.[breadcrumbs.length - 1] || {} // last crumb
slug = lastCrumbURL
} else {
slug = referenceSlug
}
}
if (relationTo !== 'pages') {
slug = `/${relationTo}/${referenceSlug}`
if (relationTo === 'media') {
slug = value.url
}
}
}
return slug
}

View File

@@ -0,0 +1,5 @@
export const toKebabCase = (string) =>
string
?.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/\s+/g, '-')
.toLowerCase()

View File

@@ -1,4 +1,15 @@
module.exports = {
extends: ['plugin:@next/next/core-web-vitals', '@payloadcms'],
ignorePatterns: ['**/payload-types.ts'],
overrides: [
{
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
root: true,
extends: ['plugin:@next/next/recommended', '@payloadcms'],
}

View File

@@ -1,13 +1,14 @@
import React, { useState, useCallback } from 'react'
import type { Form as FormType } from '@payloadcms/plugin-form-builder/types'
import { useRouter } from 'next/router'
import React, { useCallback, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '../../Button'
import { Gutter } from '../../Gutter'
import RichText from '../../RichText'
import { buildInitialFormState } from './buildInitialFormState'
import { fields } from './fields'
import { Form as FormType } from 'payload-plugin-form-builder/dist/types'
import RichText from '../../RichText'
import { useForm } from 'react-hook-form'
import { useRouter } from 'next/router'
import { Gutter } from '../../Gutter'
import { Button } from '../../Button'
import classes from './index.module.scss'
export type Value = unknown
@@ -17,13 +18,13 @@ export interface Property {
}
export interface Data {
[key: string]: Value | Property | Property[]
[key: string]: Property | Property[] | Value
}
export type FormBlockType = {
blockName?: string
blockType?: 'formBlock'
enableIntro: Boolean
enableIntro: boolean
form: FormType
introContent?: {
[k: string]: unknown
@@ -34,35 +35,34 @@ export const FormBlock: React.FC<
FormBlockType & {
id?: string
}
> = props => {
> = (props) => {
const {
enableIntro,
introContent,
form: formFromProps,
form: { id: formID, submitButtonLabel, confirmationType, redirect, confirmationMessage } = {},
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
introContent,
} = props
const formMethods = useForm({
defaultValues: buildInitialFormState(formFromProps.fields),
})
const {
register,
handleSubmit,
formState: { errors },
control,
setValue,
formState: { errors },
getValues,
handleSubmit,
register,
setValue,
} = formMethods
const [isLoading, setIsLoading] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState<boolean>()
const [error, setError] = useState<{ status?: string; message: string } | undefined>()
const [error, setError] = useState<{ message: string; status?: string } | undefined>()
const router = useRouter()
const onSubmit = useCallback(
(data: Data) => {
let loadingTimerID: NodeJS.Timer
let loadingTimerID: ReturnType<typeof setTimeout>
const submitForm = async () => {
setError(undefined)
@@ -78,14 +78,14 @@ export const FormBlock: React.FC<
try {
const req = await fetch(`${process.env.NEXT_PUBLIC_PAYLOAD_URL}/api/form-submissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
form: formID,
submissionData: dataToSend,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
const res = await req.json()
@@ -95,8 +95,8 @@ export const FormBlock: React.FC<
if (req.status >= 400) {
setIsLoading(false)
setError({
status: res.status,
message: res.errors?.[0]?.message || 'Internal Server Error',
status: res.status,
})
return
@@ -110,7 +110,7 @@ export const FormBlock: React.FC<
const redirectUrl = url
if (redirectUrl) router.push(redirectUrl)
if (redirectUrl) void router.push(redirectUrl)
}
} catch (err) {
console.warn(err)
@@ -121,7 +121,7 @@ export const FormBlock: React.FC<
}
}
submitForm()
void submitForm()
},
[router, formID, redirect, confirmationType],
)
@@ -153,9 +153,9 @@ export const FormBlock: React.FC<
form={formFromProps}
{...field}
{...formMethods}
register={register}
errors={errors}
control={control}
errors={errors}
register={register}
/>
</React.Fragment>
)
@@ -163,7 +163,7 @@ export const FormBlock: React.FC<
return null
})}
</div>
<Button label={submitButtonLabel} appearance="primary" el="button" form={formID} />
<Button appearance="primary" el="button" form={formID} label={submitButtonLabel} />
</form>
)}
</div>

View File

@@ -1,5 +1,7 @@
import React, { Fragment } from 'react'
import { Page } from '../../payload-types'
import type { Page } from '../../payload-types'
import { toKebabCase } from '../../utilities/toKebabCase'
import { VerticalPadding } from '../VerticalPadding'
import { FormBlock } from './Form'
@@ -10,7 +12,7 @@ const blockComponents = {
const Blocks: React.FC<{
blocks: Page['layout']
}> = props => {
}> = (props) => {
const { blocks } = props
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
@@ -23,16 +25,16 @@ const Blocks: React.FC<{
const isFormBlock = blockType === 'formBlock'
{
/*@ts-ignore*/
/*@ts-expect-error*/
}
const formID: string = isFormBlock && form && form.id
const formID: string = isFormBlock && form && (typeof form === 'string' ? form : form.id)
if (blockType && blockType in blockComponents) {
const Block = blockComponents[blockType]
return (
<VerticalPadding key={isFormBlock ? formID : index} top="small" bottom="small">
{/*@ts-ignore*/}
<VerticalPadding bottom="small" key={isFormBlock ? formID : index} top="small">
{/*@ts-expect-error*/}
<Block id={toKebabCase(blockName)} {...block} />
</VerticalPadding>
)

View File

@@ -1,5 +1,3 @@
.richText {
:first-child {
margin-top: 0;
}
.container {
max-width: 480px;
}

View File

@@ -1,16 +1,24 @@
import React from 'react'
import serialize from './serialize'
import classes from './index.module.scss'
import styles from './index.module.scss'
import { serializeLexical } from './serialize'
const RichText: React.FC<{ className?: string; content: any }> = ({ className, content }) => {
const RichText: React.FC<{ className?: string; content: any; enableGutter?: boolean }> = ({
className,
content,
enableGutter = true,
}) => {
if (!content) {
return null
}
return (
<div className={[classes.richText, className].filter(Boolean).join(' ')}>
{serialize(content)}
<div className={[className].filter(Boolean).join(' ')}>
{content &&
!Array.isArray(content) &&
typeof content === 'object' &&
'root' in content &&
serializeLexical({ nodes: content?.root?.children })}
</div>
)
}

View File

@@ -0,0 +1,128 @@
/* eslint-disable regexp/no-obscure-range */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
// @ts-nocheck
//This copy-and-pasted from lexical here 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'
// eslint-disable-next-line
export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']')
// eslint-disable-next-line
export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']')
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
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<TextDetailType | string, number> = {
directionless: IS_DIRECTIONLESS,
unmergeable: IS_UNMERGEABLE,
}
export const ELEMENT_TYPE_TO_FORMAT: Record<Exclude<ElementFormatType, ''>, 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<number, ElementFormatType> = {
[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<TextModeType, 0 | 1 | 2> = {
normal: IS_NORMAL,
segmented: IS_SEGMENTED,
token: IS_TOKEN,
}
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
[IS_NORMAL]: 'normal',
[IS_SEGMENTED]: 'segmented',
[IS_TOKEN]: 'token',
}

View File

@@ -1,93 +1,200 @@
import Link from 'next/link'
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list'
import type { SerializedHeadingNode } from '@lexical/rich-text'
import type { LinkFields, SerializedLinkNode } from '@payloadcms/richtext-lexical'
import type { SerializedElementNode, SerializedLexicalNode, SerializedTextNode } from 'lexical'
import React, { Fragment } from 'react'
import escapeHTML from 'escape-html'
import { Text } from 'slate'
// eslint-disable-next-line no-use-before-define
type Children = Leaf[]
import { CMSLink } from '../Link'
import {
IS_BOLD,
IS_CODE,
IS_ITALIC,
IS_STRIKETHROUGH,
IS_SUBSCRIPT,
IS_SUPERSCRIPT,
IS_UNDERLINE,
} from './nodeFormat'
type Leaf = {
type: string
value?: {
url: string
alt: string
}
children?: Children
url?: string
[key: string]: unknown
interface Props {
nodes: SerializedLexicalNode[]
}
const serialize = (children: Children): React.ReactElement[] =>
children.map((node, i) => {
if (Text.isText(node)) {
let text = <span dangerouslySetInnerHTML={{ __html: escapeHTML(node.text) }} />
export function serializeLexical({ nodes }: Props): JSX.Element {
return (
<Fragment>
{nodes?.map((_node, index): JSX.Element | null => {
if (_node.type === 'text') {
const node = _node as SerializedTextNode
let text = <React.Fragment key={index}>{node.text}</React.Fragment>
if (node.format & IS_BOLD) {
text = <strong key={index}>{text}</strong>
}
if (node.format & IS_ITALIC) {
text = <em key={index}>{text}</em>
}
if (node.format & IS_STRIKETHROUGH) {
text = (
<span key={index} style={{ textDecoration: 'line-through' }}>
{text}
</span>
)
}
if (node.format & IS_UNDERLINE) {
text = (
<span key={index} style={{ textDecoration: 'underline' }}>
{text}
</span>
)
}
if (node.format & IS_CODE) {
text = <code key={index}>{node.text}</code>
}
if (node.format & IS_SUBSCRIPT) {
text = <sub key={index}>{text}</sub>
}
if (node.format & IS_SUPERSCRIPT) {
text = <sup key={index}>{text}</sup>
}
if (node.bold) {
text = <strong key={i}>{text}</strong>
}
return text
}
if (node.code) {
text = <code key={i}>{text}</code>
}
if (_node == null) {
return null
}
if (node.italic) {
text = <em key={i}>{text}</em>
}
// 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: SerializedElementNode): JSX.Element | null => {
if (node.children == null) {
return null
} else {
if (node?.type === 'list' && (node as SerializedListNode)?.listType === 'check') {
for (const item of node.children) {
if ('checked' in item) {
if (!item?.checked) {
item.checked = false
}
}
}
return serializeLexical({ nodes: node.children })
} else {
return serializeLexical({ nodes: node.children })
}
}
}
if (node.underline) {
text = (
<span style={{ textDecoration: 'underline' }} key={i}>
{text}
</span>
)
}
const serializedChildren =
'children' in _node ? serializedChildrenFn(_node as SerializedElementNode) : ''
if (node.strikethrough) {
text = (
<span style={{ textDecoration: 'line-through' }} key={i}>
{text}
</span>
)
}
switch (_node.type) {
case 'linebreak': {
return <br key={index} />
}
case 'paragraph': {
return <p key={index}>{serializedChildren}</p>
}
case 'heading': {
const node = _node as SerializedHeadingNode
return <Fragment key={i}>{text}</Fragment>
}
type Heading = Extract<keyof JSX.IntrinsicElements, 'h1' | 'h2' | 'h3' | 'h4' | 'h5'>
const Tag = node?.tag as Heading
return <Tag key={index}>{serializedChildren}</Tag>
}
case 'list': {
const node = _node as SerializedListNode
if (!node) {
return null
}
type List = Extract<keyof JSX.IntrinsicElements, 'ol' | 'ul'>
const Tag = node?.tag as List
return (
<Tag className="list" key={index}>
{serializedChildren}
</Tag>
)
}
case 'listitem': {
const node = _node as SerializedListItemNode
switch (node.type) {
case 'h1':
return <h1 key={i}>{serialize(node.children)}</h1>
case 'h2':
return <h2 key={i}>{serialize(node.children)}</h2>
case 'h3':
return <h3 key={i}>{serialize(node.children)}</h3>
case 'h4':
return <h4 key={i}>{serialize(node.children)}</h4>
case 'h5':
return <h5 key={i}>{serialize(node.children)}</h5>
case 'h6':
return <h6 key={i}>{serialize(node.children)}</h6>
case 'blockquote':
return <blockquote key={i}>{serialize(node.children)}</blockquote>
case 'ul':
return <ul key={i}>{serialize(node.children)}</ul>
case 'ol':
return <ol key={i}>{serialize(node.children)}</ol>
case 'li':
return <li key={i}>{serialize(node.children)}</li>
case 'link':
return (
<Link href={escapeHTML(node.url)} key={i}>
{serialize(node.children)}
</Link>
)
if (node?.checked != null) {
return (
<li
aria-checked={node.checked ? 'true' : 'false'}
className={` ${node.checked ? '' : ''}`}
key={index}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="checkbox"
tabIndex={-1}
value={node?.value}
>
{serializedChildren}
</li>
)
} else {
return (
<li key={index} value={node?.value}>
{serializedChildren}
</li>
)
}
}
case 'quote': {
return <blockquote key={index}>{serializedChildren}</blockquote>
}
case 'link': {
const node = _node as SerializedLinkNode
default:
return <p key={i}>{serialize(node.children)}</p>
}
})
const fields: LinkFields = node.fields
export default serialize
return (
<CMSLink
key={index}
newTab={Boolean(fields?.newTab)}
reference={fields.doc as any}
type={fields.linkType === 'internal' ? 'reference' : 'custom'}
url={fields.url}
>
{serializedChildren}
</CMSLink>
)
}
/* case 'block': {
// todo: fix types
//@ts-expect-error
const block = _node.fields
//@ts-expect-error
const blockType = _node.fields?.blockType
if (!block || !blockType) {
return null
}
switch (blockType) {
case 'content':
return <ContentBlock {...block} />
case 'cta':
return <CallToActionBlock {...block} />
case 'archive':
return <ArchiveBlock {...block} />
case 'mediaBlock':
return <MediaBlock {...block} />
case 'banner':
return <BannerBlock {...block} />
case 'code':
return <CodeBlock {...block} />
default:
return null
}
} */
default:
return null
}
})}
</Fragment>
)
}

View File

@@ -3,20 +3,26 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev": "next dev -p 3001",
"lint": "next lint",
"start": "next start"
},
"dependencies": {
"@apollo/client": "^3.7.0",
"@faceless-ui/css-grid": "^1.2.0",
"@faceless-ui/modal": "^2.0.2",
"@lexical/list": "^0.14.5",
"@lexical/react": "0.14.5",
"@lexical/rich-text": "^0.14.5",
"@lexical/utils": "0.14.5",
"@payloadcms/plugin-form-builder": "beta",
"@payloadcms/richtext-lexical": "beta",
"escape-html": "^1.0.3",
"graphql": "^16.8.1",
"next": "^13.5.6",
"next": "^14.3.0-canary.68",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.41.0",
"react-select": "^5.7.0",
"sass": "^1.55.0",
@@ -24,11 +30,16 @@
},
"devDependencies": {
"@next/eslint-plugin-next": "^13.5.6",
"@types/node": "18.11.3",
"@types/react": "18.0.21",
"eslint": "8.25.0",
"@types/node": "^20",
"@types/react": "^18.0.21",
"eslint": "8.57.0",
"eslint-config-next": "12.3.1",
"payload-plugin-form-builder": "^1.0.8",
"typescript": "4.8.4"
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"typescript": "^5.4"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
DATABASE_URI=mongodb://127.0.0.1/payload-example-form-builder
PAYLOAD_SECRET=ENTER-STRING-HERE
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
PAYLOAD_PUBLIC_SITE_URL=http://localhost:3001
DATABASE_URI=mongodb://127.0.0.1/payload-template-blank-3-0
PAYLOAD_SECRET=YOUR_SECRET_HERE
PAYLOAD_SEED=true
NEXT_PUBLIC_PAYLOAD_URL=http://localhost:3000

Some files were not shown because too many files have changed in this diff Show More