first commit
This commit is contained in:
commit
c452a505e2
167 changed files with 17149 additions and 0 deletions
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.bun/
|
||||
|
||||
# Build outputs
|
||||
.next/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.prod
|
||||
apps/cms/.env
|
||||
apps/web/.env
|
||||
|
||||
# Payload generated
|
||||
apps/cms/src/payload-types.ts
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OpenTofu state (contains secrets)
|
||||
infra/tofu/.terraform/
|
||||
infra/tofu/*.tfstate
|
||||
infra/tofu/*.tfstate.backup
|
||||
infra/tofu/.terraform.lock.hcl
|
||||
EOF
|
||||
22
README.md
Normal file
22
README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Donauschwaben.online monorepo
|
||||
|
||||
This monorepo contains the cms and frontend of the donauschwaben.online website. It's built on Payload CMS and SvelteKit. Payload connects to a PostgreSQL database.
|
||||
|
||||
Donauschwaben.online is a project that aims to be the one-stop shop for all things Donauschwaben. History, Genealogy, Networking, and more.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Bun
|
||||
|
||||
To start the development servers, first spin up the database, then execute the dev script from the root of the project.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
bun --bun run dev
|
||||
```
|
||||
|
||||
2
apps/cms/.env.example
Normal file
2
apps/cms/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DATABASE_URL=postgres://donauschwaben:devpassword@localhost:5432/donauschwaben
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
50
apps/cms/.gitignore
vendored
Normal file
50
apps/cms/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# 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
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
1
apps/cms/.npmrc
Normal file
1
apps/cms/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
legacy-peer-deps=true
|
||||
6
apps/cms/.prettierrc.json
Normal file
6
apps/cms/.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
149
apps/cms/.vibe/skills/cms-migration/SKILL.md
Normal file
149
apps/cms/.vibe/skills/cms-migration/SKILL.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
name: cms-migration
|
||||
description: Use when user wants to migrate content from another CMS (WordPress, Contentful, Strapi, Sanity, Webflow, etc.) to Payload CMS
|
||||
---
|
||||
|
||||
# CMS Migration to Payload
|
||||
|
||||
Interactive workflow to design Payload collections from source CMS data. Config-first approach: establish the data structure through conversation before any data import.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Start
|
||||
↓
|
||||
Ask for data sample
|
||||
↓
|
||||
Analyze data shape
|
||||
↓
|
||||
Propose collection config
|
||||
↓
|
||||
User reviews ──────────────┐
|
||||
│ │
|
||||
├─ changes needed ───→ Adjust config ──→ (back to User reviews)
|
||||
│
|
||||
└─ looks good ───→ Config confirmed
|
||||
↓
|
||||
More collections? ──────┐
|
||||
│ │
|
||||
├─ yes ──→ (back to Ask for data sample)
|
||||
│
|
||||
└─ no ───→ All collections confirmed
|
||||
↓
|
||||
Discuss migration approach
|
||||
↓
|
||||
Done
|
||||
```
|
||||
|
||||
## Phase 1: Data Analysis
|
||||
|
||||
When user provides data (JSON, CSV, or describes their schema):
|
||||
|
||||
1. **Identify field types** - text, number, date, relationships, media, rich text
|
||||
2. **Spot patterns** - IDs, timestamps, nested objects, arrays
|
||||
3. **Note relationships** - foreign keys, embedded refs, linked content types
|
||||
4. **Flag ambiguities** - fields that could be multiple types, unclear purposes
|
||||
|
||||
## Phase 2: Propose Collection Config
|
||||
|
||||
Present a Payload collection config based on analysis:
|
||||
|
||||
```typescript
|
||||
// Example output format
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'content', type: 'richText' },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users' },
|
||||
// ...
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Explain your reasoning for each field choice. When something could go multiple ways (group vs JSON, text vs textarea, select vs relationship), ask rather than assume.
|
||||
|
||||
## Phase 3: Iterate with User
|
||||
|
||||
Work through uncertainties: required fields, hasMany relationships, rich text vs HTML, custom timestamps vs built-in. Continue until the user confirms the config.
|
||||
|
||||
## Phase 4: Additional Collections
|
||||
|
||||
After each confirmation, ask:
|
||||
|
||||
> "Are there other content types we should create collections for?"
|
||||
|
||||
If yes, loop back to Phase 1 with new data sample.
|
||||
|
||||
Common related collections to prompt for:
|
||||
- Media/uploads
|
||||
- Users/authors
|
||||
- Categories/tags
|
||||
- Settings (global)
|
||||
|
||||
## Phase 5: Migration Approach
|
||||
|
||||
Only after ALL collections are confirmed, discuss data import:
|
||||
|
||||
1. **Order matters** - which collections have no dependencies? Migrate those first
|
||||
2. **Relationship mapping** - how to resolve source IDs to Payload IDs
|
||||
3. **Media handling** - download/re-upload vs external URLs
|
||||
4. **Rich text** - HTML conversion needs or keep raw
|
||||
|
||||
Offer to generate a seed script or walk through manual import.
|
||||
|
||||
## Things to Clarify
|
||||
|
||||
Throughout the process, watch for these:
|
||||
|
||||
- **ID references** - are they relationships to other collections?
|
||||
- **Image/file URLs** - upload fields or keep as external URLs?
|
||||
- **Nested objects** - group, array, or blocks?
|
||||
- **Localization** - any fields need per-locale values?
|
||||
- **Access control** - who can read/write this collection?
|
||||
- **Related content types** - categories, tags, authors that need their own collections?
|
||||
|
||||
## Critical: Select vs Relationship
|
||||
|
||||
**This is the most common migration mistake.** Data that looks static often needs to be dynamic.
|
||||
|
||||
When you see repeated string values (categories, tags, types, statuses):
|
||||
|
||||
```json
|
||||
{ "category": "Technology" }
|
||||
{ "category": "News" }
|
||||
{ "category": "Technology" }
|
||||
```
|
||||
|
||||
**Don't assume it's a select field.** Ask:
|
||||
|
||||
> "I see `category` has values like 'Technology', 'News'. Should this be:
|
||||
> - A **select field** with fixed options (values won't change)
|
||||
> - A **relationship** to a Categories collection (users can add/edit/remove categories later)"
|
||||
|
||||
**Default to relationship** for anything that looks like:
|
||||
- Categories, tags, topics, labels
|
||||
- Authors, assignees, reviewers
|
||||
- Statuses beyond simple draft/published
|
||||
- Types that might expand over time
|
||||
|
||||
**Use select only for:**
|
||||
- Truly fixed enums (yes/no, draft/published/archived)
|
||||
- Options defined by business logic, not content (payment status, priority levels)
|
||||
- Values that would break functionality if changed (role types with code dependencies)
|
||||
|
||||
If creating a relationship, remember to add the related collection (Categories, Tags, etc.) to the migration plan.
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- **[PAYLOAD-FIELD-REFERENCE.md](reference/PAYLOAD-FIELD-REFERENCE.md)** - Complete Payload field type schemas with examples
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Issue | How to Handle |
|
||||
|-------|---------------|
|
||||
| User provides partial data | Ask for more samples, especially edge cases |
|
||||
| Unclear relationships | Ask user to describe how content types connect |
|
||||
| Rich text ambiguity | Clarify: Lexical editor, Slate, or store raw HTML |
|
||||
| Missing media collection | Always confirm upload collection exists before referencing |
|
||||
| Overly complex nested data | Consider flattening or using blocks instead of deep groups |
|
||||
File diff suppressed because it is too large
Load diff
60
apps/cms/.vibe/skills/payload/README.md
Normal file
60
apps/cms/.vibe/skills/payload/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Payload Skill for AI Coding Agents
|
||||
|
||||
Agent skill providing comprehensive guidance for Payload development with TypeScript patterns, field configurations, hooks, access control, and API examples.
|
||||
|
||||
## What's Included
|
||||
|
||||
The `payload` skill provides expert guidance on:
|
||||
|
||||
- **Collections**: Auth, uploads, drafts, live preview configurations
|
||||
- **Fields**: All field types including relationships, arrays, blocks, joins, virtual fields
|
||||
- **Hooks**: beforeChange, afterChange, beforeValidate, field hooks
|
||||
- **Access Control**: Collection, field, and global access patterns including RBAC and multi-tenant
|
||||
- **Queries**: Local API, REST, and GraphQL with complex operators
|
||||
- **Database Adapters**: MongoDB, Postgres, SQLite configurations and transactions
|
||||
- **Advanced Features**: Jobs queue, custom endpoints, localization, plugins
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, the Agent will automatically invoke the skill when you're working on Payload projects. The skill activates when you:
|
||||
|
||||
- Edit `payload.config.ts` files
|
||||
- Work with collection or global configurations
|
||||
- Ask about Payload-specific patterns
|
||||
- Need guidance on fields, hooks, or access control
|
||||
|
||||
You can also explicitly invoke it:
|
||||
|
||||
```
|
||||
@payload how do I implement row-level access control?
|
||||
```
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
skills/payload/
|
||||
├── SKILL.md # Main skill file with quick reference
|
||||
└── reference/
|
||||
├── FIELDS.md # All field types and configurations
|
||||
├── FIELD-TYPE-GUARDS.md # Type guards for field discrimination
|
||||
├── COLLECTIONS.md # Collection patterns
|
||||
├── HOOKS.md # Hook patterns and examples
|
||||
├── ACCESS-CONTROL.md # Basic access control
|
||||
├── ACCESS-CONTROL-ADVANCED.md # Advanced access patterns
|
||||
├── QUERIES.md # Query patterns and APIs
|
||||
├── ENDPOINTS.md # Custom endpoints
|
||||
├── ADAPTERS.md # Database and storage adapters
|
||||
├── PLUGIN-DEVELOPMENT.md # Plugin development patterns
|
||||
└── ADVANCED.md # Jobs, endpoints, localization
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Payload Documentation](https://payloadcms.com/docs)
|
||||
- [GitHub Repository](https://github.com/payloadcms/payload)
|
||||
- [Examples](https://github.com/payloadcms/payload/tree/main/examples)
|
||||
- [Templates](https://github.com/payloadcms/payload/tree/main/templates)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
518
apps/cms/.vibe/skills/payload/SKILL.md
Normal file
518
apps/cms/.vibe/skills/payload/SKILL.md
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
---
|
||||
name: payload
|
||||
description: Use when working with Payload projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior.
|
||||
---
|
||||
|
||||
# Payload Application Development
|
||||
|
||||
Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Solution | Details |
|
||||
| ------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Auto-generate slugs | `slugField()` | [FIELDS.md#slug-field-helper](reference/FIELDS.md#slug-field-helper) |
|
||||
| Restrict content by user | Access control with query | [ACCESS-CONTROL.md#row-level-security-with-complex-queries](reference/ACCESS-CONTROL.md#row-level-security-with-complex-queries) |
|
||||
| Local API user ops | `user` + `overrideAccess: false` | [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api) |
|
||||
| Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) |
|
||||
| Computed fields | `virtual: true` with **field-level** `hooks.afterRead` returning the value | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) |
|
||||
| Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) |
|
||||
| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) |
|
||||
| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) |
|
||||
| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) |
|
||||
| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
||||
| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) |
|
||||
| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) |
|
||||
| Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) |
|
||||
| Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) |
|
||||
| Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) |
|
||||
| Query by relationship | Nested property syntax | [QUERIES.md#nested-properties](reference/QUERIES.md#nested-properties) |
|
||||
| Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) |
|
||||
| Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) |
|
||||
| Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) |
|
||||
| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) |
|
||||
| Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) |
|
||||
| Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) |
|
||||
| Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) |
|
||||
| Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) |
|
||||
| Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) |
|
||||
| Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) |
|
||||
| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npx create-payload-app@latest my-app
|
||||
cd my-app
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Minimal Config
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: 'users',
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URL,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Essential Patterns
|
||||
|
||||
### Defaults & Conventions
|
||||
|
||||
Apply these defaults when modeling content unless there's a clear reason not to:
|
||||
|
||||
- **Enable drafts/versions by default:** `versions: { drafts: true }`. This is the
|
||||
recommended starting point for any content collection. It auto-injects a
|
||||
`_status` field (`draft` / `published` / `changed`) — **don't add your own
|
||||
`status` field**, it's redundant. Only skip versions for collections that have
|
||||
no publish/draft lifecycle (e.g. internal join tables, settings).
|
||||
- **Use `slugField()` for all slugs** instead of hand-rolling
|
||||
`{ name: 'slug', type: 'text', unique: true }`. It auto-generates the slug from
|
||||
the title, adds a regenerate toggle, and handles uniqueness/indexing for you.
|
||||
It defaults to generating from a `title` field — if the collection has no
|
||||
`title`, pass the source field: `slugField({ useAsSlug: 'name' })`.
|
||||
- **`position: 'sidebar'` is for short, at-a-glance fields** — status, category,
|
||||
author, publish date. Avoid it for long fields that need horizontal space to be
|
||||
usable (description, rich text content, long text). Those belong in the main
|
||||
document area.
|
||||
|
||||
### Basic Collection
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
// _status (from versions.drafts) shows the draft/published state — no custom status field needed
|
||||
defaultColumns: ['title', 'author', '_status', 'createdAt'],
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
slugField(), // auto-generates from `title`, unique + indexed, sidebar position
|
||||
{ name: 'content', type: 'richText' }, // long field — stays in the main area, not the sidebar
|
||||
// short, at-a-glance field — good sidebar candidate
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users', admin: { position: 'sidebar' } },
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
```
|
||||
|
||||
For more collection patterns (auth, upload, drafts, live preview), see [COLLECTIONS.md](reference/COLLECTIONS.md).
|
||||
|
||||
### Common Fields
|
||||
|
||||
```ts
|
||||
// Text field
|
||||
{ name: 'title', type: 'text', required: true }
|
||||
|
||||
// Relationship
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users', required: true }
|
||||
|
||||
// Rich text
|
||||
{ name: 'content', type: 'richText', required: true }
|
||||
|
||||
// Slug — use the helper instead of a hand-rolled text field
|
||||
slugField()
|
||||
|
||||
// Select (for genuine taxonomy — NOT publish state; use versions.drafts + _status for that)
|
||||
{ name: 'category', type: 'select', options: ['news', 'tutorial', 'opinion'] }
|
||||
|
||||
// Upload
|
||||
{ name: 'image', type: 'upload', relationTo: 'media' }
|
||||
```
|
||||
|
||||
For all field types (array, blocks, point, join, virtual, conditional, etc.), see [FIELDS.md](reference/FIELDS.md).
|
||||
|
||||
### Hook Example
|
||||
|
||||
Hooks live at one of two levels and they are not interchangeable. **Collection hooks** receive `{ doc, data, req, operation, ... }` and act on the whole document. **Field hooks** live inside an individual field's `hooks` object, receive `{ value, siblingData, ... }`, and **return the new value** for that field. Computed/virtual fields, per-field formatters, and per-field access masking are field hooks; cross-field business logic is a collection hook.
|
||||
|
||||
```ts
|
||||
// Collection-level: business logic across the document
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create') {
|
||||
data.slug = slugify(data.title)
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
|
||||
// Field-level: compute / format a single field's value (virtual fields use this)
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
fields: [
|
||||
{ name: 'firstName', type: 'text' },
|
||||
{ name: 'lastName', type: 'text' },
|
||||
{
|
||||
name: 'fullName',
|
||||
type: 'text',
|
||||
virtual: true,
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
When asked to "compute a field" or "populate a field's value in a hook", use a **field-level** hook on that field — never a collection-level `afterRead` that mutates `doc`.
|
||||
|
||||
For all hook patterns, see [HOOKS.md](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md).
|
||||
|
||||
### Access Control with Type Safety
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
import type { User } from '@/payload-types'
|
||||
|
||||
// Type-safe access control
|
||||
export const adminOnly: Access = ({ req }) => {
|
||||
const user = req.user as User
|
||||
return user?.roles?.includes('admin') || false
|
||||
}
|
||||
|
||||
// Row-level access control
|
||||
export const ownPostsOnly: Access = ({ req }) => {
|
||||
const user = req.user as User
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
author: { equals: user.id },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query Example
|
||||
|
||||
```ts
|
||||
// Local API
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
status: { equals: 'published' },
|
||||
'author.name': { contains: 'john' },
|
||||
},
|
||||
depth: 2,
|
||||
limit: 10,
|
||||
sort: '-createdAt',
|
||||
})
|
||||
|
||||
// Query with populated relationships
|
||||
const post = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
depth: 2, // Populates relationships (default is 2)
|
||||
})
|
||||
// Returns: { author: { id: "user123", name: "John" } }
|
||||
|
||||
// Without depth, relationships return IDs only
|
||||
const post = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
depth: 0,
|
||||
})
|
||||
// Returns: { author: "user123" }
|
||||
```
|
||||
|
||||
For all query operators and REST/GraphQL examples, see [QUERIES.md](reference/QUERIES.md).
|
||||
|
||||
### Getting Payload Instance
|
||||
|
||||
```ts
|
||||
// In API routes (Next.js)
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
export async function GET() {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
})
|
||||
|
||||
return Response.json(posts)
|
||||
}
|
||||
|
||||
// In Server Components
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
export default async function Page() {
|
||||
const payload = await getPayload({ config })
|
||||
const { docs } = await payload.find({ collection: 'posts' })
|
||||
|
||||
return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Security Pitfalls
|
||||
|
||||
### 1. Local API Access Control (CRITICAL)
|
||||
|
||||
**By default, Local API operations bypass ALL access control**, even when passing a user.
|
||||
|
||||
```ts
|
||||
// ❌ SECURITY BUG: Passes user but ignores their permissions
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser, // Access control is BYPASSED!
|
||||
})
|
||||
|
||||
// ✅ SECURE: Actually enforces the user's permissions
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser,
|
||||
overrideAccess: false, // REQUIRED for access control
|
||||
})
|
||||
```
|
||||
|
||||
**When to use each:**
|
||||
|
||||
- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks)
|
||||
- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks)
|
||||
|
||||
See [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api).
|
||||
|
||||
### 2. Transaction Failures in Hooks
|
||||
|
||||
**Nested operations in hooks without `req` break transaction atomicity.**
|
||||
|
||||
```ts
|
||||
// ❌ DATA CORRUPTION RISK: Separate transaction
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
await req.payload.create({
|
||||
collection: 'audit-log',
|
||||
data: { docId: doc.id },
|
||||
// Missing req - runs in separate transaction!
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ✅ ATOMIC: Same transaction
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
await req.payload.create({
|
||||
collection: 'audit-log',
|
||||
data: { docId: doc.id },
|
||||
req, // Maintains atomicity
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations).
|
||||
|
||||
### 3. Infinite Hook Loops
|
||||
|
||||
**Hooks triggering operations that trigger the same hooks create infinite loops.**
|
||||
|
||||
```ts
|
||||
// ❌ INFINITE LOOP
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
await req.payload.update({
|
||||
collection: 'posts',
|
||||
id: doc.id,
|
||||
data: { views: doc.views + 1 },
|
||||
req,
|
||||
}) // Triggers afterChange again!
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// ✅ SAFE: Use context flag
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req, context }) => {
|
||||
if (context.skipHooks) return
|
||||
|
||||
await req.payload.update({
|
||||
collection: 'posts',
|
||||
id: doc.id,
|
||||
data: { views: doc.views + 1 },
|
||||
context: { skipHooks: true },
|
||||
req,
|
||||
})
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [HOOKS.md#context](reference/HOOKS.md#context).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```txt
|
||||
src/
|
||||
├── app/
|
||||
│ ├── (frontend)/
|
||||
│ │ └── page.tsx
|
||||
│ └── (payload)/
|
||||
│ └── admin/[[...segments]]/page.tsx
|
||||
├── collections/
|
||||
│ ├── Posts.ts
|
||||
│ ├── Media.ts
|
||||
│ └── Users.ts
|
||||
├── globals/
|
||||
│ └── Header.ts
|
||||
├── components/
|
||||
│ └── CustomField.tsx
|
||||
├── hooks/
|
||||
│ └── slugify.ts
|
||||
└── payload.config.ts
|
||||
```
|
||||
|
||||
## Building & Type Generation
|
||||
|
||||
Payload generates `payload-types.ts` for you — you rarely need to run `generate:types` by hand.
|
||||
|
||||
- **During development:** `typescript.autoGenerate` defaults to `true`, so the dev
|
||||
server regenerates types automatically whenever your config changes. Don't run
|
||||
`generate:types` manually while the dev server is running — it's redundant.
|
||||
- **During builds:** `payload build` generates the import map and types before
|
||||
running `next build`. Prefer it over calling `next build` directly so neither is
|
||||
ever stale. Pass `--no-types` to skip type generation.
|
||||
- **Manual generation** (`payload generate:types`) is an escape hatch — only when
|
||||
neither the dev server nor a build is in the loop (e.g. a one-off script, or CI
|
||||
before a step that doesn't run `payload build`).
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
export default buildConfig({
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
// autoGenerate defaults to true — types regenerate in dev automatically
|
||||
},
|
||||
})
|
||||
|
||||
// Usage
|
||||
import type { Post, User } from '@/payload-types'
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Local API bypasses access control** unless you pass `overrideAccess: false`
|
||||
2. **Missing `req` in nested operations** breaks transaction atomicity
|
||||
3. **Hook loops** — operations in hooks can re-trigger the same hooks; use `req.context` flags
|
||||
4. **Field-level access** returns boolean only, no query constraints
|
||||
5. **Relationship depth** defaults to 2; set `depth: 0` for IDs only
|
||||
6. **Draft status** — `_status` field is auto-injected when drafts are enabled
|
||||
7. **Types regenerate automatically** in dev (`autoGenerate`) and during `payload build` — avoid running `generate:types` manually
|
||||
8. **MongoDB transactions** require replica set configuration
|
||||
9. **SQLite transactions** are disabled by default; enable with `transactionOptions: {}`
|
||||
10. **Point fields** are not supported in SQLite
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Content Modeling
|
||||
|
||||
- Enable `versions: { drafts: true }` by default on content collections; rely on the
|
||||
auto-injected `_status` field rather than adding a custom `status` field
|
||||
- Use `slugField()` for slugs instead of hand-rolling a unique text field
|
||||
- Reserve `position: 'sidebar'` for short, at-a-glance fields (status, category,
|
||||
author, date); keep long fields (description, rich text) in the main area
|
||||
|
||||
### Security
|
||||
|
||||
- Default to restrictive access, gradually add permissions
|
||||
- Use `overrideAccess: false` when passing `user` to Local API
|
||||
- Field-level access only returns boolean (no query constraints)
|
||||
- Never trust client-provided data
|
||||
- Use `saveToJWT: true` for roles to avoid database lookups
|
||||
|
||||
### Performance
|
||||
|
||||
- Index frequently queried fields
|
||||
- Use `select` to limit returned fields
|
||||
- Set `maxDepth` on relationships to prevent over-fetching
|
||||
- Prefer query constraints over async operations in access control
|
||||
- Cache expensive operations in `req.context`
|
||||
|
||||
### Data Integrity
|
||||
|
||||
- Always pass `req` to nested operations in hooks
|
||||
- Use context flags to prevent infinite hook loops
|
||||
- Enable transactions for MongoDB (requires replica set) and Postgres
|
||||
- Use `beforeValidate` for data formatting
|
||||
- Use `beforeChange` for business logic
|
||||
|
||||
### Type Safety
|
||||
|
||||
- Let dev (`autoGenerate`) and `payload build` generate types; run `generate:types` manually only when neither is running
|
||||
- Import types from generated `payload-types.ts`
|
||||
- Type your user object: `import type { User } from '@/payload-types'`
|
||||
- Use field type guards for runtime type checking
|
||||
- When extracting any Payload value into a named constant — a collection, field, hook, access function, plugin, etc. — annotate it with the matching Payload type (`CollectionConfig`, `Field`, `CollectionBeforeChangeHook`, `Access`, `Plugin`, …) or use `satisfies <Type>`. Without an annotation, string properties like `type: 'text'` widen to `string` and discriminated unions (`Field`, `CollectionConfig`) fail to resolve. Inline literals get this for free via contextual typing; extracted constants do not.
|
||||
|
||||
### Organization
|
||||
|
||||
- Keep collections in separate files
|
||||
- Extract access control to `access/` directory
|
||||
- Extract hooks to `hooks/` directory
|
||||
- Use reusable field factories for common patterns
|
||||
- Document complex access control with comments
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- **[FIELDS.md](reference/FIELDS.md)** - All field types, validation, admin options
|
||||
- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing
|
||||
- **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview
|
||||
- **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns
|
||||
- **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant
|
||||
- **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates
|
||||
- **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs
|
||||
- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns
|
||||
- **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions
|
||||
- **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization
|
||||
- **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices
|
||||
|
||||
## Resources
|
||||
|
||||
- llms-full.txt: <https://payloadcms.com/llms-full.txt>
|
||||
- Docs: <https://payloadcms.com/docs>
|
||||
- GitHub: <https://github.com/payloadcms/payload>
|
||||
- Examples: <https://github.com/payloadcms/payload/tree/main/examples>
|
||||
- Templates: <https://github.com/payloadcms/payload/tree/main/templates>
|
||||
|
|
@ -0,0 +1,704 @@
|
|||
# Payload Access Control - Advanced Patterns
|
||||
|
||||
Advanced access control patterns including context-aware access, time-based restrictions, factory functions, and production templates.
|
||||
|
||||
## Context-Aware Access Patterns
|
||||
|
||||
### Locale-Specific Access
|
||||
|
||||
Control access based on user locale for internationalized content.
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const localeSpecificAccess: Access = ({ req: { user, locale } }) => {
|
||||
// Authenticated users can access all locales
|
||||
if (user) return true
|
||||
|
||||
// Public users can only access English content
|
||||
if (locale === 'en') return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Usage in collection
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
read: localeSpecificAccess,
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text', localized: true }],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `docs/access-control/overview.mdx` (req.locale argument)
|
||||
|
||||
### Device-Specific Access
|
||||
|
||||
Restrict access based on device type or user agent.
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const mobileOnlyAccess: Access = ({ req: { headers } }) => {
|
||||
const userAgent = headers?.get('user-agent') || ''
|
||||
return /mobile|android|iphone/i.test(userAgent)
|
||||
}
|
||||
|
||||
export const desktopOnlyAccess: Access = ({ req: { headers } }) => {
|
||||
const userAgent = headers?.get('user-agent') || ''
|
||||
return !/mobile|android|iphone/i.test(userAgent)
|
||||
}
|
||||
|
||||
// Usage
|
||||
export const MobileContent: CollectionConfig = {
|
||||
slug: 'mobile-content',
|
||||
access: {
|
||||
read: mobileOnlyAccess,
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Synthesized (headers pattern)
|
||||
|
||||
### IP-Based Access
|
||||
|
||||
Restrict access from specific IP addresses (requires middleware/proxy headers).
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const restrictedIpAccess = (allowedIps: string[]): Access => {
|
||||
return ({ req: { headers } }) => {
|
||||
const ip = headers?.get('x-forwarded-for') || headers?.get('x-real-ip')
|
||||
return allowedIps.includes(ip || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const internalIps = ['192.168.1.0/24', '10.0.0.5']
|
||||
|
||||
export const InternalDocs: CollectionConfig = {
|
||||
slug: 'internal-docs',
|
||||
access: {
|
||||
read: restrictedIpAccess(internalIps),
|
||||
},
|
||||
fields: [{ name: 'content', type: 'richText' }],
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Requires your server to pass IP address via headers (common with proxies/load balancers).
|
||||
|
||||
**Source**: Synthesized (headers pattern)
|
||||
|
||||
## Time-Based Access Patterns
|
||||
|
||||
### Today's Records Only
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const todayOnlyAccess: Access = ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
|
||||
const now = new Date()
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000)
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
greater_than_equal: startOfDay.toISOString(),
|
||||
less_than: endOfDay.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `test/access-control/config.ts` (query constraint patterns)
|
||||
|
||||
### Recent Records (Last N Days)
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const recentRecordsAccess = (days: number): Access => {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - days)
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
greater_than_equal: cutoff.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Users see only last 30 days, admins see all
|
||||
export const Logs: CollectionConfig = {
|
||||
slug: 'logs',
|
||||
access: {
|
||||
read: recentRecordsAccess(30),
|
||||
},
|
||||
fields: [{ name: 'message', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled Content (Publish Date Range)
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const scheduledContentAccess: Access = ({ req: { user } }) => {
|
||||
// Editors see all content
|
||||
if (user?.roles?.includes('admin') || user?.roles?.includes('editor')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Public sees only content within publish window
|
||||
return {
|
||||
and: [
|
||||
{ publishDate: { less_than_equal: now } },
|
||||
{
|
||||
or: [{ unpublishDate: { exists: false } }, { unpublishDate: { greater_than: now } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Synthesized (query constraint + date patterns)
|
||||
|
||||
## Subscription-Based Access
|
||||
|
||||
### Active Subscription Required
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const activeSubscriptionAccess: Access = async ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
try {
|
||||
const subscription = await req.payload.findByID({
|
||||
collection: 'subscriptions',
|
||||
id: user.subscriptionId,
|
||||
})
|
||||
|
||||
return subscription?.status === 'active'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export const PremiumContent: CollectionConfig = {
|
||||
slug: 'premium-content',
|
||||
access: {
|
||||
read: activeSubscriptionAccess,
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription Tier-Based Access
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const tierBasedAccess = (requiredTier: string): Access => {
|
||||
const tierHierarchy = ['free', 'basic', 'pro', 'enterprise']
|
||||
|
||||
return async ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
try {
|
||||
const subscription = await req.payload.findByID({
|
||||
collection: 'subscriptions',
|
||||
id: user.subscriptionId,
|
||||
})
|
||||
|
||||
if (subscription?.status !== 'active') return false
|
||||
|
||||
const userTierIndex = tierHierarchy.indexOf(subscription.tier)
|
||||
const requiredTierIndex = tierHierarchy.indexOf(requiredTier)
|
||||
|
||||
return userTierIndex >= requiredTierIndex
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export const EnterpriseFeatures: CollectionConfig = {
|
||||
slug: 'enterprise-features',
|
||||
access: {
|
||||
read: tierBasedAccess('enterprise'),
|
||||
},
|
||||
fields: [{ name: 'feature', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Synthesized (async + cross-collection pattern)
|
||||
|
||||
## Factory Functions
|
||||
|
||||
Reusable functions that generate access control configurations.
|
||||
|
||||
### createRoleBasedAccess
|
||||
|
||||
Generate access control for specific roles.
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export function createRoleBasedAccess(roles: string[]): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return roles.some((role) => user.roles?.includes(role))
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const adminOrEditor = createRoleBasedAccess(['admin', 'editor'])
|
||||
const moderatorAccess = createRoleBasedAccess(['admin', 'moderator'])
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
create: adminOrEditor,
|
||||
update: adminOrEditor,
|
||||
delete: moderatorAccess,
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `test/access-control/config.ts`
|
||||
|
||||
### createOrgScopedAccess
|
||||
|
||||
Generate organization-scoped access with optional admin bypass.
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export function createOrgScopedAccess(allowAdmin = true): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (allowAdmin && user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
organizationId: { in: user.organizationIds || [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const orgScoped = createOrgScopedAccess() // Admins bypass
|
||||
const strictOrgScoped = createOrgScopedAccess(false) // Admins also scoped
|
||||
|
||||
export const Projects: CollectionConfig = {
|
||||
slug: 'projects',
|
||||
access: {
|
||||
read: orgScoped,
|
||||
update: orgScoped,
|
||||
delete: strictOrgScoped,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'organizationId', type: 'text', required: true },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `test/access-control/config.ts`
|
||||
|
||||
### createTeamBasedAccess
|
||||
|
||||
Generate team-scoped access with configurable field name.
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export function createTeamBasedAccess(teamField = 'teamId'): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
[teamField]: { in: user.teamIds || [] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with custom field name
|
||||
const projectTeamAccess = createTeamBasedAccess('projectTeam')
|
||||
|
||||
export const Tasks: CollectionConfig = {
|
||||
slug: 'tasks',
|
||||
access: {
|
||||
read: projectTeamAccess,
|
||||
update: projectTeamAccess,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'projectTeam', type: 'text', required: true },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Synthesized (org pattern variation)
|
||||
|
||||
### createTimeLimitedAccess
|
||||
|
||||
Generate access limited to records within specified days.
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export function createTimeLimitedAccess(daysAccess: number): Access {
|
||||
return ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - daysAccess)
|
||||
|
||||
return {
|
||||
createdAt: {
|
||||
greater_than_equal: cutoff.toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Users see 90 days, admins see all
|
||||
export const ActivityLogs: CollectionConfig = {
|
||||
slug: 'activity-logs',
|
||||
access: {
|
||||
read: createTimeLimitedAccess(90),
|
||||
},
|
||||
fields: [{ name: 'action', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: Synthesized (time + query pattern)
|
||||
|
||||
## Configuration Templates
|
||||
|
||||
Complete collection configurations for common scenarios.
|
||||
|
||||
### Basic Authenticated Collection
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const BasicCollection: CollectionConfig = {
|
||||
slug: 'basic-collection',
|
||||
access: {
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
read: ({ req: { user } }) => Boolean(user),
|
||||
update: ({ req: { user } }) => Boolean(user),
|
||||
delete: ({ req: { user } }) => Boolean(user),
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'content', type: 'richText' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `docs/access-control/collections.mdx`
|
||||
|
||||
### Public + Authenticated Collection
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const PublicAuthCollection: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
// Only admins/editors can create
|
||||
create: ({ req: { user } }) => {
|
||||
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
|
||||
},
|
||||
|
||||
// Authenticated users see all, public sees only published
|
||||
read: ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { _status: { equals: 'published' } }
|
||||
},
|
||||
|
||||
// Only admins/editors can update
|
||||
update: ({ req: { user } }) => {
|
||||
return user?.roles?.some((role) => ['admin', 'editor'].includes(role)) || false
|
||||
},
|
||||
|
||||
// Only admins can delete
|
||||
delete: ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin') || false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'content', type: 'richText', required: true },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `templates/website/src/collections/Posts/index.ts`
|
||||
|
||||
### Multi-User/Self-Service Collection
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const SelfServiceCollection: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
access: {
|
||||
// Admins can create users
|
||||
create: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
|
||||
// Anyone can read user profiles
|
||||
read: () => true,
|
||||
|
||||
// Users can update self, admins can update anyone
|
||||
update: ({ req: { user }, id }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
return user.id === id
|
||||
},
|
||||
|
||||
// Only admins can delete
|
||||
delete: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'email', type: 'email', required: true },
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
access: {
|
||||
// Only admins can read/update roles
|
||||
read: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
update: ({ req: { user } }) => user?.roles?.includes('admin') || false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Source**: `templates/website/src/collections/Users/index.ts`
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Log Access Check Execution
|
||||
|
||||
```ts
|
||||
export const debugAccess: Access = ({ req: { user }, id }) => {
|
||||
console.log('Access check:', {
|
||||
userId: user?.id,
|
||||
userRoles: user?.roles,
|
||||
docId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Arguments Availability
|
||||
|
||||
```ts
|
||||
export const checkArgsAccess: Access = (args) => {
|
||||
console.log('Available arguments:', {
|
||||
hasReq: 'req' in args,
|
||||
hasUser: args.req?.user ? 'yes' : 'no',
|
||||
hasId: args.id ? 'provided' : 'undefined',
|
||||
hasData: args.data ? 'provided' : 'undefined',
|
||||
})
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
### Measure Async Operation Timing
|
||||
|
||||
```ts
|
||||
export const timedAsyncAccess: Access = async ({ req }) => {
|
||||
const start = Date.now()
|
||||
|
||||
const result = await fetch('https://auth-service.example.com/validate', {
|
||||
headers: { userId: req.user?.id },
|
||||
})
|
||||
|
||||
console.log(`Access check took ${Date.now() - start}ms`)
|
||||
|
||||
return result.ok
|
||||
}
|
||||
```
|
||||
|
||||
### Test Access Without User
|
||||
|
||||
```ts
|
||||
// In test/development
|
||||
const testAccess = await payload.find({
|
||||
collection: 'posts',
|
||||
overrideAccess: false, // Enforce access control
|
||||
user: undefined, // Simulate no user
|
||||
})
|
||||
|
||||
console.log('Public access result:', testAccess.docs.length)
|
||||
```
|
||||
|
||||
**Source**: Synthesized (debugging best practices)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Async Operations Impact
|
||||
|
||||
```ts
|
||||
// ❌ Slow: Multiple sequential async calls
|
||||
export const slowAccess: Access = async ({ req: { user } }) => {
|
||||
const org = await req.payload.findByID({ collection: 'orgs', id: user.orgId })
|
||||
const team = await req.payload.findByID({ collection: 'teams', id: user.teamId })
|
||||
const subscription = await req.payload.findByID({ collection: 'subs', id: user.subId })
|
||||
|
||||
return org.active && team.active && subscription.active
|
||||
}
|
||||
|
||||
// ✅ Fast: Use query constraints or cache in context
|
||||
export const fastAccess: Access = ({ req: { user, context } }) => {
|
||||
// Cache expensive lookups
|
||||
if (!context.orgStatus) {
|
||||
context.orgStatus = checkOrgStatus(user.orgId)
|
||||
}
|
||||
|
||||
return context.orgStatus
|
||||
}
|
||||
```
|
||||
|
||||
### Query Constraint Optimization
|
||||
|
||||
```ts
|
||||
// ❌ Avoid: Non-indexed fields in constraints
|
||||
export const slowQuery: Access = () => ({
|
||||
'metadata.internalCode': { equals: 'ABC123' }, // Slow if not indexed
|
||||
})
|
||||
|
||||
// ✅ Better: Use indexed fields
|
||||
export const fastQuery: Access = () => ({
|
||||
status: { equals: 'active' }, // Indexed field
|
||||
organizationId: { in: ['org1', 'org2'] }, // Indexed field
|
||||
})
|
||||
```
|
||||
|
||||
### Field Access on Large Arrays
|
||||
|
||||
```ts
|
||||
// ❌ Slow: Complex access on array fields
|
||||
const arrayField: ArrayField = {
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'secretData',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: async ({ req }) => {
|
||||
// Async call runs for EVERY array item
|
||||
const result = await expensiveCheck()
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ✅ Fast: Simple checks or cache result
|
||||
const optimizedArrayField: ArrayField = {
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'secretData',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: ({ req: { user }, context }) => {
|
||||
// Cache once, reuse for all items
|
||||
if (context.canReadSecret === undefined) {
|
||||
context.canReadSecret = user?.roles?.includes('admin')
|
||||
}
|
||||
return context.canReadSecret
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid N+1 Queries
|
||||
|
||||
```ts
|
||||
// ❌ N+1 Problem: Query per access check
|
||||
export const n1Access: Access = async ({ req, id }) => {
|
||||
// Runs for EACH document in list
|
||||
const doc = await req.payload.findByID({ collection: 'docs', id })
|
||||
return doc.isPublic
|
||||
}
|
||||
|
||||
// ✅ Better: Use query constraint to filter at DB level
|
||||
export const efficientAccess: Access = () => {
|
||||
return { isPublic: { equals: true } }
|
||||
}
|
||||
```
|
||||
|
||||
**Performance Best Practices:**
|
||||
|
||||
1. **Minimize Async Operations**: Use query constraints over async lookups when possible
|
||||
2. **Cache Expensive Checks**: Store results in `req.context` for reuse
|
||||
3. **Index Query Fields**: Ensure fields in query constraints are indexed
|
||||
4. **Avoid Complex Logic in Array Fields**: Simple boolean checks preferred
|
||||
5. **Use Query Constraints**: Let database filter rather than loading all records
|
||||
|
||||
**Source**: Synthesized (operational best practices)
|
||||
|
||||
## Enhanced Best Practices
|
||||
|
||||
Comprehensive security and implementation guidelines:
|
||||
|
||||
1. **Default Deny**: Start with restrictive access, gradually add permissions
|
||||
2. **Type Guards**: Use TypeScript for user type safety and better IDE support
|
||||
3. **Validate Data**: Never trust frontend-provided IDs or data
|
||||
4. **Async for Critical Checks**: Use async operations for important security decisions
|
||||
5. **Consistent Logic**: Apply same rules at field and collection levels
|
||||
6. **Test Edge Cases**: Test with no user, wrong user, admin user scenarios
|
||||
7. **Monitor Access**: Log failed access attempts for security review
|
||||
8. **Regular Audit**: Review access rules quarterly or after major changes
|
||||
9. **Cache Wisely**: Use `req.context` for expensive operations
|
||||
10. **Document Intent**: Add comments explaining complex access rules
|
||||
11. **Avoid Secrets in Client**: Never expose sensitive logic to client-side
|
||||
12. **Rate Limit External Calls**: Protect against DoS on external validation services
|
||||
13. **Handle Errors Gracefully**: Access functions should return `false` on error, not throw
|
||||
14. **Use Environment Vars**: Store configuration (IPs, API keys) in env vars
|
||||
15. **Test Local API**: Remember to set `overrideAccess: false` when testing
|
||||
16. **Consider Performance**: Measure impact of async operations on login time
|
||||
17. **Version Control**: Track access control changes in git history
|
||||
18. **Principle of Least Privilege**: Grant minimum access required for functionality
|
||||
|
||||
**Sources**: `docs/access-control/*.mdx`, synthesized best practices
|
||||
696
apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL.md
Normal file
696
apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL.md
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
# Payload Access Control Reference
|
||||
|
||||
Complete reference for access control patterns across collections, fields, and globals.
|
||||
|
||||
## At a Glance
|
||||
|
||||
| Feature | Scope | Returns | Use Case |
|
||||
| --------------------- | --------------------------------------------------------- | ---------------------- | ---------------------------------- |
|
||||
| **Collection Access** | create, read, update, delete, admin, unlock, readVersions | boolean \| Where query | Document-level permissions |
|
||||
| **Field Access** | create, read, update | boolean only | Field-level visibility/editability |
|
||||
| **Global Access** | read, update, readVersions | boolean \| Where query | Global document permissions |
|
||||
|
||||
## Three Layers of Access Control
|
||||
|
||||
Payload provides three distinct access control layers:
|
||||
|
||||
1. **Collection-Level**: Controls operations on entire documents (create, read, update, delete, admin, unlock, readVersions)
|
||||
2. **Field-Level**: Controls access to individual fields (create, read, update)
|
||||
3. **Global-Level**: Controls access to global documents (read, update, readVersions)
|
||||
|
||||
## Return Value Types
|
||||
|
||||
Access control functions can return:
|
||||
|
||||
- **Boolean**: `true` (allow) or `false` (deny)
|
||||
- **Query Constraint**: `Where` object for row-level security (collection-level only)
|
||||
|
||||
Field-level access does NOT support query constraints - only boolean returns.
|
||||
|
||||
## Operation Decision Tree
|
||||
|
||||
```txt
|
||||
User makes request
|
||||
│
|
||||
├─ Collection access check
|
||||
│ ├─ Returns false? → Deny entire operation
|
||||
│ ├─ Returns true? → Continue
|
||||
│ └─ Returns Where? → Apply query constraint
|
||||
│
|
||||
├─ Field access check (if applicable)
|
||||
│ ├─ Returns false? → Field omitted from result
|
||||
│ └─ Returns true? → Include field
|
||||
│
|
||||
└─ Operation completed
|
||||
```
|
||||
|
||||
## Collection Access Control
|
||||
|
||||
### Basic Patterns
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig, Access } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
// Boolean: Only authenticated users can create
|
||||
create: ({ req: { user } }) => Boolean(user),
|
||||
|
||||
// Query constraint: Public sees published, users see all
|
||||
read: ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { status: { equals: 'published' } }
|
||||
},
|
||||
|
||||
// User-specific: Admins or document owner
|
||||
update: ({ req: { user }, id }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
return { author: { equals: user?.id } }
|
||||
},
|
||||
|
||||
// Async: Check related data
|
||||
delete: async ({ req, id }) => {
|
||||
const hasComments = await req.payload.count({
|
||||
collection: 'comments',
|
||||
where: { post: { equals: id } },
|
||||
})
|
||||
return hasComments === 0
|
||||
},
|
||||
|
||||
// Admin panel visibility
|
||||
admin: ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin') || user?.roles?.includes('editor')
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'author', type: 'relationship', relationTo: 'users' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Role-Based Access Control (RBAC) Pattern
|
||||
|
||||
Payload does NOT provide a roles system by default. The following is a commonly accepted pattern for implementing role-based access control in auth collections:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'email', type: 'email', required: true },
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
defaultValue: ['user'],
|
||||
required: true,
|
||||
// Save roles to JWT for access control without database lookups
|
||||
saveToJWT: true,
|
||||
access: {
|
||||
// Only admins can update roles
|
||||
update: ({ req: { user } }) => user?.roles?.includes('admin'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
1. **Not Built-In**: Payload does not provide a roles system out of the box. You must add a `roles` field to your auth collection.
|
||||
2. **Save to JWT**: Use `saveToJWT: true` to include roles in the JWT token, enabling role checks without database queries.
|
||||
3. **Default Value**: Set a `defaultValue` to automatically assign new users a default role.
|
||||
4. **Access Control**: Restrict who can modify roles (typically only admins).
|
||||
5. **Role Options**: Define your own role hierarchy based on your application needs.
|
||||
|
||||
**Using Roles in Access Control:**
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
// Check for specific role
|
||||
export const adminOnly: Access = ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin')
|
||||
}
|
||||
|
||||
// Check for multiple roles
|
||||
export const adminOrEditor: Access = ({ req: { user } }) => {
|
||||
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
|
||||
}
|
||||
|
||||
// Role hierarchy check
|
||||
export const hasMinimumRole: Access = ({ req: { user } }, minRole: string) => {
|
||||
const roleHierarchy = ['user', 'editor', 'admin']
|
||||
const userHighestRole = Math.max(...(user?.roles?.map((r) => roleHierarchy.indexOf(r)) || [-1]))
|
||||
const requiredRoleIndex = roleHierarchy.indexOf(minRole)
|
||||
|
||||
return userHighestRole >= requiredRoleIndex
|
||||
}
|
||||
```
|
||||
|
||||
### Reusable Access Functions
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
// Anyone (public)
|
||||
export const anyone: Access = () => true
|
||||
|
||||
// Authenticated only
|
||||
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
|
||||
|
||||
// Authenticated or published content
|
||||
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
|
||||
if (user) return true
|
||||
return { _status: { equals: 'published' } }
|
||||
}
|
||||
|
||||
// Admin only
|
||||
export const admins: Access = ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin')
|
||||
}
|
||||
|
||||
// Admin or editor
|
||||
export const adminsOrEditors: Access = ({ req: { user } }) => {
|
||||
return Boolean(user?.roles?.some((role) => ['admin', 'editor'].includes(role)))
|
||||
}
|
||||
|
||||
// Self or admin
|
||||
export const adminsOrSelf: Access = ({ req: { user } }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
return { id: { equals: user?.id } }
|
||||
}
|
||||
|
||||
// Usage
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
create: authenticated,
|
||||
read: authenticatedOrPublished,
|
||||
update: adminsOrEditors,
|
||||
delete: admins,
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
### Row-Level Security with Complex Queries
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
// Organization-scoped access
|
||||
export const organizationScoped: Access = ({ req: { user } }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
|
||||
// Users see only their organization's data
|
||||
return {
|
||||
organization: {
|
||||
equals: user?.organization,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple conditions with AND
|
||||
export const complexAccess: Access = ({ req: { user } }) => {
|
||||
return {
|
||||
and: [
|
||||
{ status: { equals: 'published' } },
|
||||
{ 'author.isActive': { equals: true } },
|
||||
{
|
||||
or: [{ visibility: { equals: 'public' } }, { author: { equals: user?.id } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Team-based access
|
||||
export const teamMemberAccess: Access = ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
return {
|
||||
'team.members': {
|
||||
contains: user.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Header-Based Access (API Keys)
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const apiKeyAccess: Access = ({ req }) => {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
|
||||
if (!apiKey) return false
|
||||
|
||||
// Validate against stored keys
|
||||
return apiKey === process.env.VALID_API_KEY
|
||||
}
|
||||
|
||||
// Bearer token validation
|
||||
export const bearerTokenAccess: Access = async ({ req }) => {
|
||||
const auth = req.headers.get('authorization')
|
||||
|
||||
if (!auth?.startsWith('Bearer ')) return false
|
||||
|
||||
const token = auth.slice(7)
|
||||
const isValid = await validateToken(token)
|
||||
|
||||
return isValid
|
||||
}
|
||||
```
|
||||
|
||||
## Field Access Control
|
||||
|
||||
Field access does NOT support query constraints - only boolean returns.
|
||||
|
||||
### Basic Field Access
|
||||
|
||||
```ts
|
||||
import type { NumberField, FieldAccess } from 'payload'
|
||||
|
||||
const salaryReadAccess: FieldAccess = ({ req: { user }, doc }) => {
|
||||
// Self can read own salary
|
||||
if (user?.id === doc?.id) return true
|
||||
// Admin can read all salaries
|
||||
return user?.roles?.includes('admin')
|
||||
}
|
||||
|
||||
const salaryUpdateAccess: FieldAccess = ({ req: { user } }) => {
|
||||
// Only admins can update salary
|
||||
return user?.roles?.includes('admin')
|
||||
}
|
||||
|
||||
const salaryField: NumberField = {
|
||||
name: 'salary',
|
||||
type: 'number',
|
||||
access: {
|
||||
read: salaryReadAccess,
|
||||
update: salaryUpdateAccess,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Sibling Data Access
|
||||
|
||||
```ts
|
||||
import type { ArrayField, FieldAccess } from 'payload'
|
||||
|
||||
const contentReadAccess: FieldAccess = ({ req: { user }, siblingData }) => {
|
||||
// Authenticated users see all
|
||||
if (user) return true
|
||||
// Public sees only if marked public
|
||||
return siblingData?.isPublic === true
|
||||
}
|
||||
|
||||
const arrayField: ArrayField = {
|
||||
name: 'sections',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'isPublic',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
access: {
|
||||
read: contentReadAccess,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Nested Field Access
|
||||
|
||||
```ts
|
||||
import type { GroupField, FieldAccess } from 'payload'
|
||||
|
||||
const internalOnlyAccess: FieldAccess = ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin') || user?.roles?.includes('internal')
|
||||
}
|
||||
|
||||
const groupField: GroupField = {
|
||||
name: 'internalMetadata',
|
||||
type: 'group',
|
||||
access: {
|
||||
read: internalOnlyAccess,
|
||||
update: internalOnlyAccess,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'internalNotes', type: 'textarea' },
|
||||
{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Hiding Admin Fields
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'email', type: 'email', required: true },
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
access: {
|
||||
// Hide from UI, but still saved/queried
|
||||
read: ({ req: { user } }) => user?.roles?.includes('admin'),
|
||||
// Only admins can update roles
|
||||
update: ({ req: { user } }) => user?.roles?.includes('admin'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Global Access Control
|
||||
|
||||
```ts
|
||||
import type { GlobalConfig, Access } from 'payload'
|
||||
|
||||
const adminOnly: Access = ({ req: { user } }) => {
|
||||
return user?.roles?.includes('admin')
|
||||
}
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: 'site-settings',
|
||||
access: {
|
||||
read: () => true, // Anyone can read settings
|
||||
update: adminOnly, // Only admins can update
|
||||
readVersions: adminOnly, // Only admins can see version history
|
||||
},
|
||||
fields: [
|
||||
{ name: 'siteName', type: 'text' },
|
||||
{ name: 'maintenanceMode', type: 'checkbox' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Tenant Access Control
|
||||
|
||||
```ts
|
||||
import type { Access, CollectionConfig } from 'payload'
|
||||
|
||||
// Add tenant field to user type
|
||||
interface User {
|
||||
id: string
|
||||
tenantId: string
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
// Tenant-scoped access
|
||||
const tenantAccess: Access = ({ req: { user } }) => {
|
||||
// No user = no access
|
||||
if (!user) return false
|
||||
|
||||
// Super admin sees all
|
||||
if (user.roles?.includes('super-admin')) return true
|
||||
|
||||
// Users see only their tenant's data
|
||||
return {
|
||||
tenant: {
|
||||
equals: (user as User).tenantId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
create: tenantAccess,
|
||||
read: tenantAccess,
|
||||
update: tenantAccess,
|
||||
delete: tenantAccess,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{
|
||||
name: 'tenant',
|
||||
type: 'text',
|
||||
required: true,
|
||||
access: {
|
||||
// Tenant field hidden from non-admins
|
||||
update: ({ req: { user } }) => user?.roles?.includes('super-admin'),
|
||||
},
|
||||
hooks: {
|
||||
// Auto-set tenant on create
|
||||
beforeChange: [
|
||||
({ req, operation, value }) => {
|
||||
if (operation === 'create' && !value) {
|
||||
return (req.user as User)?.tenantId
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Collection Patterns
|
||||
|
||||
### Self or Admin Pattern
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
access: {
|
||||
// Anyone can read user profiles
|
||||
read: () => true,
|
||||
|
||||
// Users can update themselves, admins can update anyone
|
||||
update: ({ req: { user }, id }) => {
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
return user?.id === id
|
||||
},
|
||||
|
||||
// Only admins can delete
|
||||
delete: ({ req: { user } }) => user?.roles?.includes('admin'),
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text' },
|
||||
{ name: 'email', type: 'email' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Restrict Self-Updates
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig, FieldAccess } from 'payload'
|
||||
|
||||
const preventSelfRoleChange: FieldAccess = ({ req: { user }, id }) => {
|
||||
// Admins can change anyone's roles
|
||||
if (user?.roles?.includes('admin')) return true
|
||||
// Users cannot change their own roles
|
||||
if (user?.id === id) return false
|
||||
return false
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
access: {
|
||||
update: preventSelfRoleChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Collection Validation
|
||||
|
||||
```ts
|
||||
import type { Access } from 'payload'
|
||||
|
||||
// Check if user is a project member before allowing access
|
||||
export const projectMemberAccess: Access = async ({ req, id }) => {
|
||||
const { user, payload } = req
|
||||
|
||||
if (!user) return false
|
||||
if (user.roles?.includes('admin')) return true
|
||||
|
||||
// Check if document exists and user is member
|
||||
const project = await payload.findByID({
|
||||
collection: 'projects',
|
||||
id: id as string,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return project.members?.includes(user.id)
|
||||
}
|
||||
|
||||
// Prevent deletion if document has dependencies
|
||||
export const preventDeleteWithDependencies: Access = async ({ req, id }) => {
|
||||
const { payload } = req
|
||||
|
||||
const dependencyCount = await payload.count({
|
||||
collection: 'related-items',
|
||||
where: {
|
||||
parent: { equals: id },
|
||||
},
|
||||
})
|
||||
|
||||
return dependencyCount === 0
|
||||
}
|
||||
```
|
||||
|
||||
## Access Control Function Arguments
|
||||
|
||||
### Collection Create
|
||||
|
||||
```ts
|
||||
create: ({ req, data }) => boolean | Where
|
||||
|
||||
// req: PayloadRequest
|
||||
// - req.user: Authenticated user (if any)
|
||||
// - req.payload: Payload instance for queries
|
||||
// - req.headers: Request headers
|
||||
// - req.locale: Current locale
|
||||
// data: The data being created
|
||||
```
|
||||
|
||||
### Collection Read
|
||||
|
||||
```ts
|
||||
read: ({ req, id }) => boolean | Where
|
||||
|
||||
// req: PayloadRequest
|
||||
// id: Document ID being read
|
||||
// - undefined during Access Operation (login check)
|
||||
// - string when reading specific document
|
||||
```
|
||||
|
||||
### Collection Update
|
||||
|
||||
```ts
|
||||
update: ({ req, id, data }) => boolean | Where
|
||||
|
||||
// req: PayloadRequest
|
||||
// id: Document ID being updated
|
||||
// data: New values being applied
|
||||
```
|
||||
|
||||
### Collection Delete
|
||||
|
||||
```ts
|
||||
delete: ({ req, id }) => boolean | Where
|
||||
|
||||
// req: PayloadRequest
|
||||
// id: Document ID being deleted
|
||||
```
|
||||
|
||||
### Field Create
|
||||
|
||||
```ts
|
||||
access: {
|
||||
create: ({ req, data, siblingData }) => boolean
|
||||
}
|
||||
|
||||
// req: PayloadRequest
|
||||
// data: Full document data
|
||||
// siblingData: Adjacent field values at same level
|
||||
```
|
||||
|
||||
### Field Read
|
||||
|
||||
```ts
|
||||
access: {
|
||||
read: ({ req, id, doc, siblingData }) => boolean
|
||||
}
|
||||
|
||||
// req: PayloadRequest
|
||||
// id: Document ID
|
||||
// doc: Full document
|
||||
// siblingData: Adjacent field values
|
||||
```
|
||||
|
||||
### Field Update
|
||||
|
||||
```ts
|
||||
access: {
|
||||
update: ({ req, id, data, doc, siblingData }) => boolean
|
||||
}
|
||||
|
||||
// req: PayloadRequest
|
||||
// id: Document ID
|
||||
// data: New values
|
||||
// doc: Current document
|
||||
// siblingData: Adjacent field values
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Local API Default**: Access control is **skipped by default** in Local API (`overrideAccess: true`). When passing a `user` parameter, you almost always want to set `overrideAccess: false` to respect that user's permissions:
|
||||
|
||||
```ts
|
||||
// ❌ WRONG: Passes user but bypasses access control (default behavior)
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser, // User is ignored for access control!
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Respects the user's permissions
|
||||
await payload.find({
|
||||
collection: 'posts',
|
||||
user: someUser,
|
||||
overrideAccess: false, // Required to enforce access control
|
||||
})
|
||||
```
|
||||
|
||||
**Why this matters**: If you pass `user` without `overrideAccess: false`, the operation runs with admin privileges regardless of the user's actual permissions. This is a common security mistake.
|
||||
|
||||
2. **Field Access Limitations**: Field-level access does NOT support query constraints - only boolean returns.
|
||||
|
||||
3. **Admin Panel Visibility**: The `admin` access control determines if a collection appears in the admin panel for a user.
|
||||
|
||||
4. **Access Before Hooks**: Access control executes BEFORE hooks run, so hooks cannot modify access behavior.
|
||||
|
||||
5. **Query Constraints**: Only collection-level `read` access supports query constraints. All other operations and field-level access require boolean returns.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Reusable Functions**: Create named access functions for common patterns
|
||||
2. **Fail Secure**: Default to `false` for sensitive operations
|
||||
3. **Cache Checks**: Use `req.context` to cache expensive validation
|
||||
4. **Type Safety**: Type your user object for better IDE support
|
||||
5. **Test Thoroughly**: Write tests for complex access control logic
|
||||
6. **Document Intent**: Add comments explaining access rules
|
||||
7. **Audit Logs**: Track access control decisions for security review
|
||||
8. **Performance**: Avoid N+1 queries in access functions
|
||||
9. **Error Handling**: Access functions should not throw - return `false` instead
|
||||
10. **Tenant Hooks**: Auto-set tenant fields in `beforeChange` hooks
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
For advanced access control patterns including context-aware access, time-based restrictions, subscription-based access, factory functions, configuration templates, debugging tips, and performance optimization, see [ACCESS-CONTROL-ADVANCED.md](ACCESS-CONTROL-ADVANCED.md).
|
||||
326
apps/cms/.vibe/skills/payload/reference/ADAPTERS.md
Normal file
326
apps/cms/.vibe/skills/payload/reference/ADAPTERS.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# Payload Adapters Reference
|
||||
|
||||
Complete reference for database, storage, and email adapters.
|
||||
|
||||
## Database Adapters
|
||||
|
||||
### MongoDB
|
||||
|
||||
```ts
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
|
||||
export default buildConfig({
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URL,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Postgres
|
||||
|
||||
```ts
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
|
||||
export default buildConfig({
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
},
|
||||
push: false, // Don't auto-push schema changes
|
||||
migrationDir: './migrations',
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```ts
|
||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||
|
||||
export default buildConfig({
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: 'file:./payload.db',
|
||||
},
|
||||
transactionOptions: {}, // Enable transactions (disabled by default)
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Transactions
|
||||
|
||||
Payload automatically uses transactions for all-or-nothing database operations. Pass `req` to include operations in the same transaction.
|
||||
|
||||
```ts
|
||||
import type { CollectionAfterChangeHook } from 'payload'
|
||||
|
||||
const afterChange: CollectionAfterChangeHook = async ({ req, doc }) => {
|
||||
// This will be part of the same transaction
|
||||
await req.payload.create({
|
||||
req, // Pass req to use same transaction
|
||||
collection: 'audit-log',
|
||||
data: { action: 'created', docId: doc.id },
|
||||
})
|
||||
}
|
||||
|
||||
// Manual transaction control
|
||||
const transactionID = await payload.db.beginTransaction()
|
||||
try {
|
||||
await payload.create({
|
||||
collection: 'orders',
|
||||
data: orderData,
|
||||
req: { transactionID },
|
||||
})
|
||||
await payload.update({
|
||||
collection: 'inventory',
|
||||
id: itemId,
|
||||
data: { stock: newStock },
|
||||
req: { transactionID },
|
||||
})
|
||||
await payload.db.commitTransaction(transactionID)
|
||||
} catch (error) {
|
||||
await payload.db.rollbackTransaction(transactionID)
|
||||
throw error
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: MongoDB requires replicaset for transactions. SQLite requires `transactionOptions: {}` to enable.
|
||||
|
||||
### Threading req Through Operations
|
||||
|
||||
**Critical**: When performing nested operations in hooks, always pass `req` to maintain transaction context. Failing to do so breaks atomicity and can cause partial updates.
|
||||
|
||||
```ts
|
||||
import type { CollectionAfterChangeHook } from 'payload'
|
||||
|
||||
// ✅ CORRECT: Thread req through nested operations
|
||||
const resaveChildren: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
|
||||
// Find children - pass req
|
||||
const children = await req.payload.find({
|
||||
collection: 'children',
|
||||
where: { parent: { equals: doc.id } },
|
||||
req, // Maintains transaction context
|
||||
})
|
||||
|
||||
// Update each child - pass req
|
||||
for (const child of children.docs) {
|
||||
await req.payload.update({
|
||||
id: child.id,
|
||||
collection: 'children',
|
||||
data: { updatedField: 'value' },
|
||||
req, // Same transaction as parent operation
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ WRONG: Missing req breaks transaction
|
||||
const brokenHook: CollectionAfterChangeHook = async ({ collection, doc, req }) => {
|
||||
const children = await req.payload.find({
|
||||
collection: 'children',
|
||||
where: { parent: { equals: doc.id } },
|
||||
// Missing req - separate transaction or no transaction
|
||||
})
|
||||
|
||||
for (const child of children.docs) {
|
||||
await req.payload.update({
|
||||
id: child.id,
|
||||
collection: 'children',
|
||||
data: { updatedField: 'value' },
|
||||
// Missing req - if parent operation fails, these updates persist
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Matters:**
|
||||
|
||||
- **MongoDB (with replica sets)**: Creates atomic session across operations
|
||||
- **PostgreSQL**: All operations use same Drizzle transaction
|
||||
- **SQLite (with transactions enabled)**: Ensures rollback on errors
|
||||
- **Without req**: Each operation runs independently, breaking atomicity
|
||||
|
||||
**When req is Required:**
|
||||
|
||||
- All mutating operations in hooks (create, update, delete)
|
||||
- Operations that must succeed/fail together
|
||||
- When using MongoDB replica sets or Postgres
|
||||
- Any operation that relies on `req.context` or `req.user`
|
||||
|
||||
**When req is Optional:**
|
||||
|
||||
- Read-only lookups independent of current transaction
|
||||
- Operations with `disableTransaction: true`
|
||||
- Administrative operations with `overrideAccess: true`
|
||||
|
||||
## Storage Adapters
|
||||
|
||||
Available storage adapters:
|
||||
|
||||
- **@payloadcms/storage-s3** - AWS S3
|
||||
- **@payloadcms/storage-azure** - Azure Blob Storage
|
||||
- **@payloadcms/storage-gcs** - Google Cloud Storage
|
||||
- **@payloadcms/storage-r2** - Cloudflare R2
|
||||
- **@payloadcms/storage-vercel-blob** - Vercel Blob
|
||||
- **@payloadcms/storage-uploadthing** - Uploadthing
|
||||
|
||||
### AWS S3
|
||||
|
||||
```ts
|
||||
import { s3Storage } from '@payloadcms/storage-s3'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
bucket: process.env.S3_BUCKET,
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
},
|
||||
region: process.env.S3_REGION,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Azure Blob Storage
|
||||
|
||||
```ts
|
||||
import { azureStorage } from '@payloadcms/storage-azure'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
azureStorage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
||||
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Google Cloud Storage
|
||||
|
||||
```ts
|
||||
import { gcsStorage } from '@payloadcms/storage-gcs'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
gcsStorage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
bucket: process.env.GCS_BUCKET,
|
||||
options: {
|
||||
projectId: process.env.GCS_PROJECT_ID,
|
||||
credentials: JSON.parse(process.env.GCS_CREDENTIALS),
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
```ts
|
||||
import { r2Storage } from '@payloadcms/storage-r2'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
r2Storage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
bucket: process.env.R2_BUCKET,
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
||||
},
|
||||
region: 'auto',
|
||||
endpoint: process.env.R2_ENDPOINT,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Vercel Blob
|
||||
|
||||
```ts
|
||||
import { vercelBlobStorage } from '@payloadcms/storage-vercel-blob'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
vercelBlobStorage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
token: process.env.BLOB_READ_WRITE_TOKEN,
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Uploadthing
|
||||
|
||||
```ts
|
||||
import { uploadthingStorage } from '@payloadcms/storage-uploadthing'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
uploadthingStorage({
|
||||
collections: {
|
||||
media: true,
|
||||
},
|
||||
options: {
|
||||
token: process.env.UPLOADTHING_TOKEN,
|
||||
acl: 'public-read',
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Email Adapters
|
||||
|
||||
### Nodemailer (SMTP)
|
||||
|
||||
```ts
|
||||
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
|
||||
|
||||
export default buildConfig({
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: 'noreply@example.com',
|
||||
defaultFromName: 'My App',
|
||||
transportOptions: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: 587,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Resend
|
||||
|
||||
```ts
|
||||
import { resendAdapter } from '@payloadcms/email-resend'
|
||||
|
||||
export default buildConfig({
|
||||
email: resendAdapter({
|
||||
defaultFromAddress: 'noreply@example.com',
|
||||
defaultFromName: 'My App',
|
||||
apiKey: process.env.RESEND_API_KEY,
|
||||
}),
|
||||
})
|
||||
```
|
||||
386
apps/cms/.vibe/skills/payload/reference/ADVANCED.md
Normal file
386
apps/cms/.vibe/skills/payload/reference/ADVANCED.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
# Payload Advanced Features
|
||||
|
||||
Complete reference for authentication, jobs, custom endpoints, components, plugins, and localization.
|
||||
|
||||
## Authentication
|
||||
|
||||
### Login
|
||||
|
||||
```ts
|
||||
// REST API
|
||||
const response = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: 'user@example.com',
|
||||
password: 'password',
|
||||
}),
|
||||
})
|
||||
|
||||
// Local API
|
||||
const result = await payload.login({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'user@example.com',
|
||||
password: 'password',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Forgot Password
|
||||
|
||||
```ts
|
||||
await payload.forgotPassword({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'user@example.com',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Custom Strategy
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig, Strategy } from 'payload'
|
||||
|
||||
const customStrategy: Strategy = {
|
||||
name: 'custom',
|
||||
authenticate: async ({ payload, headers }) => {
|
||||
const token = headers.get('authorization')?.split(' ')[1]
|
||||
if (!token) return { user: null }
|
||||
|
||||
const user = await verifyToken(token)
|
||||
return { user }
|
||||
},
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: {
|
||||
strategies: [customStrategy],
|
||||
},
|
||||
fields: [],
|
||||
}
|
||||
```
|
||||
|
||||
### API Keys
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const APIKeys: CollectionConfig = {
|
||||
slug: 'api-keys',
|
||||
auth: {
|
||||
disableLocalStrategy: true,
|
||||
useAPIKey: true,
|
||||
},
|
||||
fields: [],
|
||||
}
|
||||
```
|
||||
|
||||
## Jobs Queue
|
||||
|
||||
Offload long-running or scheduled tasks to background workers.
|
||||
|
||||
### Tasks
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import type { TaskConfig } from 'payload'
|
||||
|
||||
export default buildConfig({
|
||||
jobs: {
|
||||
tasks: [
|
||||
{
|
||||
slug: 'sendWelcomeEmail',
|
||||
inputSchema: [
|
||||
{ name: 'userEmail', type: 'text', required: true },
|
||||
{ name: 'userName', type: 'text', required: true },
|
||||
],
|
||||
outputSchema: [{ name: 'emailSent', type: 'checkbox', required: true }],
|
||||
retries: 2, // Retry up to 2 times on failure
|
||||
handler: async ({ input, req }) => {
|
||||
await sendEmail({
|
||||
to: input.userEmail,
|
||||
subject: `Welcome ${input.userName}`,
|
||||
})
|
||||
return { output: { emailSent: true } }
|
||||
},
|
||||
} as TaskConfig<'sendWelcomeEmail'>,
|
||||
],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Queueing Jobs
|
||||
|
||||
```ts
|
||||
// In a hook or endpoint
|
||||
await req.payload.jobs.queue({
|
||||
task: 'sendWelcomeEmail',
|
||||
input: {
|
||||
userEmail: 'user@example.com',
|
||||
userName: 'John',
|
||||
},
|
||||
waitUntil: new Date('2024-12-31'), // Optional: schedule for future
|
||||
})
|
||||
```
|
||||
|
||||
### Workflows
|
||||
|
||||
Multi-step jobs that run in sequence:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: 'onboardUser',
|
||||
inputSchema: [{ name: 'userId', type: 'text' }],
|
||||
handler: async ({ job, req }) => {
|
||||
const results = await job.runInlineTask({
|
||||
task: async ({ input }) => {
|
||||
// Step 1: Send welcome email
|
||||
await sendEmail(input.userId)
|
||||
return { output: { emailSent: true } }
|
||||
},
|
||||
})
|
||||
|
||||
await job.runInlineTask({
|
||||
task: async () => {
|
||||
// Step 2: Create onboarding tasks
|
||||
await createTasks()
|
||||
return { output: { tasksCreated: true } }
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Endpoints
|
||||
|
||||
Add custom REST API routes to collections, globals, or root config. See [ENDPOINTS.md](ENDPOINTS.md) for detailed patterns, authentication, helpers, and real-world examples.
|
||||
|
||||
### Root Endpoints
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import type { Endpoint } from 'payload'
|
||||
|
||||
const helloEndpoint: Endpoint = {
|
||||
path: '/hello',
|
||||
method: 'get',
|
||||
handler: () => {
|
||||
return Response.json({ message: 'Hello!' })
|
||||
},
|
||||
}
|
||||
|
||||
const greetEndpoint: Endpoint = {
|
||||
path: '/greet/:name',
|
||||
method: 'get',
|
||||
handler: (req) => {
|
||||
return Response.json({
|
||||
message: `Hello ${req.routeParams.name}!`,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default buildConfig({
|
||||
endpoints: [helloEndpoint, greetEndpoint],
|
||||
collections: [],
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
})
|
||||
```
|
||||
|
||||
### Collection Endpoints
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig, Endpoint } from 'payload'
|
||||
|
||||
const featuredEndpoint: Endpoint = {
|
||||
path: '/featured',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const posts = await req.payload.find({
|
||||
collection: 'posts',
|
||||
where: { featured: { equals: true } },
|
||||
})
|
||||
return Response.json(posts)
|
||||
},
|
||||
}
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
endpoints: [featuredEndpoint],
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'featured', type: 'checkbox' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Components
|
||||
|
||||
### Field Component (Client)
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { useField } from '@payloadcms/ui'
|
||||
import type { TextFieldClientComponent } from 'payload'
|
||||
|
||||
export const CustomField: TextFieldClientComponent = () => {
|
||||
const { value, setValue } = useField()
|
||||
|
||||
return <input value={value || ''} onChange={(e) => setValue(e.target.value)} />
|
||||
}
|
||||
```
|
||||
|
||||
### Custom View
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import { DefaultTemplate } from '@payloadcms/ui/rsc'
|
||||
|
||||
export const CustomView = () => {
|
||||
return (
|
||||
<DefaultTemplate>
|
||||
<h1>Custom Dashboard</h1>
|
||||
{/* Your content */}
|
||||
</DefaultTemplate>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Config
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
beforeDashboard: ['/components/BeforeDashboard'],
|
||||
beforeLogin: ['/components/BeforeLogin'],
|
||||
views: {
|
||||
custom: {
|
||||
Component: '/views/Custom',
|
||||
path: '/custom',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
collections: [],
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
})
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
### Available Plugins
|
||||
|
||||
- **@payloadcms/plugin-seo** - SEO fields with meta title/description, Open Graph, preview generation
|
||||
- **@payloadcms/plugin-redirects** - Manage URL redirects (301/302) for Next.js apps
|
||||
- **@payloadcms/plugin-nested-docs** - Hierarchical document structures with breadcrumbs
|
||||
- **@payloadcms/plugin-form-builder** - Dynamic form builder with submissions and validation
|
||||
- **@payloadcms/plugin-search** - Full-text search integration (Algolia support)
|
||||
- **@payloadcms/plugin-stripe** - Stripe payments, subscriptions, webhooks
|
||||
- **@payloadcms/plugin-ecommerce** - Complete ecommerce solution (products, variants, carts, orders)
|
||||
- **@payloadcms/plugin-import-export** - Import/export data via CSV
|
||||
- **@payloadcms/plugin-multi-tenant** - Multi-tenancy with tenant isolation
|
||||
- **@payloadcms/plugin-sentry** - Sentry error tracking integration
|
||||
- **@payloadcms/plugin-mcp** - Model Context Protocol for AI integrations
|
||||
|
||||
### Using Plugins
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import { seoPlugin } from '@payloadcms/plugin-seo'
|
||||
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
|
||||
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
seoPlugin({
|
||||
collections: ['posts', 'pages'],
|
||||
}),
|
||||
redirectsPlugin({
|
||||
collections: ['pages'],
|
||||
}),
|
||||
],
|
||||
collections: [],
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
})
|
||||
```
|
||||
|
||||
### Creating Plugins
|
||||
|
||||
```ts
|
||||
import type { Config } from 'payload'
|
||||
|
||||
interface PluginOptions {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const myPlugin =
|
||||
(options: PluginOptions) =>
|
||||
(config: Config): Config => ({
|
||||
...config,
|
||||
collections: [
|
||||
...(config.collections || []),
|
||||
{
|
||||
slug: 'plugin-collection',
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
},
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
if (config.onInit) await config.onInit(payload)
|
||||
// Plugin initialization
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Localization
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import type { Field, Payload } from 'payload'
|
||||
|
||||
export default buildConfig({
|
||||
localization: {
|
||||
locales: ['en', 'es', 'de'],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
collections: [],
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
})
|
||||
|
||||
// Localized field
|
||||
const localizedField: TextField = {
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
}
|
||||
|
||||
// Query with locale
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
locale: 'es',
|
||||
})
|
||||
```
|
||||
|
||||
## TypeScript Type References
|
||||
|
||||
For complete TypeScript type definitions and signatures, reference these files from the Payload source:
|
||||
|
||||
### Core Configuration Types
|
||||
|
||||
- **[All Commonly-Used Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/index.ts)** - Check here first for commonly used types and interfaces. All core types are exported from this file.
|
||||
|
||||
### Database & Adapters
|
||||
|
||||
- **[Database Adapter Types](https://github.com/payloadcms/payload/blob/main/packages/payload/src/database/types.ts)** - Base adapter interface
|
||||
- **[MongoDB Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-mongodb/src/index.ts)** - MongoDB-specific options
|
||||
- **[Postgres Adapter](https://github.com/payloadcms/payload/blob/main/packages/db-postgres/src/index.ts)** - Postgres-specific options
|
||||
|
||||
### Rich Text & Plugins
|
||||
|
||||
- **[Lexical Types](https://github.com/payloadcms/payload/blob/main/packages/richtext-lexical/src/exports/server/index.ts)** - Lexical editor configuration
|
||||
|
||||
When users need detailed type information, fetch these URLs to provide complete signatures and optional parameters.
|
||||
300
apps/cms/.vibe/skills/payload/reference/COLLECTIONS.md
Normal file
300
apps/cms/.vibe/skills/payload/reference/COLLECTIONS.md
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# Payload Collections Reference
|
||||
|
||||
Complete reference for collection configurations and patterns.
|
||||
|
||||
## Basic Collection
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
labels: {
|
||||
singular: 'Post',
|
||||
plural: 'Posts',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
// _status comes from versions.drafts below — no custom status field needed
|
||||
defaultColumns: ['title', 'author', '_status', 'createdAt'],
|
||||
group: 'Content', // Organize in admin sidebar
|
||||
description: 'Blog posts and articles',
|
||||
listSearchableFields: ['title', 'slug'],
|
||||
},
|
||||
// Enable drafts by default — auto-injects the _status field (draft/published/changed)
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
slugField(), // unique + indexed, sidebar position — don't hand-roll a slug text field
|
||||
],
|
||||
defaultSort: '-createdAt',
|
||||
timestamps: true,
|
||||
}
|
||||
```
|
||||
|
||||
> Don't add a custom `status` select for publish state — enabling
|
||||
> `versions: { drafts: true }` injects a managed `_status` field
|
||||
> (`draft` / `published` / `changed`) that the admin UI and Draft Preview already
|
||||
> understand. Use it in `defaultColumns` and access control directly.
|
||||
|
||||
## Auth Collection
|
||||
|
||||
```ts
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: {
|
||||
tokenExpiration: 7200, // 2 hours
|
||||
verify: true,
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 600000, // 10 minutes
|
||||
useAPIKey: true,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['admin', 'editor', 'user'],
|
||||
required: true,
|
||||
defaultValue: ['user'],
|
||||
saveToJWT: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Upload Collection
|
||||
|
||||
```ts
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
staticDir: 'media',
|
||||
mimeTypes: ['image/*'],
|
||||
imageSizes: [
|
||||
{
|
||||
name: 'thumbnail',
|
||||
width: 400,
|
||||
height: 300,
|
||||
position: 'centre',
|
||||
},
|
||||
{
|
||||
name: 'card',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
adminThumbnail: 'thumbnail',
|
||||
focalPoint: true,
|
||||
crop: true,
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Live Preview
|
||||
|
||||
Enable real-time content preview during editing.
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { slugField } from 'payload'
|
||||
|
||||
const generatePreviewPath = ({
|
||||
slug,
|
||||
collection,
|
||||
req,
|
||||
}: {
|
||||
slug: string
|
||||
collection: string
|
||||
req: any
|
||||
}) => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL
|
||||
return `${baseUrl}/api/preview?slug=${slug}&collection=${collection}`
|
||||
}
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
// Live preview during editing
|
||||
livePreview: {
|
||||
url: ({ data, req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug as string,
|
||||
collection: 'pages',
|
||||
req,
|
||||
}),
|
||||
},
|
||||
// Static preview button
|
||||
preview: (data, { req }) =>
|
||||
generatePreviewPath({
|
||||
slug: data?.slug as string,
|
||||
collection: 'pages',
|
||||
req,
|
||||
}),
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }, slugField()],
|
||||
}
|
||||
```
|
||||
|
||||
## Versioning & Drafts
|
||||
|
||||
Payload maintains version history and supports draft/publish workflows.
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
// Basic versioning (audit log only)
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
versions: true, // or { maxPerDoc: 100 }
|
||||
fields: [{ name: 'name', type: 'text' }],
|
||||
}
|
||||
|
||||
// Drafts enabled (draft/publish workflow)
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
versions: {
|
||||
drafts: true, // Enables _status field
|
||||
maxPerDoc: 50,
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
|
||||
// Full configuration with autosave and scheduled publish
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: true, // Auto-save while editing
|
||||
schedulePublish: true, // Schedule future publish/unpublish
|
||||
validate: false, // Don't validate drafts (default)
|
||||
},
|
||||
maxPerDoc: 100, // Keep last 100 versions (0 = unlimited)
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
### Draft API Usage
|
||||
|
||||
```ts
|
||||
// Create draft
|
||||
await payload.create({
|
||||
collection: 'posts',
|
||||
data: { title: 'Draft Post' },
|
||||
draft: true, // Saves as draft, skips required field validation
|
||||
})
|
||||
|
||||
// Update as draft
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
data: { title: 'Updated Draft' },
|
||||
draft: true,
|
||||
})
|
||||
|
||||
// Read with drafts (returns newest draft if available)
|
||||
const post = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
draft: true, // Returns draft version if exists
|
||||
})
|
||||
|
||||
// Query only published (REST API)
|
||||
// GET /api/posts (returns only _status: 'published')
|
||||
|
||||
// Access control for drafts
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
versions: { drafts: true },
|
||||
access: {
|
||||
read: ({ req: { user } }) => {
|
||||
// Public can only see published
|
||||
if (!user) return { _status: { equals: 'published' } }
|
||||
// Authenticated can see all
|
||||
return true
|
||||
},
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
### Document Status
|
||||
|
||||
The `_status` field is auto-injected when drafts are enabled:
|
||||
|
||||
- `draft` - Never published
|
||||
- `published` - Published with no newer drafts
|
||||
- `changed` - Published but has newer unpublished drafts
|
||||
|
||||
## Globals
|
||||
|
||||
Globals are single-instance documents (not collections).
|
||||
|
||||
```ts
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Header: GlobalConfig = {
|
||||
slug: 'header',
|
||||
label: 'Header',
|
||||
admin: {
|
||||
group: 'Settings',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'logo',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'nav',
|
||||
type: 'array',
|
||||
maxRows: 8,
|
||||
fields: [
|
||||
{
|
||||
name: 'link',
|
||||
type: 'relationship',
|
||||
relationTo: 'pages',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
634
apps/cms/.vibe/skills/payload/reference/ENDPOINTS.md
Normal file
634
apps/cms/.vibe/skills/payload/reference/ENDPOINTS.md
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
# Payload Custom API Endpoints Reference
|
||||
|
||||
Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Endpoint Configuration
|
||||
|
||||
| Property | Type | Description |
|
||||
| --------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) |
|
||||
| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) |
|
||||
| `handler` | `(req: PayloadRequest) => Promise<Response>` | Async function returning Web API Response |
|
||||
| `custom` | `Record<string, any>` | Extension point for plugins/metadata |
|
||||
|
||||
### Request Context
|
||||
|
||||
| Property | Type | Description |
|
||||
| ----------------- | ----------------------- | ------------------------------------------------------ |
|
||||
| `req.user` | `User \| null` | Authenticated user (null if not authenticated) |
|
||||
| `req.payload` | `Payload` | Payload instance for operations (find, create...) |
|
||||
| `req.routeParams` | `Record<string, any>` | Path parameters (e.g., `:id`) |
|
||||
| `req.url` | `string` | Full request URL |
|
||||
| `req.method` | `string` | HTTP method |
|
||||
| `req.headers` | `Headers` | Request headers |
|
||||
| `req.json()` | `() => Promise<any>` | Parse JSON body |
|
||||
| `req.text()` | `() => Promise<string>` | Read body as text |
|
||||
| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) |
|
||||
| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) |
|
||||
| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) |
|
||||
| `req.i18n` | `I18n` | i18n instance |
|
||||
| `req.t` | `TFunction` | Translation function |
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication Check
|
||||
|
||||
Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication.
|
||||
|
||||
```ts
|
||||
import { APIError } from 'payload'
|
||||
|
||||
export const authenticatedEndpoint = {
|
||||
path: '/protected',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
if (!req.user) {
|
||||
throw new APIError('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// User is authenticated
|
||||
return Response.json({ message: 'Access granted' })
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Using Payload Operations
|
||||
|
||||
Use `req.payload` for database operations with access control and hooks.
|
||||
|
||||
```ts
|
||||
export const getRelatedPosts = {
|
||||
path: '/:id/related',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const { id } = req.routeParams
|
||||
|
||||
// Find related posts
|
||||
const posts = await req.payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
category: {
|
||||
equals: id,
|
||||
},
|
||||
},
|
||||
limit: 5,
|
||||
sort: '-createdAt',
|
||||
})
|
||||
|
||||
return Response.json(posts)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Route Parameters
|
||||
|
||||
Access path parameters via `req.routeParams`.
|
||||
|
||||
```ts
|
||||
export const getTrackingEndpoint = {
|
||||
path: '/:id/tracking',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const orderId = req.routeParams.id
|
||||
|
||||
const tracking = await getTrackingInfo(orderId)
|
||||
|
||||
if (!tracking) {
|
||||
return Response.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return Response.json(tracking)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Request Body Handling
|
||||
|
||||
**Option 1: Manual JSON parsing**
|
||||
|
||||
```ts
|
||||
export const createEndpoint = {
|
||||
path: '/create',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const data = await req.json()
|
||||
|
||||
const result = await req.payload.create({
|
||||
collection: 'posts',
|
||||
data,
|
||||
})
|
||||
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Option 2: Using helper (handles JSON + files)**
|
||||
|
||||
```ts
|
||||
import { addDataAndFileToRequest } from 'payload'
|
||||
|
||||
export const uploadEndpoint = {
|
||||
path: '/upload',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
await addDataAndFileToRequest(req)
|
||||
|
||||
// req.data now contains parsed body
|
||||
// req.file contains uploaded file (if multipart)
|
||||
|
||||
const result = await req.payload.create({
|
||||
collection: 'media',
|
||||
data: req.data,
|
||||
file: req.file,
|
||||
})
|
||||
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### CORS Headers
|
||||
|
||||
Use `headersWithCors` helper to apply config CORS settings.
|
||||
|
||||
```ts
|
||||
import { headersWithCors } from 'payload'
|
||||
|
||||
export const corsEndpoint = {
|
||||
path: '/public-data',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const data = await fetchPublicData()
|
||||
|
||||
return Response.json(data, {
|
||||
headers: headersWithCors({
|
||||
headers: new Headers(),
|
||||
req,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Throw `APIError` with status codes for proper error responses.
|
||||
|
||||
```ts
|
||||
import { APIError } from 'payload'
|
||||
|
||||
export const validateEndpoint = {
|
||||
path: '/validate',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const data = await req.json()
|
||||
|
||||
if (!data.email) {
|
||||
throw new APIError('Email is required', 400)
|
||||
}
|
||||
|
||||
// Validation passed
|
||||
return Response.json({ valid: true })
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
Extract query params from URL.
|
||||
|
||||
```ts
|
||||
export const searchEndpoint = {
|
||||
path: '/search',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const url = new URL(req.url)
|
||||
const query = url.searchParams.get('q')
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10')
|
||||
|
||||
const results = await req.payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
title: {
|
||||
contains: query,
|
||||
},
|
||||
},
|
||||
limit,
|
||||
})
|
||||
|
||||
return Response.json(results)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### addDataAndFileToRequest
|
||||
|
||||
Parses request body and attaches to `req.data` and `req.file`.
|
||||
|
||||
```ts
|
||||
import { addDataAndFileToRequest } from 'payload'
|
||||
|
||||
export const endpoint = {
|
||||
path: '/process',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
await addDataAndFileToRequest(req)
|
||||
|
||||
// req.data: parsed JSON or form data
|
||||
// req.file: uploaded file (if multipart)
|
||||
|
||||
console.log(req.data) // { title: 'My Post' }
|
||||
console.log(req.file) // File object or undefined
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Handles:**
|
||||
|
||||
- JSON bodies (`Content-Type: application/json`)
|
||||
- Form data (`Content-Type: multipart/form-data`)
|
||||
- File uploads
|
||||
|
||||
### addLocalesToRequestFromData
|
||||
|
||||
Extracts locale from request data and validates against config.
|
||||
|
||||
```ts
|
||||
import { addLocalesToRequestFromData } from 'payload'
|
||||
|
||||
export const endpoint = {
|
||||
path: '/translate',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
await addLocalesToRequestFromData(req)
|
||||
|
||||
// req.locale: validated locale string
|
||||
// req.fallbackLocale: fallback locale string
|
||||
|
||||
const result = await req.payload.find({
|
||||
collection: 'posts',
|
||||
locale: req.locale,
|
||||
})
|
||||
|
||||
return Response.json(result)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### headersWithCors
|
||||
|
||||
Applies CORS headers from Payload config.
|
||||
|
||||
```ts
|
||||
import { headersWithCors } from 'payload'
|
||||
|
||||
export const endpoint = {
|
||||
path: '/data',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const data = { message: 'Hello' }
|
||||
|
||||
return Response.json(data, {
|
||||
headers: headersWithCors({
|
||||
headers: new Headers({
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
}),
|
||||
req,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Multi-Tenant Login Endpoint
|
||||
|
||||
From `examples/multi-tenant`:
|
||||
|
||||
```ts
|
||||
import { APIError, generatePayloadCookie, headersWithCors } from 'payload'
|
||||
|
||||
export const externalUsersLogin = {
|
||||
path: '/login-external',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const { email, password, tenant } = await req.json()
|
||||
|
||||
if (!email || !password || !tenant) {
|
||||
throw new APIError('Missing credentials', 400)
|
||||
}
|
||||
|
||||
// Find user with tenant constraint
|
||||
const userQuery = await req.payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
and: [
|
||||
{ email: { equals: email } },
|
||||
{
|
||||
or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (!userQuery.docs.length) {
|
||||
throw new APIError('Invalid credentials', 401)
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
const result = await req.payload.login({
|
||||
collection: 'users',
|
||||
data: { email, password },
|
||||
})
|
||||
|
||||
return Response.json(result, {
|
||||
headers: headersWithCors({
|
||||
headers: new Headers({
|
||||
'Set-Cookie': generatePayloadCookie({
|
||||
collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users')
|
||||
.auth,
|
||||
cookiePrefix: req.payload.config.cookiePrefix,
|
||||
token: result.token,
|
||||
}),
|
||||
}),
|
||||
req,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Handler (Stripe)
|
||||
|
||||
From `packages/plugin-ecommerce`:
|
||||
|
||||
```ts
|
||||
export const webhookEndpoint = {
|
||||
path: '/webhooks',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const body = await req.text()
|
||||
const signature = req.headers.get('stripe-signature')
|
||||
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
||||
|
||||
// Process event
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
await handlePaymentSuccess(req.payload, event.data.object)
|
||||
break
|
||||
case 'payment_intent.failed':
|
||||
await handlePaymentFailure(req.payload, event.data.object)
|
||||
break
|
||||
}
|
||||
|
||||
return Response.json({ received: true })
|
||||
} catch (err) {
|
||||
req.payload.logger.error(`Webhook error: ${err.message}`)
|
||||
return Response.json({ error: err.message }, { status: 400 })
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Data Preview Endpoint
|
||||
|
||||
From `packages/plugin-import-export`:
|
||||
|
||||
```ts
|
||||
import { addDataAndFileToRequest } from 'payload'
|
||||
|
||||
export const previewEndpoint = {
|
||||
path: '/preview',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
if (!req.user) {
|
||||
throw new APIError('Unauthorized', 401)
|
||||
}
|
||||
|
||||
await addDataAndFileToRequest(req)
|
||||
|
||||
const { collection, where, limit = 10 } = req.data
|
||||
|
||||
// Validate collection exists
|
||||
const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection)
|
||||
if (!collectionConfig) {
|
||||
throw new APIError('Collection not found', 404)
|
||||
}
|
||||
|
||||
// Preview data
|
||||
const results = await req.payload.find({
|
||||
collection,
|
||||
where,
|
||||
limit,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
docs: results.docs,
|
||||
totalDocs: results.totalDocs,
|
||||
fields: collectionConfig.fields,
|
||||
})
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Reindex Action Endpoint
|
||||
|
||||
From `packages/plugin-search`:
|
||||
|
||||
```ts
|
||||
export const reindexEndpoint = (pluginConfig) => ({
|
||||
path: '/reindex',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
if (!req.user) {
|
||||
throw new APIError('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const { collection } = req.routeParams
|
||||
|
||||
// Reindex collection
|
||||
const result = await reindexCollection(req.payload, collection, pluginConfig)
|
||||
|
||||
return Response.json({
|
||||
message: `Reindexed ${result.count} documents`,
|
||||
count: result.count,
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Endpoint Placement
|
||||
|
||||
### Collection Endpoints
|
||||
|
||||
Mounted at `/api/{collection-slug}/{path}`.
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Orders: CollectionConfig = {
|
||||
slug: 'orders',
|
||||
fields: [
|
||||
/* ... */
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
path: '/:id/tracking',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
// Available at: /api/orders/:id/tracking
|
||||
const orderId = req.routeParams.id
|
||||
return Response.json({ orderId })
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Global Endpoints
|
||||
|
||||
Mounted at `/api/globals/{global-slug}/{path}`.
|
||||
|
||||
```ts
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Settings: GlobalConfig = {
|
||||
slug: 'settings',
|
||||
fields: [
|
||||
/* ... */
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
path: '/clear-cache',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
// Available at: /api/globals/settings/clear-cache
|
||||
await clearCache()
|
||||
return Response.json({ message: 'Cache cleared' })
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Factory Functions
|
||||
|
||||
Create reusable endpoint factories for plugins.
|
||||
|
||||
```ts
|
||||
export const createWebhookEndpoint = (config) => ({
|
||||
path: '/webhook',
|
||||
method: 'post',
|
||||
handler: async (req) => {
|
||||
const signature = req.headers.get('x-webhook-signature')
|
||||
|
||||
if (!verifySignature(signature, config.secret)) {
|
||||
throw new APIError('Invalid signature', 401)
|
||||
}
|
||||
|
||||
const data = await req.json()
|
||||
await processWebhook(req.payload, data, config)
|
||||
|
||||
return Response.json({ received: true })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Conditional Endpoints
|
||||
|
||||
Add endpoints based on config options.
|
||||
|
||||
```ts
|
||||
export const MyCollection: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
fields: [
|
||||
/* ... */
|
||||
],
|
||||
endpoints: [
|
||||
// Always included
|
||||
{
|
||||
path: '/public',
|
||||
method: 'get',
|
||||
handler: async (req) => Response.json({ data: [] }),
|
||||
},
|
||||
// Conditionally included
|
||||
...(process.env.ENABLE_ANALYTICS
|
||||
? [
|
||||
{
|
||||
path: '/analytics',
|
||||
method: 'get',
|
||||
handler: async (req) => Response.json({ analytics: [] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAPI Documentation
|
||||
|
||||
Use `custom` property for API documentation metadata.
|
||||
|
||||
```ts
|
||||
export const endpoint = {
|
||||
path: '/search',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
// Handler implementation
|
||||
},
|
||||
custom: {
|
||||
openapi: {
|
||||
summary: 'Search posts',
|
||||
parameters: [
|
||||
{
|
||||
name: 'q',
|
||||
in: 'query',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Search results',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always check authentication** - Custom endpoints are not authenticated by default
|
||||
2. **Use `req.payload` for operations** - Ensures access control and hooks execute
|
||||
3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`, etc.
|
||||
4. **Throw `APIError` for errors** - Provides consistent error responses
|
||||
5. **Return Web API `Response`** - Use `Response.json()` for consistent responses
|
||||
6. **Validate input** - Check required fields, validate types
|
||||
7. **Handle CORS** - Use `headersWithCors` for cross-origin requests
|
||||
8. **Log errors** - Use `req.payload.logger` for debugging
|
||||
9. **Document with `custom`** - Add OpenAPI metadata for API docs
|
||||
10. **Factory pattern for reuse** - Create endpoint factories for plugins
|
||||
|
||||
## Resources
|
||||
|
||||
- REST API Overview: <https://payloadcms.com/docs/rest-api/overview>
|
||||
- Custom Endpoints: <https://payloadcms.com/docs/rest-api/overview#custom-endpoints>
|
||||
- Access Control: <https://payloadcms.com/docs/access-control/overview>
|
||||
- Local API: <https://payloadcms.com/docs/local-api/overview>
|
||||
553
apps/cms/.vibe/skills/payload/reference/FIELD-TYPE-GUARDS.md
Normal file
553
apps/cms/.vibe/skills/payload/reference/FIELD-TYPE-GUARDS.md
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
# Payload Field Type Guards Reference
|
||||
|
||||
Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards.
|
||||
|
||||
## Structural Guards
|
||||
|
||||
### fieldHasSubFields
|
||||
|
||||
Checks if field contains nested fields (group, array, row, or collapsible).
|
||||
|
||||
```ts
|
||||
import type { Field } from 'payload'
|
||||
import { fieldHasSubFields } from 'payload'
|
||||
|
||||
function traverseFields(fields: Field[]): void {
|
||||
fields.forEach((field) => {
|
||||
if (fieldHasSubFields(field)) {
|
||||
// Safe to access field.fields
|
||||
traverseFields(field.fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldHasSubFields<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is TField & (FieldWithSubFieldsClient | FieldWithSubFields)
|
||||
```
|
||||
|
||||
**Common Pattern - Exclude Arrays:**
|
||||
|
||||
```ts
|
||||
if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
|
||||
// Groups, rows, collapsibles only (not arrays)
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsArrayType
|
||||
|
||||
Checks if field type is `'array'`.
|
||||
|
||||
```ts
|
||||
import { fieldIsArrayType } from 'payload'
|
||||
|
||||
if (fieldIsArrayType(field)) {
|
||||
// field.type === 'array'
|
||||
console.log(`Min rows: ${field.minRows}`)
|
||||
console.log(`Max rows: ${field.maxRows}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsArrayType<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is TField & (ArrayFieldClient | ArrayField)
|
||||
```
|
||||
|
||||
### fieldIsBlockType
|
||||
|
||||
Checks if field type is `'blocks'`.
|
||||
|
||||
```ts
|
||||
import { fieldIsBlockType } from 'payload'
|
||||
|
||||
if (fieldIsBlockType(field)) {
|
||||
// field.type === 'blocks'
|
||||
field.blocks.forEach((block) => {
|
||||
console.log(`Block: ${block.slug}`)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsBlockType<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is TField & (BlocksFieldClient | BlocksField)
|
||||
```
|
||||
|
||||
**Common Pattern - Distinguish Containers:**
|
||||
|
||||
```ts
|
||||
if (fieldIsArrayType(field)) {
|
||||
// Handle array rows
|
||||
} else if (fieldIsBlockType(field)) {
|
||||
// Handle block types
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsGroupType
|
||||
|
||||
Checks if field type is `'group'`.
|
||||
|
||||
```ts
|
||||
import { fieldIsGroupType } from 'payload'
|
||||
|
||||
if (fieldIsGroupType(field)) {
|
||||
// field.type === 'group'
|
||||
console.log(`Interface: ${field.interfaceName}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsGroupType<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is TField & (GroupFieldClient | GroupField)
|
||||
```
|
||||
|
||||
## Capability Guards
|
||||
|
||||
### fieldSupportsMany
|
||||
|
||||
Checks if field can have multiple values (select, relationship, or upload with `hasMany`).
|
||||
|
||||
```ts
|
||||
import { fieldSupportsMany } from 'payload'
|
||||
|
||||
if (fieldSupportsMany(field)) {
|
||||
// field.type is 'select' | 'relationship' | 'upload'
|
||||
// Safe to check field.hasMany
|
||||
if (field.hasMany) {
|
||||
console.log('Field accepts multiple values')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldSupportsMany<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is TField & (FieldWithManyClient | FieldWithMany)
|
||||
```
|
||||
|
||||
### fieldHasMaxDepth
|
||||
|
||||
Checks if field is relationship/upload/join with numeric `maxDepth` property.
|
||||
|
||||
```ts
|
||||
import { fieldHasMaxDepth } from 'payload'
|
||||
|
||||
if (fieldHasMaxDepth(field)) {
|
||||
// field.type is 'upload' | 'relationship' | 'join'
|
||||
// AND field.maxDepth is number
|
||||
const remainingDepth = field.maxDepth - currentDepth
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldHasMaxDepth<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is TField & (FieldWithMaxDepthClient | FieldWithMaxDepth)
|
||||
```
|
||||
|
||||
### fieldShouldBeLocalized
|
||||
|
||||
Checks if field needs localization handling (accounts for parent localization).
|
||||
|
||||
```ts
|
||||
import { fieldShouldBeLocalized } from 'payload'
|
||||
|
||||
function processField(field: Field, parentIsLocalized: boolean) {
|
||||
if (fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
// Create locale-specific table or index
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldShouldBeLocalized({
|
||||
field,
|
||||
parentIsLocalized,
|
||||
}: {
|
||||
field: ClientField | ClientTab | Field | Tab
|
||||
parentIsLocalized: boolean
|
||||
}): boolean
|
||||
```
|
||||
|
||||
```ts
|
||||
// Accounts for parent localization
|
||||
if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### fieldIsVirtual
|
||||
|
||||
Checks if field is virtual (computed or virtual relationship).
|
||||
|
||||
```ts
|
||||
import { fieldIsVirtual } from 'payload'
|
||||
|
||||
if (fieldIsVirtual(field)) {
|
||||
// field.virtual is truthy
|
||||
if (typeof field.virtual === 'string') {
|
||||
// Virtual relationship path
|
||||
console.log(`Virtual path: ${field.virtual}`)
|
||||
} else {
|
||||
// Computed virtual field (uses hooks)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsVirtual(field: Field | Tab): boolean
|
||||
```
|
||||
|
||||
## Data Guards
|
||||
|
||||
### fieldAffectsData
|
||||
|
||||
**Most commonly used guard.** Checks if field stores data (has name and is not UI-only).
|
||||
|
||||
```ts
|
||||
import { fieldAffectsData } from 'payload'
|
||||
|
||||
function generateSchema(fields: Field[]) {
|
||||
fields.forEach((field) => {
|
||||
if (fieldAffectsData(field)) {
|
||||
// Safe to access field.name
|
||||
schema[field.name] = getFieldType(field)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldAffectsData<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||
field: TField
|
||||
): field is TField & (FieldAffectingDataClient | FieldAffectingData)
|
||||
```
|
||||
|
||||
**Pattern - Data Fields Only:**
|
||||
|
||||
```ts
|
||||
const dataFields = fields.filter(fieldAffectsData)
|
||||
```
|
||||
|
||||
### fieldIsPresentationalOnly
|
||||
|
||||
Checks if field is UI-only (type `'ui'`).
|
||||
|
||||
```ts
|
||||
import { fieldIsPresentationalOnly } from 'payload'
|
||||
|
||||
if (fieldIsPresentationalOnly(field)) {
|
||||
// field.type === 'ui'
|
||||
// Skip in data operations, GraphQL schema, etc.
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsPresentationalOnly<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||
field: TField
|
||||
): field is TField & (UIFieldClient | UIField)
|
||||
```
|
||||
|
||||
### fieldIsID
|
||||
|
||||
Checks if field name is exactly `'id'`.
|
||||
|
||||
```ts
|
||||
import { fieldIsID } from 'payload'
|
||||
|
||||
if (fieldIsID(field)) {
|
||||
// field.name === 'id'
|
||||
// Special handling for ID field
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsID<TField extends ClientField | Field>(
|
||||
field: TField
|
||||
): field is { name: 'id' } & TField
|
||||
```
|
||||
|
||||
### fieldIsHiddenOrDisabled
|
||||
|
||||
Checks if field is hidden or admin-disabled.
|
||||
|
||||
```ts
|
||||
import { fieldIsHiddenOrDisabled } from 'payload'
|
||||
|
||||
const visibleFields = fields.filter((field) => !fieldIsHiddenOrDisabled(field))
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsHiddenOrDisabled<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||
field: TField
|
||||
): field is { admin: { hidden: true } } & TField
|
||||
```
|
||||
|
||||
## Layout Guards
|
||||
|
||||
### fieldIsSidebar
|
||||
|
||||
Checks if field is positioned in sidebar.
|
||||
|
||||
```ts
|
||||
import { fieldIsSidebar } from 'payload'
|
||||
|
||||
const [mainFields, sidebarFields] = fields.reduce(
|
||||
([main, sidebar], field) => {
|
||||
if (fieldIsSidebar(field)) {
|
||||
return [main, [...sidebar, field]]
|
||||
}
|
||||
return [[...main, field], sidebar]
|
||||
},
|
||||
[[], []],
|
||||
)
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
fieldIsSidebar<TField extends ClientField | Field | TabAsField | TabAsFieldClient>(
|
||||
field: TField
|
||||
): field is { admin: { position: 'sidebar' } } & TField
|
||||
```
|
||||
|
||||
## Tab & Group Guards
|
||||
|
||||
### tabHasName
|
||||
|
||||
Checks if tab is named (stores data under tab name).
|
||||
|
||||
```ts
|
||||
import { tabHasName } from 'payload'
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
if (tabHasName(tab)) {
|
||||
// tab.name exists
|
||||
dataPath.push(tab.name)
|
||||
}
|
||||
// Process tab.fields
|
||||
})
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
tabHasName<TField extends ClientTab | Tab>(
|
||||
tab: TField
|
||||
): tab is NamedTab & TField
|
||||
```
|
||||
|
||||
### groupHasName
|
||||
|
||||
Checks if group is named (stores data under group name).
|
||||
|
||||
```ts
|
||||
import { groupHasName } from 'payload'
|
||||
|
||||
if (groupHasName(group)) {
|
||||
// group.name exists
|
||||
return data[group.name]
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
groupHasName(group: Partial<NamedGroupFieldClient>): group is NamedGroupFieldClient
|
||||
```
|
||||
|
||||
## Option & Value Guards
|
||||
|
||||
### optionIsObject
|
||||
|
||||
Checks if option is object format `{label, value}` vs string.
|
||||
|
||||
```ts
|
||||
import { optionIsObject } from 'payload'
|
||||
|
||||
field.options.forEach((option) => {
|
||||
if (optionIsObject(option)) {
|
||||
console.log(`${option.label}: ${option.value}`)
|
||||
} else {
|
||||
console.log(option) // string value
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
optionIsObject(option: Option): option is OptionObject
|
||||
```
|
||||
|
||||
### optionsAreObjects
|
||||
|
||||
Checks if entire options array contains objects.
|
||||
|
||||
```ts
|
||||
import { optionsAreObjects } from 'payload'
|
||||
|
||||
if (optionsAreObjects(field.options)) {
|
||||
// All options are OptionObject[]
|
||||
const labels = field.options.map((opt) => opt.label)
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
optionsAreObjects(options: Option[]): options is OptionObject[]
|
||||
```
|
||||
|
||||
### optionIsValue
|
||||
|
||||
Checks if option is string value (not object).
|
||||
|
||||
```ts
|
||||
import { optionIsValue } from 'payload'
|
||||
|
||||
if (optionIsValue(option)) {
|
||||
// option is string
|
||||
const value = option
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
optionIsValue(option: Option): option is string
|
||||
```
|
||||
|
||||
### valueIsValueWithRelation
|
||||
|
||||
Checks if relationship value is polymorphic format `{relationTo, value}`.
|
||||
|
||||
```ts
|
||||
import { valueIsValueWithRelation } from 'payload'
|
||||
|
||||
if (valueIsValueWithRelation(fieldValue)) {
|
||||
// fieldValue.relationTo exists
|
||||
// fieldValue.value exists
|
||||
console.log(`Related to ${fieldValue.relationTo}: ${fieldValue.value}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Signature:**
|
||||
|
||||
```ts
|
||||
valueIsValueWithRelation(value: unknown): value is ValueWithRelation
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Recursive Field Traversal
|
||||
|
||||
```ts
|
||||
import { fieldAffectsData, fieldHasSubFields } from 'payload'
|
||||
|
||||
function traverseFields(fields: Field[], callback: (field: Field) => void) {
|
||||
fields.forEach((field) => {
|
||||
if (fieldAffectsData(field)) {
|
||||
callback(field)
|
||||
}
|
||||
|
||||
if (fieldHasSubFields(field)) {
|
||||
traverseFields(field.fields, callback)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Filter Data-Bearing Fields
|
||||
|
||||
```ts
|
||||
import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload'
|
||||
|
||||
const dataFields = fields.filter(
|
||||
(field) =>
|
||||
fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field),
|
||||
)
|
||||
```
|
||||
|
||||
### Container Type Switching
|
||||
|
||||
```ts
|
||||
import { fieldIsArrayType, fieldIsBlockType, fieldHasSubFields } from 'payload'
|
||||
|
||||
if (fieldIsArrayType(field)) {
|
||||
// Handle array-specific logic
|
||||
} else if (fieldIsBlockType(field)) {
|
||||
// Handle blocks-specific logic
|
||||
} else if (fieldHasSubFields(field)) {
|
||||
// Handle group/row/collapsible
|
||||
}
|
||||
```
|
||||
|
||||
### Safe Property Access
|
||||
|
||||
```ts
|
||||
import { fieldSupportsMany, fieldHasMaxDepth } from 'payload'
|
||||
|
||||
// Without guard - TypeScript error
|
||||
// if (field.hasMany) { /* ... */ }
|
||||
|
||||
// With guard - safe access
|
||||
if (fieldSupportsMany(field) && field.hasMany) {
|
||||
console.log('Multiple values supported')
|
||||
}
|
||||
|
||||
if (fieldHasMaxDepth(field)) {
|
||||
const depth = field.maxDepth // TypeScript knows this is number
|
||||
}
|
||||
```
|
||||
|
||||
## Type Preservation
|
||||
|
||||
All guards preserve the original type constraint:
|
||||
|
||||
```ts
|
||||
import type { ClientField, Field } from 'payload'
|
||||
import { fieldHasSubFields } from 'payload'
|
||||
|
||||
function processServerField(field: Field) {
|
||||
if (fieldHasSubFields(field)) {
|
||||
// field is Field & FieldWithSubFields (not ClientField)
|
||||
}
|
||||
}
|
||||
|
||||
function processClientField(field: ClientField) {
|
||||
if (fieldHasSubFields(field)) {
|
||||
// field is ClientField & FieldWithSubFieldsClient
|
||||
}
|
||||
}
|
||||
```
|
||||
773
apps/cms/.vibe/skills/payload/reference/FIELDS.md
Normal file
773
apps/cms/.vibe/skills/payload/reference/FIELDS.md
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
# Payload Field Types Reference
|
||||
|
||||
Complete reference for all Payload field types with examples.
|
||||
|
||||
## Text Field
|
||||
|
||||
```ts
|
||||
import type { TextField } from 'payload'
|
||||
|
||||
const textField: TextField = {
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
minLength: 5,
|
||||
maxLength: 100,
|
||||
index: true,
|
||||
localized: true,
|
||||
defaultValue: 'Default Title',
|
||||
validate: (value) => Boolean(value) || 'Required',
|
||||
admin: {
|
||||
placeholder: 'Enter title...',
|
||||
position: 'sidebar',
|
||||
condition: (data) => data.showTitle === true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **When to use `position: 'sidebar'`:** Reserve the sidebar for short fields that
|
||||
> give quick insight into the content — status, category, author, publish date,
|
||||
> slug. Avoid it for fields that need horizontal space to be useful, like a
|
||||
> description, rich text content, or long text — those belong in the main document
|
||||
> area. (`title` above is shown in the sidebar only to demonstrate the option.)
|
||||
|
||||
### Slug Field Helper
|
||||
|
||||
Built-in helper for auto-generating slugs. **Use this for all slugs** instead of
|
||||
hand-rolling a `{ name: 'slug', type: 'text', unique: true }` field — it
|
||||
auto-generates the slug from the title, adds a regenerate toggle, and handles
|
||||
uniqueness and indexing. Call it with no args when the collection has a `title`
|
||||
field — the slug is generated from `title` by default:
|
||||
|
||||
```ts
|
||||
import { slugField } from 'payload'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
slugField(), // name: 'slug', useAsSlug: 'title', required, unique, position: 'sidebar'
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
`useAsSlug` defaults to `'title'`, so if the collection has **no `title` field**,
|
||||
you must pass the source field explicitly — otherwise the slug generates from a
|
||||
field that doesn't exist:
|
||||
|
||||
```ts
|
||||
// Collection keyed on `name` instead of `title`
|
||||
fields: [{ name: 'name', type: 'text', required: true }, slugField({ useAsSlug: 'name' })]
|
||||
```
|
||||
|
||||
Override defaults when needed (`overrides` receives the generated `RowField`):
|
||||
|
||||
```ts
|
||||
slugField({
|
||||
name: 'slug', // defaults to 'slug'
|
||||
useAsSlug: 'title', // defaults to 'title'
|
||||
checkboxName: 'generateSlug', // defaults to 'generateSlug'
|
||||
localized: true,
|
||||
required: true, // defaults to true
|
||||
position: 'sidebar', // default; the slug is a short field well-suited to the sidebar
|
||||
overrides: (field) => {
|
||||
field.fields[1].label = 'Custom Slug Label'
|
||||
return field
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Rich Text (Lexical)
|
||||
|
||||
```ts
|
||||
import type { RichTextField } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { HeadingFeature, LinkFeature } from '@payloadcms/richtext-lexical'
|
||||
|
||||
const richTextField: RichTextField = {
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
localized: true,
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
HeadingFeature({
|
||||
enabledHeadingSizes: ['h1', 'h2', 'h3'],
|
||||
}),
|
||||
LinkFeature({
|
||||
enabledCollections: ['posts', 'pages'],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Lexical Configuration
|
||||
|
||||
```ts
|
||||
import {
|
||||
BoldFeature,
|
||||
EXPERIMENTAL_TableFeature,
|
||||
FixedToolbarFeature,
|
||||
HeadingFeature,
|
||||
IndentFeature,
|
||||
InlineToolbarFeature,
|
||||
ItalicFeature,
|
||||
LinkFeature,
|
||||
OrderedListFeature,
|
||||
UnderlineFeature,
|
||||
UnorderedListFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
// Global editor config with full features
|
||||
export default buildConfig({
|
||||
editor: lexicalEditor({
|
||||
features: () => {
|
||||
return [
|
||||
UnderlineFeature(),
|
||||
BoldFeature(),
|
||||
ItalicFeature(),
|
||||
OrderedListFeature(),
|
||||
UnorderedListFeature(),
|
||||
LinkFeature({
|
||||
enabledCollections: ['pages'],
|
||||
fields: ({ defaultFields }) => {
|
||||
const defaultFieldsWithoutUrl = defaultFields.filter((field) => {
|
||||
if ('name' in field && field.name === 'url') return false
|
||||
return true
|
||||
})
|
||||
|
||||
return [
|
||||
...defaultFieldsWithoutUrl,
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: ({ linkType }) => linkType !== 'internal',
|
||||
},
|
||||
label: ({ t }) => t('fields:enterURL'),
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
}),
|
||||
IndentFeature(),
|
||||
EXPERIMENTAL_TableFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Field-specific editor with custom toolbar
|
||||
const richTextWithToolbars: RichTextField = {
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ rootFeatures }) => {
|
||||
return [
|
||||
...rootFeatures,
|
||||
HeadingFeature({ enabledHeadingSizes: ['h2', 'h3', 'h4'] }),
|
||||
FixedToolbarFeature(),
|
||||
InlineToolbarFeature(),
|
||||
]
|
||||
},
|
||||
}),
|
||||
label: false,
|
||||
}
|
||||
```
|
||||
|
||||
## Relationship
|
||||
|
||||
```ts
|
||||
import type { RelationshipField } from 'payload'
|
||||
|
||||
// Single relationship
|
||||
const singleRelationship: RelationshipField = {
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
maxDepth: 2,
|
||||
}
|
||||
|
||||
// Multiple relationships (hasMany)
|
||||
const multipleRelationship: RelationshipField = {
|
||||
name: 'categories',
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
hasMany: true,
|
||||
filterOptions: {
|
||||
active: { equals: true },
|
||||
},
|
||||
}
|
||||
|
||||
// Polymorphic relationship
|
||||
const polymorphicRelationship: PolymorphicRelationshipField = {
|
||||
name: 'relatedContent',
|
||||
type: 'relationship',
|
||||
relationTo: ['posts', 'pages'],
|
||||
hasMany: true,
|
||||
}
|
||||
```
|
||||
|
||||
## Array
|
||||
|
||||
```ts
|
||||
import type { ArrayField } from 'payload'
|
||||
|
||||
const arrayField: ArrayField = {
|
||||
name: 'slides',
|
||||
type: 'array',
|
||||
minRows: 2,
|
||||
maxRows: 10,
|
||||
labels: {
|
||||
singular: 'Slide',
|
||||
plural: 'Slides',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Blocks
|
||||
|
||||
```ts
|
||||
import type { BlocksField, Block } from 'payload'
|
||||
|
||||
const HeroBlock: Block = {
|
||||
slug: 'hero',
|
||||
interfaceName: 'HeroBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'heading',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'background',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const ContentBlock: Block = {
|
||||
slug: 'content',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'richText',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const blocksField: BlocksField = {
|
||||
name: 'layout',
|
||||
type: 'blocks',
|
||||
blocks: [HeroBlock, ContentBlock],
|
||||
}
|
||||
```
|
||||
|
||||
## Select
|
||||
|
||||
```ts
|
||||
import type { SelectField } from 'payload'
|
||||
|
||||
// Use select for genuine taxonomy. For publish state, enable versions.drafts
|
||||
// and rely on the auto-injected _status field instead of a custom select.
|
||||
const selectField: SelectField = {
|
||||
name: 'priority',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Low', value: 'low' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'High', value: 'high' },
|
||||
],
|
||||
defaultValue: 'medium',
|
||||
required: true,
|
||||
}
|
||||
|
||||
// Multiple select
|
||||
const multiSelectField: SelectField = {
|
||||
name: 'tags',
|
||||
type: 'select',
|
||||
hasMany: true,
|
||||
options: ['tech', 'news', 'sports'],
|
||||
}
|
||||
```
|
||||
|
||||
## Upload
|
||||
|
||||
```ts
|
||||
import type { UploadField } from 'payload'
|
||||
|
||||
const uploadField: UploadField = {
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
filterOptions: {
|
||||
mimeType: { contains: 'image' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Point (Geolocation)
|
||||
|
||||
Point fields store geographic coordinates with automatic 2dsphere indexing for geospatial queries.
|
||||
|
||||
```ts
|
||||
import type { PointField } from 'payload'
|
||||
|
||||
const locationField: PointField = {
|
||||
name: 'location',
|
||||
type: 'point',
|
||||
label: 'Location',
|
||||
required: true,
|
||||
}
|
||||
|
||||
// Returns [longitude, latitude]
|
||||
// Example: [-122.4194, 37.7749] for San Francisco
|
||||
```
|
||||
|
||||
### Geospatial Queries
|
||||
|
||||
```ts
|
||||
// Query by distance (sorted by nearest first)
|
||||
const nearbyLocations = await payload.find({
|
||||
collection: 'stores',
|
||||
where: {
|
||||
location: {
|
||||
near: [10, 20], // [longitude, latitude]
|
||||
maxDistance: 5000, // in meters
|
||||
minDistance: 1000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Query within polygon area
|
||||
const polygon: Point[] = [
|
||||
[9.0, 19.0], // bottom-left
|
||||
[9.0, 21.0], // top-left
|
||||
[11.0, 21.0], // top-right
|
||||
[11.0, 19.0], // bottom-right
|
||||
[9.0, 19.0], // closing point
|
||||
]
|
||||
|
||||
const withinArea = await payload.find({
|
||||
collection: 'stores',
|
||||
where: {
|
||||
location: {
|
||||
within: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Query intersecting area
|
||||
const intersecting = await payload.find({
|
||||
collection: 'stores',
|
||||
where: {
|
||||
location: {
|
||||
intersects: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygon],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Note**: Point fields are not supported in SQLite.
|
||||
|
||||
## Join Fields
|
||||
|
||||
Join fields create reverse relationships, allowing you to access related documents from the "other side" of a relationship.
|
||||
|
||||
```ts
|
||||
import type { JoinField } from 'payload'
|
||||
|
||||
// From Users collection - show user's orders
|
||||
const ordersJoinField: JoinField = {
|
||||
name: 'orders',
|
||||
type: 'join',
|
||||
collection: 'orders',
|
||||
on: 'customer', // The field in 'orders' that references this user
|
||||
admin: {
|
||||
allowCreate: false,
|
||||
defaultColumns: ['id', 'createdAt', 'total', 'currency', 'items'],
|
||||
},
|
||||
}
|
||||
|
||||
// From Users collection - show user's cart
|
||||
const cartJoinField: JoinField = {
|
||||
name: 'cart',
|
||||
type: 'join',
|
||||
collection: 'carts',
|
||||
on: 'customer',
|
||||
admin: {
|
||||
allowCreate: false,
|
||||
defaultColumns: ['id', 'createdAt', 'total', 'currency'],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Virtual Fields
|
||||
|
||||
```ts
|
||||
import type { TextField } from 'payload'
|
||||
|
||||
// Computed from siblings
|
||||
const computedVirtualField: TextField = {
|
||||
name: 'fullName',
|
||||
type: 'text',
|
||||
virtual: true,
|
||||
hooks: {
|
||||
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
|
||||
},
|
||||
}
|
||||
|
||||
// From relationship path
|
||||
const pathVirtualField: TextField = {
|
||||
name: 'authorName',
|
||||
type: 'text',
|
||||
virtual: 'author.name',
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Fields
|
||||
|
||||
```ts
|
||||
import type { UploadField, CheckboxField } from 'payload'
|
||||
|
||||
// Simple boolean condition
|
||||
const enableFeatureField: CheckboxField = {
|
||||
name: 'enableFeature',
|
||||
type: 'checkbox',
|
||||
}
|
||||
|
||||
const conditionalField: TextField = {
|
||||
name: 'featureText',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data.enableFeature === true,
|
||||
},
|
||||
}
|
||||
|
||||
// Sibling data condition (from hero field pattern)
|
||||
const typeField: SelectField = {
|
||||
name: 'type',
|
||||
type: 'select',
|
||||
options: ['none', 'highImpact', 'mediumImpact', 'lowImpact'],
|
||||
defaultValue: 'lowImpact',
|
||||
}
|
||||
|
||||
const mediaField: UploadField = {
|
||||
name: 'media',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
condition: (_, { type } = {}) => ['highImpact', 'mediumImpact'].includes(type),
|
||||
},
|
||||
required: true,
|
||||
}
|
||||
```
|
||||
|
||||
## Radio
|
||||
|
||||
Radio fields present options as radio buttons for single selection.
|
||||
|
||||
```ts
|
||||
import type { RadioField } from 'payload'
|
||||
|
||||
const radioField: RadioField = {
|
||||
name: 'priority',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Low', value: 'low' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'High', value: 'high' },
|
||||
],
|
||||
defaultValue: 'medium',
|
||||
admin: {
|
||||
layout: 'horizontal', // or 'vertical'
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Row (Layout)
|
||||
|
||||
Row fields arrange fields horizontally in the admin panel (presentational only).
|
||||
|
||||
```ts
|
||||
import type { RowField } from 'payload'
|
||||
|
||||
const rowField: RowField = {
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'firstName',
|
||||
type: 'text',
|
||||
admin: { width: '50%' },
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
type: 'text',
|
||||
admin: { width: '50%' },
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Collapsible (Layout)
|
||||
|
||||
Collapsible fields group fields in an expandable/collapsible section.
|
||||
|
||||
```ts
|
||||
import type { CollapsibleField } from 'payload'
|
||||
|
||||
const collapsibleField: CollapsibleField = {
|
||||
label: ({ data }) => data?.title || 'Advanced Options',
|
||||
type: 'collapsible',
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'customCSS', type: 'textarea' },
|
||||
{ name: 'customJS', type: 'code' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## UI (Custom Components)
|
||||
|
||||
UI fields allow fully custom React components in the admin (no data stored).
|
||||
|
||||
```ts
|
||||
import type { UIField } from 'payload'
|
||||
|
||||
const uiField: UIField = {
|
||||
name: 'customMessage',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '/path/to/CustomFieldComponent',
|
||||
Cell: '/path/to/CustomCellComponent', // For list view
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Tabs & Groups
|
||||
|
||||
```ts
|
||||
import type { TabsField, GroupField } from 'payload'
|
||||
|
||||
// Tabs
|
||||
const tabsField: TabsField = {
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: 'Content',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'body', type: 'richText' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'SEO',
|
||||
fields: [
|
||||
{ name: 'metaTitle', type: 'text' },
|
||||
{ name: 'metaDescription', type: 'textarea' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Group (named)
|
||||
const groupField: GroupField = {
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'description', type: 'textarea' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Reusable Field Factories
|
||||
|
||||
Create composable field patterns that can be customized with overrides.
|
||||
|
||||
```ts
|
||||
import type { Field, GroupField } from 'payload'
|
||||
|
||||
// Utility for deep merging
|
||||
const deepMerge = <T>(target: T, source: Partial<T>): T => {
|
||||
// Implementation would deeply merge objects
|
||||
return { ...target, ...source }
|
||||
}
|
||||
|
||||
// Reusable link field factory
|
||||
type LinkType = (options?: {
|
||||
appearances?: ('default' | 'outline')[] | false
|
||||
disableLabel?: boolean
|
||||
overrides?: Record<string, unknown>
|
||||
}) => GroupField
|
||||
|
||||
export const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
|
||||
const linkField: GroupField = {
|
||||
name: 'link',
|
||||
type: 'group',
|
||||
admin: {
|
||||
hideGutter: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Internal link', value: 'reference' },
|
||||
{ label: 'Custom URL', value: 'custom' },
|
||||
],
|
||||
defaultValue: 'reference',
|
||||
admin: {
|
||||
layout: 'horizontal',
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'newTab',
|
||||
type: 'checkbox',
|
||||
label: 'Open in new tab',
|
||||
admin: {
|
||||
width: '50%',
|
||||
style: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'reference',
|
||||
type: 'relationship',
|
||||
relationTo: ['pages'],
|
||||
required: true,
|
||||
maxDepth: 1,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'reference',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
label: 'Custom URL',
|
||||
required: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'custom',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (!disableLabel) {
|
||||
linkField.fields.push({
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (appearances !== false) {
|
||||
linkField.fields.push({
|
||||
name: 'appearance',
|
||||
type: 'select',
|
||||
defaultValue: 'default',
|
||||
options: [
|
||||
{ label: 'Default', value: 'default' },
|
||||
{ label: 'Outline', value: 'outline' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return deepMerge(linkField, overrides) as GroupField
|
||||
}
|
||||
|
||||
// Usage
|
||||
const navItem = link({ appearances: false })
|
||||
const ctaButton = link({
|
||||
overrides: {
|
||||
name: 'cta',
|
||||
admin: {
|
||||
description: 'Call to action button',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Field Type Guards
|
||||
|
||||
Type guards for runtime field type checking and safe type narrowing.
|
||||
|
||||
| Type Guard | Checks For | Use When |
|
||||
| --------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
|
||||
| `fieldAffectsData` | Field stores data (has name, not UI-only) | Need to access field data or name |
|
||||
| `fieldHasSubFields` | Field contains nested fields (group/array/row/collapsible) | Need to recursively traverse fields |
|
||||
| `fieldIsArrayType` | Field is array type | Distinguish arrays from other containers |
|
||||
| `fieldIsBlockType` | Field is blocks type | Handle blocks-specific logic |
|
||||
| `fieldIsGroupType` | Field is group type | Handle group-specific logic |
|
||||
| `fieldSupportsMany` | Field can have multiple values (select/relationship/upload) | Check for `hasMany` support |
|
||||
| `fieldHasMaxDepth` | Field supports population depth control | Control relationship/upload/join depth |
|
||||
| `fieldIsPresentationalOnly` | Field is UI-only (no data storage) | Exclude from data operations |
|
||||
| `fieldIsSidebar` | Field positioned in sidebar | Separate sidebar rendering |
|
||||
| `fieldIsID` | Field name is 'id' | Special ID field handling |
|
||||
| `fieldIsHiddenOrDisabled` | Field is hidden or disabled | Filter from UI operations |
|
||||
| `fieldShouldBeLocalized` | Field needs localization handling | Proper locale table checks |
|
||||
| `fieldIsVirtual` | Field is virtual (computed/no DB column) | Skip in database transforms |
|
||||
| `tabHasName` | Tab is named (stores data) | Distinguish named vs unnamed tabs |
|
||||
| `groupHasName` | Group is named (stores data) | Distinguish named vs unnamed groups |
|
||||
| `optionIsObject` | Option is `{label, value}` format | Access option properties safely |
|
||||
| `optionsAreObjects` | All options are objects | Batch option processing |
|
||||
| `optionIsValue` | Option is string value | Handle string options |
|
||||
| `valueIsValueWithRelation` | Value is polymorphic relationship | Handle polymorphic relationships |
|
||||
|
||||
```ts
|
||||
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload'
|
||||
|
||||
function processField(field: Field) {
|
||||
if (fieldAffectsData(field)) {
|
||||
// Safe to access field.name
|
||||
console.log(field.name)
|
||||
}
|
||||
|
||||
if (fieldHasSubFields(field)) {
|
||||
// Safe to access field.fields
|
||||
field.fields.forEach(processField)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [FIELD-TYPE-GUARDS.md](FIELD-TYPE-GUARDS.md) for detailed usage patterns.
|
||||
186
apps/cms/.vibe/skills/payload/reference/HOOKS.md
Normal file
186
apps/cms/.vibe/skills/payload/reference/HOOKS.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# Payload Hooks Reference
|
||||
|
||||
Complete reference for collection hooks, field hooks, and hook context patterns.
|
||||
|
||||
## Collection Hooks
|
||||
|
||||
```ts
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
// Before validation
|
||||
beforeValidate: [
|
||||
async ({ data, operation }) => {
|
||||
if (operation === 'create') {
|
||||
data.slug = slugify(data.title)
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
|
||||
// Before save
|
||||
beforeChange: [
|
||||
async ({ data, req, operation, originalDoc }) => {
|
||||
if (operation === 'update' && data.status === 'published') {
|
||||
data.publishedAt = new Date()
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
|
||||
// After save
|
||||
afterChange: [
|
||||
async ({ doc, req, operation, previousDoc }) => {
|
||||
if (operation === 'create') {
|
||||
await sendNotification(doc)
|
||||
}
|
||||
return doc
|
||||
},
|
||||
],
|
||||
|
||||
// After read
|
||||
afterRead: [
|
||||
async ({ doc, req }) => {
|
||||
doc.viewCount = await getViewCount(doc.id)
|
||||
return doc
|
||||
},
|
||||
],
|
||||
|
||||
// Before delete
|
||||
beforeDelete: [
|
||||
async ({ req, id }) => {
|
||||
await cleanupRelatedData(id)
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Field Hooks
|
||||
|
||||
```ts
|
||||
import type { EmailField, FieldHook } from 'payload'
|
||||
|
||||
const beforeValidateHook: FieldHook = ({ value }) => {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const afterReadHook: FieldHook = ({ value, req }) => {
|
||||
// Hide email from non-admins
|
||||
if (!req.user?.roles?.includes('admin')) {
|
||||
return value.replace(/(.{2})(.*)(@.*)/, '$1***$3')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const emailField: EmailField = {
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
hooks: {
|
||||
beforeValidate: [beforeValidateHook],
|
||||
afterRead: [afterReadHook],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Hook Context
|
||||
|
||||
Share data between hooks or control hook behavior using request context:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
async ({ context }) => {
|
||||
context.expensiveData = await fetchExpensiveData()
|
||||
},
|
||||
],
|
||||
afterChange: [
|
||||
async ({ context, doc }) => {
|
||||
// Reuse from previous hook
|
||||
await processData(doc, context.expensiveData)
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [{ name: 'title', type: 'text' }],
|
||||
}
|
||||
```
|
||||
|
||||
## Next.js Revalidation with Context Control
|
||||
|
||||
```ts
|
||||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { Page } from '../payload-types'
|
||||
|
||||
export const revalidatePage: CollectionAfterChangeHook<Page> = ({
|
||||
doc,
|
||||
previousDoc,
|
||||
req: { payload, context },
|
||||
}) => {
|
||||
if (!context.disableRevalidate) {
|
||||
if (doc._status === 'published') {
|
||||
const path = doc.slug === 'home' ? '/' : `/${doc.slug}`
|
||||
payload.logger.info(`Revalidating page at path: ${path}`)
|
||||
revalidatePath(path)
|
||||
}
|
||||
|
||||
// Revalidate old path if unpublished
|
||||
if (previousDoc?._status === 'published' && doc._status !== 'published') {
|
||||
const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}`
|
||||
payload.logger.info(`Revalidating old page at path: ${oldPath}`)
|
||||
revalidatePath(oldPath)
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => {
|
||||
if (!context.disableRevalidate) {
|
||||
const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}`
|
||||
revalidatePath(path)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
```
|
||||
|
||||
## Date Field Auto-Set
|
||||
|
||||
Automatically set date when document is published:
|
||||
|
||||
```ts
|
||||
import type { DateField } from 'payload'
|
||||
|
||||
const publishedOnField: DateField = {
|
||||
name: 'publishedOn',
|
||||
type: 'date',
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ siblingData, value }) => {
|
||||
if (siblingData._status === 'published' && !value) {
|
||||
return new Date()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Hook Patterns Best Practices
|
||||
|
||||
- Use `beforeValidate` for data formatting
|
||||
- Use `beforeChange` for business logic
|
||||
- Use `afterChange` for side effects
|
||||
- Use `afterRead` for computed fields
|
||||
- Store expensive operations in `context`
|
||||
- Pass `req` to nested operations for transaction safety (see [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations))
|
||||
1436
apps/cms/.vibe/skills/payload/reference/PLUGIN-DEVELOPMENT.md
Normal file
1436
apps/cms/.vibe/skills/payload/reference/PLUGIN-DEVELOPMENT.md
Normal file
File diff suppressed because it is too large
Load diff
274
apps/cms/.vibe/skills/payload/reference/QUERIES.md
Normal file
274
apps/cms/.vibe/skills/payload/reference/QUERIES.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Payload Querying Reference
|
||||
|
||||
Complete reference for querying data across Local API, REST, and GraphQL.
|
||||
|
||||
## Query Operators
|
||||
|
||||
```ts
|
||||
import type { Where } from 'payload'
|
||||
|
||||
// Equals
|
||||
const equalsQuery: Where = { color: { equals: 'blue' } }
|
||||
|
||||
// Not equals
|
||||
const notEqualsQuery: Where = { status: { not_equals: 'draft' } }
|
||||
|
||||
// Greater/less than
|
||||
const greaterThanQuery: Where = { price: { greater_than: 100 } }
|
||||
const lessThanEqualQuery: Where = { age: { less_than_equal: 65 } }
|
||||
|
||||
// Contains (case-insensitive)
|
||||
const containsQuery: Where = { title: { contains: 'payload' } }
|
||||
|
||||
// Like (all words present)
|
||||
const likeQuery: Where = { description: { like: 'cms headless' } }
|
||||
|
||||
// In/not in
|
||||
const inQuery: Where = { category: { in: ['tech', 'news'] } }
|
||||
|
||||
// Exists
|
||||
const existsQuery: Where = { image: { exists: true } }
|
||||
|
||||
// Near (point fields)
|
||||
const nearQuery: Where = { location: { near: '-122.4194,37.7749,10000' } }
|
||||
```
|
||||
|
||||
## AND/OR Logic
|
||||
|
||||
```ts
|
||||
import type { Where } from 'payload'
|
||||
|
||||
const complexQuery: Where = {
|
||||
or: [
|
||||
{ color: { equals: 'mint' } },
|
||||
{
|
||||
and: [{ color: { equals: 'white' } }, { featured: { equals: false } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Nested Properties
|
||||
|
||||
```ts
|
||||
import type { Where } from 'payload'
|
||||
|
||||
const nestedQuery: Where = {
|
||||
'author.role': { equals: 'editor' },
|
||||
'meta.featured': { exists: true },
|
||||
}
|
||||
```
|
||||
|
||||
## Local API
|
||||
|
||||
```ts
|
||||
// Find documents
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
status: { equals: 'published' },
|
||||
'author.name': { contains: 'john' },
|
||||
},
|
||||
depth: 2,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
sort: '-createdAt',
|
||||
locale: 'en',
|
||||
select: {
|
||||
title: true,
|
||||
author: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Find by ID
|
||||
const post = await payload.findByID({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
// Create
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'New Post',
|
||||
status: 'draft',
|
||||
},
|
||||
})
|
||||
|
||||
// Update
|
||||
await payload.update({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
data: {
|
||||
status: 'published',
|
||||
},
|
||||
})
|
||||
|
||||
// Delete
|
||||
await payload.delete({
|
||||
collection: 'posts',
|
||||
id: '123',
|
||||
})
|
||||
|
||||
// Count
|
||||
const count = await payload.count({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
status: { equals: 'published' },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Threading req Parameter
|
||||
|
||||
When performing operations in hooks or nested operations, pass the `req` parameter to maintain transaction context:
|
||||
|
||||
```ts
|
||||
// ✅ CORRECT: Pass req for transaction safety
|
||||
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
|
||||
await req.payload.create({
|
||||
collection: 'audit-log',
|
||||
data: { action: 'created', docId: doc.id },
|
||||
req, // Maintains transaction atomicity
|
||||
})
|
||||
}
|
||||
|
||||
// ❌ WRONG: Missing req breaks transaction
|
||||
const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => {
|
||||
await req.payload.create({
|
||||
collection: 'audit-log',
|
||||
data: { action: 'created', docId: doc.id },
|
||||
// Missing req - runs in separate transaction
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This is critical for MongoDB replica sets and Postgres. See [ADAPTERS.md#threading-req-through-operations](ADAPTERS.md#threading-req-through-operations) for details.
|
||||
|
||||
### Access Control in Local API
|
||||
|
||||
**Important**: Local API bypasses access control by default (`overrideAccess: true`). When passing a `user` parameter, you must explicitly set `overrideAccess: false` to respect that user's permissions.
|
||||
|
||||
```ts
|
||||
// ❌ WRONG: User is passed but access control is bypassed
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
user: currentUser,
|
||||
// Missing: overrideAccess: false
|
||||
// Result: Operation runs with ADMIN privileges, ignoring user's permissions
|
||||
})
|
||||
|
||||
// ✅ CORRECT: Respects user's access control permissions
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
user: currentUser,
|
||||
overrideAccess: false, // Required to enforce access control
|
||||
// Result: User only sees posts they have permission to read
|
||||
})
|
||||
|
||||
// Administrative operation (intentionally bypass access control)
|
||||
const allPosts = await payload.find({
|
||||
collection: 'posts',
|
||||
// No user parameter
|
||||
// overrideAccess defaults to true
|
||||
// Result: Returns all posts regardless of access control
|
||||
})
|
||||
```
|
||||
|
||||
**When to use `overrideAccess: false`:**
|
||||
|
||||
- Performing operations on behalf of a user
|
||||
- Testing access control logic
|
||||
- API routes that should respect user permissions
|
||||
- Any operation where `user` parameter is provided
|
||||
|
||||
**When `overrideAccess: true` is appropriate:**
|
||||
|
||||
- Administrative operations (migrations, seeds, cron jobs)
|
||||
- Internal system operations
|
||||
- Operations explicitly intended to bypass access control
|
||||
|
||||
See [ACCESS-CONTROL.md#important-notes](ACCESS-CONTROL.md#important-notes) for more details.
|
||||
|
||||
## REST API
|
||||
|
||||
```ts
|
||||
import { stringify } from 'qs-esm'
|
||||
|
||||
const query = {
|
||||
status: { equals: 'published' },
|
||||
}
|
||||
|
||||
const queryString = stringify(
|
||||
{
|
||||
where: query,
|
||||
depth: 2,
|
||||
limit: 10,
|
||||
},
|
||||
{ addQueryPrefix: true },
|
||||
)
|
||||
|
||||
const response = await fetch(`https://api.example.com/api/posts${queryString}`)
|
||||
const data = await response.json()
|
||||
```
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
```txt
|
||||
GET /api/{collection} - Find documents
|
||||
GET /api/{collection}/{id} - Find by ID
|
||||
POST /api/{collection} - Create
|
||||
PATCH /api/{collection}/{id} - Update
|
||||
DELETE /api/{collection}/{id} - Delete
|
||||
GET /api/{collection}/count - Count documents
|
||||
|
||||
GET /api/globals/{slug} - Get global
|
||||
POST /api/globals/{slug} - Update global
|
||||
```
|
||||
|
||||
## GraphQL
|
||||
|
||||
```graphql
|
||||
query {
|
||||
Posts(where: { status: { equals: published } }, limit: 10, sort: "-createdAt") {
|
||||
docs {
|
||||
id
|
||||
title
|
||||
author {
|
||||
name
|
||||
}
|
||||
}
|
||||
totalDocs
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
|
||||
mutation {
|
||||
createPost(data: { title: "New Post", status: draft }) {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
mutation {
|
||||
updatePost(id: "123", data: { status: published }) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
mutation {
|
||||
deletePost(id: "123") {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
- Set `maxDepth` on relationships to prevent over-fetching
|
||||
- Use `select` to limit returned fields
|
||||
- Index frequently queried fields
|
||||
- Use `virtual` fields for computed data
|
||||
- Cache expensive operations in hook `context`
|
||||
1
apps/cms/.yarnrc
Normal file
1
apps/cms/.yarnrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
--install.ignore-engines true
|
||||
71
apps/cms/Dockerfile
Normal file
71
apps/cms/Dockerfile
Normal 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.17.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
|
||||
67
apps/cms/README.md
Normal file
67
apps/cms/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Payload Blank Template
|
||||
|
||||
This template comes configured with the bare minimum to get started on anything you need.
|
||||
|
||||
## Quick start
|
||||
|
||||
This template can be deployed directly from our Cloud hosting and it will setup MongoDB and cloud S3 object storage for media.
|
||||
|
||||
## Quick Start - local setup
|
||||
|
||||
To spin up this template locally, follow these steps:
|
||||
|
||||
### Clone
|
||||
|
||||
After you click the `Deploy` button above, you'll want to have standalone copy of this repo on your machine. If you've already cloned this repo, skip to [Development](#development).
|
||||
|
||||
### Development
|
||||
|
||||
1. First [clone the repo](#clone) if you have not done so already
|
||||
2. `cd my-project && cp .env.example .env` to copy the example environment variables. You'll need to add the `MONGODB_URL` from your Cloud project to your `.env` if you want to use S3 storage and the MongoDB database that was created for you.
|
||||
|
||||
3. `pnpm install && pnpm dev` to install dependencies and start the dev server
|
||||
4. open `http://localhost:3000` to open the app in your browser
|
||||
|
||||
That's it! Changes made in `./src` will be reflected in your app. Follow the on-screen instructions to login and create your first admin user. Then check out [Production](#production) once you're ready to build and serve your app, and [Deployment](#deployment) when you're ready to go live.
|
||||
|
||||
#### Docker (Optional)
|
||||
|
||||
If you prefer to use Docker for local development instead of a local MongoDB instance, the provided docker-compose.yml file can be used.
|
||||
|
||||
To do so, follow these steps:
|
||||
|
||||
- Modify the `MONGODB_URL` in your `.env` file to `mongodb://127.0.0.1/<dbname>`
|
||||
- Modify the `docker-compose.yml` file's `MONGODB_URL` to match the above `<dbname>`
|
||||
- Run `docker-compose up` to start the database, optionally pass `-d` to run in the background.
|
||||
|
||||
## How it works
|
||||
|
||||
The Payload config is tailored specifically to the needs of most websites. It is pre-configured in the following ways:
|
||||
|
||||
### Collections
|
||||
|
||||
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend this functionality.
|
||||
|
||||
- #### Users (Authentication)
|
||||
|
||||
Users are auth-enabled collections that have access to the admin panel.
|
||||
|
||||
For additional help, see the official [Auth Example](https://github.com/payloadcms/payload/tree/3.x/examples/auth) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
|
||||
|
||||
- #### Media
|
||||
|
||||
This is the uploads enabled collection. It features pre-configured sizes, focal point and manual resizing to help you manage your pictures.
|
||||
|
||||
### Docker
|
||||
|
||||
Alternatively, you can use [Docker](https://www.docker.com) to spin up this template locally. To do so, follow these steps:
|
||||
|
||||
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
|
||||
1. Next run `docker-compose up`
|
||||
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
|
||||
|
||||
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
|
||||
|
||||
## Questions
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
43
apps/cms/docker-compose.yml
Normal file
43
apps/cms/docker-compose.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
payload:
|
||||
image: node:20-alpine
|
||||
ports:
|
||||
- '3000:3000'
|
||||
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:
|
||||
- mongo
|
||||
# - postgres
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
# Ensure your DATABASE_URL uses 'mongo' as the hostname ie. mongodb://mongo/my-db-name
|
||||
mongo:
|
||||
image: mongo:latest
|
||||
ports:
|
||||
- '27017:27017'
|
||||
command:
|
||||
- --storageEngine=wiredTiger
|
||||
volumes:
|
||||
- data:/data/db
|
||||
logging:
|
||||
driver: none
|
||||
|
||||
# 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:
|
||||
38
apps/cms/eslint.config.mjs
Normal file
38
apps/cms/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'after-used',
|
||||
ignoreRestSiblings: false,
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^(_|ignore)',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['.next/', 'src/payload-types.ts', 'src/payload-generated-schema.ts'],
|
||||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
30
apps/cms/next.config.mjs
Normal file
30
apps/cms/next.config.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
localPatterns: [
|
||||
{
|
||||
pathname: '/api/media/file/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
}
|
||||
return webpackConfig
|
||||
},
|
||||
turbopack: {
|
||||
root: path.resolve(__dirname, '../..'), // ← monorepo root
|
||||
},
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig, { devBundleServerPackages: false })
|
||||
53
apps/cms/package.json
Normal file
53
apps/cms/package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "cms",
|
||||
"version": "1.0.0",
|
||||
"description": "A blank template to get started with Payload 3.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"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 eslint .",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"test": "bun run test:int && bun run test:e2e",
|
||||
"test:e2e": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" playwright test --config=playwright.config.ts",
|
||||
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@donauschwaben/types": "workspace:*",
|
||||
"@payloadcms/db-postgres": "3.84.1",
|
||||
"@payloadcms/next": "3.84.1",
|
||||
"@payloadcms/richtext-lexical": "3.84.1",
|
||||
"@payloadcms/translations": "^3.84.1",
|
||||
"@payloadcms/ui": "3.84.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"graphql": "^16.8.1",
|
||||
"next": "16.2.3",
|
||||
"payload": "3.84.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sharp": "0.34.2",
|
||||
"slugify": "^1.6.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@types/node": "22.19.9",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.5.2",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"jsdom": "28.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.7.3",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
41
apps/cms/playwright.config.ts
Normal file
41
apps/cms/playwright.config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
import 'dotenv/config'
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
reuseExistingServer: true,
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
})
|
||||
4
apps/cms/src/access/index.ts
Normal file
4
apps/cms/src/access/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import type { Access } from 'payload'
|
||||
|
||||
export const publicRead: Access = () => true
|
||||
export const isLoggedIn: Access = ({ req }) => Boolean(req.user)
|
||||
19
apps/cms/src/app/(frontend)/layout.tsx
Normal file
19
apps/cms/src/app/(frontend)/layout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react'
|
||||
import './styles.css'
|
||||
|
||||
export const metadata = {
|
||||
description: 'A blank template using Payload in a Next.js app.',
|
||||
title: 'Payload Blank Template',
|
||||
}
|
||||
|
||||
export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
59
apps/cms/src/app/(frontend)/page.tsx
Normal file
59
apps/cms/src/app/(frontend)/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { headers as getHeaders } from 'next/headers.js'
|
||||
import Image from 'next/image'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import config from '@/payload.config'
|
||||
import './styles.css'
|
||||
|
||||
export default async function HomePage() {
|
||||
const headers = await getHeaders()
|
||||
const payloadConfig = await config
|
||||
const payload = await getPayload({ config: payloadConfig })
|
||||
const { user } = await payload.auth({ headers })
|
||||
|
||||
const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}`
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<div className="content">
|
||||
<picture>
|
||||
<source srcSet="https://raw.githubusercontent.com/payloadcms/payload/3.x/packages/ui/src/assets/payload-favicon.svg" />
|
||||
<Image
|
||||
alt="Payload Logo"
|
||||
height={65}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/3.x/packages/ui/src/assets/payload-favicon.svg"
|
||||
width={65}
|
||||
/>
|
||||
</picture>
|
||||
{!user && <h1>Welcome to your new project.</h1>}
|
||||
{user && <h1>Welcome back, {user.email}</h1>}
|
||||
<div className="links">
|
||||
<a
|
||||
className="admin"
|
||||
href={payloadConfig.routes.admin}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Go to admin panel
|
||||
</a>
|
||||
<a
|
||||
className="docs"
|
||||
href="https://payloadcms.com/docs"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer">
|
||||
<p>Update this page by editing</p>
|
||||
<a className="codeLink" href={fileURL}>
|
||||
<code>app/(frontend)/page.tsx</code>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
apps/cms/src/app/(frontend)/styles.css
Normal file
164
apps/cms/src/app/(frontend)/styles.css
Normal 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
|
||||
24
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
24
apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
|
|
@ -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
|
||||
52
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
52
apps/cms/src/app/(payload)/admin/importMap.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_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 { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"@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#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
19
apps/cms/src/app/(payload)/api/[...slug]/route.ts
Normal file
|
|
@ -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)
|
||||
8
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
8
apps/cms/src/app/(payload)/api/graphql/route.ts
Normal file
|
|
@ -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)
|
||||
0
apps/cms/src/app/(payload)/custom.scss
Normal file
0
apps/cms/src/app/(payload)/custom.scss
Normal file
31
apps/cms/src/app/(payload)/layout.tsx
Normal file
31
apps/cms/src/app/(payload)/layout.tsx
Normal 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
|
||||
12
apps/cms/src/app/my-route/route.ts
Normal file
12
apps/cms/src/app/my-route/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
message: 'This is an example of a custom route.',
|
||||
})
|
||||
}
|
||||
398
apps/cms/src/collections/Archiv.ts
Normal file
398
apps/cms/src/collections/Archiv.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import slugify from 'slugify'
|
||||
|
||||
export const Archiv: CollectionConfig = {
|
||||
slug: 'archiv',
|
||||
labels: {
|
||||
singular: 'Quelle',
|
||||
plural: 'Quellen',
|
||||
},
|
||||
|
||||
admin: {
|
||||
useAsTitle: 'displayTitle',
|
||||
defaultColumns: ['displayTitle', 'year', 'type', 'digitizationStatus'],
|
||||
description: 'Archiv aller Quellen und Literatur — verknüpfbar mit Artikeln.',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
delete: ({ req }) => req.user?.role === 'admin',
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data, req }) => {
|
||||
const locale = req.locale || 'de'
|
||||
if (data?.title && !data?.slug) {
|
||||
let raw = slugify(data.title as string, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale,
|
||||
})
|
||||
data.slug = raw.replace(/^\{.*?"([^"]+)"\}$/, '$1')
|
||||
}
|
||||
return data
|
||||
},
|
||||
],
|
||||
beforeChange: [
|
||||
({ data }) => {
|
||||
const firstAuthor = data?.authors?.[0]
|
||||
const authorStr = firstAuthor
|
||||
? `${firstAuthor.lastName}${firstAuthor.firstName ? ', ' + firstAuthor.firstName : ''}`
|
||||
: null
|
||||
const yearStr = data?.year ? `(${data.year})` : null
|
||||
data.displayTitle = [authorStr, yearStr, data?.title]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
// ── Bibliographic metadata ────────────────────────────────────────
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Quellentyp',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'book',
|
||||
admin: { position: 'sidebar' },
|
||||
options: [
|
||||
{ label: 'Buch', value: 'book' },
|
||||
{ label: 'Buchkapitel', value: 'chapter' },
|
||||
{ label: 'Zeitschriftenartikel', value: 'journal' },
|
||||
{ label: 'Dissertation', value: 'thesis' },
|
||||
{ label: 'Konferenzbeitrag', value: 'conference' },
|
||||
{ label: 'Webseite', value: 'website' },
|
||||
{ label: 'Archivdokument', value: 'archive' },
|
||||
{ label: 'Sonstiges', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'digitizationStatus',
|
||||
label: 'Digitalisierungsstatus',
|
||||
type: 'select',
|
||||
defaultValue: 'unknown',
|
||||
admin: { position: 'sidebar' },
|
||||
options: [
|
||||
{ label: 'Unbekannt', value: 'unknown' },
|
||||
{ label: 'Nicht digitalisiert', value: 'none' },
|
||||
{ label: 'Teilweise verfügbar', value: 'partial' },
|
||||
{ label: 'Vollständig verfügbar', value: 'full' },
|
||||
{ label: 'Verloren / vernichtet', value: 'lost' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'accessRestriction',
|
||||
label: 'Zugangsbeschränkung',
|
||||
type: 'select',
|
||||
defaultValue: 'open',
|
||||
admin: { position: 'sidebar' },
|
||||
options: [
|
||||
{ label: 'Öffentlich zugänglich', value: 'open' },
|
||||
{ label: 'Eingeschränkt', value: 'restricted' },
|
||||
{ label: 'Nur vor Ort', value: 'onsite' },
|
||||
{ label: 'Nicht zugänglich', value: 'closed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
label: 'URL-Slug',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Wird automatisch generiert.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'displayTitle',
|
||||
label: 'Anzeigetitel',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Titel',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'authors',
|
||||
label: 'Autor(en)',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Mehrere Autoren in der gewünschten Reihenfolge hinzufügen.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Nachname',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'Vorname',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'editors',
|
||||
label: 'Herausgeber(in)',
|
||||
type: 'array',
|
||||
admin: {
|
||||
condition: (data) => ['book', 'chapter', 'conference'].includes(data?.type),
|
||||
description: 'Mehrere Herausgeber in der gewünschten Reihenfolge hinzufügen.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Nachname',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'Vorname',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
label: 'Erscheinungsjahr',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
label: 'Sprache',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Deutsch', value: 'de' },
|
||||
{ label: 'Englisch', value: 'en' },
|
||||
{ label: 'Ungarisch', value: 'hu' },
|
||||
{ label: 'Französisch', value: 'fr' },
|
||||
{ label: 'Latein', value: 'la' },
|
||||
{ label: 'Sonstiges', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'abstract',
|
||||
label: 'Abstract',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
condition: (data) => ['journal', 'thesis', 'conference'].includes(data?.type),
|
||||
description: 'Kurze inhaltliche Zusammenfassung.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Schlagwörter',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Thematische Schlagwörter für die Archivsuche, z.B. "Siedlungsgeschichte", "Pécs", "Sprache".',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
label: 'Schlagwort',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
// ── Book ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'isbn',
|
||||
label: 'ISBN',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => ['book', 'chapter'].includes(data?.type),
|
||||
},
|
||||
},
|
||||
// ── Book / Chapter / Thesis / Conference ──────────────────────────
|
||||
{
|
||||
name: 'publisher',
|
||||
label: 'Verlag',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) =>
|
||||
['book', 'chapter', 'thesis', 'conference'].includes(data?.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'place',
|
||||
label: 'Erscheinungsort',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) =>
|
||||
['book', 'chapter', 'thesis', 'conference'].includes(data?.type),
|
||||
},
|
||||
},
|
||||
// ── Chapter ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'bookTitle',
|
||||
label: 'Buchtitel (Sammelband)',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'chapter',
|
||||
},
|
||||
},
|
||||
// ── Journal ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'journal',
|
||||
label: 'Zeitschrift',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'journal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'volume',
|
||||
label: 'Band',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'journal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'issue',
|
||||
label: 'Heft',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'journal',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'issn',
|
||||
label: 'ISSN',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'journal',
|
||||
},
|
||||
},
|
||||
// ── Thesis ────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'institution',
|
||||
label: 'Institution / Universität',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'thesis',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'thesisType',
|
||||
label: 'Art der Arbeit',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Dissertation', value: 'phd' },
|
||||
{ label: 'Masterarbeit', value: 'master' },
|
||||
{ label: 'Bachelorarbeit', value: 'bachelor' },
|
||||
{ label: 'Habilitationsschrift', value: 'habil' },
|
||||
],
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'thesis',
|
||||
},
|
||||
},
|
||||
// ── Series (Book / Thesis) ─────────────────────────────────────────
|
||||
{
|
||||
name: 'series',
|
||||
label: 'Schriftenreihe',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => ['thesis', 'book'].includes(data?.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'seriesNumber',
|
||||
label: 'Nummer in der Reihe',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => ['thesis', 'book'].includes(data?.type),
|
||||
},
|
||||
},
|
||||
// ── Archive document ──────────────────────────────────────────────
|
||||
{
|
||||
name: 'archiveCollection',
|
||||
label: 'Archiv / Bestand',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => data?.type === 'archive',
|
||||
description: 'z.B. "Stadtarchiv Pécs, Grundbuch 1723"',
|
||||
},
|
||||
},
|
||||
// ── Shared ────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'pages',
|
||||
label: 'Seiten',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) =>
|
||||
['chapter', 'journal', 'conference'].includes(data?.type),
|
||||
description: 'z.B. "45–67"',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'shelfMark',
|
||||
label: 'Signatur',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Archiv- oder Bibliothekssignatur.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'physicalLocation',
|
||||
label: 'Physischer Standort',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Name des Archivs oder der Bibliothek.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'doi',
|
||||
label: 'DOI',
|
||||
type: 'text',
|
||||
admin: {
|
||||
condition: (data) => ['journal', 'conference'].includes(data?.type),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'externalUrl',
|
||||
label: 'Externe URL',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Link zu Digitalia, Archiv-Portal, Google Books etc.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'accessed',
|
||||
label: 'Abgerufen am',
|
||||
type: 'date',
|
||||
admin: {
|
||||
condition: (data) => Boolean(data?.externalUrl),
|
||||
date: { pickerAppearance: 'dayOnly' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'file',
|
||||
label: 'Datei (PDF / Scan)',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
description: 'Digitalisat direkt hochladen, falls vorhanden.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
label: 'Anmerkung',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
}
|
||||
210
apps/cms/src/collections/Artikel.ts
Normal file
210
apps/cms/src/collections/Artikel.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import slugify from 'slugify'
|
||||
|
||||
export const Artikel: CollectionConfig = {
|
||||
slug: 'artikel',
|
||||
labels: {
|
||||
singular: 'Artikel',
|
||||
plural: 'Artikel',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'author', 'publishedAt', '_status'],
|
||||
description: 'Wissenschaftliche und halbwissenschaftliche Beiträge.',
|
||||
},
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => ['admin', 'editor', 'author'].includes(req.user?.role ?? ''),
|
||||
update: ({ req }) => ['admin', 'editor', 'author'].includes(req.user?.role ?? ''),
|
||||
delete: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data, req }) => {
|
||||
if (!data) return data
|
||||
const locale = req.locale || 'de'
|
||||
const title = typeof data?.title === 'object' ? data.title?.[locale] : data?.title
|
||||
if (title && !data?.slug) {
|
||||
let raw = slugify(title as string, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale,
|
||||
})
|
||||
data.slug = raw.replace(/^\{.*?"([^"]+)"\}$/, '$1')
|
||||
}
|
||||
if (!data.meta) data.meta = {}
|
||||
if (!data.meta.title) data.meta.title = data.title
|
||||
if (!data.meta.description && data.excerpt) data.meta.description = data.excerpt
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Titel',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
label: 'URL-Slug',
|
||||
type: 'text',
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Wird automatisch aus dem Titel generiert.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
label: 'Abstract',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'Kurze wissenschaftliche Zusammenfassung.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'featuredImage',
|
||||
label: 'Titelbild',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
label: 'Autor',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ value, req }) => {
|
||||
if (!value && req.user) return req.user.id
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Schlagwörter',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Thematische Schlagwörter für die Filterung, z.B. "Siedlungsgeschichte", "Pécs".',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
label: 'Schlagwort',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
label: 'Veröffentlichungsdatum',
|
||||
type: 'date',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
date: { pickerAppearance: 'dayAndTime' },
|
||||
description: 'Wird automatisch gesetzt wenn der Artikel veröffentlicht wird.',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ value, siblingData }) => {
|
||||
if (!value && siblingData?._status === 'published') {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
label: 'Inhalt',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
localized: true,
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => defaultFeatures,
|
||||
}),
|
||||
},
|
||||
// ── References ────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'references',
|
||||
label: 'Quellen & Literatur',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Quellen aus dem Archiv auswählen. Werden am Ende des Artikels als Literaturverzeichnis dargestellt.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'source',
|
||||
label: 'Quelle',
|
||||
type: 'relationship',
|
||||
relationTo: 'archiv',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'pages',
|
||||
label: 'Seiten (spezifisch)',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Optional: spezifische Seiten für diesen Verweis, z.B. "45–67"',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'note',
|
||||
label: 'Anmerkung',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Optionale Anmerkung zu dieser spezifischen Verwendung der Quelle.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'meta',
|
||||
label: 'SEO (automatisch)',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Wird automatisch aus Titel und Abstract befüllt.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'SEO-Titel',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Meta-Beschreibung',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
48
apps/cms/src/collections/KulturSeiten.ts
Normal file
48
apps/cms/src/collections/KulturSeiten.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const KulturSeiten: CollectionConfig = {
|
||||
slug: 'kultur-seiten',
|
||||
labels: {
|
||||
singular: 'Kulturseite',
|
||||
plural: 'Kulturseiten',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'titel',
|
||||
group: 'Donauschwaben',
|
||||
defaultColumns: ['titel', 'kategorie', 'updatedAt'],
|
||||
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
delete: ({ req }) => req.user?.role === 'admin',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'titel',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
required: true,
|
||||
label: { de: 'Titel', en: 'Title', hu: 'Cím' },
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'inhalt',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
label: { de: 'Inhalt', en: 'Content', hu: 'Tartalom' },
|
||||
},
|
||||
{
|
||||
name: 'bild',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
label: { de: 'Headerbild', en: 'Header Image', hu: 'Fejléckép' },
|
||||
},
|
||||
],
|
||||
}
|
||||
22
apps/cms/src/collections/Media.ts
Normal file
22
apps/cms/src/collections/Media.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
admin: {
|
||||
group: 'System'
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
upload: true,
|
||||
}
|
||||
173
apps/cms/src/collections/Neuigkeiten.ts
Normal file
173
apps/cms/src/collections/Neuigkeiten.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import slugify from 'slugify'
|
||||
|
||||
export const Neuigkeiten: CollectionConfig = {
|
||||
labels: {
|
||||
singular: 'Beitrag',
|
||||
plural: 'Beiträge',
|
||||
},
|
||||
slug: 'neuigkeiten',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'author', 'publishedAt', '_status'],
|
||||
description: 'Nachrichtenartikel und Beiträge',
|
||||
},
|
||||
// Draft/publish workflow
|
||||
versions: {
|
||||
drafts: {
|
||||
autosave: {
|
||||
interval: 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Restrict writes to logged-in users, reads are public
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => ['admin', 'editor', 'author'].includes(req.user?.role ?? ''),
|
||||
update: ({ req }) => ['admin', 'editor', 'author'].includes(req.user?.role ?? ''),
|
||||
delete: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
hooks: {
|
||||
beforeValidate: [
|
||||
({ data, req }) => {
|
||||
if (!data) return data
|
||||
const locale = req.locale || 'de'
|
||||
const title = typeof data.title === 'object' ? data.title?.[locale] : data.title
|
||||
|
||||
if (title && !data?.slug) {
|
||||
const raw = slugify(title as string, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
locale,
|
||||
})
|
||||
// Strip any JSON wrapper if slugify somehow returns {"xx":"..."}
|
||||
data.slug = raw.replace(/^\{.*?"([^"]+)"\}$/, '$1')
|
||||
}
|
||||
return data
|
||||
}
|
||||
]
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Wird automatisch generiert — nur bei Bedarf manuell anpassen.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'Kurze Zusammenfassung für Listenansichten.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'featuredImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ value, siblingData, req }) => {
|
||||
// Auto-assign author on create
|
||||
if (!value && req.user) return req.user.id
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
label: 'Schlagwörter',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Thematische Schlagwörter für die Filterung, z.B. "Siedlungsgeschichte", "Pécs".',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
label: 'Schlagwort',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
},
|
||||
description: 'Leer lassen für sofortige Veröffentlichung.',
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ value, siblingData }) => {
|
||||
// Auto-set publishedAt when status changes to published
|
||||
if (!value && siblingData?._status === 'published') {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
return value
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
localized: true,
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => defaultFeatures,
|
||||
}),
|
||||
},
|
||||
// Auto-generated SEO — writers don't touch this
|
||||
{
|
||||
name: 'meta',
|
||||
type: 'group',
|
||||
admin: {
|
||||
description: 'Wird automatisch befüllt — kein manueller Aufwand nötig.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
88
apps/cms/src/collections/Persoenlichkeiten.ts
Normal file
88
apps/cms/src/collections/Persoenlichkeiten.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { publicRead, isLoggedIn } from '@/access'
|
||||
|
||||
export const Persoenlichkeiten: CollectionConfig = {
|
||||
slug: 'persoenlichkeiten',
|
||||
access: {
|
||||
read: () => true,
|
||||
create: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
delete: ({ req }) => req.user?.role === 'admin',
|
||||
},
|
||||
labels: {
|
||||
singular: 'Persönlichkeit',
|
||||
plural: 'Persönlichkeiten',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'kategorie', 'updatedAt'],
|
||||
group: 'Donauschwaben',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'URL-freundlicher Name, z.B. nikolaus-lenau',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'kategorie',
|
||||
type: 'select',
|
||||
required: true,
|
||||
localized: false,
|
||||
options: [
|
||||
{ label: 'Autor:in', value: 'autor' },
|
||||
{ label: 'Politiker:in', value: 'politiker' },
|
||||
{ label: 'Denker:in', value: 'denker' },
|
||||
{ label: 'Architekt:in', value: 'architekt' },
|
||||
{ label: 'Sonstige', value: 'sonstige' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bild',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
localized: false,
|
||||
},
|
||||
{
|
||||
name: 'kurzbiografie',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
admin: {
|
||||
description: 'Kurze Beschreibung für Listenansicht (max. 300 Zeichen)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'biografie',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'geboren',
|
||||
type: 'date',
|
||||
localized: false,
|
||||
},
|
||||
{
|
||||
name: 'gestorben',
|
||||
type: 'date',
|
||||
localized: false,
|
||||
admin: {
|
||||
description: 'Leer lassen wenn noch lebend',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'geburtsort',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
54
apps/cms/src/collections/Users.ts
Normal file
54
apps/cms/src/collections/Users.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
labels: {
|
||||
singular: 'Benutzer',
|
||||
plural: 'Benutzer',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
group: 'System',
|
||||
defaultColumns: ['email', 'firstName', 'lastName', 'role'],
|
||||
},
|
||||
auth: true,
|
||||
access: {
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
create: ({ req }) => req.user?.role === 'admin',
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: ({ req }) => req.user?.role === 'admin',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'Vorname',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Nachname',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Rolle',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'author',
|
||||
options: [
|
||||
{ label: 'Administrator', value: 'admin' },
|
||||
{ label: 'Redakteur', value: 'editor' },
|
||||
{ label: 'Autor', value: 'author' },
|
||||
],
|
||||
access: {
|
||||
// only admins can change roles
|
||||
update: ({ req }) => req.user?.role === 'admin',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
61
apps/cms/src/globals/Geschichte.ts
Normal file
61
apps/cms/src/globals/Geschichte.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Geschichte: GlobalConfig = {
|
||||
slug: 'geschichte',
|
||||
label: {
|
||||
de: 'Geschichte',
|
||||
en: 'History',
|
||||
hu: 'Történelem',
|
||||
},
|
||||
admin: {
|
||||
group: 'Donauschwaben',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'titel',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Titel',
|
||||
en: 'Title',
|
||||
hu: 'Cím',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'einleitung',
|
||||
type: 'textarea',
|
||||
localized: true,
|
||||
label: {
|
||||
de: 'Einleitung',
|
||||
en: 'Introduction',
|
||||
hu: 'Bevezetés',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'inhalt',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
label: {
|
||||
de: 'Inhalt',
|
||||
en: 'Content',
|
||||
hu: 'Tartalom',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bild',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
localized: false,
|
||||
label: {
|
||||
de: 'Headerbild',
|
||||
en: 'Header Image',
|
||||
hu: 'Fejléckép',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
25
apps/cms/src/globals/Kultur.ts
Normal file
25
apps/cms/src/globals/Kultur.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Kultur: GlobalConfig = {
|
||||
slug: 'kultur',
|
||||
label: { de: 'Kultur', en: 'Culture', hu: 'Kultúra' },
|
||||
admin: { group: 'Donauschwaben', },
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'titel',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
label: { de: 'Titel', en: 'Title', hu: 'Cím' },
|
||||
},
|
||||
{
|
||||
name: 'einleitung',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
label: { de: 'Einleitung', en: 'Introduction', hu: 'Bevezetés' },
|
||||
},
|
||||
],
|
||||
}
|
||||
25
apps/cms/src/globals/Politik.ts
Normal file
25
apps/cms/src/globals/Politik.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Politik: GlobalConfig = {
|
||||
slug: 'politik',
|
||||
label: { de: 'Politik', en: 'Politics', hu: 'Politika' },
|
||||
admin: { group: 'Donauschwaben', },
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'titel',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
label: { de: 'Titel', en: 'Title', hu: 'Cím' },
|
||||
},
|
||||
{
|
||||
name: 'einleitung',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
label: { de: 'Einleitung', en: 'Introduction', hu: 'Bevezetés' },
|
||||
},
|
||||
],
|
||||
}
|
||||
25
apps/cms/src/globals/Sprache.ts
Normal file
25
apps/cms/src/globals/Sprache.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const Sprache: GlobalConfig = {
|
||||
slug: 'sprache',
|
||||
label: { de: 'Sprache', en: 'Language', hu: 'Nyelv' },
|
||||
admin: { group: 'Donauschwaben', },
|
||||
access: {
|
||||
read: () => true,
|
||||
update: ({ req }) => ['admin', 'editor'].includes(req.user?.role ?? ''),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'titel',
|
||||
type: 'text',
|
||||
localized: true,
|
||||
label: { de: 'Titel', en: 'Title', hu: 'Cím' },
|
||||
},
|
||||
{
|
||||
name: 'einleitung',
|
||||
type: 'richText',
|
||||
localized: true,
|
||||
label: { de: 'Einleitung', en: 'Introduction', hu: 'Bevezetés' },
|
||||
},
|
||||
],
|
||||
}
|
||||
84
apps/cms/src/payload.config.ts
Normal file
84
apps/cms/src/payload.config.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { de } from '@payloadcms/translations/languages/de'
|
||||
import { en } from '@payloadcms/translations/languages/en'
|
||||
import { hu } from '@payloadcms/translations/languages/hu'
|
||||
|
||||
import { Users } from './collections/Users'
|
||||
import { Media } from './collections/Media'
|
||||
import {Persoenlichkeiten} from "@/collections/Persoenlichkeiten";
|
||||
import {Geschichte} from "@/globals/Geschichte";
|
||||
import {KulturSeiten} from "@/collections/KulturSeiten";
|
||||
import {Politik} from "@/globals/Politik";
|
||||
import {Kultur} from "@/globals/Kultur";
|
||||
import {Sprache} from "@/globals/Sprache";
|
||||
import {Neuigkeiten} from "@/collections/Neuigkeiten";
|
||||
import {Artikel} from "@/collections/Artikel";
|
||||
import {Archiv} from "@/collections/Archiv";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: 'http://localhost:3000',
|
||||
cors: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'https://donauschwaben.online',
|
||||
],
|
||||
csrf: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'https://donauschwaben.online',
|
||||
],
|
||||
localization: {
|
||||
locales: [
|
||||
{ label: 'Deutsch', code: 'de' },
|
||||
{ label: 'English', code: 'en' },
|
||||
{ label: 'Magyar', code: 'hu' },
|
||||
],
|
||||
defaultLocale: 'de',
|
||||
fallback: true,
|
||||
},
|
||||
i18n: {
|
||||
supportedLanguages: { de, en, hu },
|
||||
},
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Persoenlichkeiten,
|
||||
KulturSeiten,
|
||||
Neuigkeiten,
|
||||
Artikel,
|
||||
Archiv
|
||||
],
|
||||
globals: [
|
||||
Geschichte,
|
||||
Kultur,
|
||||
Sprache,
|
||||
Politik,
|
||||
],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URL || '',
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
})
|
||||
1
apps/cms/test.env
Normal file
1
apps/cms/test.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
NODE_OPTIONS="--no-deprecation --no-experimental-strip-types"
|
||||
41
apps/cms/tests/e2e/admin.e2e.spec.ts
Normal file
41
apps/cms/tests/e2e/admin.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { test, expect, Page } from '@playwright/test'
|
||||
import { login } from '../helpers/login'
|
||||
import { seedTestUser, cleanupTestUser, testUser } from '../helpers/seedUser'
|
||||
|
||||
test.describe('Admin Panel', () => {
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
await seedTestUser()
|
||||
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
|
||||
await login({ page, user: testUser })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupTestUser()
|
||||
})
|
||||
|
||||
test('can navigate to dashboard', async () => {
|
||||
await page.goto('http://localhost:3000/admin')
|
||||
await expect(page).toHaveURL('http://localhost:3000/admin')
|
||||
const dashboardArtifact = page.locator('span[title="Dashboard"]').first()
|
||||
await expect(dashboardArtifact).toBeVisible()
|
||||
})
|
||||
|
||||
test('can navigate to list view', async () => {
|
||||
await page.goto('http://localhost:3000/admin/collections/users')
|
||||
await expect(page).toHaveURL('http://localhost:3000/admin/collections/users')
|
||||
const listViewArtifact = page.locator('h1', { hasText: 'Users' }).first()
|
||||
await expect(listViewArtifact).toBeVisible()
|
||||
})
|
||||
|
||||
test('can navigate to edit view', async () => {
|
||||
await page.goto('http://localhost:3000/admin/collections/users/create')
|
||||
await expect(page).toHaveURL(/\/admin\/collections\/users\/[a-zA-Z0-9-_]+/)
|
||||
const editViewArtifact = page.locator('input[name="email"]')
|
||||
await expect(editViewArtifact).toBeVisible()
|
||||
})
|
||||
})
|
||||
20
apps/cms/tests/e2e/frontend.e2e.spec.ts
Normal file
20
apps/cms/tests/e2e/frontend.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { test, expect, Page } from '@playwright/test'
|
||||
|
||||
test.describe('Frontend', () => {
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
})
|
||||
|
||||
test('can go on homepage', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000')
|
||||
|
||||
await expect(page).toHaveTitle(/Payload Blank Template/)
|
||||
|
||||
const heading = page.locator('h1').first()
|
||||
|
||||
await expect(heading).toHaveText('Welcome to your new project.')
|
||||
})
|
||||
})
|
||||
31
apps/cms/tests/helpers/login.ts
Normal file
31
apps/cms/tests/helpers/login.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
export interface LoginOptions {
|
||||
page: Page
|
||||
serverURL?: string
|
||||
user: {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the user into the admin panel via the login page.
|
||||
*/
|
||||
export async function login({
|
||||
page,
|
||||
serverURL = 'http://localhost:3000',
|
||||
user,
|
||||
}: LoginOptions): Promise<void> {
|
||||
await page.goto(`${serverURL}/admin/login`)
|
||||
|
||||
await page.fill('#field-email', user.email)
|
||||
await page.fill('#field-password', user.password)
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
await page.waitForURL(`${serverURL}/admin`)
|
||||
|
||||
const dashboardArtifact = page.locator('span[title="Dashboard"]')
|
||||
await expect(dashboardArtifact).toBeVisible()
|
||||
}
|
||||
46
apps/cms/tests/helpers/seedUser.ts
Normal file
46
apps/cms/tests/helpers/seedUser.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { getPayload } from 'payload'
|
||||
import config from '../../src/payload.config.js'
|
||||
|
||||
export const testUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a test user for e2e admin tests.
|
||||
*/
|
||||
export async function seedTestUser(): Promise<void> {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// Delete existing test user if any
|
||||
await payload.delete({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: testUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create fresh test user
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: testUser,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up test user after tests
|
||||
*/
|
||||
export async function cleanupTestUser(): Promise<void> {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
await payload.delete({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: testUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
20
apps/cms/tests/int/api.int.spec.ts
Normal file
20
apps/cms/tests/int/api.int.spec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { getPayload, Payload } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
|
||||
import { describe, it, beforeAll, expect } from 'vitest'
|
||||
|
||||
let payload: Payload
|
||||
|
||||
describe('API', () => {
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config
|
||||
payload = await getPayload({ config: payloadConfig })
|
||||
})
|
||||
|
||||
it('fetches users', async () => {
|
||||
const users = await payload.find({
|
||||
collection: 'users',
|
||||
})
|
||||
expect(users).toBeDefined()
|
||||
})
|
||||
})
|
||||
45
apps/cms/tsconfig.json
Normal file
45
apps/cms/tsconfig.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./src/payload.config.ts"
|
||||
]
|
||||
},
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
12
apps/cms/vitest.config.mts
Normal file
12
apps/cms/vitest.config.mts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths(), react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['tests/int/**/*.int.spec.ts'],
|
||||
},
|
||||
})
|
||||
4
apps/cms/vitest.setup.ts
Normal file
4
apps/cms/vitest.setup.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Any setup scripts you might need go here
|
||||
|
||||
// Load .env files
|
||||
import 'dotenv/config'
|
||||
26
apps/web/.gitignore
vendored
Normal file
26
apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
# Paraglide
|
||||
src/lib/paraglide
|
||||
project.inlang/cache/
|
||||
1
apps/web/.npmrc
Normal file
1
apps/web/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
66
apps/web/.vibe/skills/svelte-code-writer/SKILL.md
Normal file
66
apps/web/.vibe/skills/svelte-code-writer/SKILL.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
---
|
||||
name: svelte-code-writer
|
||||
description: CLI tools for Svelte 5 documentation lookup and code analysis. MUST be used whenever creating, editing or analyzing any Svelte component (.svelte) or Svelte module (.svelte.ts/.svelte.js). If possible, this skill should be executed within the svelte-file-editor agent for optimal results.
|
||||
---
|
||||
|
||||
# Svelte 5 Code Writer
|
||||
|
||||
## CLI Tools
|
||||
|
||||
You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`:
|
||||
|
||||
### List Documentation Sections
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp list-sections
|
||||
```
|
||||
|
||||
Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths.
|
||||
|
||||
### Get Documentation
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp get-documentation "<section1>,<section2>,..."
|
||||
```
|
||||
|
||||
Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp get-documentation "$state,$derived,$effect"
|
||||
```
|
||||
|
||||
### Svelte Autofixer
|
||||
|
||||
```bash
|
||||
npx @sveltejs/mcp svelte-autofixer "<code_or_path>" [options]
|
||||
```
|
||||
|
||||
Analyzes Svelte code and suggests fixes for common issues.
|
||||
|
||||
**Options:**
|
||||
|
||||
- `--async` - Enable async Svelte mode (default: false)
|
||||
- `--svelte-version` - Target version: 4 or 5 (default: 5)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Analyze inline code (escape $ as \$)
|
||||
npx @sveltejs/mcp svelte-autofixer '<script>let count = \$state(0);</script>'
|
||||
|
||||
# Analyze a file
|
||||
npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte
|
||||
|
||||
# Target Svelte 4
|
||||
npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4
|
||||
```
|
||||
|
||||
**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics
|
||||
2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues
|
||||
3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component
|
||||
176
apps/web/.vibe/skills/svelte-core-bestpractices/SKILL.md
Normal file
176
apps/web/.vibe/skills/svelte-core-bestpractices/SKILL.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
---
|
||||
name: svelte-core-bestpractices
|
||||
description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more.
|
||||
---
|
||||
|
||||
## `$state`
|
||||
|
||||
Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable.
|
||||
|
||||
Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example.
|
||||
|
||||
## `$derived`
|
||||
|
||||
To compute something from state, use `$derived` rather than `$effect`:
|
||||
|
||||
```js
|
||||
// do this
|
||||
let square = $derived(num * num);
|
||||
|
||||
// don't do this
|
||||
let square;
|
||||
|
||||
$effect(() => {
|
||||
square = num * num;
|
||||
});
|
||||
```
|
||||
|
||||
> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`.
|
||||
|
||||
Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes.
|
||||
|
||||
If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this.
|
||||
|
||||
## `$effect`
|
||||
|
||||
Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects.
|
||||
|
||||
- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](references/@attach.md)
|
||||
- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](references/bind.md) as appropriate
|
||||
- If you need to log values for debugging purposes, use [`$inspect`](references/$inspect.md)
|
||||
- If you need to observe something external to Svelte, use [`createSubscriber`](references/svelte-reactivity.md)
|
||||
|
||||
Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server.
|
||||
|
||||
## `$props`
|
||||
|
||||
Treat props as though they will change. For example, values that depend on props should usually use `$derived`:
|
||||
|
||||
```js
|
||||
// @errors: 2451
|
||||
let { type } = $props();
|
||||
|
||||
// do this
|
||||
let color = $derived(type === 'danger' ? 'red' : 'green');
|
||||
|
||||
// don't do this — `color` will not update if `type` changes
|
||||
let color = type === 'danger' ? 'red' : 'green';
|
||||
```
|
||||
|
||||
## `$inspect.trace`
|
||||
|
||||
`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update.
|
||||
|
||||
## Events
|
||||
|
||||
Any element attribute starting with `on` is treated as an event listener:
|
||||
|
||||
```svelte
|
||||
<button onclick={() => {...}}>click me</button>
|
||||
|
||||
<!-- attribute shorthand also works -->
|
||||
<button {onclick}>...</button>
|
||||
|
||||
<!-- so do spread attributes -->
|
||||
<button {...props}>...</button>
|
||||
```
|
||||
|
||||
If you need to attach listeners to `window` or `document` you can use `<svelte:window>` and `<svelte:document>`:
|
||||
|
||||
```svelte
|
||||
<svelte:window onkeydown={...} />
|
||||
<svelte:document onvisibilitychange={...} />
|
||||
```
|
||||
|
||||
Avoid using `onMount` or `$effect` for this.
|
||||
|
||||
## Snippets
|
||||
|
||||
[Snippets](references/snippet.md) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](references/@render.md) tag, or passed to components as props. They must be declared within the template.
|
||||
|
||||
```svelte
|
||||
{#snippet greeting(name)}
|
||||
<p>hello {name}!</p>
|
||||
{/snippet}
|
||||
|
||||
{@render greeting('world')}
|
||||
```
|
||||
|
||||
> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside `<script>`. A snippet that doesn't reference component state is also available in a `<script module>`, in which case it can be exported for use by other components.
|
||||
|
||||
## Each blocks
|
||||
|
||||
Prefer to use [keyed each blocks](references/each.md) — this improves performance by allowing Svelte to surgically insert or remove items rather than updating the DOM belonging to existing items.
|
||||
|
||||
> [!NOTE] The key _must_ uniquely identify the object. Do not use the index as a key.
|
||||
|
||||
Avoid destructuring if you need to mutate the item (with something like `bind:value={item.count}`, for example).
|
||||
|
||||
## Using JavaScript variables in CSS
|
||||
|
||||
If you have a JS variable that you want to use inside CSS you can set a CSS custom property with the `style:` directive.
|
||||
|
||||
```svelte
|
||||
<div style:--columns={columns}>...</div>
|
||||
```
|
||||
|
||||
You can then reference `var(--columns)` inside the component's `<style>`.
|
||||
|
||||
## Styling child components
|
||||
|
||||
The CSS in a component's `<style>` is scoped to that component. If a parent component needs to control the child's styles, the preferred way is to use CSS custom properties:
|
||||
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<Child --color="red" />
|
||||
|
||||
<!-- Child.svelte -->
|
||||
<h1>Hello</h1>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
color: var(--color);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
If this is impossible (for example, the child component comes from a library) you can use `:global` to override styles:
|
||||
|
||||
```svelte
|
||||
<div>
|
||||
<Child />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div :global {
|
||||
h1 {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
Consider using context instead of declaring state in a shared module. This will scope the state to the part of the app that needs it, and eliminate the possibility of it leaking between users when server-side rendering.
|
||||
|
||||
Use `createContext` rather than `setContext` and `getContext`, as it provides type safety.
|
||||
|
||||
## Async Svelte
|
||||
|
||||
If using version 5.36 or higher, you can use [await expressions](references/await-expressions.md) and [hydratable](references/hydratable.md) to use promises directly inside components. Note that these require the `experimental.async` option to be enabled in `svelte.config.js` as they are not yet considered fully stable.
|
||||
|
||||
## Avoid legacy features
|
||||
|
||||
Always use runes mode for new code, and avoid features that have more modern replacements:
|
||||
|
||||
- use `$state` instead of implicit reactivity (e.g. `let count = 0; count += 1`)
|
||||
- use `$derived` and `$effect` instead of `$:` assignments and statements (but only use effects when there is no better solution)
|
||||
- use `$props` instead of `export let`, `$$props` and `$$restProps`
|
||||
- use `onclick={...}` instead of `on:click={...}`
|
||||
- use `{#snippet ...}` and `{@render ...}` instead of `<slot>` and `$$slots` and `<svelte:fragment>`
|
||||
- use `<DynamicComponent>` instead of `<svelte:component this={DynamicComponent}>`
|
||||
- use `import Self from './ThisComponent.svelte'` and `<Self>` instead of `<svelte:self>`
|
||||
- use classes with `$state` fields to share reactivity between components, instead of using stores
|
||||
- use `{@attach ...}` instead of `use:action`
|
||||
- use clsx-style arrays and objects in `class` attributes, instead of the `class:` directive
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
> [!NOTE] `$inspect` only works during development. In a production build it becomes a noop.
|
||||
|
||||
The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object or array using fine-grained reactivity will cause it to re-fire (demo:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
let message = $state('hello');
|
||||
|
||||
$inspect(count, message); // will console.log when `count` or `message` change
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>Increment</button>
|
||||
<input bind:value={message} />
|
||||
```
|
||||
|
||||
On updates, a stack trace will be printed, making it easy to find the origin of a state change (unless you're in the playground, due to technical limitations).
|
||||
|
||||
## $inspect(...).with
|
||||
|
||||
`$inspect` returns a property `with`, which you can invoke with a callback, which will then be invoked instead of `console.log`. The first argument to the callback is either `"init"` or `"update"`; subsequent arguments are the values passed to `$inspect` (demo:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$inspect(count).with((type, count) => {
|
||||
if (type === 'update') {
|
||||
debugger; // or `console.trace`, or whatever you want
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<button onclick={() => count++}>Increment</button>
|
||||
```
|
||||
|
||||
## $inspect.trace(...)
|
||||
|
||||
This rune, added in 5.14, causes the surrounding function to be _traced_ in development. Any time the function re-runs as part of an [effect]($effect) or a [derived]($derived), information will be printed to the console about which pieces of reactive state caused the effect to fire.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { doSomeWork } from './elsewhere';
|
||||
|
||||
$effect(() => {
|
||||
+++// $inspect.trace must be the first statement of a function body+++
|
||||
+++$inspect.trace();+++
|
||||
doSomeWork();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
`$inspect.trace` takes an optional first argument which will be used as the label.
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
Attachments are functions that run in an [effect]($effect) when an element is mounted to the DOM or when [state]($state) read inside the function updates.
|
||||
|
||||
Optionally, they can return a function that is called before the attachment re-runs, or after the element is later removed from the DOM.
|
||||
|
||||
> [!NOTE]
|
||||
> Attachments are available in Svelte 5.29 and newer.
|
||||
|
||||
```svelte
|
||||
<!--- file: App.svelte --->
|
||||
<script>
|
||||
/** @type {import('svelte/attachments').Attachment} */
|
||||
function myAttachment(element) {
|
||||
console.log(element.nodeName); // 'DIV'
|
||||
|
||||
return () => {
|
||||
console.log('cleaning up');
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {@attach myAttachment}>...</div>
|
||||
```
|
||||
|
||||
An element can have any number of attachments.
|
||||
|
||||
## Attachment factories
|
||||
|
||||
A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment (demo:
|
||||
|
||||
```svelte
|
||||
<!--- file: App.svelte --->
|
||||
<script>
|
||||
import tippy from 'tippy.js';
|
||||
|
||||
let content = $state('Hello!');
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {import('svelte/attachments').Attachment}
|
||||
*/
|
||||
function tooltip(content) {
|
||||
return (element) => {
|
||||
const tooltip = tippy(element, { content });
|
||||
return tooltip.destroy;
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<input bind:value={content} />
|
||||
|
||||
<button {@attach tooltip(content)}> Hover me </button>
|
||||
```
|
||||
|
||||
Since the `tooltip(content)` expression runs inside an [effect]($effect), the attachment will be destroyed and recreated whenever `content` changes. The same thing would happen for any state read _inside_ the attachment function when it first runs. (If this isn't what you want, see [Controlling when attachments re-run](#Controlling-when-attachments-re-run).)
|
||||
|
||||
## Inline attachments
|
||||
|
||||
Attachments can also be created inline (demo:
|
||||
|
||||
```svelte
|
||||
<!--- file: App.svelte --->
|
||||
<canvas
|
||||
width={32}
|
||||
height={32}
|
||||
{@attach (canvas) => {
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
$effect(() => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
});
|
||||
}}
|
||||
></canvas>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state.
|
||||
|
||||
## Conditional attachments
|
||||
|
||||
Falsy values like `false` or `undefined` are treated as no attachment, enabling conditional usage:
|
||||
|
||||
```svelte
|
||||
<div {@attach enabled && myAttachment}>...</div>
|
||||
```
|
||||
|
||||
## Passing attachments to components
|
||||
|
||||
When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](/tutorial/svelte/spread-props) props onto an element, the element will receive those attachments.
|
||||
|
||||
This allows you to create _wrapper components_ that augment elements (demo:
|
||||
|
||||
```svelte
|
||||
<!--- file: Button.svelte --->
|
||||
<script>
|
||||
/** @type {import('svelte/elements').HTMLButtonAttributes} */
|
||||
let { children, ...props } = $props();
|
||||
</script>
|
||||
|
||||
<!-- `props` includes attachments -->
|
||||
<button {...props}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!--- file: App.svelte --->
|
||||
<script>
|
||||
import tippy from 'tippy.js';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
let content = $state('Hello!');
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {import('svelte/attachments').Attachment}
|
||||
*/
|
||||
function tooltip(content) {
|
||||
return (element) => {
|
||||
const tooltip = tippy(element, { content });
|
||||
return tooltip.destroy;
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<input bind:value={content} />
|
||||
|
||||
<Button {@attach tooltip(content)}>Hover me</Button>
|
||||
```
|
||||
|
||||
## Controlling when attachments re-run
|
||||
|
||||
Attachments, unlike [actions](use), are fully reactive: `{@attach foo(bar)}` will re-run on changes to `foo` _or_ `bar` (or any state read inside `foo`):
|
||||
|
||||
```js
|
||||
// @errors: 7006 2304 2552
|
||||
function foo(bar) {
|
||||
return (node) => {
|
||||
veryExpensiveSetupWork(node);
|
||||
update(node, bar);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
In the rare case that this is a problem (for example, if `foo` does expensive and unavoidable setup work) consider passing the data inside a function and reading it in a child effect:
|
||||
|
||||
```js
|
||||
// @errors: 7006 2304 2552
|
||||
function foo(+++getBar+++) {
|
||||
return (node) => {
|
||||
veryExpensiveSetupWork(node);
|
||||
|
||||
+++ $effect(() => {
|
||||
update(node, getBar());
|
||||
});+++
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Creating attachments programmatically
|
||||
|
||||
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](svelte-attachments#createAttachmentKey).
|
||||
|
||||
## Converting actions to attachments
|
||||
|
||||
If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](svelte-attachments#fromAction), allowing you to (for example) use them with components.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
To render a [snippet](snippet), use a `{@render ...}` tag.
|
||||
|
||||
```svelte
|
||||
{#snippet sum(a, b)}
|
||||
<p>{a} + {b} = {a + b}</p>
|
||||
{/snippet}
|
||||
|
||||
{@render sum(1, 2)}
|
||||
{@render sum(3, 4)}
|
||||
{@render sum(5, 6)}
|
||||
```
|
||||
|
||||
The expression can be an identifier like `sum`, or an arbitrary JavaScript expression:
|
||||
|
||||
```svelte
|
||||
{@render (cool ? coolSnippet : lameSnippet)()}
|
||||
```
|
||||
|
||||
## Optional snippets
|
||||
|
||||
If the snippet is potentially undefined — for example, because it's an incoming prop — then you can use optional chaining to only render it when it _is_ defined:
|
||||
|
||||
```svelte
|
||||
{@render children?.()}
|
||||
```
|
||||
|
||||
Alternatively, use an [`{#if ...}`](if) block with an `:else` clause to render fallback content:
|
||||
|
||||
```svelte
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<p>fallback content</p>
|
||||
{/if}
|
||||
```
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
As of Svelte 5.36, you can use the `await` keyword inside your components in three places where it was previously unavailable:
|
||||
|
||||
- at the top level of your component's `<script>`
|
||||
- inside `$derived(...)` declarations
|
||||
- inside your markup
|
||||
|
||||
This feature is currently experimental, and you must opt in by adding the `experimental.async` option wherever you [configure](/docs/kit/configuration) Svelte, usually `svelte.config.js`:
|
||||
|
||||
```js
|
||||
/// file: svelte.config.js
|
||||
export default {
|
||||
compilerOptions: {
|
||||
experimental: {
|
||||
async: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The experimental flag will be removed in Svelte 6.
|
||||
|
||||
## Synchronized updates
|
||||
|
||||
When an `await` expression depends on a particular piece of state, changes to that state will not be reflected in the UI until the asynchronous work has completed, so that the UI is not left in an inconsistent state. In other words, in an example like this...
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let a = $state(1);
|
||||
let b = $state(2);
|
||||
|
||||
async function add(a, b) {
|
||||
await new Promise((f) => setTimeout(f, 500)); // artificial delay
|
||||
return a + b;
|
||||
}
|
||||
</script>
|
||||
|
||||
<input type="number" bind:value={a} />
|
||||
<input type="number" bind:value={b} />
|
||||
|
||||
<p>{a} + {b} = {await add(a, b)}</p>
|
||||
```
|
||||
|
||||
...if you increment `a`, the contents of the `<p>` will _not_ immediately update to read this —
|
||||
|
||||
```html
|
||||
<p>2 + 2 = 3</p>
|
||||
```
|
||||
|
||||
— instead, the text will update to `2 + 2 = 4` when `add(a, b)` resolves.
|
||||
|
||||
Updates can overlap — a fast update will be reflected in the UI while an earlier slow update is still ongoing.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup...
|
||||
|
||||
```svelte
|
||||
<p>{await one()}</p><p>{await two()}</p>
|
||||
```
|
||||
|
||||
...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential.
|
||||
|
||||
This does not apply to sequential `await` expressions inside your `<script>` or inside async functions — these run like any other asynchronous JavaScript. An exception is that independent `$derived` expressions will update independently, even though they will run sequentially when they are first created:
|
||||
|
||||
```js
|
||||
// these will run sequentially the first time,
|
||||
// but will update independently
|
||||
let a = $derived(await one());
|
||||
let b = $derived(await two());
|
||||
```
|
||||
|
||||
> [!NOTE] If you write code like this, expect Svelte to give you an [`await_waterfall`](runtime-warnings#Client-warnings-await_waterfall) warning
|
||||
|
||||
## Indicating loading states
|
||||
|
||||
To render placeholder UI, you can wrap content in a `<svelte:boundary>` with a [`pending`](svelte-boundary#Properties-pending) snippet. This will be shown when the boundary is first created, but not for subsequent updates, which are globally coordinated.
|
||||
|
||||
After the contents of a boundary have resolved for the first time and have replaced the `pending` snippet, you can detect subsequent async work with [`$effect.pending()`]($effect#$effect.pending). This is what you would use to display a "we're asynchronously validating your input" spinner next to a form field, for example.
|
||||
|
||||
You can also use [`settled()`](svelte#settled) to get a promise that resolves when the current update is complete:
|
||||
|
||||
```js
|
||||
import { tick, settled } from 'svelte';
|
||||
|
||||
async function onclick() {
|
||||
updating = true;
|
||||
|
||||
// without this, the change to `updating` will be
|
||||
// grouped with the other changes, meaning it
|
||||
// won't be reflected in the UI
|
||||
await tick();
|
||||
|
||||
color = 'octarine';
|
||||
answer = 42;
|
||||
|
||||
await settled();
|
||||
|
||||
// any updates affected by `color` or `answer`
|
||||
// have now been applied
|
||||
updating = false;
|
||||
}
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
Errors in `await` expressions will bubble to the nearest [error boundary](svelte-boundary).
|
||||
|
||||
## Server-side rendering
|
||||
|
||||
Svelte supports asynchronous server-side rendering (SSR) with the `render(...)` API. To use it, simply await the return value:
|
||||
|
||||
```js
|
||||
/// file: server.js
|
||||
import { render } from 'svelte/server';
|
||||
import App from './App.svelte';
|
||||
|
||||
const { head, body } = +++await+++ render(App);
|
||||
```
|
||||
|
||||
> [!NOTE] If you're using a framework like SvelteKit, this is done on your behalf.
|
||||
|
||||
If a `<svelte:boundary>` with a `pending` snippet is encountered during SSR, that snippet will be rendered while the rest of the content is ignored. All `await` expressions encountered outside boundaries with `pending` snippets will resolve and render their contents prior to `await render(...)` returning.
|
||||
|
||||
> [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background.
|
||||
|
||||
## Forking
|
||||
|
||||
The [`fork(...)`](svelte#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when (for example) users signal an intent to navigate.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { fork } from 'svelte';
|
||||
import Menu from './Menu.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
/** @type {import('svelte').Fork | null} */
|
||||
let pending = null;
|
||||
|
||||
function preload() {
|
||||
pending ??= fork(() => {
|
||||
open = true;
|
||||
});
|
||||
}
|
||||
|
||||
function discard() {
|
||||
pending?.discard();
|
||||
pending = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
onfocusin={preload}
|
||||
onfocusout={discard}
|
||||
onpointerenter={preload}
|
||||
onpointerleave={discard}
|
||||
onclick={() => {
|
||||
pending?.commit();
|
||||
pending = null;
|
||||
|
||||
// in case `pending` didn't exist
|
||||
// (if it did, this is a no-op)
|
||||
open = true;
|
||||
}}>open menu</button
|
||||
>
|
||||
|
||||
{#if open}
|
||||
<!-- any async work inside this component will start
|
||||
as soon as the fork is created -->
|
||||
<Menu onclose={() => (open = false)} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
Effects run in a slightly different order when the `experimental.async` option is `true`. Specifically, _block_ effects like `{#if ...}` and `{#each ...}` now run before an `$effect.pre` or `beforeUpdate` in the same component, which means that in very rare situations.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## Function bindings
|
||||
|
||||
You can also use `bind:property={get, set}`, where `get` and `set` are functions, allowing you to perform validation and transformation:
|
||||
|
||||
```svelte
|
||||
<input bind:value={() => value, (v) => (value = v.toLowerCase())} />
|
||||
```
|
||||
|
||||
In the case of readonly bindings like [dimension bindings](#Dimensions), the `get` value should be `null`:
|
||||
|
||||
```svelte
|
||||
<div bind:clientWidth={null, redraw} bind:clientHeight={null, redraw}>...</div>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Function bindings are available in Svelte 5.9.0 and newer.
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
## Keyed each blocks
|
||||
|
||||
```svelte
|
||||
<!--- copy: false --->
|
||||
{#each expression as name (key)}...{/each}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!--- copy: false --->
|
||||
{#each expression as name, index (key)}...{/each}
|
||||
```
|
||||
|
||||
If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to intelligently update the list when data changes by inserting, moving and deleting items, rather than adding or removing items at the end and updating the state in the middle.
|
||||
|
||||
The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change.
|
||||
|
||||
```svelte
|
||||
{#each items as item (item.id)}
|
||||
<li>{item.name} x {item.qty}</li>
|
||||
{/each}
|
||||
|
||||
<!-- or with additional index value -->
|
||||
{#each items as item, i (item.id)}
|
||||
<li>{i + 1}: {item.name} x {item.qty}</li>
|
||||
{/each}
|
||||
```
|
||||
|
||||
You can freely use destructuring and rest patterns in each blocks.
|
||||
|
||||
```svelte
|
||||
{#each items as { id, name, qty }, i (id)}
|
||||
<li>{i + 1}: {name} x {qty}</li>
|
||||
{/each}
|
||||
|
||||
{#each objects as { id, ...rest }}
|
||||
<li><span>{id}</span><MyComponent {...rest} /></li>
|
||||
{/each}
|
||||
|
||||
{#each items as [id, ...rest]}
|
||||
<li><span>{id}</span><MyComponent values={rest} /></li>
|
||||
{/each}
|
||||
```
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
In Svelte, when you want to render asynchronous content data on the server, you can simply `await` it. This is great! However, it comes with a pitfall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { getUser } from 'my-database-library';
|
||||
|
||||
// This will get the user on the server, render the user's name into the h1,
|
||||
// and then, during hydration on the client, it will get the user _again_,
|
||||
// blocking hydration until it's done.
|
||||
const user = await getUser();
|
||||
</script>
|
||||
|
||||
<h1>{user.name}</h1>
|
||||
```
|
||||
|
||||
That's silly, though. If we've already done the hard work of getting the data on the server, we don't want to get it again during hydration on the client. `hydratable` is a low-level API built to solve this problem. You probably won't need this very often — it will be used behind the scenes by whatever datafetching library you use. For example, it powers [remote functions in SvelteKit](/docs/kit/remote-functions).
|
||||
|
||||
To fix the example above:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { hydratable } from 'svelte';
|
||||
import { getUser } from 'my-database-library';
|
||||
|
||||
// During server rendering, this will serialize and stash the result of `getUser`, associating
|
||||
// it with the provided key and baking it into the `head` content. During hydration, it will
|
||||
// look for the serialized version, returning it instead of running `getUser`. After hydration
|
||||
// is done, if it's called again, it'll simply invoke `getUser`.
|
||||
const user = await hydratable('user', () => getUser());
|
||||
</script>
|
||||
|
||||
<h1>{user.name}</h1>
|
||||
```
|
||||
|
||||
This API can also be used to provide access to random or time-based values that are stable between server rendering and hydration. For example, to get a random number that doesn't update on hydration:
|
||||
|
||||
```ts
|
||||
import { hydratable } from 'svelte';
|
||||
const rand = hydratable('random', () => Math.random());
|
||||
```
|
||||
|
||||
If you're a library author, be sure to prefix the keys of your `hydratable` values with the name of your library so that your keys don't conflict with other libraries.
|
||||
|
||||
## Serialization
|
||||
|
||||
All data returned from a `hydratable` function must be serializable. But this doesn't mean you're limited to JSON — Svelte uses [`devalue`](https://npmjs.com/package/devalue), which can serialize all sorts of things including `Map`, `Set`, `URL`, and `BigInt`. Check the documentation page for a full list. In addition to these, thanks to some Svelte magic, you can also fearlessly use promises:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { hydratable } from 'svelte';
|
||||
const promises = hydratable('random', () => {
|
||||
return {
|
||||
one: Promise.resolve(1),
|
||||
two: Promise.resolve(2),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{await promises.one}
|
||||
{await promises.two}
|
||||
```
|
||||
|
||||
## CSP
|
||||
|
||||
`hydratable` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:
|
||||
|
||||
```js
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
const { head, body } = await render(App, {
|
||||
csp: { nonce },
|
||||
});
|
||||
```
|
||||
|
||||
This will add the `nonce` to the script block, on the assumption that you will later add the same nonce to the CSP header of the document that contains it:
|
||||
|
||||
```js
|
||||
response.headers.set('Content-Security-Policy', `script-src 'nonce-${nonce}'`);
|
||||
```
|
||||
|
||||
It's essential that a `nonce` — which, British slang definition aside, means 'number used once' — is only used when dynamically server rendering an individual response.
|
||||
|
||||
If instead you are generating static HTML ahead of time, you must use hashes instead:
|
||||
|
||||
```js
|
||||
const { head, body, hashes } = await render(App, {
|
||||
csp: { hash: true },
|
||||
});
|
||||
```
|
||||
|
||||
`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:
|
||||
|
||||
```js
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`,
|
||||
);
|
||||
```
|
||||
|
||||
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
```svelte
|
||||
<!--- copy: false --->
|
||||
{#snippet name()}...{/snippet}
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!--- copy: false --->
|
||||
{#snippet name(param1, param2, paramN)}...{/snippet}
|
||||
```
|
||||
|
||||
Snippets, and [render tags](@render), are a way to create reusable chunks of markup inside your components. Instead of writing duplicative code like this...
|
||||
|
||||
```svelte
|
||||
{#each images as image}
|
||||
{#if image.href}
|
||||
<a href={image.href}>
|
||||
<figure>
|
||||
<img src={image.src} alt={image.caption} width={image.width} height={image.height} />
|
||||
<figcaption>{image.caption}</figcaption>
|
||||
</figure>
|
||||
</a>
|
||||
{:else}
|
||||
<figure>
|
||||
<img src={image.src} alt={image.caption} width={image.width} height={image.height} />
|
||||
<figcaption>{image.caption}</figcaption>
|
||||
</figure>
|
||||
{/if}
|
||||
{/each}
|
||||
```
|
||||
|
||||
...you can write this:
|
||||
|
||||
```svelte
|
||||
{#snippet figure(image)}
|
||||
<figure>
|
||||
<img src={image.src} alt={image.caption} width={image.width} height={image.height} />
|
||||
<figcaption>{image.caption}</figcaption>
|
||||
</figure>
|
||||
{/snippet}
|
||||
|
||||
{#each images as image}
|
||||
{#if image.href}
|
||||
<a href={image.href}>
|
||||
{@render figure(image)}
|
||||
</a>
|
||||
{:else}
|
||||
{@render figure(image)}
|
||||
{/if}
|
||||
{/each}
|
||||
```
|
||||
|
||||
Like function declarations, snippets can have an arbitrary number of parameters, which can have default values, and you can destructure each parameter. You cannot use rest parameters, however.
|
||||
|
||||
## Snippet scope
|
||||
|
||||
Snippets can be declared anywhere inside your component. They can reference values declared outside themselves, for example in the `<script>` tag or in `{#each ...}` blocks (demo...
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { message = `it's great to see you!` } = $props();
|
||||
</script>
|
||||
|
||||
{#snippet hello(name)}
|
||||
<p>hello {name}! {message}!</p>
|
||||
{/snippet}
|
||||
|
||||
{@render hello('alice')}
|
||||
{@render hello('bob')}
|
||||
```
|
||||
|
||||
...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings):
|
||||
|
||||
```svelte
|
||||
<div>
|
||||
{#snippet x()}
|
||||
{#snippet y()}...{/snippet}
|
||||
|
||||
<!-- this is fine -->
|
||||
{@render y()}
|
||||
{/snippet}
|
||||
|
||||
<!-- this will error, as `y` is not in scope -->
|
||||
{@render y()}
|
||||
</div>
|
||||
|
||||
<!-- this will also error, as `x` is not in scope -->
|
||||
{@render x()}
|
||||
```
|
||||
|
||||
Snippets can reference themselves and each other (demo:
|
||||
|
||||
```svelte
|
||||
{#snippet blastoff()}
|
||||
<span>🚀</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet countdown(n)}
|
||||
{#if n > 0}
|
||||
<span>{n}...</span>
|
||||
{@render countdown(n - 1)}
|
||||
{:else}
|
||||
{@render blastoff()}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{@render countdown(10)}
|
||||
```
|
||||
|
||||
## Passing snippets to components
|
||||
|
||||
### Explicit props
|
||||
|
||||
Within the template, snippets are values just like any other. As such, they can be passed to components as props (demo:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Table from './Table.svelte';
|
||||
|
||||
const fruits = [
|
||||
{ name: 'apples', qty: 5, price: 2 },
|
||||
{ name: 'bananas', qty: 10, price: 1 },
|
||||
{ name: 'cherries', qty: 20, price: 0.5 },
|
||||
];
|
||||
</script>
|
||||
|
||||
{#snippet header()}
|
||||
<th>fruit</th>
|
||||
<th>qty</th>
|
||||
<th>price</th>
|
||||
<th>total</th>
|
||||
{/snippet}
|
||||
|
||||
{#snippet row(d)}
|
||||
<td>{d.name}</td>
|
||||
<td>{d.qty}</td>
|
||||
<td>{d.price}</td>
|
||||
<td>{d.qty * d.price}</td>
|
||||
{/snippet}
|
||||
|
||||
<Table data={fruits} {header} {row} />
|
||||
```
|
||||
|
||||
Think about it like passing content instead of data to a component. The concept is similar to slots in web components.
|
||||
|
||||
### Implicit props
|
||||
|
||||
As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component (demo:
|
||||
|
||||
```svelte
|
||||
<!-- this is semantically the same as the above -->
|
||||
<Table data={fruits}>
|
||||
{#snippet header()}
|
||||
<th>fruit</th>
|
||||
<th>qty</th>
|
||||
<th>price</th>
|
||||
<th>total</th>
|
||||
{/snippet}
|
||||
|
||||
{#snippet row(d)}
|
||||
<td>{d.name}</td>
|
||||
<td>{d.qty}</td>
|
||||
<td>{d.price}</td>
|
||||
<td>{d.qty * d.price}</td>
|
||||
{/snippet}
|
||||
</Table>
|
||||
```
|
||||
|
||||
### Implicit `children` snippet
|
||||
|
||||
Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet (demo:
|
||||
|
||||
```svelte
|
||||
<!--- file: App.svelte --->
|
||||
<Button>click me</Button>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!--- file: Button.svelte --->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<!-- result will be <button>click me</button> -->
|
||||
<button>{@render children()}</button>
|
||||
```
|
||||
|
||||
> [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name
|
||||
|
||||
### Optional snippet props
|
||||
|
||||
You can declare snippet props as being optional. You can either use optional chaining to not render anything if the snippet isn't set...
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
```
|
||||
|
||||
...or use an `#if` block to render fallback content:
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
fallback content
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Typing snippets
|
||||
|
||||
Snippets implement the `Snippet` interface imported from `'svelte'`:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data: any[];
|
||||
children: Snippet;
|
||||
row: Snippet<[any]>;
|
||||
}
|
||||
|
||||
let { data, children, row }: Props = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
With this change, red squigglies will appear if you try and use the component without providing a `data` prop and a `row` snippet. Notice that the type argument provided to `Snippet` is a tuple, since snippets can have multiple parameters.
|
||||
|
||||
We can tighten things up further by declaring a generic, so that `data` and `row` refer to the same type:
|
||||
|
||||
```svelte
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
data,
|
||||
children,
|
||||
row,
|
||||
}: {
|
||||
data: T[];
|
||||
children: Snippet;
|
||||
row: Snippet<[T]>;
|
||||
} = $props();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Exporting snippets
|
||||
|
||||
Snippets declared at the top level of a `.svelte` file can be exported from a `<script module>` for use in other components, provided they don't reference any declarations in a non-module `<script>` (whether directly or indirectly, via other snippets) (demo:
|
||||
|
||||
```svelte
|
||||
<script module>
|
||||
export { add };
|
||||
</script>
|
||||
|
||||
{#snippet add(a, b)}
|
||||
{a} + {b} = {a + b}
|
||||
{/snippet}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> This requires Svelte 5.5.0 or newer
|
||||
|
||||
## Programmatic snippets
|
||||
|
||||
Snippets can be created programmatically with the [`createRawSnippet`](svelte#createRawSnippet) API. This is intended for advanced use cases.
|
||||
|
||||
## Snippets and slots
|
||||
|
||||
In Svelte 4, content can be passed to components using [slots](legacy-slots). Snippets are more powerful and flexible, and so slots have been deprecated in Svelte 5.
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
## createSubscriber
|
||||
|
||||
<blockquote class="since note">
|
||||
|
||||
Available since 5.7.0
|
||||
|
||||
</blockquote>
|
||||
|
||||
Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity.
|
||||
It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`.
|
||||
|
||||
If `subscribe` is called inside an effect (including indirectly, for example inside a getter),
|
||||
the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs.
|
||||
|
||||
If `start` returns a cleanup function, it will be called when the effect is destroyed.
|
||||
|
||||
If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects
|
||||
are active, and the returned teardown function will only be called when all effects are destroyed.
|
||||
|
||||
It's best understood with an example. Here's an implementation of [`MediaQuery`](/docs/svelte/svelte-reactivity#MediaQuery):
|
||||
|
||||
```js
|
||||
// @errors: 7031
|
||||
import { createSubscriber } from 'svelte/reactivity';
|
||||
import { on } from 'svelte/events';
|
||||
|
||||
export class MediaQuery {
|
||||
#query;
|
||||
#subscribe;
|
||||
|
||||
constructor(query) {
|
||||
this.#query = window.matchMedia(`(${query})`);
|
||||
|
||||
this.#subscribe = createSubscriber((update) => {
|
||||
// when the `change` event occurs, re-run any effects that read `this.current`
|
||||
const off = on(this.#query, 'change', update);
|
||||
|
||||
// stop listening when all the effects are destroyed
|
||||
return () => off();
|
||||
});
|
||||
}
|
||||
|
||||
get current() {
|
||||
// This makes the getter reactive, if read in an effect
|
||||
this.#subscribe();
|
||||
|
||||
// Return the current state of the query, whether or not we're in an effect
|
||||
return this.#query.matches;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<div class="ts-block">
|
||||
|
||||
```dts
|
||||
function createSubscriber(
|
||||
start: (update: () => void) => (() => void) | void
|
||||
): () => void;
|
||||
```
|
||||
|
||||
</div>
|
||||
42
apps/web/README.md
Normal file
42
apps/web/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
bun x sv create --template minimal --types ts --add tailwindcss="plugins:typography,forms" paraglide="languageTags:en, de, ru+demo:yes" --install bun .
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
394
apps/web/bun.lock
Normal file
394
apps/web/bun.lock
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "altynay",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.3.2",
|
||||
"mdsvex": "^0.12.7",
|
||||
"simple-icons": "^16.16.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||
|
||||
"@inlang/paraglide-js": ["@inlang/paraglide-js@2.16.0", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^2.9.1", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "bin": { "paraglide-js": "bin/run.js" } }, "sha512-O7KKvVoTsGqPRt1VfSvd0UyfSjU2qHiABx968M2decgG7Af6TddW3dTJrTS3I78nOUgRAlYwCYfKefSGD4rGMA=="],
|
||||
|
||||
"@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="],
|
||||
|
||||
"@inlang/sdk": ["@inlang/sdk@2.9.1", "", { "dependencies": { "@lix-js/sdk": "0.4.9", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^13.0.0" } }, "sha512-y0C3xaKo6pSGDr3p5OdreRVT3THJpgKVe1lLvG3BE4v9lskp3UfI9cPCbN8X2dpfLt/4ljtehMb5SykpMfJrMg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@lix-js/sdk": ["@lix-js/sdk@0.4.9", "", { "dependencies": { "@lix-js/server-protocol-schema": "0.1.1", "dedent": "1.5.1", "human-id": "^4.1.1", "js-sha256": "^0.11.0", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^10.0.0" } }, "sha512-30mDkXpx704359oRrJI42bjfCspCiaMItngVBbPkiTGypS7xX4jYbHWQkXI8XuJ7VDB69D0MsVU6xfrBAIrM4A=="],
|
||||
|
||||
"@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.31.28", "", {}, "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ=="],
|
||||
|
||||
"@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.48.0-build4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||
|
||||
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.57.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="],
|
||||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"comment-json": ["comment-json@4.6.2", "", { "dependencies": { "array-timsort": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w=="],
|
||||
|
||||
"consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.7.1", "", {}, "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-sha256": ["js-sha256@0.11.1", "", {}, "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mdsvex": ["mdsvex@0.12.7", "", { "dependencies": { "@types/mdast": "^4.0.4", "@types/unist": "^2.0.3", "prism-svelte": "^0.4.7", "prismjs": "^1.17.1", "unist-util-visit": "^2.0.1", "vfile-message": "^2.0.4" }, "peerDependencies": { "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0-next.120" } }, "sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"prism-svelte": ["prism-svelte@0.4.7", "", {}, "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ=="],
|
||||
|
||||
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"simple-icons": ["simple-icons@16.16.0", "", {}, "sha512-H+Z29a0TrCw6mrG42V2aqHQaKdJCT87x5aojLlPiIXOf1lpMqnKFAR/jP5xkI5hLrVTCBWs33e9sOtyNWqCx1A=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sqlite-wasm-kysely": ["sqlite-wasm-kysely@0.3.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, "peerDependencies": { "kysely": "*" } }, "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg=="],
|
||||
|
||||
"svelte": ["svelte@5.55.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.4.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@4.1.0", "", {}, "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.2" } }, "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@2.0.3", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0", "unist-util-visit-parents": "^3.0.0" } }, "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@3.1.1", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-is": "^4.0.0" } }, "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
|
||||
"vfile-message": ["vfile-message@2.0.4", "", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^2.0.0" } }, "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ=="],
|
||||
|
||||
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@lix-js/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
}
|
||||
}
|
||||
11
apps/web/example.env
Normal file
11
apps/web/example.env
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Friendly Captcha
|
||||
PUBLIC_FRIENDLYCAPTCHA_SITEKEY=""
|
||||
FRIENDLYCAPTCHA_API_KEY=""
|
||||
|
||||
# Email (example SMTP)
|
||||
SMTP_HOST="smtp.ionos.com"
|
||||
SMTP_PORT="465"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
CONTACT_TO_EMAIL=""
|
||||
CONTACT_FROM_EMAIL=""
|
||||
32
apps/web/messages/de.json
Normal file
32
apps/web/messages/de.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"back": "Zurück",
|
||||
"nav_about": "Über uns",
|
||||
"nav_artikel": "Artikel",
|
||||
"nav_contact": "Kontakt",
|
||||
"nav_news": "Neuigkeiten",
|
||||
"nav_org": "Organisationen",
|
||||
"nav_donauschwaben": "Donauschwaben",
|
||||
"nav_geschichte": "Geschichte",
|
||||
"nav_kultur": "Kultur",
|
||||
"nav_brauchtum": "Brauchtum",
|
||||
"nav_tracht": "Tracht",
|
||||
"nav_religion": "Religion",
|
||||
"nav_kueche": "Küche",
|
||||
"nav_politik": "Politik",
|
||||
"nav_persoenlichkeiten": "Persönlichkeiten",
|
||||
"footer_privacy": "Datenschutzerklärung",
|
||||
"footer_terms": "AGB",
|
||||
"footer_imprint": "Impressum",
|
||||
|
||||
"article_references": "Bibliographie",
|
||||
|
||||
"persoenlichkeiten_titel": "Persönlichkeiten",
|
||||
"persoenlichkeiten_untertitel": "Donauschwäbische Persönlichkeiten aus Geschichte, Kultur und Politik",
|
||||
|
||||
"contact_cta_heading": "Kontakt aufnehmen",
|
||||
"contact_cta_body": "Interesse an einer Zusammenarbeit?",
|
||||
"contact_cta_link": "Nachricht senden",
|
||||
"contact_heading": "Kontakt",
|
||||
"contact_intro": "Interesse an einer Zusammenarbeit oder Feedback zum Blog? Schreib mir gerne über einen meiner Socials oder per E-Mail."
|
||||
}
|
||||
33
apps/web/messages/en.json
Normal file
33
apps/web/messages/en.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"back": "Back",
|
||||
"nav_about": "About me",
|
||||
"nav_artikel": "Articles",
|
||||
"nav_contact": "Contact",
|
||||
"nav_org": "Organizations",
|
||||
"nav_news": "News",
|
||||
"nav_donauschwaben": "Danube Swabians",
|
||||
"nav_geschichte": "History",
|
||||
"nav_kultur": "Culture",
|
||||
"nav_brauchtum": "Customs",
|
||||
"nav_tracht": "Traditional Dress",
|
||||
"nav_religion": "Religion",
|
||||
"nav_kueche": "Cuisine",
|
||||
"nav_politik": "Politics",
|
||||
"nav_persoenlichkeiten": "Notable Figures",
|
||||
"footer_privacy": "Privacy Policy",
|
||||
"footer_terms": "Terms of Service",
|
||||
"footer_imprint": "Legal Notice",
|
||||
|
||||
"article_references": "Bibliography",
|
||||
|
||||
"persoenlichkeiten_titel": "Notable Figures",
|
||||
"persoenlichkeiten_untertitel": "Danube Swabian personalities from history, culture and politics",
|
||||
|
||||
"contact_cta_heading": "Get in touch",
|
||||
"contact_cta_body": "Interested in working together?",
|
||||
"contact_cta_link": "Send a message",
|
||||
"contact_heading": "Contact",
|
||||
"contact_intro": "Interested in working together or have feedback about the blog? Reach out via one of my socials or drop me an email.",
|
||||
"contact_email_label": "Email"
|
||||
}
|
||||
34
apps/web/messages/hu.json
Normal file
34
apps/web/messages/hu.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"back": "Vissza",
|
||||
"nav_about": "Rólunk",
|
||||
"nav_artikel": "Cikkek",
|
||||
"nav_contact": "Kapcsolat",
|
||||
"nav_news": "Hírek",
|
||||
"nav_org": "Szervezetek",
|
||||
"nav_donauschwaben": "Dunai svábok",
|
||||
"nav_geschichte": "Történelem",
|
||||
"nav_kultur": "Kultúra",
|
||||
"nav_brauchtum": "Hagyományok",
|
||||
"nav_tracht": "Népviselet",
|
||||
"nav_religion": "Vallás",
|
||||
"nav_kueche": "Konyha",
|
||||
"nav_politik": "Politika",
|
||||
"nav_persoenlichkeiten": "Személyiségek",
|
||||
|
||||
"footer_privacy": "Adatvédelem",
|
||||
"footer_terms": "ÁSZF",
|
||||
"footer_imprint": "Impresszum",
|
||||
|
||||
"article_references": "Bibliográfia",
|
||||
|
||||
"persoenlichkeiten_titel": "Személyiségek",
|
||||
"persoenlichkeiten_untertitel": "Dunai sváb személyiségek a történelemből, kultúrából és politikából",
|
||||
|
||||
"contact_cta_heading": "Lépj kapcsolatba",
|
||||
"contact_cta_body": "Érdekel az együttműködés?",
|
||||
"contact_cta_link": "Üzenet küldése",
|
||||
"contact_heading": "Kapcsolat",
|
||||
"contact_intro": "Érdekel az együttműködés, vagy visszajelzésed van a bloggal kapcsolatban? Keress meg valamelyik közösségi felületemen, vagy írj emailt.",
|
||||
"contact_email_label": "Email"
|
||||
}
|
||||
35
apps/web/package.json
Normal file
35
apps/web/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/qs": "^6.15.1",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "^25.3.2",
|
||||
"bits-ui": "^2.18.1",
|
||||
"mdsvex": "^0.12.7",
|
||||
"qs": "^6.15.1",
|
||||
"simple-icons": "^16.16.0",
|
||||
"svelte-adapter-bun": "^1.0.1"
|
||||
}
|
||||
}
|
||||
16
apps/web/project.inlang/settings.json
Normal file
16
apps/web/project.inlang/settings.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
},
|
||||
"baseLocale": "de",
|
||||
"locales": [
|
||||
"en",
|
||||
"de",
|
||||
"hu"
|
||||
]
|
||||
}
|
||||
29
apps/web/skills-lock.json
Normal file
29
apps/web/skills-lock.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"cms-migration": {
|
||||
"source": "payloadcms/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/cms-migration/SKILL.md",
|
||||
"computedHash": "c88f1e554c081d5c068f0c20cfd5a21331bb5a4e0bf9c97e80dd8175e18af94a"
|
||||
},
|
||||
"payload": {
|
||||
"source": "payloadcms/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/payload/SKILL.md",
|
||||
"computedHash": "4abfcc828ca2cc0d9142b2fee2456f257f73dd7a37bd1cce2d354231b08b8767"
|
||||
},
|
||||
"svelte-code-writer": {
|
||||
"source": "sveltejs/ai-tools",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/claude/svelte/skills/svelte-code-writer/SKILL.md",
|
||||
"computedHash": "c0e2cce9855f8e312cbb0a05aef164b4659c672d7723e4e598ffa6bc94890542"
|
||||
},
|
||||
"svelte-core-bestpractices": {
|
||||
"source": "sveltejs/ai-tools",
|
||||
"sourceType": "github",
|
||||
"skillPath": "plugins/claude/svelte/skills/svelte-core-bestpractices/SKILL.md",
|
||||
"computedHash": "bec11e369679027edf9d4acbe6d9788f8da33dc14b48b874f231d3ba13705324"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
apps/web/src/app.d.ts
vendored
Normal file
13
apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
16
apps/web/src/app.html
Normal file
16
apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
|
||||
<html lang="%paraglide.lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover"><div style="display: contents">%sveltekit.body%</div></body>
|
||||
</html>
|
||||
12
apps/web/src/hooks.server.ts
Normal file
12
apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
|
||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
});
|
||||
});
|
||||
|
||||
export const handle: Handle = handleParaglide;
|
||||
3
apps/web/src/hooks.ts
Normal file
3
apps/web/src/hooks.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||
|
||||
export const reroute = (request) => deLocalizeUrl(request.url).pathname;
|
||||
134
apps/web/src/lib/assets/favicon.svg
Normal file
134
apps/web/src/lib/assets/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
126
apps/web/src/lib/assets/logo.svg
Normal file
126
apps/web/src/lib/assets/logo.svg
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="500"
|
||||
xml:space="preserve"
|
||||
version="1.1"
|
||||
id="svg64"
|
||||
sodipodi:docname="Wappen_Donauschwaben-plain-min.svg"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs68" /><sodipodi:namedview
|
||||
id="namedview66"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.70043314"
|
||||
inkscape:cx="-76.381309"
|
||||
inkscape:cy="366.91582"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1011"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg64" /><g
|
||||
transform="matrix(0.87173623,0,0,0.87173623,-970.31473,10.753737)"
|
||||
id="g62"><path
|
||||
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:none;stroke:none;stroke-width:2;marker:none;enable-background:accumulate"
|
||||
d="m 1350.711,346.539 h 70.788 v 22.753 h -70.788 z"
|
||||
id="path2" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.110233;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1390.204,119.659 c -2.231,0.062 -4.334,1.479 -4.76,4.095 -1.332,4.93 -6.843,3.55 -10.685,3.735 -2.41,-0.21 -4.22,0.267 -5.355,1.56 -0.714,0.813 -1.226,2.145 -1.248,3.22 -0.072,3.53 0.517,9.305 2.805,10.212 1.528,-3.945 5.265,-3.291 8.75,-3.48 0.264,0.641 1.607,1.745 0.644,1.996 -5.083,1.325 -8.8,5.715 -12.824,7.232 -2.244,0.847 -5.462,1.827 -7.19,0.157 -2.57,-2.483 3.906,-9.205 -0.192,-10.747 -4.756,-2.785 -10.24,0.698 -9.775,6.258 -0.978,5.313 2.103,13.285 8.446,13.612 7.644,0.848 13.627,-3.885 19.332,-9.04 6.202,-3.015 3.072,5.635 5.71,7.162 2.454,1.42 6.464,0.017 8.388,0.799 0.607,0.723 0.335,2.732 0.334,4.218 -0.839,7.424 -6.467,13.583 -12.914,17.038 -4.171,1.47 -8.25,4.237 -12.71,4.35 -1.982,0.566 -10.924,-1.743 -7.242,0.973 6.348,7.08 16.528,4.677 24.865,6.547 -1.463,3.45 -2.843,5.793 -7.749,9.518 -3.254,1.812 -6.013,5.194 -8,0.41 -7.328,-10.338 -4.651,-12.004 -24.307,-8.29 0.737,-3.033 1.2,-7.185 -0.457,-9.977 -2.112,-3.352 -4.932,-4.655 -8.558,-4.708 -5.582,-0.506 -13.887,3.993 -19.286,4.777 2.771,-5.212 7.295,-9.9 10.274,-15.267 3.454,-6.224 7.349,-12.504 8.734,-19.501 0.923,-4.662 1.901,-10.124 -0.424,-14.258 -1.93,-3.43 -5.975,-6.348 -9.88,-6.357 -8.298,-0.02 -15.677,7.03 -21.067,13.406 -5.255,6.218 -7.267,14.706 -9.564,22.546 -3.092,10.56 -0.899,22.596 -5.546,32.556 -2.622,5.62 -7.603,10.384 -12.033,14.13 3.114,-1.212 7.572,-2.402 10.33,-1.484 -1.315,10.106 -6.971,21.64 -12.429,30.1 -5.088,4.439 5.798,-0.363 5.492,1.521 -1.021,6.3 -6.435,12.68 -7.107,19.634 7.644,-0.41 17.064,-4.695 24.715,-3.54 7.55,-16.535 3.569,-8.519 8.345,-11.738 -1.099,5.926 -2.2,6.488 -3.72,12.175 5.562,2.537 13.802,2.203 19.443,4.571 3.324,-13.49 3.836,-13.244 6.717,-14.434 2.293,-0.947 -0.974,2.83 -0.842,16.056 6.507,1.129 14.249,2.762 20.863,3.03 1.544,-12.613 -1.632,-10.83 1.393,-18.158 3.762,7.995 0.838,7.138 3.064,17.93 5.613,-2.22 13.295,-0.343 18.737,-2.982 0.838,-8.589 -2.348,-13.945 2.442,-20.86 2.357,8.894 0.634,13.117 5.597,19.366 15.226,-1.751 39.936,2.097 40.65,2.69 -0.747,-8.227 1.453,-13.138 3.822,-20.67 1.893,8.516 1.001,13.26 0.646,21.873 3.844,1.267 20.398,5.132 20.398,5.132 0.745,0.6 -1.487,-7.226 0.992,-20.582 1.45,-1.515 3.559,7.64 4.552,21.503 4.231,2.238 19.74,0.915 19.297,-0.198 0,0 -2.184,-9.962 0.906,-19.027 3.789,4.895 1.952,15.737 4.218,18.004 5.023,-0.031 14.713,-4.16 18.684,-4.795 -0.583,-8.42 -2.406,-11.93 -1.393,-20.387 5.203,4.51 6.2,19.363 6.2,19.363 -0.103,-0.414 18.045,-0.541 26.84,2.142 -0.995,-4.794 -5.106,-9.601 -4.455,-16.568 0.143,-1.534 6.53,-0.34 6.53,-0.34 0,0 -4.18,-7.219 -4.984,-9.993 -2.349,-5.166 -6.444,-14.57 -7.11,-22.371 -0.084,-0.997 -0.168,-2.094 0.412,-2.974 1.05,-1.593 4.349,-1.276 7.733,-0.714 -5.432,-6.78 -9.565,-11.131 -12.063,-17.881 -2.891,-7.813 -1.123,-16.694 -2.887,-24.839 -1.69,-7.808 -3.328,-15.907 -7.33,-22.811 -4.25,-7.33 -9.472,-15.173 -17.128,-18.744 -4.708,-2.196 -11.199,-3.477 -15.557,-0.644 -4.091,2.66 -5.093,8.75 -5.447,13.635 -0.481,6.633 1.465,13.734 4.648,19.399 1.627,6.28 5.783,11.254 8.304,17.09 -4.585,1.232 -10.384,-0.815 -14.634,1.535 -4.68,1.153 -8.003,10.797 -8.003,10.797 -8.674,-1.235 -9.733,-1.553 -14.369,0.121 -4.421,1.597 -5.874,4.033 -9.43,9.497 -3.732,0.529 -4.405,-5.122 -5.367,-7.88 -1.311,-2.956 -0.336,-9.605 4.05,-7.417 2.911,0.95 8.216,3.114 5.318,-1.74 -1.232,-5.464 -6.132,-8.838 -9.015,-13.1 -2.023,-10.47 -0.354,-21.828 -4.607,-31.93 -0.145,-3.173 4.756,-5.128 6.887,-7.522 4.978,-4.328 -0.359,-3.315 -4.052,-3.171 -9.728,-0.735 -20.047,-0.843 -28.966,-5.22 -1.1,-0.949 -2.509,-1.368 -3.848,-1.33 z m -1.929,9.228 h 3.912 l 3.019,3.578 2.848,-2.41 2.99,0.425 -5.291,6.155 h -1.38 l -6.672,-7.535 z m -72.032,73.449 c 1.298,2.902 1.182,14.11 0.633,18.615 -1.512,0.242 -7.331,-0.819 -9.622,-1.626 -0.03,-3.746 7.56,-17.01 8.989,-16.989 z m 165.746,3.686 c 2.26,2.183 8.778,11.491 9.899,17.028 -4.737,1.848 -7.13,1.737 -10.268,1.75 -0.524,-5.375 0.025,-15.455 0.369,-18.778 z m -144.16,5.298 c 0.7,2.306 0.917,12 0.91,15.147 -5.457,-0.064 -7.056,0.05 -9.217,-1.053 1.154,-3.722 7.114,-13.125 8.307,-14.094 z m 124.595,1.129 c 2.133,1.605 7.442,10.953 7.863,15.3 -2.149,1.153 -6.412,-0.834 -9.71,0.511 -0.396,-3.405 1.08,-13.216 1.847,-15.811 z m -103.86,3.048 c 1.253,2.91 1.282,13.087 0.887,15.788 -3.64,-0.125 -6.382,-0.194 -8.61,-1.244 1.456,-4.263 6.434,-14.049 7.723,-14.544 z m 84.858,2.235 c 6.195,7.39 4.356,10.836 7.645,14.315 -2.513,1.367 -6.005,0.034 -8.594,0.534 -0.543,-2.988 -0.43,-10.974 0.949,-14.849 z"
|
||||
id="path4" /><path
|
||||
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1261.415,286.337 c 0,0 -0.232,-16.306 -0.017,-23.777 6.175,-3.162 21.376,-7.561 32.51,-7.844 17.445,-0.442 34.034,9.098 51.474,9.693 17.18,0.586 34.043,-6.682 51.222,-6.07 21.3,0.758 41.526,11.622 62.836,12.025 18.81,0.356 37.157,-10.975 55.812,-8.508 8.162,1.08 15.769,5.154 22.67,9.95 l 0.147,22.107 c -5.27,-5.22 -16.661,-9.505 -25.909,-10.71 -17.649,-2.297 -35.131,7.597 -52.918,7.114 -21.582,-0.585 -42.099,-11.058 -63.664,-12.101 -17.094,-0.827 -34.035,5.368 -51.135,4.677 -17.253,-0.697 -34.07,-11.988 -51.06,-8.865 -13.23,1.225 -31.968,12.309 -31.968,12.309 z"
|
||||
id="path6" /><path
|
||||
style="fill:#2ca05a;stroke:#ffffff;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1310.514,393.724 h -45.965 m 30.96,-18.334 h -34.048"
|
||||
id="path8" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1504.723,375.495 h 33.447 m -30.507,18.138 h 28.308"
|
||||
id="path10" /><path
|
||||
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"
|
||||
d="m 1312.193,315.036 1.685,8.168 -6.604,-2.926 -1.887,7.528 -5.565,-4.795 -3.972,5.76 -3.133,-6.886 -5.882,3.697 0.808,-7.59 -7.861,2.54 3.578,-7.927 -8.07,-0.778 5.807,-5.57 -7.12,-3.726 6.8,-3.25 -4.265,-5.95 8.152,0.245 -1.687,-8.456 6.92,3.248 0.433,-6.913 5.59,3.856 3.948,-4.983 3.157,6.109 5.858,-4.221 0.643,8.149 6.433,-2.54 -2.464,7.893 6.935,0.379 -4.358,6.166 5.692,3.563 -6.823,2.98 4.265,7.124 z"
|
||||
id="path12" /><path
|
||||
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:none;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:0;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"
|
||||
d="m 1517.32,293.94 c -8.052,0 -14.578,6.686 -14.578,14.936 0,8.25 6.526,14.937 14.577,14.937 1.275,0 2.51,-0.219 3.688,-0.534 -6.265,-1.674 -10.89,-7.46 -10.89,-14.403 0,-6.944 4.625,-12.774 10.89,-14.448 a 14.262,14.262 0 0 0 -3.688,-0.489 z"
|
||||
id="path14" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1393.867,354.718 0.01,11.264 12.724,6.145 19.662,-17.552 z"
|
||||
id="path16" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:3.85714;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1353.552,369.453 17.927,-14.869 h -18 z"
|
||||
id="path18" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:3.85714;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1317.083,366.545 0.026,-6.647 5.47,-5.01 12.266,0.022 5.038,5.523 0.026,6.135 z"
|
||||
id="path20" /><path
|
||||
style="color:#000000;display:inline;overflow:visible;visibility:visible;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;enable-background:accumulate"
|
||||
d="m 1302.624,347.509 13.99,0.04 -6.642,-12.738 z"
|
||||
id="path22" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1339.844,351.567 13.674,0.147 -6.62,-25.558 z"
|
||||
id="path24" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:6;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1379.12,333.997 14.684,0.147 -7.265,-34.517 z m 46.778,2.783 13.88,0.147 -7.305,-39.642 z m 34.062,19.495 12.867,0.12 -6.772,-32.693 z"
|
||||
id="path26" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:3.85714;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1491.819,352.483 13.617,0.093 -7.167,-25.07 z m -52.198,16.052 0.022,-7.667 4.645,-5.78 10.414,0.026 5.064,6.37 0.18,7.078 z m 33.629,0.445 17.98,0.056 -9.463,-15.07 z"
|
||||
id="path28" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:3.85714;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1390.96,389.427 v 18.371 m 16.676,-35.796 17.653,10.825"
|
||||
id="path30" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1302.16,349.078 v 28.014 m 14.884,-28.186 v 41.808 m 22.932,-38.39 v 35.577 m 13.484,-35.122 v 35.477 m 25.743,-53.812 v 29.638 m 14.582,-29.256 v 35.487 m 32.478,-32.722 v 56.683 m 13.202,-56.993 v 51.452 m 20.703,-31.064 v 36.966 m 12.353,-37.353 v 42.961 m 19.591,-46.723 v 36.433 m 12.358,-35.781 v 33.155"
|
||||
id="path32" /><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:3.86;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1354.148,369.753 17.93,-14.858 34.665,16.963 -15.6,17.572 z"
|
||||
id="path34" /><path
|
||||
style="fill:#ffffff;stroke:#ffffff;stroke-width:2;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:3.86;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1317.754,389.68 -0.179,15.658 m -22.6,-19.838 0.144,-14.835 22.493,19.108 38.322,-2.833 36.298,21.616 51.237,-21.503 33.028,14.995 30.67,-25.41 -0.19,18.41 -31.084,24.249 -32.145,-16.236 -51.55,24.187 -36.658,-23.775 -37.975,2.43 z m 60.358,1.813 0.178,16.016 m 36.867,5.13 0.178,18.815 m 51.402,-39.671 0.179,15.41 m 32.572,-0.9 0.179,15.49"
|
||||
id="path36" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1;marker-start:none;marker-end:none"
|
||||
d="m 1293.986,388.228 23.398,20.564 38.155,-2.258 36.685,23.897 51.81,-24.626 32.843,16.84 33.01,-26.982"
|
||||
id="path38" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1399.41,107.973 h 139.365 v 271.61 c -8.508,82.877 -104.37,73.275 -142.307,112.72"
|
||||
id="path40" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:0;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 1400.324,107.973 H 1260.96 v 271.61 c 8.508,82.877 103.354,73.322 141.292,112.767"
|
||||
id="path42" /><path
|
||||
style="fill:#2ca05a;stroke:#ffffff;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 1286.037,394.46 76.682,75.43 m -29.35,-63.714 76.503,77.414 m -18.762,-52.207 40.482,40.586 m -19.386,-49.704 41.017,40.708 m -18.493,-51.255 41.861,41.986 m -0.565,-31.796 21.084,21.083 m 0.01,-34.67 20.108,19.832"
|
||||
id="path44" /><ellipse
|
||||
style="color:#000000;fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:round;stroke-dasharray:none"
|
||||
cx="1399.8669"
|
||||
cy="274.448"
|
||||
rx="282.784"
|
||||
ry="275.11301"
|
||||
id="ellipse46" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-dasharray:none"
|
||||
d="m 1141.61,168.288 h 104.016 m -128.543,106.16 h 128.543 M 1399.867,88.773 V -0.665 m 0,548.308 V 509.39 M 1555.61,168.288 h 104.016 m -104.016,210 h 104.016 m -519.756,0 h 105.756 m 309.457,-103.84 h 128.543"
|
||||
id="path48" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-dasharray:none"
|
||||
d="m 1304.184,88.773 c 0,-23.016 38.89,-83.661 96.536,-89.438"
|
||||
id="path50" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-dasharray:none"
|
||||
d="m 1495.55,88.773 c 0,-23.016 -38.89,-83.661 -96.536,-89.438"
|
||||
id="path52" /><g
|
||||
style="stroke-width:8;stroke-dasharray:none"
|
||||
id="g56"><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-dasharray:none"
|
||||
d="m 1303.331,460.123 c 0,23.016 38.89,83.662 96.536,89.438 m 96.536,-89.438 c 0,23.016 -38.89,83.662 -96.536,89.438"
|
||||
id="path54" /></g><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-dasharray:none"
|
||||
d="M 1399.867,549.561 C 1117.083,474.834 1117.083,74.644 1400.72,-0.665"
|
||||
id="path58" /><path
|
||||
style="fill:none;stroke:#ffffff;stroke-width:8;stroke-linecap:butt;stroke-dasharray:none"
|
||||
d="M 1399.867,549.561 C 1682.651,474.834 1682.651,74.644 1399.015,-0.665"
|
||||
id="path60" /></g></svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
1
apps/web/src/lib/components/layout/Chatbot.svelte
Normal file
1
apps/web/src/lib/components/layout/Chatbot.svelte
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!-- PLACEHOLDER FOR INTERACTIVE CHATBOT -->
|
||||
43
apps/web/src/lib/components/layout/Footer.svelte
Normal file
43
apps/web/src/lib/components/layout/Footer.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { getLocale } from '$lib/paraglide/runtime'
|
||||
import { m } from '$lib/paraglide/messages.js'
|
||||
import logo from '$lib/assets/logo.svg'
|
||||
import LanguageSwitcher from "$lib/components/layout/LanguageSwitcher.svelte";
|
||||
</script>
|
||||
|
||||
<footer class="flex flex-col justify-center items-center gap-5 mt-30 text-white px-1.5 pt-12" style="background-color: #0a3d57;">
|
||||
<div class="mb-15">
|
||||
<LanguageSwitcher showLogo={false} />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-15">
|
||||
<img
|
||||
src="{ logo }"
|
||||
alt=""
|
||||
width="150"
|
||||
height="150"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2 mb-15">
|
||||
<a href="/{getLocale()}">{ m.nav_about() }</a>
|
||||
<span> | </span>
|
||||
<a href="/{getLocale()}/neuigkeiten">{ m.nav_news() }</a>
|
||||
<span> | </span>
|
||||
<a href="/{getLocale()}/kontakt">{ m.nav_contact() }</a>
|
||||
<span> | </span>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<small>© donauscwhaben.online</small>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5"></div>
|
||||
|
||||
<nav class="text-xs text-center text-gray-200 w-full flex justify-center items-center gap-2 md:gap-5 min-h-10" style="background-color: #072d40;">
|
||||
<a href="/{getLocale()}/l/imp">{ m.footer_imprint() }</a>
|
||||
<span> | </span>
|
||||
<a href="/{getLocale()}/l/priv">{ m.footer_privacy() }</a>
|
||||
</nav>
|
||||
</footer>
|
||||
12
apps/web/src/lib/components/layout/Header.svelte
Normal file
12
apps/web/src/lib/components/layout/Header.svelte
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import LanguageSwitcher from './LanguageSwitcher.svelte'
|
||||
import MainNavigation from './MainNavigation.svelte'
|
||||
</script>
|
||||
|
||||
<header class="fixed border-b border-black w-full z-50">
|
||||
<nav class="flex gap-5 text-white items-center justify-center min-h-10 relative" style="background-color: #0a3d57;">
|
||||
<span class="text-2xl font-bold">DONAUSCHWABEN</span>
|
||||
<LanguageSwitcher />
|
||||
</nav>
|
||||
<MainNavigation />
|
||||
</header>
|
||||
66
apps/web/src/lib/components/layout/LanguageSwitcher.svelte
Normal file
66
apps/web/src/lib/components/layout/LanguageSwitcher.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { getLocale, locales, localizeHref } from '$lib/paraglide/runtime'
|
||||
import { type Locale } from '$lib/paraglide/runtime';
|
||||
import { page } from '$app/state'
|
||||
import logo from '$lib/assets/logo.svg'
|
||||
|
||||
let { showLogo = true } = $props()
|
||||
|
||||
const localeNames: Record<string, string> = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
hu: 'Magyar',
|
||||
}
|
||||
|
||||
let open = $state(false)
|
||||
|
||||
function getHref(locale: Locale): string {
|
||||
return page.data.translationSlug
|
||||
? localizeHref(`/blog/${page.data.translationSlug}`, { locale })
|
||||
: page.data.metadata
|
||||
? localizeHref('/blog', { locale })
|
||||
: localizeHref(page.url.pathname, { locale })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => open = !open}
|
||||
class="flex items-center gap-1 px-3 py-1.5 hover:bg-white/10 transition cursor-pointer select-none"
|
||||
>
|
||||
{#if showLogo}
|
||||
<img src={logo} alt="Logo" class="h-5 w-5" />
|
||||
{/if}
|
||||
{localeNames[getLocale()] ?? getLocale()}
|
||||
<span class="text-xs opacity-60 transition-transform duration-150" class:rotate-180={open}>▾</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
class="fixed inset-0 z-10 cursor-default"
|
||||
style="background: transparent; border: none;"
|
||||
onclick={() => open = false}
|
||||
aria-label="Close language switcher"
|
||||
></button>
|
||||
|
||||
<ul
|
||||
class="absolute right-0 mt-1 z-20 rounded shadow-lg overflow-hidden min-w-28 text-sm"
|
||||
style="background-color: #0a3d57;"
|
||||
role="menu"
|
||||
>
|
||||
{#each [...locales].filter(l => l !== getLocale()).sort() as locale}
|
||||
<li role="none">
|
||||
<a
|
||||
href={getHref(locale)}
|
||||
data-sveltekit-reload
|
||||
onclick={() => open = false}
|
||||
role="menuitem"
|
||||
class="block px-4 py-2 transition hover:bg-white/10 cursor-pointer"
|
||||
>
|
||||
{localeNames[locale] ?? locale}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
149
apps/web/src/lib/components/layout/MainNavigation.svelte
Normal file
149
apps/web/src/lib/components/layout/MainNavigation.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import { getLocale } from '$lib/paraglide/runtime'
|
||||
import { m } from '$lib/paraglide/messages.js'
|
||||
import { page } from '$app/state'
|
||||
|
||||
const locale = $derived(getLocale())
|
||||
const pathname = $derived(page.url.pathname)
|
||||
|
||||
type LeafItem = { label: () => string; href: string }
|
||||
type Column = { label: () => string; href: string; children?: LeafItem[] }
|
||||
type NavItem = { id: string; label: () => string; href?: string; columns?: Column[] }
|
||||
|
||||
const navItems = $derived<NavItem[]>([
|
||||
{
|
||||
id: 'donauschwaben',
|
||||
label: () => m.nav_donauschwaben(),
|
||||
href: `/${locale}/donauschwaben`,
|
||||
columns: [
|
||||
{
|
||||
label: () => m.nav_geschichte(),
|
||||
href: `/${locale}/donauschwaben/geschichte`,
|
||||
},
|
||||
{
|
||||
label: () => m.nav_kultur(),
|
||||
href: `/${locale}/donauschwaben/kultur`,
|
||||
children: [
|
||||
{ label: () => m.nav_brauchtum(), href: `/${locale}/donauschwaben/kultur/brauchtum` },
|
||||
{ label: () => m.nav_tracht(), href: `/${locale}/donauschwaben/kultur/tracht` },
|
||||
{ label: () => m.nav_religion(), href: `/${locale}/donauschwaben/kultur/religion` },
|
||||
{ label: () => m.nav_kueche(), href: `/${locale}/donauschwaben/kultur/kueche` },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: () => m.nav_politik(),
|
||||
href: `/${locale}/donauschwaben/politik`,
|
||||
},
|
||||
{
|
||||
label: () => m.nav_persoenlichkeiten(),
|
||||
href: `/${locale}/donauschwaben/persoenlichkeiten`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: 'neuigkeiten', label: () => m.nav_news(), href: `/${locale}/neuigkeiten` },
|
||||
{ id: 'artikel', label: () => m.nav_artikel(), href: `/${locale}/artikel` },
|
||||
{ id: 'organisationen', label: () => m.nav_org(), href: `/${locale}/organisationen` },
|
||||
])
|
||||
|
||||
let openId = $state<string | null>(null)
|
||||
|
||||
function toggle(id: string) {
|
||||
openId = openId === id ? null : id
|
||||
}
|
||||
|
||||
function close() {
|
||||
openId = null
|
||||
}
|
||||
|
||||
function isActive(href: string) {
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:document onclick={close} />
|
||||
|
||||
<nav id="main" aria-label="Main navigation" class="relative bg-white border-b border-gray-200 flex justify-center items-center px-5 min-h-10">
|
||||
<ul role="menubar" class="flex items-center gap-1 list-none m-0 p-0">
|
||||
{#each navItems as item (item.id)}
|
||||
<li role="none" class="relative">
|
||||
{#if item.columns}
|
||||
<button
|
||||
role="menuitem"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openId === item.id}
|
||||
onclick={(e) => { e.stopPropagation(); toggle(item.id) }}
|
||||
class="flex items-center gap-1 px-3 py-2 text-sm hover:bg-gray-100 transition cursor-pointer"
|
||||
class:font-semibold={isActive(item.href ?? '')}
|
||||
>
|
||||
{item.label()}
|
||||
<span class="text-xs opacity-50 transition-transform duration-150" class:rotate-180={openId === item.id}>▾</span>
|
||||
</button>
|
||||
|
||||
{#if openId === item.id}
|
||||
<!-- Backdrop -->
|
||||
<button
|
||||
class="fixed inset-0 z-10 bg-gray-600 opacity-30"
|
||||
style="top: 80px"
|
||||
onclick={close}
|
||||
aria-label="Close menu"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
|
||||
<!-- Mega menu panel -->
|
||||
<div
|
||||
class="fixed left-0 right-0 z-20 bg-white border border-gray-200 shadow-lg"
|
||||
role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="px-8 py-6 flex justify-center gap-10">
|
||||
{#each item.columns as col (col.href)}
|
||||
<div class="min-w-40">
|
||||
<a
|
||||
role="menuitem"
|
||||
href={col.href}
|
||||
onclick={close}
|
||||
class="block text-base font-semibold text-gray-800 hover:text-black mb-3 tracking-wide transition"
|
||||
class:underline={isActive(col.href)}
|
||||
>
|
||||
{col.label()}
|
||||
</a>
|
||||
|
||||
{#if col.children}
|
||||
<ul class="list-none m-0 p-0 flex flex-col gap-2">
|
||||
{#each col.children as child (child.href)}
|
||||
<li role="none">
|
||||
<a
|
||||
role="menuitem"
|
||||
href={child.href}
|
||||
onclick={close}
|
||||
class="block text-sm text-gray-500 hover:text-gray-900 tracking-wide transition pl-3 border-l border-gray-200 hover:border-gray-500"
|
||||
class:font-semibold={isActive(child.href)}
|
||||
class:text-gray-900={isActive(child.href)}
|
||||
>
|
||||
{child.label()}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
role="menuitem"
|
||||
href={item.href}
|
||||
class="block px-3 py-2 text-sm hover:bg-gray-100 transition"
|
||||
class:font-semibold={isActive(item.href ?? '')}
|
||||
>
|
||||
{item.label()}
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
46
apps/web/src/lib/components/ui/Breadcrumbs.svelte
Normal file
46
apps/web/src/lib/components/ui/Breadcrumbs.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import type { BreadcrumbItem } from '$lib/types/breadcrumbs';
|
||||
|
||||
interface Props {
|
||||
crumbs?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
let { crumbs }: Props = $props();
|
||||
|
||||
let segments = $derived.by(() => page.url.pathname.split('/').filter(Boolean));
|
||||
let locale = $derived(segments[0] || 'de');
|
||||
let showBreadcrumbs = $derived(segments.length > 2);
|
||||
|
||||
// Fallback: if no crumbs provided, generate from URL segments
|
||||
let items = $derived.by(() => {
|
||||
if (crumbs && crumbs.length > 0) {
|
||||
return crumbs;
|
||||
}
|
||||
if (!showBreadcrumbs) return [];
|
||||
return segments.slice(1).map((seg, i) => {
|
||||
const pathSoFar = `/${segments.slice(0, i + 2).join('/')}`;
|
||||
return { label: seg, href: pathSoFar };
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showBreadcrumbs && items.length > 0}
|
||||
<nav class="text-sm text-gray-500 mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex flex-wrap gap-2">
|
||||
<li>
|
||||
<a href={`/${locale}`} class="hover:text-brand">Home</a>
|
||||
</li>
|
||||
{#each items as item (item.href)}
|
||||
<li class="flex items-center gap-2">
|
||||
<span>/</span>
|
||||
{#if item.href !== page.url.pathname}
|
||||
<a href={item.href} class="hover:text-brand">{item.label}</a>
|
||||
{:else}
|
||||
<span class="text-gray-700">{item.label}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
{/if}
|
||||
281
apps/web/src/lib/components/ui/FilterPanel.svelte
Normal file
281
apps/web/src/lib/components/ui/FilterPanel.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import { Checkbox } from 'bits-ui'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/state'
|
||||
|
||||
export type TagFilterConfig = {
|
||||
type: 'tags'
|
||||
key: string
|
||||
label: string
|
||||
options: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
export type DateRangeFilterConfig = {
|
||||
type: 'date-range'
|
||||
keyFrom: string
|
||||
keyTo: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SelectFilterConfig = {
|
||||
type: 'select'
|
||||
key: string
|
||||
label: string
|
||||
options: { label: string; value: string }[]
|
||||
}
|
||||
|
||||
export type FilterConfig = TagFilterConfig | DateRangeFilterConfig | SelectFilterConfig
|
||||
|
||||
let { filters }: { filters: FilterConfig[] } = $props()
|
||||
|
||||
// ── Read current URL state ────────────────────────────────────────────
|
||||
function getParam(key: string): string {
|
||||
return page.url.searchParams.get(key) ?? ''
|
||||
}
|
||||
|
||||
function getParamArray(key: string): string[] {
|
||||
const val = page.url.searchParams.get(key)
|
||||
return val ? val.split(',').filter(Boolean) : []
|
||||
}
|
||||
|
||||
// ── Local filter state (derived from URL) ─────────────────────────────
|
||||
let tagValues = $state<Record<string, string[]>>(
|
||||
Object.fromEntries(
|
||||
filters
|
||||
.filter((f): f is TagFilterConfig => f.type === 'tags')
|
||||
.map((f) => [f.key, getParamArray(f.key)])
|
||||
)
|
||||
)
|
||||
|
||||
let excludeValues = $state<Record<string, string[]>>(
|
||||
Object.fromEntries(
|
||||
filters
|
||||
.filter((f): f is TagFilterConfig => f.type === 'tags')
|
||||
.map((f) => [f.key + '_exclude', getParamArray(f.key + '_exclude')])
|
||||
)
|
||||
)
|
||||
|
||||
let dateValues = $state<Record<string, string>>(
|
||||
Object.fromEntries(
|
||||
filters
|
||||
.filter((f): f is DateRangeFilterConfig => f.type === 'date-range')
|
||||
.flatMap((f) => [
|
||||
[f.keyFrom, getParam(f.keyFrom)],
|
||||
[f.keyTo, getParam(f.keyTo)],
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
let selectValues = $state<Record<string, string>>(
|
||||
Object.fromEntries(
|
||||
filters
|
||||
.filter((f): f is SelectFilterConfig => f.type === 'select')
|
||||
.map((f) => [f.key, getParam(f.key)])
|
||||
)
|
||||
)
|
||||
|
||||
// ── Mobile drawer ─────────────────────────────────────────────────────
|
||||
let drawerOpen = $state(false)
|
||||
|
||||
// ── Apply filters → navigate ──────────────────────────────────────────
|
||||
function apply() {
|
||||
const params = new URLSearchParams(page.url.searchParams)
|
||||
|
||||
for (const [key, vals] of Object.entries(tagValues)) {
|
||||
if (vals.length) params.set(key, vals.join(','))
|
||||
else params.delete(key)
|
||||
}
|
||||
for (const [key, vals] of Object.entries(excludeValues)) {
|
||||
if (vals.length) params.set(key, vals.join(','))
|
||||
else params.delete(key)
|
||||
}
|
||||
for (const [key, val] of Object.entries(dateValues)) {
|
||||
if (val) params.set(key, val)
|
||||
else params.delete(key)
|
||||
}
|
||||
for (const [key, val] of Object.entries(selectValues)) {
|
||||
if (val) params.set(key, val)
|
||||
else params.delete(key)
|
||||
}
|
||||
|
||||
params.delete('page') // reset pagination on filter change
|
||||
goto(`?${params.toString()}`, { keepFocus: true })
|
||||
drawerOpen = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
for (const key of Object.keys(tagValues)) tagValues[key] = []
|
||||
for (const key of Object.keys(excludeValues)) excludeValues[key] = []
|
||||
for (const key of Object.keys(dateValues)) dateValues[key] = ''
|
||||
for (const key of Object.keys(selectValues)) selectValues[key] = ''
|
||||
goto(page.url.pathname, { keepFocus: true })
|
||||
drawerOpen = false
|
||||
}
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
Object.values(tagValues).some((v) => v.length) ||
|
||||
Object.values(excludeValues).some((v) => v.length) ||
|
||||
Object.values(dateValues).some((v) => v) ||
|
||||
Object.values(selectValues).some((v) => v)
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- ── Mobile trigger ──────────────────────────────────────────────────── -->
|
||||
<div class="lg:hidden mb-4">
|
||||
<button
|
||||
onclick={() => drawerOpen = true}
|
||||
class="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded text-sm hover:bg-gray-50 transition"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h6a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h3a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Filter
|
||||
{#if hasActiveFilters}
|
||||
<span class="inline-flex items-center justify-center w-4 h-4 text-xs bg-brand text-white rounded-full">
|
||||
{Object.values(tagValues).flat().length + Object.values(excludeValues).flat().length + Object.values(selectValues).filter(Boolean).length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile drawer backdrop ─────────────────────────────────────────── -->
|
||||
{#if drawerOpen}
|
||||
<button
|
||||
class="fixed inset-0 z-30 bg-black/40 lg:hidden"
|
||||
onclick={() => drawerOpen = false}
|
||||
aria-label="Filter schließen"
|
||||
></button>
|
||||
<div class="fixed inset-y-0 left-0 z-40 w-80 bg-white shadow-xl p-6 overflow-y-auto lg:hidden">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="font-semibold text-lg">Filter</h2>
|
||||
<button onclick={() => drawerOpen = false} class="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
<svelte:self {filters} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Desktop aside (rendered by parent layout) ─────────────────────── -->
|
||||
<aside class="hidden lg:block w-56 shrink-0">
|
||||
<div class="sticky top-24 flex flex-col gap-6">
|
||||
{@render filterContent()}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ── Filter content (shared mobile/desktop) ────────────────────────── -->
|
||||
{#snippet filterContent()}
|
||||
{#each filters as filter}
|
||||
{#if filter.type === 'tags'}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">{filter.label}</h3>
|
||||
|
||||
<!-- Include -->
|
||||
<Checkbox.Group bind:value={tagValues[filter.key]} class="flex flex-col gap-2">
|
||||
{#each filter.options as opt}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
value={opt.value}
|
||||
class="w-4 h-4 rounded border border-gray-300 data-[state=checked]:bg-brand data-[state=checked]:border-brand transition flex items-center justify-center"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
{#if checked}
|
||||
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M1.5 5l2.5 2.5 4.5-4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
<label class="text-sm text-gray-700 cursor-pointer">{opt.label}</label>
|
||||
</div>
|
||||
{/each}
|
||||
</Checkbox.Group>
|
||||
|
||||
<!-- Exclude -->
|
||||
{#if tagValues[filter.key + '_exclude'] !== undefined}
|
||||
<div class="mt-3 pt-3 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400 mb-2">Ausschließen</p>
|
||||
<Checkbox.Group bind:value={excludeValues[filter.key + '_exclude']} class="flex flex-col gap-2">
|
||||
{#each filter.options as opt}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
value={opt.value}
|
||||
class="w-4 h-4 rounded border border-gray-300 data-[state=checked]:bg-red-500 data-[state=checked]:border-red-500 transition flex items-center justify-center"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
{#if checked}
|
||||
<svg class="w-2.5 h-2.5 text-white" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
<label class="text-sm text-gray-700 cursor-pointer">{opt.label}</label>
|
||||
</div>
|
||||
{/each}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if filter.type === 'date-range'}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">{filter.label}</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 mb-1 block">Von</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1600"
|
||||
max="2100"
|
||||
placeholder="z.B. 1900"
|
||||
bind:value={dateValues[filter.keyFrom]}
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-gray-500 mb-1 block">Bis</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1600"
|
||||
max="2100"
|
||||
placeholder="z.B. 2024"
|
||||
bind:value={dateValues[filter.keyTo]}
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-brand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if filter.type === 'select'}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">{filter.label}</h3>
|
||||
<select
|
||||
bind:value={selectValues[filter.key]}
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-brand bg-white"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{#each filter.options as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
<button
|
||||
onclick={apply}
|
||||
class="w-full px-4 py-2 bg-brand text-white text-sm rounded hover:bg-brand/90 transition"
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
{#if hasActiveFilters}
|
||||
<button
|
||||
onclick={reset}
|
||||
class="w-full px-4 py-2 text-sm text-gray-500 hover:text-gray-700 transition"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
106
apps/web/src/lib/components/ui/LexicalRenderer.svelte
Normal file
106
apps/web/src/lib/components/ui/LexicalRenderer.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import LexicalRenderer from './LexicalRenderer.svelte'
|
||||
|
||||
type TextNode = {
|
||||
type: 'text'
|
||||
text: string
|
||||
format: number
|
||||
}
|
||||
|
||||
type LineBreakNode = {
|
||||
type: 'linebreak'
|
||||
}
|
||||
|
||||
type UploadNode = {
|
||||
type: 'upload'
|
||||
relationTo: string
|
||||
value: {
|
||||
url: string
|
||||
alt?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
type LexicalNode = TextNode | LineBreakNode | UploadNode | {
|
||||
type: 'paragraph' | 'heading' | 'root' | 'listitem' | 'list'
|
||||
tag?: string
|
||||
children: LexicalNode[]
|
||||
format?: string
|
||||
}
|
||||
|
||||
let { nodes }: { nodes: LexicalNode[] } = $props()
|
||||
|
||||
function isBold(format: number) { return (format & 1) !== 0 }
|
||||
function isItalic(format: number) { return (format & 2) !== 0 }
|
||||
function isStrikethrough(format: number) { return (format & 4) !== 0 }
|
||||
function isUnderline(format: number) { return (format & 8) !== 0 }
|
||||
function isCode(format: number) { return (format & 16) !== 0 }
|
||||
</script>
|
||||
|
||||
{#each nodes as node}
|
||||
{#if node.type === 'paragraph'}
|
||||
{#if node.children?.length}
|
||||
<p><LexicalRenderer nodes={node.children} /></p>
|
||||
{:else}
|
||||
<br />
|
||||
{/if}
|
||||
|
||||
{:else if node.type === 'heading'}
|
||||
{#if node.tag === 'h1'}
|
||||
<h1><LexicalRenderer nodes={node.children} /></h1>
|
||||
{:else if node.tag === 'h2'}
|
||||
<h2><LexicalRenderer nodes={node.children} /></h2>
|
||||
{:else if node.tag === 'h3'}
|
||||
<h3><LexicalRenderer nodes={node.children} /></h3>
|
||||
{:else if node.tag === 'h4'}
|
||||
<h4><LexicalRenderer nodes={node.children} /></h4>
|
||||
{:else if node.tag === 'h5'}
|
||||
<h5><LexicalRenderer nodes={node.children} /></h5>
|
||||
{:else if node.tag === 'h6'}
|
||||
<h6><LexicalRenderer nodes={node.children} /></h6>
|
||||
{/if}
|
||||
|
||||
{:else if node.type === 'list'}
|
||||
{#if node.tag === 'ol'}
|
||||
<ol><LexicalRenderer nodes={node.children} /></ol>
|
||||
{:else}
|
||||
<ul><LexicalRenderer nodes={node.children} /></ul>
|
||||
{/if}
|
||||
|
||||
{:else if node.type === 'listitem'}
|
||||
<li><LexicalRenderer nodes={node.children} /></li>
|
||||
|
||||
{:else if node.type === 'text'}
|
||||
{#if isCode(node.format)}
|
||||
<code>{node.text}</code>
|
||||
{:else if isBold(node.format) && isItalic(node.format)}
|
||||
<strong><em>{node.text}</em></strong>
|
||||
{:else if isBold(node.format)}
|
||||
<strong>{node.text}</strong>
|
||||
{:else if isItalic(node.format)}
|
||||
<em>{node.text}</em>
|
||||
{:else if isStrikethrough(node.format)}
|
||||
<s>{node.text}</s>
|
||||
{:else if isUnderline(node.format)}
|
||||
<u>{node.text}</u>
|
||||
{:else}
|
||||
{node.text}
|
||||
{/if}
|
||||
|
||||
{:else if node.type === 'linebreak'}
|
||||
<br />
|
||||
|
||||
{:else if node.type === 'upload'}
|
||||
<figure class="my-6">
|
||||
<img
|
||||
src={node.value.url}
|
||||
alt={node.value.alt ?? ''}
|
||||
width={node.value.width}
|
||||
height={node.value.height}
|
||||
class="w-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
</figure>
|
||||
{/if}
|
||||
{/each}
|
||||
1
apps/web/src/lib/index.ts
Normal file
1
apps/web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue