mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge branch 'master' into feat/time-series-queries
This commit is contained in:
commit
2cb7351ec7
3514 files changed
+269503
-143189
No files matched your search
@@ -0,0 +1,59 @@
|
||||
# Documentation Embeddings Generation System
|
||||
|
||||
## Overview
|
||||
|
||||
The documentation embeddings generation system processes various documentation sources and uploads their metadata to a database for semantic search functionality. The system is located in `apps/docs/scripts/search/` and works by:
|
||||
|
||||
1. **Discovering content sources** from multiple types of documentation
|
||||
2. **Processing content** into structured sections with checksums
|
||||
3. **Generating embeddings** using OpenAI's text-embedding-ada-002 model
|
||||
4. **Storing in database** with vector embeddings for semantic search
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Entry Point
|
||||
- `generate-embeddings.ts` - Main script that orchestrates the entire process
|
||||
- Supports `--refresh` flag to force regeneration of all content
|
||||
|
||||
### Content Sources (`sources/` directory)
|
||||
|
||||
#### Base Classes
|
||||
- `BaseLoader` - Abstract class for loading content from different sources
|
||||
- `BaseSource` - Abstract class for processing and formatting content
|
||||
|
||||
#### Source Types
|
||||
1. **Markdown Sources** (`markdown.ts`)
|
||||
- Processes `.mdx` files from guides and documentation
|
||||
- Extracts frontmatter metadata and content sections
|
||||
|
||||
2. **Reference Documentation** (`reference-doc.ts`)
|
||||
- **OpenAPI References** - Management API documentation from OpenAPI specs
|
||||
- **Client Library References** - JavaScript, Dart, Python, C#, Swift, Kotlin SDKs
|
||||
- **CLI References** - Command-line interface documentation
|
||||
- Processes YAML/JSON specs and matches with common sections
|
||||
|
||||
3. **GitHub Discussions** (`github-discussion.ts`)
|
||||
- Fetches troubleshooting discussions from GitHub using GraphQL API
|
||||
- Uses GitHub App authentication for access
|
||||
|
||||
4. **Partner Integrations** (`partner-integrations.ts`)
|
||||
- Fetches approved partner integration documentation from Supabase database
|
||||
- Technology integrations only (excludes agencies)
|
||||
|
||||
### Processing Flow
|
||||
|
||||
1. **Content Discovery**: Each source loader discovers and loads content files/data
|
||||
2. **Content Processing**: Each source processes content into:
|
||||
- Checksum for change detection
|
||||
- Metadata (title, subtitle, etc.)
|
||||
- Sections with headings and content
|
||||
3. **Change Detection**: Compares checksums against existing database records
|
||||
4. **Embedding Generation**: Uses OpenAI to generate embeddings for new/changed content
|
||||
5. **Database Storage**: Stores in `page` and `page_section` tables with embeddings
|
||||
6. **Cleanup**: Removes outdated pages using version tracking
|
||||
|
||||
### Database Schema
|
||||
|
||||
- **`page`** table: Stores page metadata, content, checksum, version
|
||||
- **`page_section`** table: Stores individual sections with embeddings, token counts
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
description: Docs GraphQL Architecture
|
||||
globs: apps/docs/resources/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Docs GraphQL Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The `/apps/docs/resources` folder contains the GraphQL endpoint architecture for the docs GraphQL endpoint at `/api/graphql`. It follows a modular pattern where each top-level query is organized into its own folder with consistent file structure.
|
||||
|
||||
## Architecture Pattern
|
||||
|
||||
Each GraphQL query follows this structure:
|
||||
|
||||
```
|
||||
resources/
|
||||
├── queryObject/
|
||||
│ ├── queryObjectModel.ts # Data models and business logic
|
||||
│ ├── queryObjectSchema.ts # GraphQL type definitions
|
||||
│ ├── queryObjectResolver.ts # Query resolver and arguments
|
||||
│ ├── queryObjectTypes.ts # TypeScript interfaces (optional)
|
||||
│ └── queryObjectSync.ts # Functions for syncing repo content to the database (optional)
|
||||
├── utils/
|
||||
│ ├── connections.ts # GraphQL connection/pagination utilities
|
||||
│ └── fields.ts # GraphQL field selection utilities
|
||||
├── rootSchema.ts # Main GraphQL schema with all queries
|
||||
└── rootSync.ts # Root sync script for syncing to database
|
||||
```
|
||||
|
||||
## Example queries
|
||||
|
||||
1. **searchDocs** (`globalSearch/`) - Vector-based search across all docs content
|
||||
2. **error** (`error/`) - Error code lookup for Supabase services
|
||||
3. **schema** - GraphQL schema introspection
|
||||
|
||||
## Key Files
|
||||
|
||||
### `rootSchema.ts`
|
||||
- Main GraphQL schema definition
|
||||
- Imports all resolvers and combines them into the root query
|
||||
- Defines the `RootQueryType` with all top-level fields
|
||||
|
||||
### `utils/connections.ts`
|
||||
- Provides `createCollectionType()` for paginated collections
|
||||
- `GraphQLCollectionBuilder` for building collection responses
|
||||
- Standard pagination arguments and edge/node patterns
|
||||
|
||||
### `utils/fields.ts`
|
||||
- `graphQLFields()` utility to analyze requested fields in resolvers
|
||||
- Used for optimizing data fetching based on what fields are actually requested
|
||||
|
||||
## Creating a New Top-Level Query
|
||||
|
||||
To add a new GraphQL query, follow these steps:
|
||||
|
||||
### 1. Create Query Folder Structure
|
||||
```bash
|
||||
mkdir resources/newQuery
|
||||
touch resources/newQuery/newQueryModel.ts
|
||||
touch resources/newQuery/newQuerySchema.ts
|
||||
touch resources/newQuery/newQueryResolver.ts
|
||||
```
|
||||
|
||||
### 2. Define GraphQL Schema (`newQuerySchema.ts`)
|
||||
```typescript
|
||||
import { GraphQLObjectType, GraphQLString } from 'graphql'
|
||||
|
||||
export const GRAPHQL_FIELD_NEW_QUERY = 'newQuery' as const
|
||||
|
||||
export const GraphQLObjectTypeNewQuery = new GraphQLObjectType({
|
||||
name: 'NewQuery',
|
||||
description: 'Description of what this query returns',
|
||||
fields: {
|
||||
id: {
|
||||
type: GraphQLString,
|
||||
description: 'Unique identifier',
|
||||
},
|
||||
// Add other fields...
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Create Data Model (`newQueryModel.ts`)
|
||||
|
||||
> [!NOTE]
|
||||
> The data model should be agnostic to GraphQL. It may import argument types
|
||||
> from `~/__generated__/graphql`, but otherwise all functions and classes
|
||||
> should be unaware of whether they are called for GraphQL resolution.
|
||||
|
||||
> [!TIP]
|
||||
> The types in `~/__generated__/graphql` for a new endpoint will not exist
|
||||
> until the code generation is run in the next step.
|
||||
|
||||
```typescript
|
||||
import { type RootQueryTypeNewQueryArgs } from '~/__generated__/graphql'
|
||||
import { convertPostgrestToApiError, type ApiErrorGeneric } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { supabase } from '~/lib/supabase'
|
||||
|
||||
export class NewQueryModel {
|
||||
constructor(public readonly data: {
|
||||
id: string
|
||||
// other properties...
|
||||
}) {}
|
||||
|
||||
static async loadData(
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
requestedFields: Array<string>
|
||||
): Promise<Result<NewQueryModel[], ApiErrorGeneric>> {
|
||||
// Implement data fetching logic
|
||||
const result = new Result(
|
||||
await supabase()
|
||||
.from('your_table')
|
||||
.select('*')
|
||||
// Add filters based on args
|
||||
)
|
||||
.map((data) => data.map((item) => new NewQueryModel(item)))
|
||||
.mapError(convertPostgrestToApiError)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create Resolver (`newQueryResolver.ts`)
|
||||
```typescript
|
||||
import { GraphQLError, GraphQLNonNull, GraphQLString, type GraphQLResolveInfo } from 'graphql'
|
||||
import { type RootQueryTypeNewQueryArgs } from '~/__generated__/graphql'
|
||||
import { convertUnknownToApiError } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { graphQLFields } from '../utils/fields'
|
||||
import { NewQueryModel } from './newQueryModel'
|
||||
import { GRAPHQL_FIELD_NEW_QUERY, GraphQLObjectTypeNewQuery } from './newQuerySchema'
|
||||
|
||||
async function resolveNewQuery(
|
||||
_parent: unknown,
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
_context: unknown,
|
||||
info: GraphQLResolveInfo
|
||||
): Promise<NewQueryModel[] | GraphQLError> {
|
||||
return (
|
||||
await Result.tryCatchFlat(
|
||||
resolveNewQueryImpl,
|
||||
convertUnknownToApiError,
|
||||
args,
|
||||
info
|
||||
)
|
||||
).match(
|
||||
(data) => data,
|
||||
(error) => {
|
||||
console.error(`Error resolving ${GRAPHQL_FIELD_NEW_QUERY}:`, error)
|
||||
return new GraphQLError(error.isPrivate() ? 'Internal Server Error' : error.message)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function resolveNewQueryImpl(
|
||||
args: RootQueryTypeNewQueryArgs,
|
||||
info: GraphQLResolveInfo
|
||||
): Promise<Result<NewQueryModel[], ApiErrorGeneric>> {
|
||||
const fieldsInfo = graphQLFields(info)
|
||||
const requestedFields = Object.keys(fieldsInfo)
|
||||
return await NewQueryModel.loadData(args, requestedFields)
|
||||
}
|
||||
|
||||
export const newQueryRoot = {
|
||||
[GRAPHQL_FIELD_NEW_QUERY]: {
|
||||
description: 'Description of what this query does',
|
||||
args: {
|
||||
id: {
|
||||
type: new GraphQLNonNull(GraphQLString),
|
||||
description: 'Required argument description',
|
||||
},
|
||||
// Add other arguments...
|
||||
},
|
||||
type: GraphQLObjectTypeNewQuery, // or createCollectionType() for lists
|
||||
resolve: resolveNewQuery,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Register in Root Schema
|
||||
In `rootSchema.ts`, add your resolver:
|
||||
|
||||
```typescript
|
||||
// Import your resolver
|
||||
import { newQueryRoot } from './newQuery/newQueryResolver'
|
||||
|
||||
// Add to the query fields
|
||||
export const rootGraphQLSchema = new GraphQLSchema({
|
||||
query: new GraphQLObjectType({
|
||||
name: 'RootQueryType',
|
||||
fields: {
|
||||
...introspectRoot,
|
||||
...searchRoot,
|
||||
...errorRoot,
|
||||
...newQueryRoot, // Add this line
|
||||
},
|
||||
}),
|
||||
types: [
|
||||
GraphQLObjectTypeGuide,
|
||||
GraphQLObjectTypeReferenceCLICommand,
|
||||
GraphQLObjectTypeReferenceSDKFunction,
|
||||
GraphQLObjectTypeTroubleshooting,
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### 6. Update TypeScript Types
|
||||
Run the GraphQL codegen to update TypeScript types:
|
||||
```bash
|
||||
pnpm run -F docs codegen:graphql
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Error Handling**: Error handling always uses the Result class, defined in apps/docs/features/helpers.fn.ts
|
||||
2. **Field Optimization**: Use `graphQLFields()` to only fetch requested data
|
||||
3. **Collections**: Use `createCollectionType()` for paginated lists
|
||||
4. **Naming**: Use `GRAPHQL_FIELD_*` constants for field names
|
||||
5. **Documentation**: Add GraphQL descriptions to all fields and types
|
||||
6. **Database**: Use `supabase()` client for database operations with `convertPostgrestToApiError`
|
||||
|
||||
## Testing
|
||||
|
||||
Tests are located in apps/docs/app/api/graphql/tests. Each top-level query
|
||||
should have its own test file, located at <queryName>.test.ts.
|
||||
|
||||
### Test data
|
||||
|
||||
Test data uses a local database, seeded with the file at supabase/seed.sql. Add
|
||||
any data required for running your new query.
|
||||
|
||||
### Integration tests
|
||||
|
||||
Integration tests import the POST function defined in
|
||||
apps/docs/api/graphql/route.ts, then make a request to this function.
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import { POST } from '../route'
|
||||
|
||||
it('test name', async () => {
|
||||
const query = `
|
||||
query {
|
||||
...
|
||||
}
|
||||
`
|
||||
const request = new Request('http://localhost/api/graphql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query }),
|
||||
})
|
||||
|
||||
const result = await POST(request)
|
||||
})
|
||||
```
|
||||
|
||||
Include at least the following tests:
|
||||
|
||||
1. A test that requests all fields (including nested fields) on the new query
|
||||
object, and asserts that there are no errors, and the requested fields are
|
||||
properly returned.
|
||||
2. A test that triggers and error, and asserts that a GraphQL error is properly
|
||||
returned.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
description: Docs Testing Procedure
|
||||
globs: apps/docs/**/*.test.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Docs Test Requirements
|
||||
|
||||
Rules for running tests in the docs application, ensuring proper Supabase setup and test execution.
|
||||
|
||||
<rule>
|
||||
name: docs_test_requirements
|
||||
description: Standards for running tests in the docs application with proper Supabase setup
|
||||
filters:
|
||||
# Match test files in the docs app
|
||||
- type: file_extension
|
||||
pattern: "\\.(test|spec)\\.(ts|tsx)$"
|
||||
- type: path
|
||||
pattern: "^apps/docs/.*"
|
||||
# Match test execution events
|
||||
- type: event
|
||||
pattern: "test_execution"
|
||||
|
||||
actions:
|
||||
- type: suggest
|
||||
message: |
|
||||
Before running tests in the docs app:
|
||||
|
||||
1. Check Supabase status:
|
||||
```bash
|
||||
pnpm supabase status
|
||||
```
|
||||
|
||||
2. If Supabase is not running:
|
||||
```bash
|
||||
pnpm supabase start
|
||||
```
|
||||
|
||||
3. Reset the database to ensure clean state:
|
||||
```bash
|
||||
pnpm supabase db reset --local
|
||||
```
|
||||
|
||||
4. Run the tests:
|
||||
```bash
|
||||
pnpm run -F docs test:local:unwatch
|
||||
```
|
||||
|
||||
Important notes:
|
||||
- Always ensure Supabase is running before tests
|
||||
- Database must be reset to ensure clean state
|
||||
- Use test:local:unwatch to run tests without watch mode
|
||||
- Tests are located in apps/docs/**/*.{test,spec}.{ts,tsx}
|
||||
|
||||
examples:
|
||||
- input: |
|
||||
# Bad: Running tests without proper setup
|
||||
pnpm run -F docs test
|
||||
pnpm run -F docs test:local
|
||||
|
||||
# Good: Proper test execution sequence
|
||||
pnpm supabase status
|
||||
pnpm supabase start # if not running
|
||||
pnpm supabase db reset --local
|
||||
pnpm run -F docs test:local:unwatch
|
||||
output: "Correctly executed docs tests with proper Supabase setup"
|
||||
|
||||
metadata:
|
||||
priority: high
|
||||
version: 1.0
|
||||
</rule>
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs: apps/studio/**/*.test.ts,apps/studio/**/*.test.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
Make sure to follow the guidelines in this file to write tests: [README.md](mdc:apps/studio/tests/README.md)
|
||||
+5
-7
@@ -1,19 +1,17 @@
|
||||
/packages/shared-data/pricing.ts @roryw10 @kevcodez
|
||||
/packages/shared-data/plans.ts @roryw10 @kevcodez
|
||||
/packages/common/telemetry-constants.ts @4L3k51 @supabase/growth-eng
|
||||
/packages/ui/ @supabase/design
|
||||
/packages/shared-data/pricing.ts @roryw10 @supabase/billing
|
||||
/packages/shared-data/plans.ts @roryw10 @supabase/billing
|
||||
/packages/common/telemetry-constants.ts @supabase/growth-eng
|
||||
|
||||
/apps/studio/ @supabase/Dashboard
|
||||
|
||||
# temporary until we've ironed out all the problems with sync workflow
|
||||
/apps/docs/content/troubleshooting/ @charislam
|
||||
|
||||
/apps/www/ @supabase/marketing
|
||||
/apps/www/public/images/blog @supabase/marketing
|
||||
/apps/www/lib/redirects.js
|
||||
|
||||
/docker/ @supabase/dev-workflows
|
||||
|
||||
/apps/studio/next.config.js @supabase/security
|
||||
/apps/studio/csp.js @supabase/security
|
||||
/apps/studio/components/interfaces/Billing/Payment @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/BillingSettings/ @supabase/security
|
||||
/apps/studio/components/interfaces/Organization/Documents/ @supabase/security
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: docs_lint_comment_external
|
||||
|
||||
# This is a continuation of ./docs-lint-v2.yml, to write comments on external
|
||||
# PRs.
|
||||
#
|
||||
# SECURITY:
|
||||
# This workflow runs with write permissions, in the context of code from an
|
||||
# external PR. This is safe because no external code is executed. The
|
||||
# stringified Markdown output from the linter (downloaded as an artifact) is
|
||||
# directly written as the body of a PR comment.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [docs_lint]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment_on_pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- id: download_artifact
|
||||
name: 'Download artifact'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
});
|
||||
const matchingArtifact = artifacts?.data?.artifacts?.find(
|
||||
(artifact) => artifact.name == 'lint_results'
|
||||
);
|
||||
if (matchingArtifact) {
|
||||
core.setOutput('contains_results', 'true')
|
||||
const download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchingArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('${{ github.workspace }}/lint_results.zip', Buffer.from(download.data));
|
||||
}
|
||||
- id: unzip_results
|
||||
name: Unzip results file
|
||||
if: steps.download_artifact.outputs.contains_results == 'true'
|
||||
run: unzip lint_results.zip
|
||||
- name: 'Comment on PR'
|
||||
if: steps.download_artifact.outputs.contains_results == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const prNumber = Number(fs.readFileSync('./pr_number.txt'));
|
||||
const lintResults = fs.readFileSync('./lint_results.txt', 'utf8');
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: lintResults
|
||||
});
|
||||
@@ -31,10 +31,10 @@ jobs:
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: e5609278486869b545fda23b5732727b6e70a1c1
|
||||
key: 3186b58a532c98d7f470f2b887c2b74a086d5f2e
|
||||
- name: install linter
|
||||
if: steps.cache-cargo.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev e5609278486869b545fda23b5732727b6e70a1c1
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 3186b58a532c98d7f470f2b887c2b74a086d5f2e
|
||||
- name: run linter
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -45,18 +45,15 @@ jobs:
|
||||
git config --global user.email 'github-docs-bot@supabase.com'
|
||||
BRANCH_NAME="bot/docs-lint-fixes"
|
||||
EXISTING_BRANCH=$(git ls-remote --heads origin $BRANCH_NAME)
|
||||
if [ -n "$EXISTING_BRANCH" ]; then
|
||||
git checkout $BRANCH_NAME
|
||||
else
|
||||
git checkout -b $BRANCH_NAME
|
||||
if [[ -n "$EXISTING_BRANCH" ]]; then
|
||||
git push origin --delete $BRANCH_NAME
|
||||
fi
|
||||
git checkout -b $BRANCH_NAME
|
||||
supa-mdx-lint apps/docs/content --fix || FIX_FAILED=1
|
||||
git add .
|
||||
git commit -m '[bot] fix lint errors' || true
|
||||
git push origin $BRANCH_NAME
|
||||
if [ -z "$EXISTING_BRANCH" ]; then
|
||||
gh pr create --title '[bot] fix lint errors' --body 'This PR fixes lint errors in the documentation.' --head $BRANCH_NAME
|
||||
fi
|
||||
gh pr create --title '[bot] fix lint errors' --body 'This PR fixes lint errors in the documentation.' --head $BRANCH_NAME
|
||||
if [ "${FIX_FAILED:-0}" -eq 1 ]; then
|
||||
echo "Fix did not correct all errors."
|
||||
exit 1
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
name: '[Docs] Lint v2'
|
||||
name: docs_lint
|
||||
|
||||
# Runs the docs linter on PRs that edit docs content.
|
||||
# There are two branches of this workflow for internal and external PRs, due
|
||||
# to the security design of GitHub Actions.
|
||||
#
|
||||
# Internal PRs:
|
||||
# Have write permissions, so comments are written directly by reviewdog.
|
||||
#
|
||||
# External PRs:
|
||||
# Have read-only permissions, so lint results are uploaded as an artifact, to
|
||||
# be written to the PR in a subsequent workflow_run action that has write
|
||||
# permissions. See ./docs/lint-v2-comment.yml.
|
||||
#
|
||||
# See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -38,10 +53,10 @@ jobs:
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
key: e5609278486869b545fda23b5732727b6e70a1c1
|
||||
key: 3186b58a532c98d7f470f2b887c2b74a086d5f2e
|
||||
- name: install linter
|
||||
if: steps.filter.outputs.docs == 'true' && steps.cache-cargo.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev e5609278486869b545fda23b5732727b6e70a1c1
|
||||
run: cargo install --locked --git https://github.com/supabase-community/supa-mdx-lint --rev 3186b58a532c98d7f470f2b887c2b74a086d5f2e
|
||||
- name: install reviewdog
|
||||
if: steps.filter.outputs.docs == 'true'
|
||||
uses: reviewdog/action-setup@3f401fe1d58fe77e10d665ab713057375e39b887 # v1.3.0
|
||||
@@ -58,12 +73,12 @@ jobs:
|
||||
| { grep -E "^apps/docs/content/" || test $? = 1; } \
|
||||
| xargs -r supa-mdx-lint --format rdf \
|
||||
| reviewdog -f=rdjsonl -reporter=github-pr-review -tee
|
||||
- name: run linter (external)
|
||||
- id: external_lint
|
||||
name: run linter (external)
|
||||
if: steps.filter.outputs.docs == 'true' && github.event.pull_request.head.repo.full_name != github.repository
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BRANCH_NAME: ${{ github.head_ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
run_lints() {
|
||||
@@ -75,7 +90,18 @@ jobs:
|
||||
LINT_RESULTS=$(run_lints)
|
||||
LINT_EXIT_CODE=$?
|
||||
set -e
|
||||
echo "LINT_EXIT_CODE=$LINT_EXIT_CODE" >> $GITHUB_OUTPUT
|
||||
if [[ $LINT_EXIT_CODE -ne 0 ]]; then
|
||||
gh pr comment "$BRANCH_NAME" --body "$LINT_RESULTS"
|
||||
exit 1
|
||||
mkdir -p ./__github_actions__pr
|
||||
echo "${{ github.event.number }}" > ./__github_actions__pr/pr_number.txt
|
||||
echo "$LINT_RESULTS" > ./__github_actions__pr/lint_results.txt
|
||||
fi
|
||||
- name: save results as artifact (external)
|
||||
if: steps.filter.outputs.docs == 'true' && github.event.pull_request.head.repo.full_name != github.repository && steps.external_lint.outputs.LINT_EXIT_CODE != 0
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lint_results
|
||||
path: __github_actions__pr/
|
||||
- name: fail if linter fails (external)
|
||||
if: steps.filter.outputs.docs == 'true' && github.event.pull_request.head.repo.full_name != github.repository && steps.external_lint.outputs.LINT_EXIT_CODE != 0
|
||||
run: exit 1
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-docs:
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SEARCH_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
SEARCH_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
SEARCH_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
DOCS_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
DOCS_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
DOCS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# All syncs from the repo to the DB will eventually be consolidated in a single
|
||||
# script run by this workflow.
|
||||
|
||||
name: docs_sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
# Resync if the content changes
|
||||
- 'apps/docs/content/**'
|
||||
# Resync if the resource definition or sync scripts change
|
||||
- 'apps/docs/resources/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Run sync script
|
||||
run: pnpm run -F docs sync
|
||||
@@ -14,6 +14,9 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CI: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -25,6 +28,7 @@ jobs:
|
||||
apps/docs
|
||||
examples
|
||||
packages
|
||||
supabase
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
@@ -41,4 +45,10 @@ jobs:
|
||||
run: pnpm i
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test:docs
|
||||
run: |
|
||||
touch .env
|
||||
# Needed to prevent local Supabase startup from erroring, due to
|
||||
# GitHub Auth being enabled in config
|
||||
echo "GITHUB_CLIENT_ID=dummy-id" >> .env
|
||||
echo "GITHUB_SECRET=dummy-secret" >> .env
|
||||
pnpm run test:docs
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
flavor: |
|
||||
latest=true
|
||||
tags: |
|
||||
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ github.event_name == 'schedule' }}
|
||||
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
type=sha,prefix={{date 'YYYY.MM.DD'}}-sha-,enable=${{ github.event_name == 'schedule' }}
|
||||
type=sha,prefix={{date 'YYYY.MM.DD'}}-sha-,enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
release_x86:
|
||||
needs: settings
|
||||
|
||||
@@ -17,6 +17,10 @@ on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-prod
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -25,12 +29,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
DOCS_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
DOCS_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
DOCS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
NEXT_PUBLIC_MISC_URL: ${{ secrets.NEXT_PUBLIC_MISC_URL}}
|
||||
NEXT_PUBLIC_MISC_ANON_KEY: ${{ secrets.NEXT_PUBLIC_MISC_ANON_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
SEARCH_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
SEARCH_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
SEARCH_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
@@ -54,10 +60,6 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: pnpm i
|
||||
|
||||
# Need the miscellaneous use API, which is available publicly (by design) in www
|
||||
- name: Copy environment variables
|
||||
run: cp apps/www/.env.local.example apps/docs/.env
|
||||
|
||||
- name: Update embeddings
|
||||
working-directory: ./apps/docs
|
||||
if: ${{ !inputs.refresh }}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Studio E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
- 'apps/studio/**'
|
||||
- 'e2e/studio/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
- 'apps/studio/**'
|
||||
- 'e2e/studio/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/studio-e2e-test.yml'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
# Make the job non-blocking
|
||||
continue-on-error: true
|
||||
# Require approval only for external contributors
|
||||
environment: ${{ github.event.pull_request.author_association != 'MEMBER' && 'Studio E2E Tests' || '' }}
|
||||
|
||||
env:
|
||||
EMAIL: ${{ secrets.CI_EMAIL }}
|
||||
PASSWORD: ${{ secrets.CI_PASSWORD }}
|
||||
PROJECT_REF: ${{ secrets.CI_PROJECT_REF }}
|
||||
NEXT_PUBLIC_IS_PLATFORM: true
|
||||
NEXT_PUBLIC_API_URL: https://api.supabase.green
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_STUDIO_HOSTED_PROJECT_ID }}
|
||||
NEXT_PUBLIC_HCAPTCHA_SITE_KEY: 10000000-ffff-ffff-ffff-000000000001
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: pnpm add --global vercel@latest
|
||||
|
||||
- name: Pull Vercel Environment Information (Preview)
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Build Project Artifacts for Vercel
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Deploy Project to Vercel and Get URL
|
||||
id: deploy_vercel
|
||||
run: |
|
||||
DEPLOY_URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
|
||||
echo "Vercel Preview URL: $DEPLOY_URL"
|
||||
echo "DEPLOY_URL=$DEPLOY_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm -C e2e/studio exec playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
id: playwright
|
||||
env:
|
||||
AUTHENTICATION: true
|
||||
STUDIO_URL: ${{ steps.deploy_vercel.outputs.DEPLOY_URL }}/dashboard
|
||||
run: pnpm e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
path: |
|
||||
e2e/studio/playwright-report/
|
||||
e2e/studio/test-results/
|
||||
retention-days: 7
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Studio E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
- 'apps/studio/**'
|
||||
- 'tests/studio-tests/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'packages/pg-meta/**/*'
|
||||
- 'apps/studio/**'
|
||||
- 'tests/studio-tests/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
- uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm -C tests/studio-tests exec playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:e2e:studio-local
|
||||
# mark the action as succeeded even if the tests failed. This is temporarily until we make the tests more stable.
|
||||
# continue-on-error: true
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/studio-tests/playwright-report/
|
||||
retention-days: 7
|
||||
@@ -24,9 +24,13 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
test:
|
||||
# Uses larger hosted runner as it significantly decreases build times
|
||||
runs-on: [larger-runner-4cpu]
|
||||
runs-on:
|
||||
group: Default Larger Runners
|
||||
strategy:
|
||||
matrix:
|
||||
test_number: [1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -50,5 +54,25 @@ jobs:
|
||||
env:
|
||||
# Default is 2 GB, increase to have less frequent OOM errors
|
||||
NODE_OPTIONS: '--max_old_space_size=3072'
|
||||
run: pnpm run test:studio
|
||||
working-directory: ./
|
||||
run: pnpm run test:ci
|
||||
working-directory: ./apps/studio
|
||||
|
||||
- name: Upload coverage results to Coveralls
|
||||
uses: coverallsapp/github-action@master
|
||||
with:
|
||||
parallel: true
|
||||
flag-name: studio-tests
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path-to-lcov: ./apps/studio/coverage/lcov.info
|
||||
base-path: './apps/studio'
|
||||
|
||||
finish:
|
||||
needs: test
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Coveralls Finished
|
||||
uses: coverallsapp/github-action@master
|
||||
with:
|
||||
parallel-finished: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -16,7 +16,8 @@ permissions:
|
||||
jobs:
|
||||
typecheck:
|
||||
# Uses larger hosted runner as it significantly decreases build times
|
||||
runs-on: [larger-runner-4cpu]
|
||||
runs-on:
|
||||
group: Default Larger Runners
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -15,8 +15,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test_number: [1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -39,4 +42,25 @@ jobs:
|
||||
run: pnpm i
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test:ui
|
||||
run: pnpm run test:ci
|
||||
working-directory: ./packages/ui
|
||||
|
||||
- name: Upload coverage results to Coveralls
|
||||
uses: coverallsapp/github-action@master
|
||||
with:
|
||||
parallel: true
|
||||
flag-name: ui-tests
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path-to-lcov: ./packages/ui/coverage/lcov.info
|
||||
base-path: './packages/ui'
|
||||
|
||||
finish:
|
||||
needs: test
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Coveralls Finished
|
||||
uses: coverallsapp/github-action@master
|
||||
with:
|
||||
parallel-finished: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+10
@@ -133,3 +133,13 @@ gcloud.json
|
||||
# sitemaps
|
||||
# apps/www/public/*.xml
|
||||
# apps/docs/public/*.xml
|
||||
|
||||
# CLI version file
|
||||
.temp/cli-latest
|
||||
|
||||
.pnpm-store/*
|
||||
|
||||
# Sentry CLI config
|
||||
**/.sentryclirc
|
||||
|
||||
keys.json
|
||||
@@ -23,3 +23,10 @@ apps/ui-library/public/r
|
||||
apps/**/.contentlayer
|
||||
# invalid JSON file
|
||||
packages/ui/src/components/Form/examples/PhoneProvidersSchema.json
|
||||
apps/cms/config/api.ts
|
||||
# files auto-generated by payload cms
|
||||
apps/cms/src/app/*
|
||||
apps/cms/src/migrations/*
|
||||
apps/cms/src/payload-types.ts
|
||||
# ignore because of <br/><br/>
|
||||
apps/www/_blog/2025-07-14-supabase-ui-platform-kit.mdx
|
||||
@@ -1,14 +0,0 @@
|
||||
StylesPath = './vale/styles'
|
||||
MinAlertLevel = 'suggestion'
|
||||
|
||||
Vocab = Supabase
|
||||
|
||||
[formats]
|
||||
mdx = md
|
||||
|
||||
[*]
|
||||
BasedOnStyles = Vale, Custom
|
||||
BlockIgnores = (?s) *(<StepHikeCompact>.*?</StepHikeCompact>), \
|
||||
(?s) *(export\s+const\s+\S+\s*=\s*\[.*?\]), \
|
||||
(?s) *(<Image.*?/>)
|
||||
TokenIgnores = (href\s*([=:])\s*['"].*?\1), \
|
||||
+9
-11
@@ -22,8 +22,6 @@
|
||||
- [Community channels](#community-channels)
|
||||
- [Contributors](#contributors)
|
||||
|
||||
- [Common tasks](#common-tasks)
|
||||
- [Add a redirect](#add-a-redirect)
|
||||
- [Community channels](#community-channels)
|
||||
|
||||
## Getting started
|
||||
@@ -37,7 +35,7 @@ To ensure a positive and inclusive environment, please read our [code of conduct
|
||||
You will need to install and configure the following dependencies on your machine to build [Supabase](https://supabase.com):
|
||||
|
||||
- [Git](https://git-scm.com/)
|
||||
- [Node.js v20.x (LTS)](https://nodejs.org)
|
||||
- [Node.js v22.x or higher](https://nodejs.org)
|
||||
- [pnpm](https://pnpm.io/) version 9.x.x or higher
|
||||
- [make](https://www.gnu.org/software/make/) or the equivalent to `build-essentials` for your OS
|
||||
- [Docker](https://docs.docker.com/get-docker/) (to run studio locally)
|
||||
@@ -67,7 +65,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
|
||||
|
||||
### Install dependencies
|
||||
|
||||
1. Install the dependencies in the root of the repo.
|
||||
1. Install the dependencies in the root of the repo:
|
||||
|
||||
```sh
|
||||
pnpm install # install dependencies
|
||||
@@ -79,7 +77,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
|
||||
cp apps/www/.env.local.example apps/www/.env.local
|
||||
```
|
||||
|
||||
3. After that you can run the apps simultaneously with the following.
|
||||
3. After that you can run the apps simultaneously with the following:
|
||||
```sh
|
||||
pnpm dev # start all the applications
|
||||
```
|
||||
@@ -119,8 +117,8 @@ Installing a package in a specific workspace requires you to move to the workspa
|
||||
|
||||
For example:
|
||||
|
||||
1. `cd apps/studio`: move to the `studio` workspace
|
||||
2. `pnpm add react`: installs `react` into `studio` workspace
|
||||
1. `cd apps/studio`: move to the `studio` workspace.
|
||||
2. `pnpm add react`: installs `react` into `studio` workspace.
|
||||
|
||||
|
||||
---
|
||||
@@ -147,7 +145,7 @@ First, make sure you have the Docker installed on your device. You can download
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Run docker
|
||||
3. Run docker:
|
||||
|
||||
```sh
|
||||
docker compose up
|
||||
@@ -188,9 +186,9 @@ Create a new entry in the [`redirects.js`](https://github.com/supabase/supabase/
|
||||
We support "federating" docs, meaning doc content can come directly from external repos other than [`supabase/supabase`](https://github.com/supabase/supabase).
|
||||
|
||||
- It's great for things like client libs who have their own set of docs that we don't want to duplicate on the official Supabase docs (eg. [`supabase/vecs`](https://github.com/supabase/vecs)).
|
||||
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline
|
||||
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native
|
||||
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate
|
||||
- No duplication or manual steps required - fetches and generates automatically as part of the docs build pipeline.
|
||||
- It's flexible - you can "embed" external docs nearly anywhere at any level in Supabase docs, but they will feel native.
|
||||
- If you are maintaining a repo containing docs that you think could also live in Supabase docs, feel free to create an issue and we can work together to integrate.
|
||||
|
||||
Federated docs work using Next.js's build pipeline. We use `getStaticProps()` to fetch remote documentation (ie. markdown) at build time which is processed and passed to the respective page within the docs.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
# Supabase
|
||||
|
||||
[Supabase](https://supabase.com) is an open source Firebase alternative. We're building the features of Firebase using enterprise-grade open source tools.
|
||||
[Supabase](https://supabase.com) is the Postgres development platform. We're building the features of Firebase using enterprise-grade open source tools.
|
||||
|
||||
- [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database)
|
||||
- [x] Authentication and Authorization. [Docs](https://supabase.com/docs/guides/auth)
|
||||
@@ -244,6 +244,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
|
||||
- [Bangla / বাংলা](/i18n/README.bn.md)
|
||||
- [Bulgarian / Български](/i18n/README.bg.md)
|
||||
- [Catalan / Català](/i18n/README.ca.md)
|
||||
- [Croatian / Hrvatski](/i18n/README.hr.md)
|
||||
- [Czech / čeština](/i18n/README.cs.md)
|
||||
- [Danish / Dansk](/i18n/README.da.md)
|
||||
- [Dutch / Nederlands](/i18n/README.nl.md)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DATABASE_URI=postgres://postgres:<password>@127.0.0.1:5432/your-database-name
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
|
||||
S3_BUCKET=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000/blog
|
||||
CRON_SECRET=YOUR_CRON_SECRET_HERE
|
||||
PREVIEW_SECRET=YOUR_SECRET_HERE
|
||||
BLOG_APP_URL=http://localhost:3000
|
||||
File renamed without changes.
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
/.idea/*
|
||||
!/.idea/runConfigurations
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
|
||||
/media
|
||||
@@ -0,0 +1,71 @@
|
||||
# To use this Dockerfile, you have to set `output: 'standalone'` in your next.config.mjs file.
|
||||
# From https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
|
||||
|
||||
FROM node:22.12.0-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Remove this line if you do not have this folder
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
CMD HOSTNAME="0.0.0.0" node server.js
|
||||
@@ -0,0 +1,23 @@
|
||||
# Payload CMS
|
||||
|
||||
### Local Development
|
||||
|
||||
1. run `cd apps/cms && supabase start` to start the local supabase project
|
||||
2. run `cp .env.example .env` to copy the example environment variables and update the variables. You'll need to add the `S3_` variables to your `.env` to use Supabase Storage
|
||||
3. `pnpm install && pnpm generate:importmap` to install dependencies and start the dev server
|
||||
4. run `pnpm dev` in the apps/cms folder or `pnpm dev:cms` from the root
|
||||
5. open `http://localhost:3030` to open the app in your browser
|
||||
|
||||
Follow the on-screen instructions to login and create the first admin user.
|
||||
|
||||
### Collections
|
||||
|
||||
Collections are what data looks like in the Payload cms schema. The following are the collections currently configured in the app.
|
||||
|
||||
- Authors
|
||||
- Categories
|
||||
- Events
|
||||
- Media
|
||||
- Posts
|
||||
- Tags
|
||||
- Users
|
||||
@@ -0,0 +1,30 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
payload:
|
||||
image: node:18-alpine
|
||||
ports:
|
||||
- '3030:3030'
|
||||
volumes:
|
||||
- .:/home/node/app
|
||||
- node_modules:/home/node/app/node_modules
|
||||
working_dir: /home/node/app/
|
||||
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
|
||||
depends_on:
|
||||
- postgres
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Uncomment the following to use postgres
|
||||
postgres:
|
||||
restart: always
|
||||
image: postgres:latest
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
volumes:
|
||||
data:
|
||||
# pgdata:
|
||||
node_modules:
|
||||
@@ -0,0 +1,31 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
|
||||
import redirects from './redirects.js'
|
||||
|
||||
const NEXT_PUBLIC_SERVER_URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: undefined || process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
...[NEXT_PUBLIC_SERVER_URL /* 'https://example.com' */].map((item) => {
|
||||
const url = new URL(item)
|
||||
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
protocol: url.protocol.replace(':', ''),
|
||||
}
|
||||
}),
|
||||
],
|
||||
},
|
||||
reactStrictMode: true,
|
||||
redirects,
|
||||
eslint: {
|
||||
// We are already running linting via GH action, this will skip linting during production build on Vercel
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "cms",
|
||||
"version": "1.0.0",
|
||||
"description": "Payload CMS for Supabase",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build --turbopack",
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --turbopack --port 3030",
|
||||
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"ci": "cross-env NODE_OPTIONS=--no-deprecation payload migrate && pnpm build",
|
||||
"clean": "rimraf node_modules .next",
|
||||
"typecheck_IGNORED": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-postgres": "3.33.0",
|
||||
"@payloadcms/live-preview-react": "^3.33.0",
|
||||
"@payloadcms/next": "3.33.0",
|
||||
"@payloadcms/payload-cloud": "3.33.0",
|
||||
"@payloadcms/plugin-form-builder": "3.33.0",
|
||||
"@payloadcms/plugin-nested-docs": "3.33.0",
|
||||
"@payloadcms/plugin-seo": "3.33.0",
|
||||
"@payloadcms/richtext-lexical": "3.33.0",
|
||||
"@payloadcms/storage-s3": "3.33.0",
|
||||
"@payloadcms/ui": "3.33.0",
|
||||
"common": "workspace:*",
|
||||
"config": "workspace:*",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint-config-supabase": "workspace:*",
|
||||
"graphql": "^16.8.1",
|
||||
"next": "catalog:",
|
||||
"payload": "3.33.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"sharp": "0.32.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const redirects = async () => {
|
||||
const internetExplorerRedirect = {
|
||||
destination: '/ie-incompatible.html',
|
||||
has: [
|
||||
{
|
||||
type: 'header',
|
||||
key: 'user-agent',
|
||||
value: '(.*Trident.*)', // all ie browsers
|
||||
},
|
||||
],
|
||||
permanent: false,
|
||||
source: '/:path((?!ie-incompatible.html$).*)', // all pages except the incompatibility page
|
||||
}
|
||||
|
||||
const redirects = [internetExplorerRedirect]
|
||||
|
||||
return redirects
|
||||
}
|
||||
|
||||
export default redirects
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AccessArgs, FieldAccess } from 'payload'
|
||||
import type { User } from '@/payload-types'
|
||||
|
||||
type isAdmin = (args: AccessArgs<User>) => boolean
|
||||
|
||||
export const isAdmin: isAdmin = ({ req: { user } }) => {
|
||||
// Return true or false based on if the user has an admin role
|
||||
return Boolean(user?.roles?.includes('admin'))
|
||||
}
|
||||
|
||||
export const isAdminFieldLevel: FieldAccess<{ id: string }, User> = ({ req: { user } }) => {
|
||||
// Return true or false based on if the user has an admin role
|
||||
return Boolean(user?.roles?.includes('admin'))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const isAdminOrSelf: Access = ({ req: { user } }) => {
|
||||
// Need to be logged in
|
||||
if (user) {
|
||||
// If user has role of 'admin'
|
||||
if (user.roles?.includes('admin')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If any other type of user, only provide access to themselves
|
||||
return {
|
||||
id: {
|
||||
equals: user.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Reject everyone else
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const isAnyone: Access = () => true
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AccessArgs } from 'payload'
|
||||
|
||||
import type { User } from '@/payload-types'
|
||||
|
||||
type isAuthenticated = (args: AccessArgs<User>) => boolean
|
||||
|
||||
export const isAuthenticated: isAuthenticated = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import './styles.css'
|
||||
|
||||
export const metadata = {
|
||||
description: 'Content Management System for the Supabase website',
|
||||
title: 'Supabase CMS',
|
||||
}
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function HomePage() {
|
||||
redirect('/admin')
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
:root {
|
||||
--font-mono: 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
|
||||
background: rgb(0, 0, 0);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
|
||||
margin: 0;
|
||||
color: rgb(1000, 1000, 1000);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 40px 0;
|
||||
font-size: 64px;
|
||||
line-height: 70px;
|
||||
font-weight: bold;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin: 24px 0;
|
||||
font-size: 42px;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 38px;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
font-size: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 24px 0;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin: calc(var(--base) * 0.75) 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: currentColor;
|
||||
|
||||
&:focus {
|
||||
opacity: 0.8;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
padding: 45px;
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 400px) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.admin {
|
||||
color: rgb(0, 0, 0);
|
||||
background: rgb(1000, 1000, 1000);
|
||||
border: 1px solid rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.docs {
|
||||
color: rgb(1000, 1000, 1000);
|
||||
background: rgb(0, 0, 0);
|
||||
border: 1px solid rgb(1000, 1000, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeLink {
|
||||
text-decoration: none;
|
||||
padding: 0 0.5rem;
|
||||
background: rgb(60, 60, 60);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default NotFound
|
||||
@@ -0,0 +1,24 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
import { importMap } from '../importMap'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, params, searchParams, importMap })
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SlugComponent as SlugComponent_92cc057d0a2abb4f6cf0307edf59f986 } from '@/fields/slug/SlugComponent'
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client'
|
||||
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
|
||||
|
||||
export const importMap = {
|
||||
"@/fields/slug/SlugComponent#SlugComponent": SlugComponent_92cc057d0a2abb4f6cf0307edf59f986,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#FixedToolbarFeatureClient": FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/plugin-seo/client#OverviewComponent": OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaTitleComponent": MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaImageComponent": MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#MetaDescriptionComponent": MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/plugin-seo/client#PreviewComponent": PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860,
|
||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
File renamed without changes.
@@ -0,0 +1,31 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
@@ -0,0 +1,14 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export const GET = async () => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
const data = await payload.find({
|
||||
collection: 'users',
|
||||
})
|
||||
|
||||
return Response.json(data)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Post, ArchiveBlock as ArchiveBlockProps } from '@/payload-types'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import { CollectionArchive } from '@/components/CollectionArchive'
|
||||
|
||||
export const ArchiveBlock: React.FC<
|
||||
ArchiveBlockProps & {
|
||||
id?: string
|
||||
}
|
||||
> = async (props) => {
|
||||
const { id, categories, introContent, limit: limitFromProps, populateBy, selectedDocs } = props
|
||||
|
||||
const limit = limitFromProps || 3
|
||||
|
||||
let posts: Post[] = []
|
||||
|
||||
if (populateBy === 'collection') {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const flattenedCategories = categories?.map((category) => {
|
||||
if (typeof category === 'object') return category.id
|
||||
else return category
|
||||
})
|
||||
|
||||
const fetchedPosts = await payload.find({
|
||||
collection: 'posts',
|
||||
depth: 1,
|
||||
limit,
|
||||
...(flattenedCategories && flattenedCategories.length > 0
|
||||
? {
|
||||
where: {
|
||||
categories: {
|
||||
in: flattenedCategories,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
posts = fetchedPosts.docs
|
||||
} else {
|
||||
if (selectedDocs?.length) {
|
||||
const filteredSelectedPosts = selectedDocs.map((post) => {
|
||||
if (typeof post.value === 'object') return post.value
|
||||
}) as Post[]
|
||||
|
||||
posts = filteredSelectedPosts
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-16" id={`block-${id}`}>
|
||||
{introContent && (
|
||||
<div className="container mb-16">
|
||||
<RichText className="ms-0 max-w-[48rem]" data={introContent} enableGutter={false} />
|
||||
</div>
|
||||
)}
|
||||
<CollectionArchive posts={posts} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Archive: Block = {
|
||||
slug: 'archive',
|
||||
interfaceName: 'ArchiveBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'populateBy',
|
||||
type: 'select',
|
||||
defaultValue: 'collection',
|
||||
options: [
|
||||
{
|
||||
label: 'Collection',
|
||||
value: 'collection',
|
||||
},
|
||||
{
|
||||
label: 'Individual Selection',
|
||||
value: 'selection',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'relationTo',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
},
|
||||
defaultValue: 'posts',
|
||||
label: 'Collections To Show',
|
||||
options: [
|
||||
{
|
||||
label: 'Posts',
|
||||
value: 'posts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
},
|
||||
hasMany: true,
|
||||
label: 'Categories To Show',
|
||||
relationTo: 'categories',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'collection',
|
||||
step: 1,
|
||||
},
|
||||
defaultValue: 10,
|
||||
label: 'Limit',
|
||||
},
|
||||
{
|
||||
name: 'selectedDocs',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData.populateBy === 'selection',
|
||||
},
|
||||
hasMany: true,
|
||||
label: 'Selection',
|
||||
relationTo: ['posts'],
|
||||
},
|
||||
],
|
||||
labels: {
|
||||
plural: 'Archives',
|
||||
singular: 'Archive',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { BannerBlock as BannerBlockProps } from 'src/payload-types'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
} & BannerBlockProps
|
||||
|
||||
export const BannerBlock: React.FC<Props> = ({ className, content, style }) => {
|
||||
return (
|
||||
<div className={cn('mx-auto my-8 w-full', className)}>
|
||||
<div
|
||||
className={cn('border py-3 px-6 flex items-center rounded', {
|
||||
'border-border bg-card': style === 'info',
|
||||
'border-error bg-error/30': style === 'error',
|
||||
'border-success bg-success/30': style === 'success',
|
||||
'border-warning bg-warning/30': style === 'warning',
|
||||
})}
|
||||
>
|
||||
<RichText data={content} enableGutter={false} enableProse={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const Banner: Block = {
|
||||
slug: 'banner',
|
||||
fields: [
|
||||
{
|
||||
name: 'style',
|
||||
type: 'select',
|
||||
defaultValue: 'info',
|
||||
options: [
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warning', value: 'warning' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Success', value: 'success' },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
interfaceName: 'BannerBlock',
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { CallToActionBlock as CTABlockProps } from '@/payload-types'
|
||||
|
||||
import RichText from '@/components/RichText'
|
||||
import { CMSLink } from '@/components/Link'
|
||||
|
||||
export const CallToActionBlock: React.FC<CTABlockProps> = ({ links, richText }) => {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="bg-card rounded border-border border p-4 flex flex-col gap-8 md:flex-row md:justify-between md:items-center">
|
||||
<div className="max-w-[48rem] flex items-center">
|
||||
{richText && <RichText className="mb-0" data={richText} enableGutter={false} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
{(links || []).map(({ link }, i) => {
|
||||
return <CMSLink key={i} size="lg" {...link} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { linkGroup } from '../../fields/linkGroup'
|
||||
|
||||
export const CallToAction: Block = {
|
||||
slug: 'cta',
|
||||
interfaceName: 'CallToActionBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
},
|
||||
linkGroup({
|
||||
appearances: ['default', 'outline'],
|
||||
overrides: {
|
||||
maxRows: 2,
|
||||
},
|
||||
}),
|
||||
],
|
||||
labels: {
|
||||
plural: 'Calls to Action',
|
||||
singular: 'Call to Action',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { Highlight, themes } from 'prism-react-renderer'
|
||||
import React from 'react'
|
||||
import { CopyButton } from './CopyButton'
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export const Code: React.FC<Props> = ({ code, language = '' }) => {
|
||||
if (!code) return null
|
||||
|
||||
return (
|
||||
<Highlight code={code} language={language} theme={themes.vsDark}>
|
||||
{({ getLineProps, getTokenProps, tokens }) => (
|
||||
<pre className="bg-black p-4 border text-xs border-border rounded overflow-x-auto">
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ className: 'table-row', line })}>
|
||||
<span className="table-cell select-none text-right text-white/25">{i + 1}</span>
|
||||
<span className="table-cell pl-4">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<CopyButton code={code} />
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Code } from './Component.client'
|
||||
|
||||
export type CodeBlockProps = {
|
||||
code: string
|
||||
language?: string
|
||||
blockType: 'code'
|
||||
}
|
||||
|
||||
type Props = CodeBlockProps & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const CodeBlock: React.FC<Props> = ({ className, code, language }) => {
|
||||
return (
|
||||
<div className={[className, 'not-prose'].filter(Boolean).join(' ')}>
|
||||
<Code code={code} language={language} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CopyIcon } from '@payloadcms/ui/icons/Copy'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function CopyButton({ code }: { code: string }) {
|
||||
const [text, setText] = useState('Copy')
|
||||
|
||||
function updateCopyStatus() {
|
||||
if (text === 'Copy') {
|
||||
setText(() => 'Copied!')
|
||||
setTimeout(() => {
|
||||
setText(() => 'Copy')
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end align-middle">
|
||||
<Button
|
||||
className="flex gap-1"
|
||||
variant={'secondary'}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(code)
|
||||
updateCopyStatus()
|
||||
}}
|
||||
>
|
||||
<p>{text}</p>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const Code: Block = {
|
||||
slug: 'code',
|
||||
interfaceName: 'CodeBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'language',
|
||||
type: 'select',
|
||||
defaultValue: 'typescript',
|
||||
options: [
|
||||
{
|
||||
label: 'Typescript',
|
||||
value: 'typescript',
|
||||
},
|
||||
{
|
||||
label: 'Javascript',
|
||||
value: 'javascript',
|
||||
},
|
||||
{
|
||||
label: 'CSS',
|
||||
value: 'css',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
type: 'code',
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { ContentBlock as ContentBlockProps } from '@/payload-types'
|
||||
|
||||
import { CMSLink } from '../../components/Link'
|
||||
|
||||
export const ContentBlock: React.FC<ContentBlockProps> = (props) => {
|
||||
const { columns } = props
|
||||
|
||||
const colsSpanClasses = {
|
||||
full: '12',
|
||||
half: '6',
|
||||
oneThird: '4',
|
||||
twoThirds: '8',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container my-16">
|
||||
<div className="grid grid-cols-4 lg:grid-cols-12 gap-y-8 gap-x-16">
|
||||
{columns &&
|
||||
columns.length > 0 &&
|
||||
columns.map((col, index) => {
|
||||
const { enableLink, link, richText, size } = col
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(`col-span-4 lg:col-span-${colsSpanClasses[size!]}`, {
|
||||
'md:col-span-2': size !== 'full',
|
||||
})}
|
||||
key={index}
|
||||
>
|
||||
{richText && <RichText data={richText} enableGutter={false} />}
|
||||
|
||||
{enableLink && <CMSLink {...link} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Block, Field } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { link } from '@/fields/link'
|
||||
|
||||
const columnFields: Field[] = [
|
||||
{
|
||||
name: 'size',
|
||||
type: 'select',
|
||||
defaultValue: 'oneThird',
|
||||
options: [
|
||||
{
|
||||
label: 'One Third',
|
||||
value: 'oneThird',
|
||||
},
|
||||
{
|
||||
label: 'Half',
|
||||
value: 'half',
|
||||
},
|
||||
{
|
||||
label: 'Two Thirds',
|
||||
value: 'twoThirds',
|
||||
},
|
||||
{
|
||||
label: 'Full',
|
||||
value: 'full',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
},
|
||||
{
|
||||
name: 'enableLink',
|
||||
type: 'checkbox',
|
||||
},
|
||||
link({
|
||||
overrides: {
|
||||
admin: {
|
||||
condition: (_data, siblingData) => {
|
||||
return Boolean(siblingData?.enableLink)
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
export const Content: Block = {
|
||||
slug: 'content',
|
||||
interfaceName: 'ContentBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: columnFields,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CheckboxField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
import { Checkbox as CheckboxUi } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Checkbox: React.FC<
|
||||
CheckboxField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
const props = register(name, { required: required })
|
||||
const { setValue } = useFormContext()
|
||||
|
||||
return (
|
||||
<Width width={width}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckboxUi
|
||||
defaultChecked={defaultValue}
|
||||
id={name}
|
||||
{...props}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue(props.name, checked)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={name}>
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
import type { FormFieldBlock, Form as FormType } from '@payloadcms/plugin-form-builder/types'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useForm, FormProvider } from 'react-hook-form'
|
||||
import RichText from '@/components/RichText'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
import { fields } from './fields'
|
||||
import { getClientSideURL } from '@/utilities/getURL'
|
||||
|
||||
export type FormBlockType = {
|
||||
blockName?: string
|
||||
blockType?: 'formBlock'
|
||||
enableIntro: boolean
|
||||
form: FormType
|
||||
introContent?: SerializedEditorState
|
||||
}
|
||||
|
||||
export const FormBlock: React.FC<
|
||||
{
|
||||
id?: string
|
||||
} & FormBlockType
|
||||
> = (props) => {
|
||||
const {
|
||||
enableIntro,
|
||||
form: formFromProps,
|
||||
form: { id: formID, confirmationMessage, confirmationType, redirect, submitButtonLabel } = {},
|
||||
introContent,
|
||||
} = props
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: formFromProps.fields,
|
||||
})
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
handleSubmit,
|
||||
register,
|
||||
} = 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: FormFieldBlock[]) => {
|
||||
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(`${getClientSideURL()}/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 (
|
||||
<div className="container lg:max-w-[48rem]">
|
||||
{enableIntro && introContent && !hasSubmitted && (
|
||||
<RichText className="mb-8 lg:mb-12" data={introContent} enableGutter={false} />
|
||||
)}
|
||||
<div className="p-4 lg:p-6 border border-border rounded-[0.8rem]">
|
||||
<FormProvider {...formMethods}>
|
||||
{!isLoading && hasSubmitted && confirmationType === 'message' && (
|
||||
<RichText data={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="mb-4 last:mb-0">
|
||||
{formFromProps &&
|
||||
formFromProps.fields &&
|
||||
formFromProps.fields?.map((field, index) => {
|
||||
const Field: React.FC<any> = fields?.[field.blockType as keyof typeof fields]
|
||||
if (Field) {
|
||||
return (
|
||||
<div className="mb-6 last:mb-0" key={index}>
|
||||
<Field
|
||||
form={formFromProps}
|
||||
{...field}
|
||||
{...formMethods}
|
||||
control={control}
|
||||
errors={errors}
|
||||
register={register}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button form={formID} type="submit" variant="default">
|
||||
{submitButtonLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { CountryField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { countryOptions } from './options'
|
||||
|
||||
export const Country: React.FC<
|
||||
CountryField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label className="" htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = countryOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{countryOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { EmailField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Email: React.FC<
|
||||
EmailField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="text"
|
||||
{...register(name, { pattern: /^\S[^\s@]*@\S+$/, required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useFormContext } from 'react-hook-form'
|
||||
|
||||
export const Error = ({ name }: { name: string }) => {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
return (
|
||||
<div className="mt-2 text-red-500 text-sm">
|
||||
{(errors[name]?.message as string) || 'This field is required'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import RichText from '@/components/RichText'
|
||||
import React from 'react'
|
||||
|
||||
import { Width } from '../Width'
|
||||
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
export const Message: React.FC<{ message: SerializedEditorState }> = ({ message }) => {
|
||||
return (
|
||||
<Width className="my-12" width="100">
|
||||
{message && <RichText data={message} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
export const Number: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
type="number"
|
||||
{...register(name, { required })}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { SelectField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select as SelectComponent,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Select: React.FC<
|
||||
SelectField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, options, required, width, defaultValue }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = options.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<SelectComponent onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectComponent>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { StateField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { Control, FieldErrorsImpl } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import React from 'react'
|
||||
import { Controller } from 'react-hook-form'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
import { stateOptions } from './options'
|
||||
|
||||
export const State: React.FC<
|
||||
StateField & {
|
||||
control: Control
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
}
|
||||
> = ({ name, control, errors, label, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={name}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const controlledValue = stateOptions.find((t) => t.value === value)
|
||||
|
||||
return (
|
||||
<Select onValueChange={(val) => onChange(val)} value={controlledValue?.value}>
|
||||
<SelectTrigger className="w-full" id={name}>
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stateOptions.map(({ label, value }) => {
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}}
|
||||
rules={{ required }}
|
||||
/>
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Text: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input defaultValue={defaultValue} id={name} type="text" {...register(name, { required })} />
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TextField } from '@payloadcms/plugin-form-builder/types'
|
||||
import type { FieldErrorsImpl, FieldValues, UseFormRegister } from 'react-hook-form'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea as TextAreaComponent } from '@/components/ui/textarea'
|
||||
import React from 'react'
|
||||
|
||||
import { Error } from '../Error'
|
||||
import { Width } from '../Width'
|
||||
|
||||
export const Textarea: React.FC<
|
||||
TextField & {
|
||||
errors: Partial<FieldErrorsImpl>
|
||||
register: UseFormRegister<FieldValues>
|
||||
rows?: number
|
||||
}
|
||||
> = ({ name, defaultValue, errors, label, register, required, rows = 3, width }) => {
|
||||
return (
|
||||
<Width width={width}>
|
||||
<Label htmlFor={name}>
|
||||
{label}
|
||||
|
||||
{required && (
|
||||
<span className="required">
|
||||
* <span className="sr-only">(required)</span>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<TextAreaComponent
|
||||
defaultValue={defaultValue}
|
||||
id={name}
|
||||
rows={rows}
|
||||
{...register(name, { required: required })}
|
||||
/>
|
||||
|
||||
{errors[name] && <Error name={name} />}
|
||||
</Width>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export const Width: React.FC<{
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
width?: number | string
|
||||
}> = ({ children, className, width }) => {
|
||||
return (
|
||||
<div className={className} style={{ maxWidth: width ? `${width}%` : undefined }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const FormBlock: Block = {
|
||||
slug: 'formBlock',
|
||||
interfaceName: 'FormBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'form',
|
||||
type: 'relationship',
|
||||
relationTo: 'forms',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'enableIntro',
|
||||
type: 'checkbox',
|
||||
label: 'Enable Intro Content',
|
||||
},
|
||||
{
|
||||
name: 'introContent',
|
||||
type: 'richText',
|
||||
admin: {
|
||||
condition: (_, { enableIntro }) => Boolean(enableIntro),
|
||||
},
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: 'Intro Content',
|
||||
},
|
||||
],
|
||||
graphQL: {
|
||||
singularName: 'FormBlock',
|
||||
},
|
||||
labels: {
|
||||
plural: 'Form Blocks',
|
||||
singular: 'Form Block',
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { StaticImageData } from 'next/image'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { MediaBlock as MediaBlockProps } from '@/payload-types'
|
||||
|
||||
import { Media } from '../../components/Media'
|
||||
|
||||
type Props = MediaBlockProps & {
|
||||
breakout?: boolean
|
||||
captionClassName?: string
|
||||
className?: string
|
||||
enableGutter?: boolean
|
||||
imgClassName?: string
|
||||
staticImage?: StaticImageData
|
||||
disableInnerContainer?: boolean
|
||||
}
|
||||
|
||||
export const MediaBlock: React.FC<Props> = (props) => {
|
||||
const {
|
||||
captionClassName,
|
||||
className,
|
||||
enableGutter = true,
|
||||
imgClassName,
|
||||
media,
|
||||
staticImage,
|
||||
disableInnerContainer,
|
||||
} = props
|
||||
|
||||
let caption
|
||||
if (media && typeof media === 'object') caption = media.caption
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'',
|
||||
{
|
||||
container: enableGutter,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(media || staticImage) && (
|
||||
<Media
|
||||
imgClassName={cn('border border-border rounded-[0.8rem]', imgClassName)}
|
||||
resource={media}
|
||||
src={staticImage}
|
||||
/>
|
||||
)}
|
||||
{caption && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-6',
|
||||
{
|
||||
container: !disableInnerContainer,
|
||||
},
|
||||
captionClassName
|
||||
)}
|
||||
>
|
||||
<RichText data={caption} enableGutter={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const MediaBlock: Block = {
|
||||
slug: 'mediaBlock',
|
||||
interfaceName: 'MediaBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'media',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { QuoteBlock as QuoteBlockProps } from 'src/payload-types'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
} & QuoteBlockProps
|
||||
|
||||
export const QuoteBlock: React.FC<Props> = ({ className, img, caption, text }) => {
|
||||
return `<Quote img={${img}} caption={${caption ?? ''}} className={${className}}>
|
||||
|
||||
{${text}}
|
||||
|
||||
</Quote>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const Quote: Block = {
|
||||
slug: 'quote',
|
||||
fields: [
|
||||
{
|
||||
name: 'img',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: 'Avatar',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'text',
|
||||
label: 'Caption',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'textarea',
|
||||
label: 'Quote Text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
interfaceName: 'QuoteBlock',
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import clsx from 'clsx'
|
||||
import React from 'react'
|
||||
import RichText from '@/components/RichText'
|
||||
|
||||
import type { Post } from '@/payload-types'
|
||||
|
||||
import { Card } from '../../components/Card'
|
||||
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
export type RelatedPostsProps = {
|
||||
className?: string
|
||||
docs?: Post[]
|
||||
introContent?: SerializedEditorState
|
||||
}
|
||||
|
||||
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {
|
||||
const { className, docs, introContent } = props
|
||||
|
||||
return (
|
||||
<div className={clsx('lg:container', className)}>
|
||||
{introContent && <RichText data={introContent} enableGutter={false} />}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 items-stretch">
|
||||
{docs?.map((doc, index) => {
|
||||
if (typeof doc === 'string') return null
|
||||
|
||||
return <Card key={index} doc={doc} relationTo="posts" showCategories />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import type { Page } from '@/payload-types'
|
||||
|
||||
import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component'
|
||||
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
|
||||
import { ContentBlock } from '@/blocks/Content/Component'
|
||||
import { FormBlock } from '@/blocks/Form/Component'
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/Component'
|
||||
|
||||
const blockComponents = {
|
||||
archive: ArchiveBlock,
|
||||
content: ContentBlock,
|
||||
cta: CallToActionBlock,
|
||||
formBlock: FormBlock,
|
||||
mediaBlock: MediaBlock,
|
||||
}
|
||||
|
||||
export const RenderBlocks: React.FC<{
|
||||
blocks: Page['layout'][0][]
|
||||
}> = (props) => {
|
||||
const { blocks } = props
|
||||
|
||||
const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0
|
||||
|
||||
if (hasBlocks) {
|
||||
return (
|
||||
<Fragment>
|
||||
{blocks.map((block, index) => {
|
||||
const { blockType } = block
|
||||
|
||||
if (blockType && blockType in blockComponents) {
|
||||
const Block = blockComponents[blockType]
|
||||
|
||||
if (Block) {
|
||||
return (
|
||||
<div className="my-16" key={index}>
|
||||
{/* @ts-expect-error there may be some mismatch between the expected types here */}
|
||||
<Block {...block} disableInnerContainer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { YouTubeBlock as YouTubeBlockProps } from 'src/payload-types'
|
||||
|
||||
import { cn } from '@/utilities/ui'
|
||||
import React from 'react'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
} & YouTubeBlockProps
|
||||
|
||||
export const YouTubeBlock: React.FC<Props> = ({ className, youtubeId }) => {
|
||||
return (
|
||||
<div className={cn('video-container', className)}>
|
||||
<iframe
|
||||
className="w-full"
|
||||
src={`https://www.youtube-nocookie.com/embed/${youtubeId}`}
|
||||
title="YouTube video"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Block } from 'payload'
|
||||
|
||||
export const YouTube: Block = {
|
||||
slug: 'youtube',
|
||||
fields: [
|
||||
{
|
||||
name: 'youtubeId',
|
||||
type: 'text',
|
||||
label: 'YouTube Video ID',
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
'Enter the YouTube video ID (e.g., "dQw4w9WgXcQ" from https://www.youtube.com/watch?v=dQw4w9WgXcQ)',
|
||||
},
|
||||
},
|
||||
],
|
||||
interfaceName: 'YouTubeBlock',
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CollectionConfig } from 'payload'
|
||||
|
||||
export const Authors: CollectionConfig = {
|
||||
slug: 'authors',
|
||||
admin: {
|
||||
useAsTitle: 'author',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'author',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'author_id',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'position',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'author_url',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'author_image_url',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
export default Authors
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CollectionConfig } from 'payload'
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: 'categories',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
export default Categories
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
import type { Customer } from '../../../payload-types'
|
||||
|
||||
export const revalidateCustomer: CollectionAfterChangeHook<Customer> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = `/customers/${doc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating event at path: ${path}`)
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('customers-sitemap')
|
||||
}
|
||||
|
||||
// If the event was previously published, we need to revalidate the old path
|
||||
if (previousDoc._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = `/customers/${previousDoc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
|
||||
|
||||
revalidatePath(oldPath)
|
||||
revalidateTag('customers-sitemap')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Customer> = ({
|
||||
doc,
|
||||
req: { context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = `/customers/${doc?.slug}`
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('customers-sitemap')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
BlocksFeature,
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
HorizontalRuleFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { isAnyone } from '@/access/isAnyone'
|
||||
import { isAuthenticated } from '@/access/isAuthenticated'
|
||||
|
||||
import { Banner } from '../../blocks/Banner/config'
|
||||
import { Code } from '../../blocks/Code/config'
|
||||
import { MediaBlock } from '../../blocks/MediaBlock/config'
|
||||
import { Quote } from '../../blocks/Quote/config'
|
||||
import { YouTube } from '../../blocks/YouTube/config'
|
||||
import { revalidateDelete, revalidateCustomer } from './hooks/revalidateCustomer'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaImageField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
const industryOptions = [
|
||||
{ label: 'Healthcare', value: 'healthcare' },
|
||||
{ label: 'Fintech', value: 'fintech' },
|
||||
{ label: 'E-commerce', value: 'ecommerce' },
|
||||
{ label: 'Education', value: 'education' },
|
||||
{ label: 'Gaming', value: 'gaming' },
|
||||
{ label: 'Media', value: 'media' },
|
||||
{ label: 'Real Estate', value: 'real-estate' },
|
||||
{ label: 'SaaS', value: 'saas' },
|
||||
{ label: 'Social', value: 'social' },
|
||||
{ label: 'Analytics', value: 'analytics' },
|
||||
{ label: 'AI', value: 'ai' },
|
||||
{ label: 'Developer Tools', value: 'developer-tools' },
|
||||
]
|
||||
|
||||
const companySizeOptions = [
|
||||
{ label: 'Startup', value: 'startup' },
|
||||
{ label: 'Enterprise', value: 'enterprise' },
|
||||
{ label: 'Independent Developer', value: 'indie_dev' },
|
||||
]
|
||||
|
||||
const regionOptions = [
|
||||
{ label: 'Asia', value: 'Asia' },
|
||||
{ label: 'Europe', value: 'Europe' },
|
||||
{ label: 'North America', value: 'North America' },
|
||||
{ label: 'South America', value: 'South America' },
|
||||
{ label: 'Africa', value: 'Africa' },
|
||||
{ label: 'Oceania', value: 'Oceania' },
|
||||
]
|
||||
|
||||
const supabaseProductOptions = [
|
||||
{ label: 'Database', value: 'database' },
|
||||
{ label: 'Auth', value: 'auth' },
|
||||
{ label: 'Storage', value: 'storage' },
|
||||
{ label: 'Realtime', value: 'realtime' },
|
||||
{ label: 'Functions', value: 'functions' },
|
||||
{ label: 'Vector', value: 'vector' },
|
||||
]
|
||||
|
||||
export const Customers: CollectionConfig = {
|
||||
slug: 'customers',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'slug', 'updatedAt'],
|
||||
preview: (data) => {
|
||||
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
|
||||
const isDraft = data?._status === 'draft'
|
||||
return `${baseUrl}/customers/${data?.slug}${isDraft ? '?preview=true' : ''}`
|
||||
},
|
||||
},
|
||||
access: {
|
||||
create: isAuthenticated,
|
||||
delete: isAuthenticated,
|
||||
read: isAnyone,
|
||||
update: isAuthenticated,
|
||||
},
|
||||
defaultPopulate: {
|
||||
name: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
categories: true,
|
||||
meta: {
|
||||
image: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
...slugField('name'),
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: 'Content',
|
||||
fields: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
BlocksFeature({ blocks: [Banner, Code, MediaBlock, Quote, YouTube] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
HorizontalRuleFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Metadata',
|
||||
fields: [
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'about',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Short description about the company',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'company_url',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'URL of the company website',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Key statistics or metrics to highlight',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stat',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'misc',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Miscellaneous information (e.g., Founded, Location)',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: industryOptions,
|
||||
admin: {
|
||||
description: 'Industry categories',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'company_size',
|
||||
type: 'select',
|
||||
options: companySizeOptions,
|
||||
admin: {
|
||||
description: 'Size of the company',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'region',
|
||||
type: 'select',
|
||||
options: regionOptions,
|
||||
admin: {
|
||||
description: 'Geographic region',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'supabase_products',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: supabaseProductOptions,
|
||||
admin: {
|
||||
description: 'Supabase products being used',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.name',
|
||||
descriptionPath: 'meta.description',
|
||||
imagePath: 'meta.image',
|
||||
}),
|
||||
MetaTitleField({
|
||||
hasGenerateFn: true,
|
||||
}),
|
||||
MetaImageField({
|
||||
relationTo: 'media',
|
||||
}),
|
||||
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
// if the `generateUrl` function is configured
|
||||
hasGenerateFn: true,
|
||||
|
||||
// field paths to match the target field for data
|
||||
titlePath: 'meta.name',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (siblingData._status === 'published' && !value) {
|
||||
return new Date()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'logo_inverse',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Light mode logo',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
afterChange: [revalidateCustomer],
|
||||
afterDelete: [revalidateDelete],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 100,
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
|
||||
export default Customers
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
import type { Event } from '../../../payload-types'
|
||||
|
||||
export const revalidateEvent: CollectionAfterChangeHook<Event> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = `/events/${doc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating event at path: ${path}`)
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('events-sitemap')
|
||||
}
|
||||
|
||||
// If the event was previously published, we need to revalidate the old path
|
||||
if (previousDoc._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = `/events/${previousDoc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating old event at path: ${oldPath}`)
|
||||
|
||||
revalidatePath(oldPath)
|
||||
revalidateTag('events-sitemap')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Event> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = `/events/${doc?.slug}`
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('events-sitemap')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
BlocksFeature,
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
HorizontalRuleFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { isAnyone } from '@/access/isAnyone'
|
||||
import { isAuthenticated } from '@/access/isAuthenticated'
|
||||
|
||||
import { Banner } from '@/blocks/Banner/config'
|
||||
import { Code } from '@/blocks/Code/config'
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/config'
|
||||
import { Quote } from '@/blocks/Quote/config'
|
||||
import { YouTube } from '@/blocks/YouTube/config'
|
||||
import { revalidateDelete, revalidateEvent } from './hooks/revalidateEvent'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaImageField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
import { slugField } from '@/fields/slug'
|
||||
import { timezoneOptions } from '../../utilities/timezones'
|
||||
|
||||
const eventTypeOptions = [
|
||||
{ label: 'Conference', value: 'conference' },
|
||||
{ label: 'Hackathon', value: 'hackathon' },
|
||||
{ label: 'Launch Week', value: 'launch-week' },
|
||||
{ label: 'Meetup', value: 'meetup' },
|
||||
{ label: 'Office Hours', value: 'office-hours' },
|
||||
{ label: 'Talk', value: 'talk' },
|
||||
{ label: 'Webinar', value: 'webinar' },
|
||||
{ label: 'Workshop', value: 'workshop' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
]
|
||||
|
||||
export const Events: CollectionConfig = {
|
||||
slug: 'events',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
preview: (data) => {
|
||||
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
|
||||
const isDraft = data?._status === 'draft'
|
||||
return `${baseUrl}/events/${data?.slug}${isDraft ? '?preview=true' : ''}`
|
||||
},
|
||||
},
|
||||
access: {
|
||||
create: isAuthenticated,
|
||||
delete: isAuthenticated,
|
||||
read: isAnyone,
|
||||
update: isAuthenticated,
|
||||
},
|
||||
defaultPopulate: {
|
||||
title: true,
|
||||
slug: true,
|
||||
categories: true,
|
||||
meta: {
|
||||
image: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
...slugField(),
|
||||
{
|
||||
name: 'subtitle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Used in the event page as subtitle.',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: 'Content',
|
||||
fields: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
BlocksFeature({ blocks: [Banner, Code, MediaBlock, Quote, YouTube] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
HorizontalRuleFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Metadata',
|
||||
fields: [
|
||||
{
|
||||
name: 'thumb',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: eventTypeOptions,
|
||||
admin: {
|
||||
description: 'Event type',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
type: 'select',
|
||||
options: timezoneOptions,
|
||||
},
|
||||
{
|
||||
name: 'showEndDate',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {},
|
||||
},
|
||||
{
|
||||
name: 'endDate',
|
||||
type: 'date',
|
||||
admin: {
|
||||
description:
|
||||
'If "showEndDate" is true, this will define when the event terminates.',
|
||||
condition: (data) => {
|
||||
return data.showEndDate
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description:
|
||||
'Text string to display on the event page to indicate the duration of the event. (e.g. "45 mins", "2 days")',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'onDemand',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'Events that are will remain available on the events page after the event has ended.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'disablePageBuild',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'When true, the event page will not be built. It will link directly to an external event page (requires Link to be set)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description:
|
||||
'Used on event previews to link to a custom page if "disablePageBuild" is true.',
|
||||
condition: (data) => {
|
||||
return data.disablePageBuild
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'href',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'target',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Same window', value: '_self' },
|
||||
{ label: 'New window', value: '_blank' },
|
||||
],
|
||||
defaultValue: '_blank',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'mainCta',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Main CTA button on the event page',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'href',
|
||||
type: 'text',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'target',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Same window', value: '_self' },
|
||||
{ label: 'New window', value: '_blank' },
|
||||
],
|
||||
defaultValue: '_blank',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'disabled',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'disabled_label',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Text for the main CTA button if "mainCta.disabled" is true.',
|
||||
condition: (data) => data.mainCta.disabled,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Participants',
|
||||
fields: [
|
||||
{
|
||||
name: 'company',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'showCompany',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'If an external company is collaborating with the event, this will display their logo on the event page.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: false,
|
||||
admin: {
|
||||
condition: (data) => data.company.showCompany,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'websiteUrl',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data.company.showCompany,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
admin: {
|
||||
condition: (data) => data.company.showCompany,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'logo_light',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
admin: {
|
||||
description: 'Light mode logo',
|
||||
condition: (data) => data.company.showCompany,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'participants',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
name: 'showParticipants',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'Could be speakers, authors, guests, etc. It would source from Authors collections.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'participants',
|
||||
type: 'relationship',
|
||||
relationTo: 'authors',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
condition: (data) => data.participants.showParticipants,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
imagePath: 'meta.image',
|
||||
}),
|
||||
MetaTitleField({
|
||||
hasGenerateFn: true,
|
||||
}),
|
||||
MetaImageField({
|
||||
relationTo: 'media',
|
||||
}),
|
||||
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
// if the `generateUrl` function is configured
|
||||
hasGenerateFn: true,
|
||||
|
||||
// field paths to match the target field for data
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (siblingData._status === 'published' && !value) {
|
||||
return new Date()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
afterChange: [revalidateEvent],
|
||||
// afterRead: [populateAuthors],
|
||||
afterDelete: [revalidateDelete],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 100,
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
|
||||
export default Events
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import {
|
||||
FixedToolbarFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { isAnyone } from '@/access/isAnyone'
|
||||
import { isAuthenticated } from '@/access/isAuthenticated'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: {
|
||||
create: isAuthenticated,
|
||||
delete: isAuthenticated,
|
||||
read: isAnyone,
|
||||
update: isAuthenticated,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [...rootFeatures, FixedToolbarFeature(), InlineToolbarFeature()]
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
upload: {
|
||||
adminThumbnail: 'thumbnail',
|
||||
focalPoint: true,
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
name: 'square',
|
||||
width: 500,
|
||||
height: 500,
|
||||
},
|
||||
{
|
||||
name: 'small',
|
||||
width: 600,
|
||||
},
|
||||
{
|
||||
name: 'medium',
|
||||
width: 900,
|
||||
},
|
||||
{
|
||||
name: 'large',
|
||||
width: 1400,
|
||||
},
|
||||
{
|
||||
name: 'xlarge',
|
||||
width: 1920,
|
||||
},
|
||||
{
|
||||
name: 'og',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
crop: 'center',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CollectionAfterReadHook } from 'payload'
|
||||
import { Author } from 'src/payload-types'
|
||||
|
||||
export const populateAuthors: CollectionAfterReadHook = async ({ doc, req: { payload } }) => {
|
||||
if (doc?.authors && doc?.authors?.length > 0) {
|
||||
const authorDocs: Author[] = []
|
||||
|
||||
for (const author of doc.authors) {
|
||||
try {
|
||||
const authorDoc = await payload.findByID({
|
||||
id: typeof author === 'object' ? author?.id : author,
|
||||
collection: 'authors',
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (authorDoc) {
|
||||
authorDocs.push(authorDoc)
|
||||
}
|
||||
|
||||
if (authorDocs.length > 0) {
|
||||
doc.populatedAuthors = authorDocs.map((authorDoc) => ({
|
||||
id: authorDoc.id,
|
||||
name: authorDoc.author,
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
// swallow error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
|
||||
import type { Post } from '../../../payload-types'
|
||||
|
||||
export const revalidatePost: CollectionAfterChangeHook<Post> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = `/posts/${doc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating post at path: ${path}`)
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('posts-sitemap')
|
||||
}
|
||||
|
||||
// If the post was previously published, we need to revalidate the old path
|
||||
if (previousDoc._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = `/posts/${previousDoc.slug}`
|
||||
|
||||
payload.logger.info(`Revalidating old post at path: ${oldPath}`)
|
||||
|
||||
revalidatePath(oldPath)
|
||||
revalidateTag('posts-sitemap')
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Post> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = `/posts/${doc?.slug}`
|
||||
|
||||
revalidatePath(path)
|
||||
revalidateTag('posts-sitemap')
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import {
|
||||
BlocksFeature,
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
HorizontalRuleFeature,
|
||||
InlineToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
import { isAnyone } from '@/access/isAnyone'
|
||||
import { isAuthenticated } from '@/access/isAuthenticated'
|
||||
|
||||
import { Banner } from '@/blocks/Banner/config'
|
||||
import { Code } from '@/blocks/Code/config'
|
||||
import { MediaBlock } from '@/blocks/MediaBlock/config'
|
||||
import { Quote } from '@/blocks/Quote/config'
|
||||
import { YouTube } from '@/blocks/YouTube/config'
|
||||
import { populateAuthors } from './hooks/populateAuthors'
|
||||
import { revalidateDelete, revalidatePost } from './hooks/revalidatePost'
|
||||
|
||||
import {
|
||||
MetaDescriptionField,
|
||||
MetaImageField,
|
||||
MetaTitleField,
|
||||
OverviewField,
|
||||
PreviewField,
|
||||
} from '@payloadcms/plugin-seo/fields'
|
||||
import { slugField } from '@/fields/slug'
|
||||
|
||||
const launchweekOptions = [
|
||||
{ label: '6', value: '6' },
|
||||
{ label: '7', value: '7' },
|
||||
{ label: '8', value: '8' },
|
||||
{ label: 'x', value: 'x' },
|
||||
{ label: 'ga', value: 'ga' },
|
||||
{ label: '12', value: '12' },
|
||||
{ label: '13', value: '13' },
|
||||
{ label: '14', value: '14' },
|
||||
{ label: '15', value: '15' },
|
||||
]
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
livePreview: {
|
||||
url: ({ data }) => {
|
||||
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
|
||||
// Always use the preview route for live preview to ensure draft mode is enabled
|
||||
return `${baseUrl}/api/preview?slug=${data?.slug}&secret=${process.env.PREVIEW_SECRET || 'preview-secret'}`
|
||||
},
|
||||
breakpoints: [
|
||||
{
|
||||
label: 'Desktop',
|
||||
name: 'desktop',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
label: 'Tablet',
|
||||
name: 'tablet',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
},
|
||||
{
|
||||
label: 'Mobile',
|
||||
name: 'mobile',
|
||||
width: 375,
|
||||
height: 667,
|
||||
},
|
||||
],
|
||||
},
|
||||
preview: (data) => {
|
||||
const baseUrl = process.env.BLOG_APP_URL || 'http://localhost:3000'
|
||||
// Always use the preview route to ensure draft mode is enabled
|
||||
return `${baseUrl}/api/preview?slug=${data?.slug}&secret=${process.env.PREVIEW_SECRET || 'preview-secret'}`
|
||||
},
|
||||
},
|
||||
access: {
|
||||
create: isAuthenticated,
|
||||
delete: isAuthenticated,
|
||||
read: isAnyone,
|
||||
update: isAuthenticated,
|
||||
},
|
||||
defaultPopulate: {
|
||||
title: true,
|
||||
slug: true,
|
||||
categories: true,
|
||||
meta: {
|
||||
image: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
...slugField(),
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
|
||||
BlocksFeature({ blocks: [Banner, Code, MediaBlock, Quote, YouTube] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
HorizontalRuleFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
label: 'Content',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
name: 'thumb',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hasMany: true,
|
||||
relationTo: 'categories',
|
||||
},
|
||||
{
|
||||
name: 'launchweek',
|
||||
type: 'select',
|
||||
options: launchweekOptions,
|
||||
admin: {
|
||||
description:
|
||||
'Select a launch week to show launch week summary at the bottom of the blog post.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'readingTime',
|
||||
type: 'number',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'date',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'toc_depth',
|
||||
type: 'number',
|
||||
defaultValue: 2,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
},
|
||||
{
|
||||
name: 'authors',
|
||||
type: 'relationship',
|
||||
relationTo: 'authors',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'relationship',
|
||||
relationTo: 'tags',
|
||||
hasMany: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
],
|
||||
label: 'Metadata',
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
OverviewField({
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
imagePath: 'meta.image',
|
||||
}),
|
||||
MetaTitleField({
|
||||
hasGenerateFn: true,
|
||||
}),
|
||||
MetaImageField({
|
||||
relationTo: 'media',
|
||||
}),
|
||||
|
||||
MetaDescriptionField({}),
|
||||
PreviewField({
|
||||
// if the `generateUrl` function is configured
|
||||
hasGenerateFn: true,
|
||||
|
||||
// field paths to match the target field for data
|
||||
titlePath: 'meta.title',
|
||||
descriptionPath: 'meta.description',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (siblingData._status === 'published' && !value) {
|
||||
return new Date()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
afterChange: [revalidatePost],
|
||||
afterRead: [populateAuthors],
|
||||
afterDelete: [revalidateDelete],
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 200,
|
||||
},
|
||||
schedulePublish: true,
|
||||
},
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
}
|
||||
|
||||
export default Posts
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CollectionConfig } from 'payload'
|
||||
|
||||
export const Tags: CollectionConfig = {
|
||||
slug: 'tags',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
|
||||
export default Tags
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { isAdmin, isAdminFieldLevel } from '@/access/isAdmin'
|
||||
import { isAdminOrSelf } from '@/access/isAdminOrSelf'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
access: {
|
||||
// Only admins can create users
|
||||
create: isAdmin,
|
||||
// Admins can read all, but any other logged in user can only read themselves
|
||||
read: isAdminOrSelf,
|
||||
// Admins can update all, but any other logged in user can only update themselves
|
||||
update: isAdminOrSelf,
|
||||
// Only admins can delete
|
||||
delete: isAdmin,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'roles',
|
||||
// Save this field to JWT so we can use from `req.user`
|
||||
saveToJWT: true,
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
defaultValue: ['editor'],
|
||||
access: {
|
||||
// Only admins can create or update a value for this field
|
||||
create: isAdminFieldLevel,
|
||||
update: isAdminFieldLevel,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'Editor',
|
||||
value: 'editor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import '~@payloadcms/ui/scss';
|
||||
|
||||
.admin-bar {
|
||||
@include small-break {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 3514 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user