Merge branch 'master' into feat/time-series-queries

This commit is contained in:
Jonathan Summers-Muir committed 2025-08-08 16:31:05 +01:00
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
+267
View File
@@ -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.
+71
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
});
+6 -9
View File
@@ -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
+34 -8
View File
@@ -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
+1 -1
View File
@@ -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 }}
+49
View File
@@ -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
+11 -1
View File
@@ -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
+2 -2
View File
@@ -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
+11 -9
View File
@@ -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 }}
+93
View File
@@ -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
-58
View File
@@ -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
+28 -4
View File
@@ -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 }}
+2 -1
View File
@@ -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
+26 -2
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
20
22
+7
View File
@@ -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
-14
View File
@@ -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
View File
@@ -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.
+2 -1
View File
@@ -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)
+13
View File
@@ -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.
+43
View File
@@ -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
+71
View File
@@ -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
+23
View File
@@ -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
+30
View File
@@ -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:
+31
View File
@@ -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 })
+47
View File
@@ -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"
}
}
+20
View File
@@ -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
+14
View File
@@ -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'))
}
+21
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
import type { Access } from 'payload'
export const isAnyone: Access = () => true
+9
View File
@@ -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)
}
+19
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default async function HomePage() {
redirect('/admin')
}
+164
View File
@@ -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)
+31
View File
@@ -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
+14
View File
@@ -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',
},
}
+26
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+21
View File
@@ -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>
)
}
+33
View File
@@ -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>
)
}
+33
View File
@@ -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,
},
],
}
+43
View File
@@ -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>
)
}
+79
View File
@@ -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>
)
}
+162
View File
@@ -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>
)
}
+982
View File
@@ -0,0 +1,982 @@
export const countryOptions = [
{
label: 'Afghanistan',
value: 'AF',
},
{
label: 'Åland Islands',
value: 'AX',
},
{
label: 'Albania',
value: 'AL',
},
{
label: 'Algeria',
value: 'DZ',
},
{
label: 'American Samoa',
value: 'AS',
},
{
label: 'Andorra',
value: 'AD',
},
{
label: 'Angola',
value: 'AO',
},
{
label: 'Anguilla',
value: 'AI',
},
{
label: 'Antarctica',
value: 'AQ',
},
{
label: 'Antigua and Barbuda',
value: 'AG',
},
{
label: 'Argentina',
value: 'AR',
},
{
label: 'Armenia',
value: 'AM',
},
{
label: 'Aruba',
value: 'AW',
},
{
label: 'Australia',
value: 'AU',
},
{
label: 'Austria',
value: 'AT',
},
{
label: 'Azerbaijan',
value: 'AZ',
},
{
label: 'Bahamas',
value: 'BS',
},
{
label: 'Bahrain',
value: 'BH',
},
{
label: 'Bangladesh',
value: 'BD',
},
{
label: 'Barbados',
value: 'BB',
},
{
label: 'Belarus',
value: 'BY',
},
{
label: 'Belgium',
value: 'BE',
},
{
label: 'Belize',
value: 'BZ',
},
{
label: 'Benin',
value: 'BJ',
},
{
label: 'Bermuda',
value: 'BM',
},
{
label: 'Bhutan',
value: 'BT',
},
{
label: 'Bolivia',
value: 'BO',
},
{
label: 'Bosnia and Herzegovina',
value: 'BA',
},
{
label: 'Botswana',
value: 'BW',
},
{
label: 'Bouvet Island',
value: 'BV',
},
{
label: 'Brazil',
value: 'BR',
},
{
label: 'British Indian Ocean Territory',
value: 'IO',
},
{
label: 'Brunei Darussalam',
value: 'BN',
},
{
label: 'Bulgaria',
value: 'BG',
},
{
label: 'Burkina Faso',
value: 'BF',
},
{
label: 'Burundi',
value: 'BI',
},
{
label: 'Cambodia',
value: 'KH',
},
{
label: 'Cameroon',
value: 'CM',
},
{
label: 'Canada',
value: 'CA',
},
{
label: 'Cape Verde',
value: 'CV',
},
{
label: 'Cayman Islands',
value: 'KY',
},
{
label: 'Central African Republic',
value: 'CF',
},
{
label: 'Chad',
value: 'TD',
},
{
label: 'Chile',
value: 'CL',
},
{
label: 'China',
value: 'CN',
},
{
label: 'Christmas Island',
value: 'CX',
},
{
label: 'Cocos (Keeling) Islands',
value: 'CC',
},
{
label: 'Colombia',
value: 'CO',
},
{
label: 'Comoros',
value: 'KM',
},
{
label: 'Congo',
value: 'CG',
},
{
label: 'Congo, The Democratic Republic of the',
value: 'CD',
},
{
label: 'Cook Islands',
value: 'CK',
},
{
label: 'Costa Rica',
value: 'CR',
},
{
label: "Cote D'Ivoire",
value: 'CI',
},
{
label: 'Croatia',
value: 'HR',
},
{
label: 'Cuba',
value: 'CU',
},
{
label: 'Cyprus',
value: 'CY',
},
{
label: 'Czech Republic',
value: 'CZ',
},
{
label: 'Denmark',
value: 'DK',
},
{
label: 'Djibouti',
value: 'DJ',
},
{
label: 'Dominica',
value: 'DM',
},
{
label: 'Dominican Republic',
value: 'DO',
},
{
label: 'Ecuador',
value: 'EC',
},
{
label: 'Egypt',
value: 'EG',
},
{
label: 'El Salvador',
value: 'SV',
},
{
label: 'Equatorial Guinea',
value: 'GQ',
},
{
label: 'Eritrea',
value: 'ER',
},
{
label: 'Estonia',
value: 'EE',
},
{
label: 'Ethiopia',
value: 'ET',
},
{
label: 'Falkland Islands (Malvinas)',
value: 'FK',
},
{
label: 'Faroe Islands',
value: 'FO',
},
{
label: 'Fiji',
value: 'FJ',
},
{
label: 'Finland',
value: 'FI',
},
{
label: 'France',
value: 'FR',
},
{
label: 'French Guiana',
value: 'GF',
},
{
label: 'French Polynesia',
value: 'PF',
},
{
label: 'French Southern Territories',
value: 'TF',
},
{
label: 'Gabon',
value: 'GA',
},
{
label: 'Gambia',
value: 'GM',
},
{
label: 'Georgia',
value: 'GE',
},
{
label: 'Germany',
value: 'DE',
},
{
label: 'Ghana',
value: 'GH',
},
{
label: 'Gibraltar',
value: 'GI',
},
{
label: 'Greece',
value: 'GR',
},
{
label: 'Greenland',
value: 'GL',
},
{
label: 'Grenada',
value: 'GD',
},
{
label: 'Guadeloupe',
value: 'GP',
},
{
label: 'Guam',
value: 'GU',
},
{
label: 'Guatemala',
value: 'GT',
},
{
label: 'Guernsey',
value: 'GG',
},
{
label: 'Guinea',
value: 'GN',
},
{
label: 'Guinea-Bissau',
value: 'GW',
},
{
label: 'Guyana',
value: 'GY',
},
{
label: 'Haiti',
value: 'HT',
},
{
label: 'Heard Island and Mcdonald Islands',
value: 'HM',
},
{
label: 'Holy See (Vatican City State)',
value: 'VA',
},
{
label: 'Honduras',
value: 'HN',
},
{
label: 'Hong Kong',
value: 'HK',
},
{
label: 'Hungary',
value: 'HU',
},
{
label: 'Iceland',
value: 'IS',
},
{
label: 'India',
value: 'IN',
},
{
label: 'Indonesia',
value: 'ID',
},
{
label: 'Iran, Islamic Republic Of',
value: 'IR',
},
{
label: 'Iraq',
value: 'IQ',
},
{
label: 'Ireland',
value: 'IE',
},
{
label: 'Isle of Man',
value: 'IM',
},
{
label: 'Israel',
value: 'IL',
},
{
label: 'Italy',
value: 'IT',
},
{
label: 'Jamaica',
value: 'JM',
},
{
label: 'Japan',
value: 'JP',
},
{
label: 'Jersey',
value: 'JE',
},
{
label: 'Jordan',
value: 'JO',
},
{
label: 'Kazakhstan',
value: 'KZ',
},
{
label: 'Kenya',
value: 'KE',
},
{
label: 'Kiribati',
value: 'KI',
},
{
label: "Democratic People's Republic of Korea",
value: 'KP',
},
{
label: 'Korea, Republic of',
value: 'KR',
},
{
label: 'Kosovo',
value: 'XK',
},
{
label: 'Kuwait',
value: 'KW',
},
{
label: 'Kyrgyzstan',
value: 'KG',
},
{
label: "Lao People's Democratic Republic",
value: 'LA',
},
{
label: 'Latvia',
value: 'LV',
},
{
label: 'Lebanon',
value: 'LB',
},
{
label: 'Lesotho',
value: 'LS',
},
{
label: 'Liberia',
value: 'LR',
},
{
label: 'Libyan Arab Jamahiriya',
value: 'LY',
},
{
label: 'Liechtenstein',
value: 'LI',
},
{
label: 'Lithuania',
value: 'LT',
},
{
label: 'Luxembourg',
value: 'LU',
},
{
label: 'Macao',
value: 'MO',
},
{
label: 'Macedonia, The Former Yugoslav Republic of',
value: 'MK',
},
{
label: 'Madagascar',
value: 'MG',
},
{
label: 'Malawi',
value: 'MW',
},
{
label: 'Malaysia',
value: 'MY',
},
{
label: 'Maldives',
value: 'MV',
},
{
label: 'Mali',
value: 'ML',
},
{
label: 'Malta',
value: 'MT',
},
{
label: 'Marshall Islands',
value: 'MH',
},
{
label: 'Martinique',
value: 'MQ',
},
{
label: 'Mauritania',
value: 'MR',
},
{
label: 'Mauritius',
value: 'MU',
},
{
label: 'Mayotte',
value: 'YT',
},
{
label: 'Mexico',
value: 'MX',
},
{
label: 'Micronesia, Federated States of',
value: 'FM',
},
{
label: 'Moldova, Republic of',
value: 'MD',
},
{
label: 'Monaco',
value: 'MC',
},
{
label: 'Mongolia',
value: 'MN',
},
{
label: 'Montenegro',
value: 'ME',
},
{
label: 'Montserrat',
value: 'MS',
},
{
label: 'Morocco',
value: 'MA',
},
{
label: 'Mozambique',
value: 'MZ',
},
{
label: 'Myanmar',
value: 'MM',
},
{
label: 'Namibia',
value: 'NA',
},
{
label: 'Nauru',
value: 'NR',
},
{
label: 'Nepal',
value: 'NP',
},
{
label: 'Netherlands',
value: 'NL',
},
{
label: 'Netherlands Antilles',
value: 'AN',
},
{
label: 'New Caledonia',
value: 'NC',
},
{
label: 'New Zealand',
value: 'NZ',
},
{
label: 'Nicaragua',
value: 'NI',
},
{
label: 'Niger',
value: 'NE',
},
{
label: 'Nigeria',
value: 'NG',
},
{
label: 'Niue',
value: 'NU',
},
{
label: 'Norfolk Island',
value: 'NF',
},
{
label: 'Northern Mariana Islands',
value: 'MP',
},
{
label: 'Norway',
value: 'NO',
},
{
label: 'Oman',
value: 'OM',
},
{
label: 'Pakistan',
value: 'PK',
},
{
label: 'Palau',
value: 'PW',
},
{
label: 'Palestinian Territory, Occupied',
value: 'PS',
},
{
label: 'Panama',
value: 'PA',
},
{
label: 'Papua New Guinea',
value: 'PG',
},
{
label: 'Paraguay',
value: 'PY',
},
{
label: 'Peru',
value: 'PE',
},
{
label: 'Philippines',
value: 'PH',
},
{
label: 'Pitcairn',
value: 'PN',
},
{
label: 'Poland',
value: 'PL',
},
{
label: 'Portugal',
value: 'PT',
},
{
label: 'Puerto Rico',
value: 'PR',
},
{
label: 'Qatar',
value: 'QA',
},
{
label: 'Reunion',
value: 'RE',
},
{
label: 'Romania',
value: 'RO',
},
{
label: 'Russian Federation',
value: 'RU',
},
{
label: 'Rwanda',
value: 'RW',
},
{
label: 'Saint Helena',
value: 'SH',
},
{
label: 'Saint Kitts and Nevis',
value: 'KN',
},
{
label: 'Saint Lucia',
value: 'LC',
},
{
label: 'Saint Pierre and Miquelon',
value: 'PM',
},
{
label: 'Saint Vincent and the Grenadines',
value: 'VC',
},
{
label: 'Samoa',
value: 'WS',
},
{
label: 'San Marino',
value: 'SM',
},
{
label: 'Sao Tome and Principe',
value: 'ST',
},
{
label: 'Saudi Arabia',
value: 'SA',
},
{
label: 'Senegal',
value: 'SN',
},
{
label: 'Serbia',
value: 'RS',
},
{
label: 'Seychelles',
value: 'SC',
},
{
label: 'Sierra Leone',
value: 'SL',
},
{
label: 'Singapore',
value: 'SG',
},
{
label: 'Slovakia',
value: 'SK',
},
{
label: 'Slovenia',
value: 'SI',
},
{
label: 'Solomon Islands',
value: 'SB',
},
{
label: 'Somalia',
value: 'SO',
},
{
label: 'South Africa',
value: 'ZA',
},
{
label: 'South Georgia and the South Sandwich Islands',
value: 'GS',
},
{
label: 'Spain',
value: 'ES',
},
{
label: 'Sri Lanka',
value: 'LK',
},
{
label: 'Sudan',
value: 'SD',
},
{
label: 'Suriname',
value: 'SR',
},
{
label: 'Svalbard and Jan Mayen',
value: 'SJ',
},
{
label: 'Swaziland',
value: 'SZ',
},
{
label: 'Sweden',
value: 'SE',
},
{
label: 'Switzerland',
value: 'CH',
},
{
label: 'Syrian Arab Republic',
value: 'SY',
},
{
label: 'Taiwan',
value: 'TW',
},
{
label: 'Tajikistan',
value: 'TJ',
},
{
label: 'Tanzania, United Republic of',
value: 'TZ',
},
{
label: 'Thailand',
value: 'TH',
},
{
label: 'Timor-Leste',
value: 'TL',
},
{
label: 'Togo',
value: 'TG',
},
{
label: 'Tokelau',
value: 'TK',
},
{
label: 'Tonga',
value: 'TO',
},
{
label: 'Trinidad and Tobago',
value: 'TT',
},
{
label: 'Tunisia',
value: 'TN',
},
{
label: 'Turkey',
value: 'TR',
},
{
label: 'Turkmenistan',
value: 'TM',
},
{
label: 'Turks and Caicos Islands',
value: 'TC',
},
{
label: 'Tuvalu',
value: 'TV',
},
{
label: 'Uganda',
value: 'UG',
},
{
label: 'Ukraine',
value: 'UA',
},
{
label: 'United Arab Emirates',
value: 'AE',
},
{
label: 'United Kingdom',
value: 'GB',
},
{
label: 'United States',
value: 'US',
},
{
label: 'United States Minor Outlying Islands',
value: 'UM',
},
{
label: 'Uruguay',
value: 'UY',
},
{
label: 'Uzbekistan',
value: 'UZ',
},
{
label: 'Vanuatu',
value: 'VU',
},
{
label: 'Venezuela',
value: 'VE',
},
{
label: 'Viet Nam',
value: 'VN',
},
{
label: 'Virgin Islands, British',
value: 'VG',
},
{
label: 'Virgin Islands, U.S.',
value: 'VI',
},
{
label: 'Wallis and Futuna',
value: 'WF',
},
{
label: 'Western Sahara',
value: 'EH',
},
{
label: 'Yemen',
value: 'YE',
},
{
label: 'Zambia',
value: 'ZM',
},
{
label: 'Zimbabwe',
value: 'ZW',
},
]
+38
View File
@@ -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>
)
}
+15
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+52
View File
@@ -0,0 +1,52 @@
export const stateOptions = [
{ label: 'Alabama', value: 'AL' },
{ label: 'Alaska', value: 'AK' },
{ label: 'Arizona', value: 'AZ' },
{ label: 'Arkansas', value: 'AR' },
{ label: 'California', value: 'CA' },
{ label: 'Colorado', value: 'CO' },
{ label: 'Connecticut', value: 'CT' },
{ label: 'Delaware', value: 'DE' },
{ label: 'Florida', value: 'FL' },
{ label: 'Georgia', value: 'GA' },
{ label: 'Hawaii', value: 'HI' },
{ label: 'Idaho', value: 'ID' },
{ label: 'Illinois', value: 'IL' },
{ label: 'Indiana', value: 'IN' },
{ label: 'Iowa', value: 'IA' },
{ label: 'Kansas', value: 'KS' },
{ label: 'Kentucky', value: 'KY' },
{ label: 'Louisiana', value: 'LA' },
{ label: 'Maine', value: 'ME' },
{ label: 'Maryland', value: 'MD' },
{ label: 'Massachusetts', value: 'MA' },
{ label: 'Michigan', value: 'MI' },
{ label: 'Minnesota', value: 'MN' },
{ label: 'Mississippi', value: 'MS' },
{ label: 'Missouri', value: 'MO' },
{ label: 'Montana', value: 'MT' },
{ label: 'Nebraska', value: 'NE' },
{ label: 'Nevada', value: 'NV' },
{ label: 'New Hampshire', value: 'NH' },
{ label: 'New Jersey', value: 'NJ' },
{ label: 'New Mexico', value: 'NM' },
{ label: 'New York', value: 'NY' },
{ label: 'North Carolina', value: 'NC' },
{ label: 'North Dakota', value: 'ND' },
{ label: 'Ohio', value: 'OH' },
{ label: 'Oklahoma', value: 'OK' },
{ label: 'Oregon', value: 'OR' },
{ label: 'Pennsylvania', value: 'PA' },
{ label: 'Rhode Island', value: 'RI' },
{ label: 'South Carolina', value: 'SC' },
{ label: 'South Dakota', value: 'SD' },
{ label: 'Tennessee', value: 'TN' },
{ label: 'Texas', value: 'TX' },
{ label: 'Utah', value: 'UT' },
{ label: 'Vermont', value: 'VT' },
{ label: 'Virginia', value: 'VA' },
{ label: 'Washington', value: 'WA' },
{ label: 'West Virginia', value: 'WV' },
{ label: 'Wisconsin', value: 'WI' },
{ label: 'Wyoming', value: 'WY' },
]
+32
View File
@@ -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>
)
}
+13
View File
@@ -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>
)
}
+51
View File
@@ -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',
},
}
+21
View File
@@ -0,0 +1,21 @@
import { Checkbox } from './Checkbox'
import { Country } from './Country'
import { Email } from './Email'
import { Message } from './Message'
import { Number } from './Number'
import { Select } from './Select'
import { State } from './State'
import { Text } from './Text'
import { Textarea } from './Textarea'
export const fields = {
checkbox: Checkbox,
country: Country,
email: Email,
message: Message,
number: Number,
select: Select,
state: State,
text: Text,
textarea: Textarea,
}
@@ -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>
)
}
+14
View File
@@ -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,
},
],
}
+16
View File
@@ -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>
`
}
+27
View File
@@ -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>
)
}
+51
View File
@@ -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
}
+22
View File
@@ -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>
)
}
+18
View File
@@ -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',
}
+43
View File
@@ -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
+21
View File
@@ -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
}
+315
View File
@@ -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
}
+405
View File
@@ -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
+71
View File
@@ -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
}
+276
View File
@@ -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
+21
View File
@@ -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
+46
View File
@@ -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