commit c452a505e29c17e42b4f1a8e225bd69c46a0d91e Author: Aron August Hohmann Date: Thu Sep 3 19:51:56 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b850760 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bac4a8d --- /dev/null +++ b/README.md @@ -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 +``` + diff --git a/apps/cms/.env.example b/apps/cms/.env.example new file mode 100644 index 0000000..663b584 --- /dev/null +++ b/apps/cms/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL=postgres://donauschwaben:devpassword@localhost:5432/donauschwaben +PAYLOAD_SECRET=YOUR_SECRET_HERE diff --git a/apps/cms/.gitignore b/apps/cms/.gitignore new file mode 100644 index 0000000..93c7f25 --- /dev/null +++ b/apps/cms/.gitignore @@ -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/ diff --git a/apps/cms/.npmrc b/apps/cms/.npmrc new file mode 100644 index 0000000..e9ee3cb --- /dev/null +++ b/apps/cms/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true \ No newline at end of file diff --git a/apps/cms/.prettierrc.json b/apps/cms/.prettierrc.json new file mode 100644 index 0000000..cb8ee26 --- /dev/null +++ b/apps/cms/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": false +} diff --git a/apps/cms/.vibe/skills/cms-migration/SKILL.md b/apps/cms/.vibe/skills/cms-migration/SKILL.md new file mode 100644 index 0000000..3b3693f --- /dev/null +++ b/apps/cms/.vibe/skills/cms-migration/SKILL.md @@ -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 | diff --git a/apps/cms/.vibe/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md b/apps/cms/.vibe/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md new file mode 100644 index 0000000..d80ce6c --- /dev/null +++ b/apps/cms/.vibe/skills/cms-migration/reference/PAYLOAD-FIELD-REFERENCE.md @@ -0,0 +1,1252 @@ +# Payload CMS Field Reference for AI-Assisted Migration + +This document helps AI assistants analyze source CMS data and generate appropriate Payload collection configurations. When given sample data from a source CMS, use this reference to determine the correct Payload field types. + +## How to Use This Document + +1. Analyze the source data structure (JSON, API response, or database schema) +2. For each field, determine the data type and pattern +3. Match to the appropriate Payload field type below +4. Generate a Payload collection config + +--- + +## Field Type Schemas + +Every field shares these **base properties**: + +```typescript +type BaseField = { + name: string // Required. Field identifier (camelCase) + label?: string // Admin UI label. Defaults to name + required?: boolean // Validation. Default: false + unique?: boolean // Database unique constraint + index?: boolean // Database index for faster queries + localized?: boolean // Enable per-locale values + hidden?: boolean // Hide from admin UI + saveToJWT?: boolean // Include in auth JWT + defaultValue?: unknown // Default when creating new docs + validate?: Function // Custom validation function + access?: { // Field-level access control + create?: Function + read?: Function + update?: Function + } + hooks?: { // Field lifecycle hooks + beforeValidate?: Function[] + beforeChange?: Function[] + afterChange?: Function[] + afterRead?: Function[] + } + admin?: { + condition?: Function // Conditionally show/hide field + description?: string // Help text below field + position?: 'sidebar' // Move to sidebar in admin + width?: string // CSS width (e.g., '50%') + style?: CSSProperties // Inline styles + className?: string // CSS class + readOnly?: boolean // Disable editing + disabled?: boolean // Disable field entirely + hidden?: boolean // Hide in admin + components?: { // Custom React components + Field?: Component + Cell?: Component + Filter?: Component + } + } +} +``` + +--- + +## Field Types + +### text + +Single-line text input. + +**Full schema:** +```typescript +type TextField = BaseField & { + type: 'text' + minLength?: number // Minimum character count + maxLength?: number // Maximum character count + hasMany?: boolean // Allow multiple values (array of strings) + minRows?: number // Min items when hasMany: true + maxRows?: number // Max items when hasMany: true + admin?: BaseField['admin'] & { + placeholder?: string // Placeholder text + autoComplete?: string // HTML autocomplete attribute + rtl?: boolean // Right-to-left text + } +} +``` + +**Use when:** +- Short strings (titles, names, slugs, URLs) +- Data is typically < 200 characters +- No line breaks expected + +**Source patterns:** +```json +{ "title": "Hello World" } +{ "slug": "hello-world" } +{ "url": "https://example.com" } +{ "sku": "PROD-12345" } +``` + +**Payload config examples:** +```typescript +{ name: 'title', type: 'text', required: true } +{ name: 'slug', type: 'text', unique: true, index: true } +{ name: 'tags', type: 'text', hasMany: true, maxRows: 10 } +{ name: 'sku', type: 'text', minLength: 5, maxLength: 20 } +``` + +--- + +### textarea + +Multi-line text without formatting. + +**Full schema:** +```typescript +type TextareaField = BaseField & { + type: 'textarea' + minLength?: number // Minimum character count + maxLength?: number // Maximum character count + admin?: BaseField['admin'] & { + placeholder?: string // Placeholder text + rows?: number // Visible rows (height) + rtl?: boolean // Right-to-left text + } +} +``` + +**Use when:** +- Longer text content without HTML/rich formatting +- Descriptions, excerpts, plain summaries +- Data contains line breaks but no markup + +**Source patterns:** +```json +{ "description": "A longer description\nthat spans multiple lines" } +{ "excerpt": "Brief summary of the content..." } +{ "bio": "Author biography text here" } +``` + +**Payload config examples:** +```typescript +{ name: 'description', type: 'textarea' } +{ name: 'excerpt', type: 'textarea', maxLength: 500 } +{ name: 'bio', type: 'textarea', admin: { rows: 6 } } +``` + +--- + +### richText + +Rich text editor (Lexical by default, or Slate). + +**Full schema:** +```typescript +type RichTextField = BaseField & { + type: 'richText' + editor?: LexicalEditorConfig // Lexical editor configuration + // Lexical-specific options (via editor config): + // - features: Enable/disable toolbar features + // - lexical: Raw Lexical configuration + admin?: BaseField['admin'] & { + hideGutter?: boolean // Hide left gutter + elements?: string[] // Deprecated (Slate). Use editor.features + leaves?: string[] // Deprecated (Slate). Use editor.features + } +} +``` + +**Use when:** +- HTML content from WYSIWYG editors +- Markdown content (will need conversion) +- Content with formatting (bold, italic, links, headings) +- Content blocks from Contentful, Sanity, etc. + +**Source patterns:** +```json +{ "content": "

Hello world

" } +{ "body": "# Heading\n\nParagraph with **bold**" } +{ "content": { "nodeType": "document", "content": [...] } } +``` + +**Payload config examples:** +```typescript +{ name: 'content', type: 'richText' } +{ name: 'body', type: 'richText', required: true } +``` + +**Migration notes:** +- WordPress `content.rendered` can be imported as HTML +- Contentful Rich Text requires conversion to Lexical format +- Markdown should be converted to HTML first, or use Lexical markdown plugin +- Data stored as Lexical JSON, not HTML + +--- + +### number + +Numeric values (integers or decimals). + +**Full schema:** +```typescript +type NumberField = BaseField & { + type: 'number' + min?: number // Minimum value + max?: number // Maximum value + hasMany?: boolean // Allow multiple values (array of numbers) + minRows?: number // Min items when hasMany: true + maxRows?: number // Max items when hasMany: true + admin?: BaseField['admin'] & { + placeholder?: string // Placeholder text + autoComplete?: string // HTML autocomplete attribute + step?: number // Increment step (e.g., 0.01 for currency) + } +} +``` + +**Use when:** +- Prices, quantities, counts +- Ratings, scores +- Any numeric data + +**Source patterns:** +```json +{ "price": 29.99 } +{ "quantity": 5 } +{ "rating": 4.5 } +{ "views": 1000 } +``` + +**Payload config examples:** +```typescript +{ name: 'price', type: 'number', min: 0, admin: { step: 0.01 } } +{ name: 'quantity', type: 'number', min: 0, max: 1000 } +{ name: 'rating', type: 'number', min: 0, max: 5 } +{ name: 'scores', type: 'number', hasMany: true } +``` + +--- + +### email + +Email address field with built-in validation. + +**Full schema:** +```typescript +type EmailField = BaseField & { + type: 'email' + minLength?: number // Minimum character count + maxLength?: number // Maximum character count + admin?: BaseField['admin'] & { + placeholder?: string // Placeholder text + autoComplete?: string // HTML autocomplete attribute + } +} +``` + +**Use when:** +- Field contains email addresses +- Field name suggests email (email, contactEmail, etc.) + +**Source patterns:** +```json +{ "email": "user@example.com" } +{ "contactEmail": "support@company.com" } +``` + +**Payload config examples:** +```typescript +{ name: 'email', type: 'email', required: true } +{ name: 'contactEmail', type: 'email', admin: { placeholder: 'you@example.com' } } +``` + +--- + +### date + +Date/datetime picker. + +**Full schema:** +```typescript +type DateField = BaseField & { + type: 'date' + admin?: BaseField['admin'] & { + placeholder?: string // Placeholder text + date?: { + displayFormat?: string // Display format (e.g., 'MMM d, yyyy') + pickerAppearance?: 'dayOnly' | 'dayAndTime' | 'monthOnly' | 'timeOnly' + minDate?: Date // Earliest selectable date + maxDate?: Date // Latest selectable date + } + } +} +``` + +**Use when:** +- ISO date strings +- Timestamps +- Any date/time values + +**Source patterns:** +```json +{ "publishedAt": "2024-01-15T10:30:00Z" } +{ "createdAt": "2024-01-15" } +{ "eventDate": 1705312200000 } +``` + +**Payload config examples:** +```typescript +{ name: 'publishedAt', type: 'date' } +{ name: 'eventDate', type: 'date', admin: { date: { pickerAppearance: 'dayAndTime' } } } +{ name: 'birthDate', type: 'date', admin: { date: { pickerAppearance: 'dayOnly' } } } +``` + +**Migration notes:** +- Payload stores dates as ISO strings +- Unix timestamps should be converted: `new Date(timestamp).toISOString()` + +--- + +### checkbox + +Boolean true/false toggle. + +**Full schema:** +```typescript +type CheckboxField = BaseField & { + type: 'checkbox' + defaultValue?: boolean // Default checked state + admin?: BaseField['admin'] // No additional checkbox-specific admin options +} +``` + +**Use when:** +- Boolean values +- Yes/no flags +- Feature toggles + +**Source patterns:** +```json +{ "featured": true } +{ "isPublished": false } +{ "allowComments": true } +``` + +**Payload config examples:** +```typescript +{ name: 'featured', type: 'checkbox', defaultValue: false } +{ name: 'isPublished', type: 'checkbox' } +{ name: 'allowComments', type: 'checkbox', defaultValue: true } +``` + +--- + +### select + +Dropdown with predefined options. + +**Full schema:** +```typescript +type SelectField = BaseField & { + type: 'select' + options: Array< // Required. List of options + | string // Simple: just the value (label = value) + | { label: string; value: string } // Full: separate label and value + > + hasMany?: boolean // Allow multiple selections + defaultValue?: string | string[] // Default selected value(s) + admin?: BaseField['admin'] & { + isClearable?: boolean // Allow clearing selection + isSortable?: boolean // Allow drag-to-reorder when hasMany + } +} +``` + +**Use when:** +- Enum values +- Status fields +- Category/type with fixed options +- Field has limited set of valid values + +**Source patterns:** +```json +{ "status": "published" } +{ "priority": "high" } +{ "type": "article" } +{ "tags": ["featured", "trending"] } +``` + +**Payload config examples:** +```typescript +// Simple options (value = label) +{ name: 'priority', type: 'select', options: ['low', 'medium', 'high'] } + +// Full options +{ + name: 'status', + type: 'select', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + { label: 'Archived', value: 'archived' }, + ], + defaultValue: 'draft', +} + +// Multiple selection +{ + name: 'tags', + type: 'select', + hasMany: true, + options: [ + { label: 'Featured', value: 'featured' }, + { label: 'Trending', value: 'trending' }, + { label: 'New', value: 'new' }, + ], +} +``` + +**Detecting options from data:** +If you see the same field with different values across records, collect unique values to build options: +```json +// Record 1: { "status": "draft" } +// Record 2: { "status": "published" } +// Record 3: { "status": "published" } +// options: draft, published +``` + +--- + +### radio + +Radio button group (single selection, always visible). + +**Full schema:** +```typescript +type RadioField = BaseField & { + type: 'radio' + options: Array< // Required. List of options + | string // Simple: just the value + | { label: string; value: string } // Full: separate label and value + > + defaultValue?: string // Default selected value + admin?: BaseField['admin'] & { + layout?: 'horizontal' | 'vertical' // Button arrangement + } +} +``` + +**Use when:** +- Same as select, but fewer options (2-4) +- User should see all options at once + +**Payload config examples:** +```typescript +{ + name: 'size', + type: 'radio', + options: [ + { label: 'Small', value: 'sm' }, + { label: 'Medium', value: 'md' }, + { label: 'Large', value: 'lg' }, + ], + defaultValue: 'md', +} + +{ + name: 'alignment', + type: 'radio', + options: ['left', 'center', 'right'], + admin: { layout: 'horizontal' }, +} +``` + +--- + +### relationship + +Reference to another document. + +**Full schema:** +```typescript +type RelationshipField = BaseField & { + type: 'relationship' + relationTo: string | string[] // Required. Target collection slug(s) + hasMany?: boolean // Allow multiple selections + minRows?: number // Min items when hasMany: true + maxRows?: number // Max items when hasMany: true + filterOptions?: // Limit selectable documents + | Where // Static where query + | ((args: FilterOptionsProps) => Where | boolean) // Dynamic filter + admin?: BaseField['admin'] & { + isSortable?: boolean // Allow drag-to-reorder when hasMany + allowCreate?: boolean // Allow creating new docs from field (default: true) + allowEdit?: boolean // Allow editing related doc inline + } +} + +// When relationTo is an array (polymorphic), stored value shape is: +// { relationTo: 'collectionSlug', value: 'documentId' } + +// When relationTo is a string, stored value is just the ID: +// 'documentId' +``` + +**Use when:** +- Foreign key / ID reference to another collection +- Nested object that should be a separate document +- Author, category, tag references + +**Source patterns:** +```json +// ID reference +{ "author": 123 } +{ "authorId": "user_abc123" } + +// Object with ID +{ "author": { "id": 123, "name": "John" } } + +// Contentful link +{ "author": { "sys": { "id": "abc123", "linkType": "Entry" } } } + +// Array of references +{ "categories": [1, 2, 3] } +{ "tags": [{ "id": 1 }, { "id": 2 }] } +``` + +**Payload config examples:** +```typescript +// Single relationship +{ name: 'author', type: 'relationship', relationTo: 'users' } + +// Multiple relationships (hasMany) +{ name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true } + +// Polymorphic (multiple collection types) +{ + name: 'relatedContent', + type: 'relationship', + relationTo: ['posts', 'pages', 'products'], + hasMany: true, +} + +// With filter (only show published posts) +{ + name: 'featuredPost', + type: 'relationship', + relationTo: 'posts', + filterOptions: { + status: { equals: 'published' }, + }, +} +``` + +--- + +### upload + +File/media upload field. References a document in an upload-enabled collection. + +**Full schema:** +```typescript +type UploadField = BaseField & { + type: 'upload' + relationTo: string // Required. Upload collection slug (e.g., 'media') + hasMany?: boolean // Allow multiple files + minRows?: number // Min items when hasMany: true + maxRows?: number // Max items when hasMany: true + filterOptions?: // Limit selectable files + | Where + | ((args: FilterOptionsProps) => Where | boolean) + admin?: BaseField['admin'] & { + isSortable?: boolean // Allow drag-to-reorder when hasMany + } +} + +// Stored value is the upload document ID (or array of IDs when hasMany) +``` + +**Use when:** +- Image URLs or references +- File attachments +- Media library references + +**Source patterns:** +```json +// URL reference +{ "featuredImage": "https://example.com/image.jpg" } + +// WordPress media ID +{ "featured_media": 456 } + +// Object with URL +{ "image": { "url": "https://...", "alt": "Description" } } + +// Contentful asset +{ "image": { "sys": { "linkType": "Asset" }, "fields": { "file": { "url": "//images.ctfassets.net/..." } } } } + +// Multiple images +{ "gallery": ["https://...", "https://..."] } +``` + +**Payload config examples:** +```typescript +{ name: 'featuredImage', type: 'upload', relationTo: 'media' } +{ name: 'gallery', type: 'upload', relationTo: 'media', hasMany: true, maxRows: 10 } +{ name: 'document', type: 'upload', relationTo: 'documents' } +``` + +**Migration notes:** +- Download remote images and upload to Payload +- Store the new Payload media ID in the field +- Preserve alt text as a separate field on the media collection or via a group + +--- + +### array + +Repeatable group of fields. + +**Full schema:** +```typescript +type ArrayField = BaseField & { + type: 'array' + fields: Field[] // Required. Sub-fields for each row + minRows?: number // Minimum number of rows + maxRows?: number // Maximum number of rows + labels?: { // Custom row labels + singular?: string + plural?: string + } + admin?: BaseField['admin'] & { + initCollapsed?: boolean // Start rows collapsed + isSortable?: boolean // Allow drag-to-reorder (default: true) + components?: { + RowLabel?: Component // Custom row label component + } + } + // Each row automatically gets an 'id' field +} + +// Stored as array of objects: +// [{ id: 'abc', field1: 'value', field2: 'value' }, ...] +``` + +**Use when:** +- Array of objects with consistent structure +- Repeater fields (ACF, etc.) +- List of items with multiple properties each + +**Source patterns:** +```json +{ + "socialLinks": [ + { "platform": "twitter", "url": "https://twitter.com/..." }, + { "platform": "github", "url": "https://github.com/..." } + ] +} + +{ + "features": [ + { "title": "Feature 1", "description": "..." }, + { "title": "Feature 2", "description": "..." } + ] +} +``` + +**Payload config examples:** +```typescript +{ + name: 'socialLinks', + type: 'array', + labels: { singular: 'Link', plural: 'Links' }, + minRows: 1, + maxRows: 5, + fields: [ + { + name: 'platform', + type: 'select', + options: ['twitter', 'github', 'linkedin'], + required: true, + }, + { name: 'url', type: 'text', required: true }, + ], +} + +{ + name: 'features', + type: 'array', + fields: [ + { name: 'title', type: 'text', required: true }, + { name: 'description', type: 'textarea' }, + { name: 'icon', type: 'upload', relationTo: 'media' }, + ], +} +``` + +--- + +### group + +Nested object (non-repeating). + +**Full schema:** +```typescript +type GroupField = BaseField & { + type: 'group' + fields: Field[] // Required. Sub-fields + admin?: BaseField['admin'] & { + hideGutter?: boolean // Remove left border/gutter + } +} + +// Stored as nested object: +// { field1: 'value', field2: 'value' } +``` + +**Use when:** +- Nested object that's always singular +- Organizational grouping of related fields +- SEO metadata, address blocks, etc. + +**Source patterns:** +```json +{ + "seo": { + "title": "Page Title", + "description": "Meta description", + "keywords": ["a", "b"] + } +} + +{ + "address": { + "street": "123 Main St", + "city": "Springfield", + "zip": "12345" + } +} +``` + +**Payload config examples:** +```typescript +{ + name: 'seo', + type: 'group', + label: 'SEO Settings', + fields: [ + { name: 'title', type: 'text', maxLength: 60 }, + { name: 'description', type: 'textarea', maxLength: 160 }, + { name: 'keywords', type: 'text', hasMany: true }, + ], +} + +{ + name: 'address', + type: 'group', + fields: [ + { name: 'street', type: 'text' }, + { name: 'city', type: 'text' }, + { name: 'state', type: 'text' }, + { name: 'zip', type: 'text' }, + { name: 'country', type: 'select', options: ['US', 'CA', 'UK'] }, + ], +} +``` + +--- + +### blocks + +Flexible content / page builder blocks. + +**Full schema:** +```typescript +type BlocksField = BaseField & { + type: 'blocks' + blocks: Block[] // Required. Available block types + minRows?: number // Minimum number of blocks + maxRows?: number // Maximum number of blocks + admin?: BaseField['admin'] & { + initCollapsed?: boolean // Start blocks collapsed + isSortable?: boolean // Allow drag-to-reorder (default: true) + } +} + +type Block = { + slug: string // Required. Unique block identifier + labels?: { // Custom labels + singular?: string + plural?: string + } + fields: Field[] // Required. Fields in this block + imageURL?: string // Preview image URL + imageAltText?: string // Alt text for preview + admin?: { + components?: { + Label?: Component // Custom block label + } + } +} + +// Stored as array with blockType identifier: +// [ +// { id: 'abc', blockType: 'hero', title: 'Welcome' }, +// { id: 'def', blockType: 'textBlock', content: {...} } +// ] +``` + +**Use when:** +- Dynamic content zones +- Page builder layouts +- ACF Flexible Content +- Contentful/Sanity block content + +**Source patterns:** +```json +{ + "layout": [ + { "type": "hero", "title": "Welcome", "image": "..." }, + { "type": "textBlock", "content": "

...

" }, + { "type": "gallery", "images": [...] } + ] +} +``` + +**Payload config examples:** +```typescript +{ + name: 'layout', + type: 'blocks', + minRows: 1, + blocks: [ + { + slug: 'hero', + labels: { singular: 'Hero Section', plural: 'Hero Sections' }, + fields: [ + { name: 'title', type: 'text', required: true }, + { name: 'subtitle', type: 'text' }, + { name: 'image', type: 'upload', relationTo: 'media' }, + { + name: 'cta', + type: 'group', + fields: [ + { name: 'label', type: 'text' }, + { name: 'url', type: 'text' }, + ], + }, + ], + }, + { + slug: 'textBlock', + labels: { singular: 'Text Block', plural: 'Text Blocks' }, + fields: [ + { name: 'content', type: 'richText', required: true }, + ], + }, + { + slug: 'gallery', + fields: [ + { name: 'images', type: 'upload', relationTo: 'media', hasMany: true }, + { name: 'columns', type: 'select', options: ['2', '3', '4'] }, + ], + }, + ], +} +``` + +**Migration notes:** +- Map source block `type` field to Payload `blockType` +- Each block type needs its own field definitions + +--- + +### json + +Arbitrary JSON data. + +**Full schema:** +```typescript +type JSONField = BaseField & { + type: 'json' + jsonSchema?: JSONSchema // Optional JSON Schema for validation + admin?: BaseField['admin'] & { + editorOptions?: object // Monaco editor options + } +} + +// Stored as-is (any valid JSON) +``` + +**Use when:** +- Unstructured or highly variable data +- Third-party API responses to store +- Data that doesn't fit other types +- Temporary/flexible storage during migration + +**Source patterns:** +```json +{ "metadata": { "arbitrary": "data", "nested": { "values": true } } } +{ "apiResponse": { ... } } +{ "config": { "settings": [...] } } +``` + +**Payload config examples:** +```typescript +{ name: 'metadata', type: 'json' } + +// With JSON Schema validation +{ + name: 'settings', + type: 'json', + jsonSchema: { + type: 'object', + properties: { + theme: { type: 'string' }, + notifications: { type: 'boolean' }, + }, + }, +} +``` + +**Migration notes:** +- Use as fallback when data structure is unknown or highly variable +- Consider converting to proper fields later for better querying + +--- + +### point + +Geographic coordinates (longitude, latitude). + +**Full schema:** +```typescript +type PointField = BaseField & { + type: 'point' + admin?: BaseField['admin'] // No additional point-specific admin options +} + +// Stored as GeoJSON Point: +// [longitude, latitude] // Note: longitude first! +// e.g., [-74.0060, 40.7128] for New York City +``` + +**Use when:** +- Latitude/longitude pairs +- Map locations +- Geolocation data + +**Source patterns:** +```json +{ "location": { "lat": 40.7128, "lng": -74.0060 } } +{ "coordinates": [40.7128, -74.0060] } +{ "geo": { "latitude": 40.7128, "longitude": -74.0060 } } +``` + +**Payload config examples:** +```typescript +{ name: 'location', type: 'point' } +{ name: 'coordinates', type: 'point', required: true } +``` + +**Migration notes:** +- Payload uses GeoJSON format: `[longitude, latitude]` +- Many sources use `[latitude, longitude]` - swap if needed! +- Convert from `{ lat, lng }` objects to `[lng, lat]` array + +--- + +### row (Layout) + +Horizontal layout for placing fields side-by-side. + +**Full schema:** +```typescript +type RowField = { + type: 'row' + fields: Field[] // Required. Fields to display in row + admin?: { + condition?: Function // Conditionally show/hide + } +} +// No name required - purely layout +``` + +**Payload config example:** +```typescript +{ + type: 'row', + fields: [ + { name: 'firstName', type: 'text', admin: { width: '50%' } }, + { name: 'lastName', type: 'text', admin: { width: '50%' } }, + ], +} +``` + +--- + +### collapsible (Layout) + +Collapsible section for grouping fields. + +**Full schema:** +```typescript +type CollapsibleField = { + type: 'collapsible' + label: string | Function // Required. Section header + fields: Field[] // Required. Fields inside + admin?: { + initCollapsed?: boolean // Start collapsed (default: false) + condition?: Function + } +} +// No name required - purely layout +``` + +**Payload config example:** +```typescript +{ + type: 'collapsible', + label: 'Advanced Settings', + admin: { initCollapsed: true }, + fields: [ + { name: 'customCSS', type: 'textarea' }, + { name: 'customJS', type: 'textarea' }, + ], +} +``` + +--- + +### tabs (Layout) + +Tabbed interface for organizing fields. + +**Full schema:** +```typescript +type TabsField = { + type: 'tabs' + tabs: Tab[] // Required. Array of tabs + admin?: { + condition?: Function + } +} + +type Tab = { + label: string // Required. Tab label + name?: string // If set, fields are nested under this key + fields: Field[] // Required. Fields in this tab + description?: string // Help text for tab +} +// No name on parent - tabs are layout only (unless tab has name) +``` + +**Payload config example:** +```typescript +{ + type: 'tabs', + tabs: [ + { + label: 'Content', + fields: [ + { name: 'title', type: 'text' }, + { name: 'body', type: 'richText' }, + ], + }, + { + label: 'SEO', + name: 'seo', // Fields nested under 'seo' key + fields: [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ], + }, + ], +} +``` + +--- + +### ui (Custom Component) + +Render custom React component without storing data. + +**Full schema:** +```typescript +type UIField = { + type: 'ui' + name: string // Required (for key, not storage) + admin: { + components: { + Field: Component // Required. React component to render + Cell?: Component // List view component + } + condition?: Function + } +} +// Does NOT store data - purely visual +``` + +--- + +## Collection-Level Configuration + +### Basic Collection + +```typescript +const posts: CollectionConfig = { + slug: 'posts', + labels: { + singular: 'Post', + plural: 'Posts', + }, + fields: [ + { name: 'title', type: 'text', required: true }, + { name: 'slug', type: 'text', unique: true }, + { name: 'content', type: 'richText' }, + { name: 'author', type: 'relationship', relationTo: 'users' }, + { name: 'publishedAt', type: 'date' }, + ], +} +``` + +### Upload Collection + +```typescript +const media: CollectionConfig = { + slug: 'media', + labels: { + singular: 'Media', + plural: 'Media', + }, + upload: { + staticDir: 'media', // Directory for files (relative to project) + staticURL: '/media', // URL path prefix + mimeTypes: ['image/*', 'application/pdf'], // Allowed types + filesRequiredOnCreate: true, // Require file on create (default: true) + + // Image-specific options: + imageSizes: [ // Auto-generate resized versions + { name: 'thumbnail', width: 300, height: 300, position: 'centre' }, + { name: 'card', width: 768, height: 1024, position: 'centre' }, + { name: 'tablet', width: 1024 }, // Height auto + ], + adminThumbnail: 'thumbnail', // Size to show in admin + focalPoint: true, // Enable focal point selection + crop: true, // Enable cropping + + // Storage adapter (optional - defaults to local): + // adapter: s3Adapter({ ... }) + }, + fields: [ + { name: 'alt', type: 'text', required: true }, + { name: 'caption', type: 'textarea' }, + ], +} +``` + +**Upload document auto-fields:** +When you create an upload collection, Payload automatically adds these fields: +- `filename` - Original filename +- `mimeType` - File MIME type +- `filesize` - Size in bytes +- `width` - Image width (images only) +- `height` - Image height (images only) +- `url` - Public URL to file +- `thumbnailURL` - URL to thumbnail (if imageSizes configured) +- `sizes` - Object with all generated size URLs + +### Auth Collection + +```typescript +const users: CollectionConfig = { + slug: 'users', + auth: true, + fields: [ + { name: 'name', type: 'text' }, + { name: 'role', type: 'select', options: ['admin', 'editor', 'user'] }, + ], +} +``` + +--- + +## Common Migration Patterns + +### WordPress to Payload + +| WordPress | Payload | +|-----------|---------| +| `post_title` | `text` (title) | +| `post_content` | `richText` (HTML) | +| `post_excerpt` | `textarea` | +| `post_status` | `select` (draft/published) | +| `post_author` | `relationship` to users | +| `featured_media` | `upload` to media | +| `post_date` | `date` | +| ACF Repeater | `array` | +| ACF Group | `group` | +| ACF Flexible Content | `blocks` | + +### Contentful to Payload + +| Contentful | Payload | +|------------|---------| +| Short Text | `text` | +| Long Text | `textarea` | +| Rich Text | `richText` (needs conversion) | +| Number | `number` | +| Date | `date` | +| Boolean | `checkbox` | +| Media | `upload` | +| Reference | `relationship` | +| Array of References | `relationship` (hasMany) | + +### Strapi to Payload + +| Strapi | Payload | +|--------|---------| +| string | `text` | +| text | `textarea` | +| richtext/blocks | `richText` | +| integer/float/decimal | `number` | +| boolean | `checkbox` | +| date/datetime | `date` | +| enumeration | `select` | +| media | `upload` | +| relation | `relationship` | +| component | `group` or `array` | +| dynamiczone | `blocks` | + +--- + +## AI Instructions + +When analyzing source data to generate Payload config: + +1. **Identify collections** - Each distinct content type becomes a collection +2. **Detect relationships** - ID references between types become `relationship` fields +3. **Infer field types** - Use the patterns above to match data to Payload types +4. **Preserve structure** - Nested objects become `group`, arrays of objects become `array` +5. **Flag unknowns** - If data doesn't match patterns, suggest `json` as fallback and add a warning +6. **Generate valid TypeScript** - Output should be copy-paste ready + +**Output format:** +```typescript +import type { CollectionConfig } from 'payload' + +export const collectionName: CollectionConfig = { + slug: 'collection-name', + fields: [ + // fields here + ], +} +``` diff --git a/apps/cms/.vibe/skills/payload/README.md b/apps/cms/.vibe/skills/payload/README.md new file mode 100644 index 0000000..e0f8ffd --- /dev/null +++ b/apps/cms/.vibe/skills/payload/README.md @@ -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 diff --git a/apps/cms/.vibe/skills/payload/SKILL.md b/apps/cms/.vibe/skills/payload/SKILL.md new file mode 100644 index 0000000..bd85a9d --- /dev/null +++ b/apps/cms/.vibe/skills/payload/SKILL.md @@ -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
{docs.map(post =>

{post.title}

)}
+} +``` + +## 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 `. 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: +- Docs: +- GitHub: +- Examples: +- Templates: diff --git a/apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md b/apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md new file mode 100644 index 0000000..ad164f2 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL-ADVANCED.md @@ -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 diff --git a/apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL.md b/apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL.md new file mode 100644 index 0000000..d370305 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/ACCESS-CONTROL.md @@ -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). diff --git a/apps/cms/.vibe/skills/payload/reference/ADAPTERS.md b/apps/cms/.vibe/skills/payload/reference/ADAPTERS.md new file mode 100644 index 0000000..64d0f10 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/ADAPTERS.md @@ -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, + }), +}) +``` diff --git a/apps/cms/.vibe/skills/payload/reference/ADVANCED.md b/apps/cms/.vibe/skills/payload/reference/ADVANCED.md new file mode 100644 index 0000000..d744406 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/ADVANCED.md @@ -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 setValue(e.target.value)} /> +} +``` + +### Custom View + +```tsx +'use client' +import { DefaultTemplate } from '@payloadcms/ui/rsc' + +export const CustomView = () => { + return ( + +

Custom Dashboard

+ {/* Your content */} +
+ ) +} +``` + +### 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. diff --git a/apps/cms/.vibe/skills/payload/reference/COLLECTIONS.md b/apps/cms/.vibe/skills/payload/reference/COLLECTIONS.md new file mode 100644 index 0000000..abfac20 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/COLLECTIONS.md @@ -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', + }, + ], + }, + ], +} +``` diff --git a/apps/cms/.vibe/skills/payload/reference/ENDPOINTS.md b/apps/cms/.vibe/skills/payload/reference/ENDPOINTS.md new file mode 100644 index 0000000..99ef908 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/ENDPOINTS.md @@ -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` | Async function returning Web API Response | +| `custom` | `Record` | 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` | Path parameters (e.g., `:id`) | +| `req.url` | `string` | Full request URL | +| `req.method` | `string` | HTTP method | +| `req.headers` | `Headers` | Request headers | +| `req.json()` | `() => Promise` | Parse JSON body | +| `req.text()` | `() => Promise` | 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: +- Custom Endpoints: +- Access Control: +- Local API: diff --git a/apps/cms/.vibe/skills/payload/reference/FIELD-TYPE-GUARDS.md b/apps/cms/.vibe/skills/payload/reference/FIELD-TYPE-GUARDS.md new file mode 100644 index 0000000..59ec938 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/FIELD-TYPE-GUARDS.md @@ -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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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( + 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): 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 + } +} +``` diff --git a/apps/cms/.vibe/skills/payload/reference/FIELDS.md b/apps/cms/.vibe/skills/payload/reference/FIELDS.md new file mode 100644 index 0000000..b00ca1a --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/FIELDS.md @@ -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 = (target: T, source: Partial): T => { + // Implementation would deeply merge objects + return { ...target, ...source } +} + +// Reusable link field factory +type LinkType = (options?: { + appearances?: ('default' | 'outline')[] | false + disableLabel?: boolean + overrides?: Record +}) => 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. diff --git a/apps/cms/.vibe/skills/payload/reference/HOOKS.md b/apps/cms/.vibe/skills/payload/reference/HOOKS.md new file mode 100644 index 0000000..b67556e --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/HOOKS.md @@ -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 = ({ + 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 = ({ 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)) diff --git a/apps/cms/.vibe/skills/payload/reference/PLUGIN-DEVELOPMENT.md b/apps/cms/.vibe/skills/payload/reference/PLUGIN-DEVELOPMENT.md new file mode 100644 index 0000000..cd86289 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/PLUGIN-DEVELOPMENT.md @@ -0,0 +1,1436 @@ +# Payload Plugin Development + +Complete guide to creating Payload plugins with TypeScript patterns, package structure, and best practices from the official Payload plugin template. + +## Plugin Architecture + +Plugins are functions that receive configuration options and return a function that transforms the Payload config: + +```ts +import type { Config, Plugin } from 'payload' + +interface MyPluginConfig { + enabled?: boolean + collections?: string[] +} + +export const myPlugin = + (options: MyPluginConfig): Plugin => + (config: Config): Config => ({ + ...config, + // Transform config here + }) +``` + +**Key Pattern:** Double arrow function (currying) + +- First function: Accepts plugin options, returns plugin function +- Second function: Accepts Payload config, returns modified config + +## Plugin Package Structure + +### Simple Structure + +``` +plugin-/ +├── package.json # Package metadata and dependencies +├── README.md # Plugin documentation +├── LICENSE.md # License file +└── src/ + ├── index.ts # Entry point, re-exports plugin and config types + ├── plugin.ts # Plugin implementation + ├── types.ts # TypeScript type definitions + └── exports/ # Additional entry points (optional) + └── types.ts # Type-only exports +``` + +### Exhaustive Structure + +``` +plugin-/ +├── .swcrc # SWC compiler config +├── package.json # Package metadata and dependencies +├── tsconfig.json # TypeScript config +├── README.md # Plugin documentation +├── LICENSE.md # License file +├── eslint.config.js # ESLint configuration (optional) +├── vitest.config.js # Vitest test configuration (optional) +├── playwright.config.js # Playwright e2e tests (optional) +└── src/ + ├── index.ts # Entry point, re-exports plugin and config types + ├── plugin.ts # Plugin implementation + ├── types.ts # TypeScript type definitions + ├── defaults.ts # Default configuration values (optional) + ├── endpoints/ # Custom API endpoints (optional) + │ └── handler.ts + ├── components/ # React components (optional) + │ ├── ClientComponent.tsx # 'use client' components + │ └── ServerComponent.tsx # RSC components + ├── fields/ # Custom field components (optional) + │ ├── FieldName/ + │ │ ├── index.ts # Field config + │ │ └── Component.tsx # Client component + ├── exports/ # Additional entry points + │ ├── types.ts # Type-only exports + │ ├── fields.ts # Field-only exports + │ ├── client.ts # Re-export client components + │ └── rsc.ts # Re-export server components (RSC) + ├── translations/ # i18n translations (optional) + │ └── index.ts + └── ui/ # Admin UI components (optional) + └── Component.tsx +``` + +**Key additions from official template:** + +- **dev/** directory with complete Payload project for local testing +- **src/exports/rsc.ts** for React Server Component exports +- **src/components/** for organizing React components +- **src/endpoints/** for custom API endpoint handlers +- Test configuration files (vitest.config.js, playwright.config.js) + +## Package.json Configuration + +```json +{ + "name": "payload-plugin-example", + "version": "1.0.0", + "description": "A Payload plugin", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./types": { + "import": "./dist/exports/types.js", + "types": "./dist/exports/types.d.ts" + }, + "./client": { + "import": "./dist/exports/client.js", + "types": "./dist/exports/client.d.ts" + }, + "./rsc": { + "import": "./dist/exports/rsc.js", + "types": "./dist/exports/rsc.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "npm run copyfiles && npm run build:types && npm run build:swc", + "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", + "build:types": "tsc --emitDeclarationOnly --outDir dist", + "clean": "rimraf dist *.tsbuildinfo", + "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", + "dev": "next dev dev --turbo", + "dev:generate-types": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload generate:types", + "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", + "test": "npm run test:int && npm run test:e2e", + "test:int": "vitest", + "test:e2e": "playwright test", + "lint": "eslint", + "lint:fix": "eslint ./src --fix", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@payloadcms/translations": "^3.0.0", + "@payloadcms/ui": "^3.0.0" + }, + "devDependencies": { + "@payloadcms/db-mongodb": "^3.0.0", + "@payloadcms/next": "^3.0.0", + "@payloadcms/richtext-lexical": "^3.0.0", + "@playwright/test": "^1.40.0", + "@swc/cli": "^0.1.62", + "@swc/core": "^1.3.0", + "copyfiles": "^2.4.1", + "cross-env": "10.1.0", + "eslint": "^9.0.0", + "next": "^15.4.10", + "payload": "^3.0.0", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "rimraf": "^5.0.0", + "typescript": "^6.0.0", + "vitest": "4.1.6" + }, + "peerDependencies": { + "payload": "^3.0.0" + } +} +``` + +**Key Points:** + +- `type: "module"` for ESM +- Compiled output in `./dist`, source in `./src` +- Payload as peer dependency (user installs it) +- Multiple export entry points: main, `/types`, `/client`, `/rsc` +- `/client` for client components, `/rsc` for React Server Components +- SWC for fast compilation +- Dev scripts for local development with Next.js +- Test scripts for both integration (Vitest) and e2e (Playwright) tests +- `prepublishOnly` ensures build before publish + +## Plugin Patterns + +### Adding Fields to Collections + +```ts +import type { Config, Plugin, Field } from 'payload' + +export const seoPlugin = + (options: { collections?: string[] }): Plugin => + (config: Config): Config => { + const seoFields: Field[] = [ + { + name: 'meta', + type: 'group', + fields: [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ], + }, + ] + + return { + ...config, + collections: config.collections?.map((collection) => { + if (options.collections?.includes(collection.slug)) { + return { + ...collection, + fields: [...(collection.fields || []), ...seoFields], + } + } + return collection + }), + } + } +``` + +### Adding New Collections + +```ts +import type { Config, Plugin, CollectionConfig } from 'payload' + +export const redirectsPlugin = + (options: { overrides?: Partial }): Plugin => + (config: Config): Config => { + const redirectsCollection: CollectionConfig = { + slug: 'redirects', + access: { read: () => true }, + fields: [ + { name: 'from', type: 'text', required: true, unique: true }, + { name: 'to', type: 'text', required: true }, + ], + ...options.overrides, + } + + return { + ...config, + collections: [...(config.collections || []), redirectsCollection], + } + } +``` + +### Adding Hooks + +```ts +import type { Config, Plugin, CollectionAfterChangeHook } from 'payload' + +const resaveChildrenHook: CollectionAfterChangeHook = async ({ doc, req, operation }) => { + if (operation === 'update') { + // Resave child documents + const children = await req.payload.find({ + collection: 'pages', + where: { parent: { equals: doc.id } }, + }) + + for (const child of children.docs) { + await req.payload.update({ + collection: 'pages', + id: child.id, + data: child, + }) + } + } + return doc +} + +export const nestedDocsPlugin = + (options: { collections: string[] }): Plugin => + (config: Config): Config => ({ + ...config, + collections: (config.collections || []).map((collection) => { + if (options.collections.includes(collection.slug)) { + return { + ...collection, + hooks: { + ...(collection.hooks || {}), + afterChange: [resaveChildrenHook, ...(collection.hooks?.afterChange || [])], + }, + } + } + return collection + }), + }) +``` + +### Adding Root-Level Endpoints + +Add endpoints at the root config level (accessible at `/api/`): + +```ts +import type { Config, Plugin, Endpoint } from 'payload' + +export const seoPlugin = + (options: { generateTitle?: (doc: any) => string }): Plugin => + (config: Config): Config => { + const generateTitleEndpoint: Endpoint = { + path: '/plugin-seo/generate-title', + method: 'post', + handler: async (req) => { + const data = await req.json?.() + const result = options.generateTitle ? options.generateTitle(data.doc) : '' + return Response.json({ result }) + }, + } + + return { + ...config, + endpoints: [...(config.endpoints ?? []), generateTitleEndpoint], + } + } +``` + +**Example webhook endpoint:** + +```ts +// Useful for integrations like Stripe +const webhookEndpoint: Endpoint = { + path: '/stripe/webhook', + method: 'post', + handler: async (req) => { + const signature = req.headers.get('stripe-signature') + const event = stripe.webhooks.constructEvent( + await req.text(), + signature, + process.env.STRIPE_WEBHOOK_SECRET, + ) + // Handle webhook + return Response.json({ received: true }) + }, +} +``` + +### Field Overrides with Defaults + +```ts +import type { Config, Plugin, Field } from 'payload' + +type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] + +interface PluginConfig { + collections?: string[] + fields?: FieldsOverride +} + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + const defaultFields: Field[] = [ + { name: 'title', type: 'text' }, + { name: 'description', type: 'textarea' }, + ] + + const fields = + options.fields && typeof options.fields === 'function' + ? options.fields({ defaultFields }) + : defaultFields + + return { + ...config, + collections: config.collections?.map((collection) => { + if (options.collections?.includes(collection.slug)) { + return { + ...collection, + fields: [...(collection.fields || []), ...fields], + } + } + return collection + }), + } + } +``` + +### Tabs UI Pattern + +```ts +import type { Config, Plugin, TabsField, GroupField } from 'payload' + +export const seoPlugin = + (options: { tabbedUI?: boolean }): Plugin => + (config: Config): Config => { + const seoFields: GroupField[] = [ + { + name: 'meta', + type: 'group', + fields: [{ name: 'title', type: 'text' }], + }, + ] + + return { + ...config, + collections: config.collections?.map((collection) => { + if (options.tabbedUI) { + const seoTabs: TabsField[] = [ + { + type: 'tabs', + tabs: [ + // If existing tabs, preserve them + ...(collection.fields?.[0]?.type === 'tabs' + ? collection.fields[0].tabs + : [ + { + label: 'Content', + fields: collection.fields || [], + }, + ]), + // Add SEO tab + { + label: 'SEO', + fields: seoFields, + }, + ], + }, + ] + + return { + ...collection, + fields: [ + ...seoTabs, + ...(collection.fields?.[0]?.type === 'tabs' ? collection.fields.slice(1) : []), + ], + } + } + + return { + ...collection, + fields: [...(collection.fields || []), ...seoFields], + } + }), + } + } +``` + +### Disable Plugin Pattern + +Allow users to disable plugin without removing it (important for database schema consistency): + +```ts +import type { Config, Plugin } from 'payload' + +interface PluginConfig { + disabled?: boolean + collections?: string[] +} + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + // Always add collections/fields for database schema consistency + if (!config.collections) { + config.collections = [] + } + + config.collections.push({ + slug: 'plugin-collection', + fields: [{ name: 'title', type: 'text' }], + }) + + // Add fields to specified collections + if (options.collections) { + for (const collectionSlug of options.collections) { + const collection = config.collections.find((c) => c.slug === collectionSlug) + if (collection) { + collection.fields.push({ + name: 'addedByPlugin', + type: 'text', + }) + } + } + } + + // If disabled, return early but keep schema changes + if (options.disabled) { + return config + } + + // Add endpoints, hooks, components only when enabled + config.endpoints = [ + ...(config.endpoints ?? []), + { + path: '/my-endpoint', + method: 'get', + handler: async () => Response.json({ message: 'Hello' }), + }, + ] + + return config + } +``` + +### Admin Components + +Add custom UI components to the admin panel: + +```ts +import type { Config, Plugin } from 'payload' + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + if (!config.admin) config.admin = {} + if (!config.admin.components) config.admin.components = {} + if (!config.admin.components.beforeDashboard) { + config.admin.components.beforeDashboard = [] + } + + // Add client component + config.admin.components.beforeDashboard.push('my-plugin-name/client#BeforeDashboardClient') + + // Add server component (RSC) + config.admin.components.beforeDashboard.push('my-plugin-name/rsc#BeforeDashboardServer') + + return config + } +``` + +**Component file structure:** + +```tsx +// src/components/BeforeDashboardClient.tsx +'use client' +import { useConfig } from '@payloadcms/ui' +import { useEffect, useState } from 'react' +import { formatAdminURL } from 'payload/shared' + +export const BeforeDashboardClient = () => { + const { config } = useConfig() + const [data, setData] = useState('') + + useEffect(() => { + fetch( + formatAdminURL({ + apiRoute: config.routes.api, + path: '/my-endpoint', + }), + ) + .then((res) => res.json()) + .then(setData) + }, [config.serverURL, config.routes.api]) + + return
Client Component: {data}
+} + +// src/components/BeforeDashboardServer.tsx +export const BeforeDashboardServer = () => { + return
Server Component
+} + +// src/exports/client.ts +export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js' + +// src/exports/rsc.ts +export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js' +``` + +### Translations (i18n) + +```ts +// src/translations/index.ts +export const translations = { + en: { + 'plugin-name:fieldLabel': 'Field Label', + 'plugin-name:fieldDescription': 'Field description', + }, + es: { + 'plugin-name:fieldLabel': 'Etiqueta del campo', + 'plugin-name:fieldDescription': 'Descripción del campo', + }, +} + +// src/plugin.ts +import { deepMergeSimple } from 'payload/shared' +import { translations } from './translations/index.js' + +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => ({ + ...config, + i18n: { + ...config.i18n, + translations: deepMergeSimple(translations, config.i18n?.translations ?? {}), + }, + }) +``` + +### onInit Hook + +```ts +export const myPlugin = + (options: PluginConfig): Plugin => + (config: Config): Config => { + const incomingOnInit = config.onInit + + config.onInit = async (payload) => { + // IMPORTANT: Call existing onInit first + if (incomingOnInit) await incomingOnInit(payload) + + // Plugin initialization + payload.logger.info('Plugin initialized') + + // Example: Seed data + const { totalDocs } = await payload.count({ + collection: 'plugin-collection', + where: { id: { equals: 'seeded-by-plugin' } }, + }) + + if (totalDocs === 0) { + await payload.create({ + collection: 'plugin-collection', + data: { id: 'seeded-by-plugin' }, + }) + } + } + + return config + } +``` + +## TypeScript Patterns + +### Plugin Config Types + +```ts +import type { CollectionSlug, GlobalSlug, Field, CollectionConfig } from 'payload' + +export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[] + +export interface MyPluginConfig { + /** + * Collections to enable this plugin for + */ + collections?: CollectionSlug[] + /** + * Globals to enable this plugin for + */ + globals?: GlobalSlug[] + /** + * Override default fields + */ + fields?: FieldsOverride + /** + * Enable tabbed UI + */ + tabbedUI?: boolean + /** + * Override collection config + */ + overrides?: Partial +} +``` + +### Export Types + +```ts +// src/exports/types.ts +export type { MyPluginConfig, FieldsOverride } from '../types.js' + +// Usage +import type { MyPluginConfig } from '@payloadcms/plugin-example/types' +``` + +## Client Components + +### Custom Field Component + +```tsx +// src/fields/CustomField/Component.tsx +'use client' +import { useField } from '@payloadcms/ui' +import type { TextFieldClientComponent } from 'payload' + +export const CustomFieldComponent: TextFieldClientComponent = ({ field, path }) => { + const { value, setValue } = useField({ path }) + + return ( +
+ + setValue(e.target.value)} /> +
+ ) +} +``` + +```ts +// src/fields/CustomField/index.ts +import type { Field } from 'payload' + +export const CustomField = (overrides?: Partial): Field => ({ + name: 'customField', + type: 'text', + admin: { + components: { + Field: '/fields/CustomField/Component#CustomFieldComponent', + }, + }, + ...overrides, +}) +``` + +## Best Practices + +### Preserve Existing Config + +Always spread existing config and add to arrays: + +```ts +// ✅ Good +collections: [...(config.collections || []), newCollection] + +// ❌ Bad +collections: [newCollection] +``` + +### Respect User Overrides + +Allow users to override plugin defaults: + +```ts +const collection: CollectionConfig = { + slug: 'redirects', + fields: defaultFields, + ...options.overrides, // User overrides last +} +``` + +### Conditional Logic + +Check if collections/globals are enabled: + +```ts +collections: config.collections?.map((collection) => { + const isEnabled = options.collections?.includes(collection.slug) + if (isEnabled) { + // Transform collection + } + return collection +}) +``` + +### Hook Composition + +Preserve existing hooks: + +```ts +hooks: { + ...collection.hooks, + afterChange: [ + myHook, + ...(collection.hooks?.afterChange || []), + ], +} +``` + +### Type Safety + +Use Payload's exported types: + +```ts +import type { Config, Plugin, CollectionConfig, Field, CollectionSlug, GlobalSlug } from 'payload' +``` + +### Field Path Imports + +Use absolute paths for client components: + +```ts +admin: { + components: { + Field: '/fields/CustomField/Component#CustomFieldComponent', + }, +} +``` + +### onInit Pattern + +Always call existing `onInit` before your initialization. See [onInit Hook](#oninit-hook) pattern for full example. + +## Advanced Patterns + +These patterns are extracted from official Payload plugins and represent production-ready techniques for complex plugin development. + +### Advanced Configuration + +#### Async Plugin Function + +Allow plugin function to be async for awaiting collection overrides or async operations: + +```ts +export const myPlugin = + (pluginConfig?: PluginConfig) => + async (incomingConfig: Config): Promise => { + // Can await async operations during initialization + const customCollection = await pluginConfig.collectionOverride?.({ + defaultCollection, + }) + + return { + ...incomingConfig, + collections: [...incomingConfig.collections, customCollection], + } + } +``` + +#### Collection Override with Async Support + +Allow users to override entire collections with async functions: + +```ts +type CollectionOverride = (args: { + defaultCollection: CollectionConfig +}) => CollectionConfig | Promise + +interface PluginConfig { + products?: { + collectionOverride?: CollectionOverride + } +} + +// In plugin +const defaultCollection = createProductsCollection(config) +const finalCollection = config.products?.collectionOverride + ? await config.products.collectionOverride({ defaultCollection }) + : defaultCollection +``` + +#### Config Sanitization Pattern + +Normalize plugin configuration with defaults: + +```ts +export const sanitizePluginConfig = ({ pluginConfig }: Props): SanitizedPluginConfig => { + const config = { ...pluginConfig } as Partial + + // Normalize boolean|object configs + if (typeof config.addresses === 'undefined' || config.addresses === true) { + config.addresses = { addressFields: defaultAddressFields() } + } else if (config.addresses === false) { + config.addresses = null + } + + // Validate required fields + if (!config.stripeSecretKey) { + throw new Error('Stripe secret key is required') + } + + return config as SanitizedPluginConfig +} + +// Use at plugin start +export const myPlugin = + (pluginConfig: PluginConfig): Plugin => + (config) => { + const sanitized = sanitizePluginConfig({ pluginConfig }) + // Use sanitized config throughout + } +``` + +#### Collection Slug Mapping + +Track collection slugs when users can override them: + +```ts +type CollectionSlugMap = { + products: string + variants: string + orders: string +} + +const getCollectionSlugMap = ({ config }: { config: PluginConfig }): CollectionSlugMap => ({ + products: config.products?.slug || 'products', + variants: config.variants?.slug || 'variants', + orders: config.orders?.slug || 'orders', +}) + +// Use throughout plugin +const collectionSlugMap = getCollectionSlugMap({ config: pluginConfig }) + +// When creating relationship fields +{ + name: 'product', + type: 'relationship', + relationTo: collectionSlugMap.products, +} +``` + +#### Multi-Collection Configuration + +Plugin operates on multiple collections with collection-specific config: + +```ts +interface PluginConfig { + sync: Array<{ + collection: string + fields?: string[] + onSync?: (doc: any) => Promise + }> +} + +// In plugin +for (const collection of config.collections!) { + const syncConfig = pluginConfig.sync?.find((s) => s.collection === collection.slug) + if (!syncConfig) continue + + collection.hooks.afterChange = [ + ...(collection.hooks?.afterChange || []), + async ({ doc, operation }) => { + if (operation === 'create' || operation === 'update') { + await syncConfig.onSync?.(doc) + } + }, + ] +} +``` + +### TypeScript Extensions + +#### TypeScript Schema Extension + +Add custom properties to generated TypeScript schema: + +```ts +incomingConfig.typescript = incomingConfig.typescript || {} +incomingConfig.typescript.schema = incomingConfig.typescript.schema || [] + +incomingConfig.typescript.schema.push((args) => { + const { jsonSchema } = args + + jsonSchema.properties.ecommerce = { + type: 'object', + properties: { + collections: { + type: 'object', + properties: { + products: { type: 'string' }, + orders: { type: 'string' }, + }, + }, + }, + } + + return jsonSchema +}) +``` + +#### Module Declaration Augmentation + +Extend Payload types for plugin-specific field properties: + +```ts +// In plugin types file +declare module 'payload' { + export interface FieldCustom { + 'plugin-import-export'?: { + disabled?: boolean + toCSV?: (value: any) => string + fromCSV?: (value: string) => any + } + } +} + +// Usage with TypeScript support +{ + name: 'price', + type: 'number', + custom: { + 'plugin-import-export': { + toCSV: (value) => `$${value.toFixed(2)}`, + fromCSV: (value) => parseFloat(value.replace('$', '')), + }, + }, +} +``` + +### Advanced Hooks + +#### Global Error Hooks + +Add global error handling: + +```ts +return { + ...config, + hooks: { + afterError: [ + ...(config.hooks?.afterError ?? []), + async (args) => { + const { error } = args + const status = (error as APIError).status ?? 500 + + if (status >= 500 || captureErrors.includes(status)) { + captureException(error, { + tags: { + collection: args.collection?.slug, + operation: args.operation, + }, + user: args.req?.user ? { id: args.req.user.id } : undefined, + }) + } + }, + ], + }, +} +``` + +#### Multiple Hook Types on Same Collection + +Coordinate multiple lifecycle hooks together for complex workflows (e.g., validation → sync → cache → cleanup): + +```ts +collection.hooks = { + ...collection.hooks, + + beforeValidate: [ + ...(collection.hooks?.beforeValidate || []), + async ({ data }) => { + // Normalize before validation + return data + }, + ], + + beforeChange: [ + ...(collection.hooks?.beforeChange || []), + async ({ data, operation }) => { + // Sync to external service + if (operation === 'create') { + data.externalId = await externalService.create(data) + } + return data + }, + ], + + afterChange: [ + ...(collection.hooks?.afterChange || []), + async ({ doc }) => { + // Invalidate cache + await cache.invalidate(`doc:${doc.id}`) + }, + ], + + afterDelete: [ + ...(collection.hooks?.afterDelete || []), + async ({ doc }) => { + // Cleanup external resources + await externalService.delete(doc.externalId) + }, + ], +} +``` + +### Access Control & Filtering + +#### Access Control Wrapper Pattern + +Wrap existing access control with plugin-specific logic: + +```ts +// From plugin-multi-tenant +export const multiTenantPlugin = + (pluginOptions: PluginOptions) => + (config: Config): Config => ({ + ...config, + collections: (config.collections || []).map((collection) => { + if (!pluginOptions.collections.includes(collection.slug)) { + return collection + } + + return { + ...collection, + access: { + ...collection.access, + read: ({ req }) => { + // Inject tenant filter + return { + and: [ + collection.access?.read ? collection.access.read({ req }) : {}, + { tenant: { equals: req.user?.tenant } }, + ], + } + }, + }, + } + }), + }) +``` + +#### BaseFilter Composition + +Combine plugin filters with existing baseListFilter: + +```ts +// From plugin-multi-tenant +const existingBaseFilter = collection.admin?.baseListFilter +const tenantFilter = { tenant: { equals: req.user?.tenant } } + +collection.admin = { + ...collection.admin, + baseListFilter: existingBaseFilter ? { and: [existingBaseFilter, tenantFilter] } : tenantFilter, +} +``` + +#### Relationship FilterOptions Modification + +Add filters to relationship field options: + +```ts +// From plugin-multi-tenant +collection.fields = collection.fields.map((field) => { + if (field.type === 'relationship') { + return { + ...field, + filterOptions: ({ relationTo }) => { + return { + and: [field.filterOptions?.(relationTo) || {}, { tenant: { equals: req.user?.tenant } }], + } + }, + } + } + return field +}) +``` + +### Admin UI Customization + +#### Metadata Storage Pattern + +Use admin.meta for plugin-specific UI state without database fields: + +```ts +// From plugin-nested-docs +export const nestedDocsPlugin = + (pluginOptions: PluginOptions) => + (config: Config): Config => ({ + ...config, + collections: config.collections?.map((collection) => ({ + ...collection, + admin: { + ...collection.admin, + meta: { + ...collection.admin?.meta, + nestedDocs: { + breadcrumbsFieldSlug: pluginOptions.breadcrumbsFieldSlug || 'breadcrumbs', + parentFieldSlug: pluginOptions.parentFieldSlug || 'parent', + }, + }, + }, + })), + }) +``` + +#### Conditional Component Rendering + +Add components based on plugin configuration: + +```ts +// From plugin-seo +const beforeFields = collection.admin?.components?.beforeFields || [] + +if (pluginOptions.uploadsCollection === collection.slug) { + beforeFields.push('/path/to/ImagePreview#ImagePreview') +} + +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + beforeFields, + }, +} +``` + +#### Custom Provider Pattern + +Inject context providers for shared state: + +```ts +// From plugin-nested-docs +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + providers: [ + ...(collection.admin?.components?.providers || []), + '/components/NestedDocsProvider#NestedDocsProvider', + ], + }, +} +``` + +#### Custom Actions + +Add collection-level action buttons: + +```ts +// From plugin-import-export +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + actions: [ + ...(collection.admin?.components?.actions || []), + '/components/ImportButton#ImportButton', + '/components/ExportButton#ExportButton', + ], + }, +} +``` + +#### Custom List Item Views + +Modify how items appear in collection lists: + +```ts +// From plugin-ecommerce +collection.admin = { + ...collection.admin, + components: { + ...collection.admin?.components, + views: { + ...collection.admin?.components?.views, + list: { + ...collection.admin?.components?.views?.list, + Component: '/views/ProductList#ProductList', + }, + }, + }, +} +``` + +#### Custom Collection Endpoints + +Add collection-scoped endpoints (accessible at `/api//`): + +```ts +// From plugin-import-export +collection.endpoints = [ + ...(collection.endpoints || []), + { + path: '/import', + method: 'post', + handler: async (req) => { + // Import logic accessible at /api/posts/import + return Response.json({ success: true }) + }, + }, + { + path: '/export', + method: 'get', + handler: async (req) => { + // Export logic accessible at /api/posts/export + return Response.json({ data: exportedData }) + }, + }, +] +``` + +### Field & Collection Modifications + +#### Admin Folders Override + +Control admin UI organization: + +```ts +// From plugin-redirects +collection.admin = { + ...collection.admin, + group: pluginOptions.group || 'Settings', + hidden: pluginOptions.hidden, + defaultColumns: pluginOptions.defaultColumns || ['from', 'to', 'updatedAt'], +} +``` + +### Background Jobs & Async Operations + +#### Jobs Registration + +Register plugin background tasks: + +```ts +// From plugin-stripe +export const stripePlugin = + (pluginOptions: PluginOptions) => + (config: Config): Config => ({ + ...config, + jobs: { + ...config.jobs, + tasks: [ + ...(config.jobs?.tasks || []), + { + slug: 'syncStripeProducts', + handler: async ({ req }) => { + const products = await stripe.products.list() + // Sync to Payload + return { output: { synced: products.data.length } } + }, + }, + ], + }, + }) +``` + +## Testing Plugins + +### Local Development with dev/ Directory (optional) + +Include a `dev/` directory with a complete Payload project for local development: + +1. Create `dev/.env` from `.env.example`: + +```bash +DATABASE_URL=mongodb://127.0.0.1/plugin-dev +PAYLOAD_SECRET=your-secret-here +``` + +2. Configure `dev/payload.config.ts`: + +```ts +import { buildConfig } from 'payload' +import { mongooseAdapter } from '@payloadcms/db-mongodb' +import { myPlugin } from '../src/index.js' + +export default buildConfig({ + secret: process.env.PAYLOAD_SECRET!, + db: mongooseAdapter({ url: process.env.DATABASE_URL! }), + plugins: [ + myPlugin({ + collections: ['posts'], + }), + ], + collections: [ + { + slug: 'posts', + fields: [{ name: 'title', type: 'text' }], + }, + ], +}) +``` + +3. Run development server: + +```bash +npm run dev # Starts Next.js on http://localhost:3000 +``` + +### Integration Tests (Vitest) (optional) + +Create `dev/int.spec.ts`: + +```ts +import type { Payload } from 'payload' +import config from '@payload-config' +import { createPayloadRequest, getPayload } from 'payload' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' +import { customEndpointHandler } from '../src/endpoints/handler.js' + +let payload: Payload + +beforeAll(async () => { + payload = await getPayload({ config }) +}) + +afterAll(async () => { + await payload.destroy() +}) + +describe('Plugin integration tests', () => { + test('should add field to collection', async () => { + const post = await payload.create({ + collection: 'posts', + data: { + title: 'Test', + addedByPlugin: 'plugin value', + }, + }) + expect(post.addedByPlugin).toBe('plugin value') + }) + + test('should create plugin collection', async () => { + expect(payload.collections['plugin-collection']).toBeDefined() + const { docs } = await payload.find({ collection: 'plugin-collection' }) + expect(docs.length).toBeGreaterThan(0) + }) + + test('should query custom endpoint', async () => { + const request = new Request('http://localhost:3000/api/my-endpoint') + const payloadRequest = await createPayloadRequest({ config, request }) + const response = await customEndpointHandler(payloadRequest) + const data = await response.json() + expect(data).toMatchObject({ message: 'Hello' }) + }) +}) +``` + +Run: `npm run test:int` + +### End-to-End Tests (Playwright) + +Create `dev/e2e.spec.ts`: + +```ts +import { test, expect } from '@playwright/test' + +test.describe('Plugin e2e tests', () => { + test('should render custom admin component', async ({ page }) => { + await page.goto('http://localhost:3000/admin') + await expect(page.getByText('Added by the plugin')).toBeVisible() + }) +}) +``` + +Run: `npm run test:e2e` + +## Common Plugin Types + +### Field Enhancer + +Adds fields to existing collections (SEO, timestamps, audit logs) + +### Collection Provider + +Adds new collections (redirects, forms, logs) + +### Hook Injector + +Adds hooks to collections (nested docs, cache invalidation) + +### UI Enhancer + +Adds custom components (dashboards, field types) + +### Integration + +Connects external services (Stripe, Sentry, storage adapters) + +### Adapter + +Provides infrastructure (database, storage, email) + +## Resources + +- [Plugin Examples](https://github.com/payloadcms/payload/tree/main/packages/) - Official plugins source code, payload-\* prefix +- [Plugin Template](https://github.com/payloadcms/payload/tree/main/templates/plugin) - Starter template for new plugins diff --git a/apps/cms/.vibe/skills/payload/reference/QUERIES.md b/apps/cms/.vibe/skills/payload/reference/QUERIES.md new file mode 100644 index 0000000..89cfff4 --- /dev/null +++ b/apps/cms/.vibe/skills/payload/reference/QUERIES.md @@ -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` diff --git a/apps/cms/.yarnrc b/apps/cms/.yarnrc new file mode 100644 index 0000000..5e7a4dd --- /dev/null +++ b/apps/cms/.yarnrc @@ -0,0 +1 @@ +--install.ignore-engines true diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile new file mode 100644 index 0000000..20be634 --- /dev/null +++ b/apps/cms/Dockerfile @@ -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 diff --git a/apps/cms/README.md b/apps/cms/README.md new file mode 100644 index 0000000..ddf218f --- /dev/null +++ b/apps/cms/README.md @@ -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/` +- Modify the `docker-compose.yml` file's `MONGODB_URL` to match the above `` +- 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). diff --git a/apps/cms/docker-compose.yml b/apps/cms/docker-compose.yml new file mode 100644 index 0000000..aeda4b7 --- /dev/null +++ b/apps/cms/docker-compose.yml @@ -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: diff --git a/apps/cms/eslint.config.mjs b/apps/cms/eslint.config.mjs new file mode 100644 index 0000000..9896220 --- /dev/null +++ b/apps/cms/eslint.config.mjs @@ -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 diff --git a/apps/cms/next.config.mjs b/apps/cms/next.config.mjs new file mode 100644 index 0000000..e526a76 --- /dev/null +++ b/apps/cms/next.config.mjs @@ -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 }) \ No newline at end of file diff --git a/apps/cms/package.json b/apps/cms/package.json new file mode 100644 index 0000000..9be7689 --- /dev/null +++ b/apps/cms/package.json @@ -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" + } +} diff --git a/apps/cms/playwright.config.ts b/apps/cms/playwright.config.ts new file mode 100644 index 0000000..c60fa9d --- /dev/null +++ b/apps/cms/playwright.config.ts @@ -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', + }, +}) diff --git a/apps/cms/src/access/index.ts b/apps/cms/src/access/index.ts new file mode 100644 index 0000000..797711e --- /dev/null +++ b/apps/cms/src/access/index.ts @@ -0,0 +1,4 @@ +import type { Access } from 'payload' + +export const publicRead: Access = () => true +export const isLoggedIn: Access = ({ req }) => Boolean(req.user) \ No newline at end of file diff --git a/apps/cms/src/app/(frontend)/layout.tsx b/apps/cms/src/app/(frontend)/layout.tsx new file mode 100644 index 0000000..e7681f7 --- /dev/null +++ b/apps/cms/src/app/(frontend)/layout.tsx @@ -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 ( + + +
{children}
+ + + ) +} diff --git a/apps/cms/src/app/(frontend)/page.tsx b/apps/cms/src/app/(frontend)/page.tsx new file mode 100644 index 0000000..3d630f4 --- /dev/null +++ b/apps/cms/src/app/(frontend)/page.tsx @@ -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 ( +
+
+ + + Payload Logo + + {!user &&

Welcome to your new project.

} + {user &&

Welcome back, {user.email}

} + +
+
+

Update this page by editing

+ + app/(frontend)/page.tsx + +
+
+ ) +} diff --git a/apps/cms/src/app/(frontend)/styles.css b/apps/cms/src/app/(frontend)/styles.css new file mode 100644 index 0000000..d1fb941 --- /dev/null +++ b/apps/cms/src/app/(frontend)/styles.css @@ -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; + } + } +} diff --git a/apps/cms/src/app/(payload)/admin/[[...segments]]/not-found.tsx b/apps/cms/src/app/(payload)/admin/[[...segments]]/not-found.tsx new file mode 100644 index 0000000..6410836 --- /dev/null +++ b/apps/cms/src/app/(payload)/admin/[[...segments]]/not-found.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views' +import { importMap } from '../importMap' + +type Args = { + params: Promise<{ + segments: string[] + }> + searchParams: Promise<{ + [key: string]: string | string[] + }> +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const NotFound = ({ params, searchParams }: Args) => + NotFoundPage({ config, params, searchParams, importMap }) + +export default NotFound diff --git a/apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx b/apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx new file mode 100644 index 0000000..0de685c --- /dev/null +++ b/apps/cms/src/app/(payload)/admin/[[...segments]]/page.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next' + +import config from '@payload-config' +import { RootPage, generatePageMetadata } from '@payloadcms/next/views' +import { importMap } from '../importMap' + +type Args = { + params: Promise<{ + segments: string[] + }> + searchParams: Promise<{ + [key: string]: string | string[] + }> +} + +export const generateMetadata = ({ params, searchParams }: Args): Promise => + generatePageMetadata({ config, params, searchParams }) + +const Page = ({ params, searchParams }: Args) => + RootPage({ config, params, searchParams, importMap }) + +export default Page diff --git a/apps/cms/src/app/(payload)/admin/importMap.js b/apps/cms/src/app/(payload)/admin/importMap.js new file mode 100644 index 0000000..e6e6e22 --- /dev/null +++ b/apps/cms/src/app/(payload)/admin/importMap.js @@ -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 +} diff --git a/apps/cms/src/app/(payload)/api/[...slug]/route.ts b/apps/cms/src/app/(payload)/api/[...slug]/route.ts new file mode 100644 index 0000000..e58c50f --- /dev/null +++ b/apps/cms/src/app/(payload)/api/[...slug]/route.ts @@ -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) diff --git a/apps/cms/src/app/(payload)/api/graphql-playground/route.ts b/apps/cms/src/app/(payload)/api/graphql-playground/route.ts new file mode 100644 index 0000000..17d2954 --- /dev/null +++ b/apps/cms/src/app/(payload)/api/graphql-playground/route.ts @@ -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) diff --git a/apps/cms/src/app/(payload)/api/graphql/route.ts b/apps/cms/src/app/(payload)/api/graphql/route.ts new file mode 100644 index 0000000..2069ff8 --- /dev/null +++ b/apps/cms/src/app/(payload)/api/graphql/route.ts @@ -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) diff --git a/apps/cms/src/app/(payload)/custom.scss b/apps/cms/src/app/(payload)/custom.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/cms/src/app/(payload)/layout.tsx b/apps/cms/src/app/(payload)/layout.tsx new file mode 100644 index 0000000..8df141a --- /dev/null +++ b/apps/cms/src/app/(payload)/layout.tsx @@ -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) => ( + + {children} + +) + +export default Layout diff --git a/apps/cms/src/app/my-route/route.ts b/apps/cms/src/app/my-route/route.ts new file mode 100644 index 0000000..0755886 --- /dev/null +++ b/apps/cms/src/app/my-route/route.ts @@ -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.', + }) +} diff --git a/apps/cms/src/collections/Archiv.ts b/apps/cms/src/collections/Archiv.ts new file mode 100644 index 0000000..f0c95bd --- /dev/null +++ b/apps/cms/src/collections/Archiv.ts @@ -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', + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/collections/Artikel.ts b/apps/cms/src/collections/Artikel.ts new file mode 100644 index 0000000..fcb7e9c --- /dev/null +++ b/apps/cms/src/collections/Artikel.ts @@ -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 }, + }, + ], + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/collections/KulturSeiten.ts b/apps/cms/src/collections/KulturSeiten.ts new file mode 100644 index 0000000..795d2fb --- /dev/null +++ b/apps/cms/src/collections/KulturSeiten.ts @@ -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' }, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/collections/Media.ts b/apps/cms/src/collections/Media.ts new file mode 100644 index 0000000..f5ca190 --- /dev/null +++ b/apps/cms/src/collections/Media.ts @@ -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, +} diff --git a/apps/cms/src/collections/Neuigkeiten.ts b/apps/cms/src/collections/Neuigkeiten.ts new file mode 100644 index 0000000..a3500b6 --- /dev/null +++ b/apps/cms/src/collections/Neuigkeiten.ts @@ -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 }, + }, + ], + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/collections/Persoenlichkeiten.ts b/apps/cms/src/collections/Persoenlichkeiten.ts new file mode 100644 index 0000000..84304a3 --- /dev/null +++ b/apps/cms/src/collections/Persoenlichkeiten.ts @@ -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, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/collections/Users.ts b/apps/cms/src/collections/Users.ts new file mode 100644 index 0000000..1a89ac6 --- /dev/null +++ b/apps/cms/src/collections/Users.ts @@ -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', + }, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/globals/Geschichte.ts b/apps/cms/src/globals/Geschichte.ts new file mode 100644 index 0000000..8772ac8 --- /dev/null +++ b/apps/cms/src/globals/Geschichte.ts @@ -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', + }, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/globals/Kultur.ts b/apps/cms/src/globals/Kultur.ts new file mode 100644 index 0000000..c004629 --- /dev/null +++ b/apps/cms/src/globals/Kultur.ts @@ -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' }, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/globals/Politik.ts b/apps/cms/src/globals/Politik.ts new file mode 100644 index 0000000..b1fe7cb --- /dev/null +++ b/apps/cms/src/globals/Politik.ts @@ -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' }, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/globals/Sprache.ts b/apps/cms/src/globals/Sprache.ts new file mode 100644 index 0000000..50ce85f --- /dev/null +++ b/apps/cms/src/globals/Sprache.ts @@ -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' }, + }, + ], +} \ No newline at end of file diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts new file mode 100644 index 0000000..5a43706 --- /dev/null +++ b/apps/cms/src/payload.config.ts @@ -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: [], +}) diff --git a/apps/cms/test.env b/apps/cms/test.env new file mode 100644 index 0000000..fcc9f8e --- /dev/null +++ b/apps/cms/test.env @@ -0,0 +1 @@ +NODE_OPTIONS="--no-deprecation --no-experimental-strip-types" diff --git a/apps/cms/tests/e2e/admin.e2e.spec.ts b/apps/cms/tests/e2e/admin.e2e.spec.ts new file mode 100644 index 0000000..67fc921 --- /dev/null +++ b/apps/cms/tests/e2e/admin.e2e.spec.ts @@ -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() + }) +}) diff --git a/apps/cms/tests/e2e/frontend.e2e.spec.ts b/apps/cms/tests/e2e/frontend.e2e.spec.ts new file mode 100644 index 0000000..65c31fd --- /dev/null +++ b/apps/cms/tests/e2e/frontend.e2e.spec.ts @@ -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.') + }) +}) diff --git a/apps/cms/tests/helpers/login.ts b/apps/cms/tests/helpers/login.ts new file mode 100644 index 0000000..deef8c2 --- /dev/null +++ b/apps/cms/tests/helpers/login.ts @@ -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 { + 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() +} diff --git a/apps/cms/tests/helpers/seedUser.ts b/apps/cms/tests/helpers/seedUser.ts new file mode 100644 index 0000000..f0f5c86 --- /dev/null +++ b/apps/cms/tests/helpers/seedUser.ts @@ -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 { + 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 { + const payload = await getPayload({ config }) + + await payload.delete({ + collection: 'users', + where: { + email: { + equals: testUser.email, + }, + }, + }) +} diff --git a/apps/cms/tests/int/api.int.spec.ts b/apps/cms/tests/int/api.int.spec.ts new file mode 100644 index 0000000..9bd5adb --- /dev/null +++ b/apps/cms/tests/int/api.int.spec.ts @@ -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() + }) +}) diff --git a/apps/cms/tsconfig.json b/apps/cms/tsconfig.json new file mode 100644 index 0000000..2bb00aa --- /dev/null +++ b/apps/cms/tsconfig.json @@ -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" + ] +} diff --git a/apps/cms/vitest.config.mts b/apps/cms/vitest.config.mts new file mode 100644 index 0000000..f4d8caa --- /dev/null +++ b/apps/cms/vitest.config.mts @@ -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'], + }, +}) diff --git a/apps/cms/vitest.setup.ts b/apps/cms/vitest.setup.ts new file mode 100644 index 0000000..1a76c85 --- /dev/null +++ b/apps/cms/vitest.setup.ts @@ -0,0 +1,4 @@ +// Any setup scripts you might need go here + +// Load .env files +import 'dotenv/config' diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..cd8e1d7 --- /dev/null +++ b/apps/web/.gitignore @@ -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/ diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/apps/web/.vibe/skills/svelte-code-writer/SKILL.md b/apps/web/.vibe/skills/svelte-code-writer/SKILL.md new file mode 100644 index 0000000..b50ccf9 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-code-writer/SKILL.md @@ -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 ",,..." +``` + +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 "" [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 '' + +# 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 diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/SKILL.md b/apps/web/.vibe/skills/svelte-core-bestpractices/SKILL.md new file mode 100644 index 0000000..ffa73ee --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/SKILL.md @@ -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 + + + + + + + +``` + +If you need to attach listeners to `window` or `document` you can use `` and ``: + +```svelte + + +``` + +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)} +

hello {name}!

+{/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 ` + + + +``` + +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 + + + +``` + +## $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 + +``` + +`$inspect.trace` takes an optional first argument which will be used as the label. diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/@attach.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/@attach.md new file mode 100644 index 0000000..5c113b1 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/@attach.md @@ -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 + + + +
...
+``` + +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 + + + + + + +``` + +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 + + { + const context = canvas.getContext('2d'); + + $effect(() => { + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); + }); + }} +> +``` + +> [!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 +
...
+``` + +## 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 + + + + + +``` + +```svelte + + + + + + +``` + +## 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. diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/@render.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/@render.md new file mode 100644 index 0000000..2e60685 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/@render.md @@ -0,0 +1,35 @@ +To render a [snippet](snippet), use a `{@render ...}` tag. + +```svelte +{#snippet sum(a, b)} +

{a} + {b} = {a + b}

+{/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} +

fallback content

+{/if} +``` diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/await-expressions.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/await-expressions.md new file mode 100644 index 0000000..18c2231 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/await-expressions.md @@ -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 ` + + + + +

{a} + {b} = {await add(a, b)}

+``` + +...if you increment `a`, the contents of the `

` will _not_ immediately update to read this — + +```html +

2 + 2 = 3

+``` + +— 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 +

{await one()}

{await two()}

+``` + +...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 ` + + + +{#if open} + + (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. diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/bind.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/bind.md new file mode 100644 index 0000000..80b2f4c --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/bind.md @@ -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 + value, (v) => (value = v.toLowerCase())} /> +``` + +In the case of readonly bindings like [dimension bindings](#Dimensions), the `get` value should be `null`: + +```svelte +
...
+``` + +> [!NOTE] +> Function bindings are available in Svelte 5.9.0 and newer. diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/each.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/each.md new file mode 100644 index 0000000..283b754 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/each.md @@ -0,0 +1,42 @@ +## Keyed each blocks + +```svelte + +{#each expression as name (key)}...{/each} +``` + +```svelte + +{#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)} +
  • {item.name} x {item.qty}
  • +{/each} + + +{#each items as item, i (item.id)} +
  • {i + 1}: {item.name} x {item.qty}
  • +{/each} +``` + +You can freely use destructuring and rest patterns in each blocks. + +```svelte +{#each items as { id, name, qty }, i (id)} +
  • {i + 1}: {name} x {qty}
  • +{/each} + +{#each objects as { id, ...rest }} +
  • {id}
  • +{/each} + +{#each items as [id, ...rest]} +
  • {id}
  • +{/each} +``` diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/hydratable.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/hydratable.md new file mode 100644 index 0000000..a7baf74 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/hydratable.md @@ -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 + + +

    {user.name}

    +``` + +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 + + +

    {user.name}

    +``` + +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 + + +{await promises.one} +{await promises.two} +``` + +## CSP + +`hydratable` adds an inline ` + +{#snippet hello(name)} +

    hello {name}! {message}!

    +{/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 +
    + {#snippet x()} + {#snippet y()}...{/snippet} + + + {@render y()} + {/snippet} + + + {@render y()} +
    + + +{@render x()} +``` + +Snippets can reference themselves and each other (demo: + +```svelte +{#snippet blastoff()} + 🚀 +{/snippet} + +{#snippet countdown(n)} + {#if n > 0} + {n}... + {@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 + + +{#snippet header()} + fruit + qty + price + total +{/snippet} + +{#snippet row(d)} + {d.name} + {d.qty} + {d.price} + {d.qty * d.price} +{/snippet} + + +``` + +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 + +
    + {#snippet header()} + + + + + {/snippet} + + {#snippet row(d)} + + + + + {/snippet} +
    fruitqtypricetotal{d.name}{d.qty}{d.price}{d.qty * d.price}
    +``` + +### Implicit `children` snippet + +Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet (demo: + +```svelte + + +``` + +```svelte + + + + + +``` + +> [!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 + + +{@render children?.()} +``` + +...or use an `#if` block to render fallback content: + +```svelte + + +{#if children} + {@render children()} +{:else} + fallback content +{/if} +``` + +## Typing snippets + +Snippets implement the `Snippet` interface imported from `'svelte'`: + +```svelte + +``` + +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 + +``` + +## Exporting snippets + +Snippets declared at the top level of a `.svelte` file can be exported from a ` + +{#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. diff --git a/apps/web/.vibe/skills/svelte-core-bestpractices/references/svelte-reactivity.md b/apps/web/.vibe/skills/svelte-core-bestpractices/references/svelte-reactivity.md new file mode 100644 index 0000000..262e361 --- /dev/null +++ b/apps/web/.vibe/skills/svelte-core-bestpractices/references/svelte-reactivity.md @@ -0,0 +1,61 @@ +## createSubscriber + +
    + +Available since 5.7.0 + +
    + +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; + } +} +``` + +
    + +```dts +function createSubscriber( + start: (update: () => void) => (() => void) | void +): () => void; +``` + +
    diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..c7ae8fe --- /dev/null +++ b/apps/web/README.md @@ -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. diff --git a/apps/web/bun.lock b/apps/web/bun.lock new file mode 100644 index 0000000..d674c1f --- /dev/null +++ b/apps/web/bun.lock @@ -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=="], + } +} diff --git a/apps/web/example.env b/apps/web/example.env new file mode 100644 index 0000000..af0f350 --- /dev/null +++ b/apps/web/example.env @@ -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="" \ No newline at end of file diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json new file mode 100644 index 0000000..0029964 --- /dev/null +++ b/apps/web/messages/de.json @@ -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." +} \ No newline at end of file diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json new file mode 100644 index 0000000..0b3b6c7 --- /dev/null +++ b/apps/web/messages/en.json @@ -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" +} \ No newline at end of file diff --git a/apps/web/messages/hu.json b/apps/web/messages/hu.json new file mode 100644 index 0000000..2e09718 --- /dev/null +++ b/apps/web/messages/hu.json @@ -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" +} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3e192b8 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/project.inlang/settings.json b/apps/web/project.inlang/settings.json new file mode 100644 index 0000000..038ac63 --- /dev/null +++ b/apps/web/project.inlang/settings.json @@ -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" + ] +} diff --git a/apps/web/skills-lock.json b/apps/web/skills-lock.json new file mode 100644 index 0000000..f600df6 --- /dev/null +++ b/apps/web/skills-lock.json @@ -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" + } + } +} diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/apps/web/src/app.d.ts @@ -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 {}; diff --git a/apps/web/src/app.html b/apps/web/src/app.html new file mode 100644 index 0000000..8bf98e7 --- /dev/null +++ b/apps/web/src/app.html @@ -0,0 +1,16 @@ + + + + + + + + + %sveltekit.head% + + +
    %sveltekit.body%
    + diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 0000000..5182210 --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -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; diff --git a/apps/web/src/hooks.ts b/apps/web/src/hooks.ts new file mode 100644 index 0000000..e75600b --- /dev/null +++ b/apps/web/src/hooks.ts @@ -0,0 +1,3 @@ +import { deLocalizeUrl } from '$lib/paraglide/runtime'; + +export const reroute = (request) => deLocalizeUrl(request.url).pathname; diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg new file mode 100644 index 0000000..d86cd23 --- /dev/null +++ b/apps/web/src/lib/assets/favicon.svg @@ -0,0 +1,134 @@ + + diff --git a/apps/web/src/lib/assets/logo.svg b/apps/web/src/lib/assets/logo.svg new file mode 100644 index 0000000..8471342 --- /dev/null +++ b/apps/web/src/lib/assets/logo.svg @@ -0,0 +1,126 @@ + + diff --git a/apps/web/src/lib/components/layout/Chatbot.svelte b/apps/web/src/lib/components/layout/Chatbot.svelte new file mode 100644 index 0000000..b0fbf93 --- /dev/null +++ b/apps/web/src/lib/components/layout/Chatbot.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/lib/components/layout/Footer.svelte b/apps/web/src/lib/components/layout/Footer.svelte new file mode 100644 index 0000000..4a2478b --- /dev/null +++ b/apps/web/src/lib/components/layout/Footer.svelte @@ -0,0 +1,43 @@ + + + \ No newline at end of file diff --git a/apps/web/src/lib/components/layout/Header.svelte b/apps/web/src/lib/components/layout/Header.svelte new file mode 100644 index 0000000..3f836fd --- /dev/null +++ b/apps/web/src/lib/components/layout/Header.svelte @@ -0,0 +1,12 @@ + + +
    + + +
    \ No newline at end of file diff --git a/apps/web/src/lib/components/layout/LanguageSwitcher.svelte b/apps/web/src/lib/components/layout/LanguageSwitcher.svelte new file mode 100644 index 0000000..2a143b7 --- /dev/null +++ b/apps/web/src/lib/components/layout/LanguageSwitcher.svelte @@ -0,0 +1,66 @@ + + +
    + + + {#if open} + + + + {/if} +
    \ No newline at end of file diff --git a/apps/web/src/lib/components/layout/MainNavigation.svelte b/apps/web/src/lib/components/layout/MainNavigation.svelte new file mode 100644 index 0000000..249a1ac --- /dev/null +++ b/apps/web/src/lib/components/layout/MainNavigation.svelte @@ -0,0 +1,149 @@ + + + + + + diff --git a/apps/web/src/lib/components/ui/Breadcrumbs.svelte b/apps/web/src/lib/components/ui/Breadcrumbs.svelte new file mode 100644 index 0000000..fd40244 --- /dev/null +++ b/apps/web/src/lib/components/ui/Breadcrumbs.svelte @@ -0,0 +1,46 @@ + + +{#if showBreadcrumbs && items.length > 0} + +{/if} diff --git a/apps/web/src/lib/components/ui/FilterPanel.svelte b/apps/web/src/lib/components/ui/FilterPanel.svelte new file mode 100644 index 0000000..7d054cb --- /dev/null +++ b/apps/web/src/lib/components/ui/FilterPanel.svelte @@ -0,0 +1,281 @@ + + + +
    + +
    + + +{#if drawerOpen} + +
    +
    +

    Filter

    + +
    + +
    +{/if} + + + + + +{#snippet filterContent()} + {#each filters as filter} + {#if filter.type === 'tags'} +
    +

    {filter.label}

    + + + + {#each filter.options as opt} +
    + + {#snippet children({ checked })} + {#if checked} + + + + {/if} + {/snippet} + + +
    + {/each} +
    + + + {#if tagValues[filter.key + '_exclude'] !== undefined} +
    +

    Ausschließen

    + + {#each filter.options as opt} +
    + + {#snippet children({ checked })} + {#if checked} + + + + {/if} + {/snippet} + + +
    + {/each} +
    +
    + {/if} +
    + + {:else if filter.type === 'date-range'} +
    +

    {filter.label}

    +
    +
    + + +
    +
    + + +
    +
    +
    + + {:else if filter.type === 'select'} +
    +

    {filter.label}

    + +
    + {/if} + {/each} + + +
    + + {#if hasActiveFilters} + + {/if} +
    +{/snippet} \ No newline at end of file diff --git a/apps/web/src/lib/components/ui/LexicalRenderer.svelte b/apps/web/src/lib/components/ui/LexicalRenderer.svelte new file mode 100644 index 0000000..8d91cd6 --- /dev/null +++ b/apps/web/src/lib/components/ui/LexicalRenderer.svelte @@ -0,0 +1,106 @@ + + +{#each nodes as node} + {#if node.type === 'paragraph'} + {#if node.children?.length} +

    + {:else} +
    + {/if} + + {:else if node.type === 'heading'} + {#if node.tag === 'h1'} +

    + {:else if node.tag === 'h2'} +

    + {:else if node.tag === 'h3'} +

    + {:else if node.tag === 'h4'} +

    + {:else if node.tag === 'h5'} +
    + {:else if node.tag === 'h6'} +
    + {/if} + + {:else if node.type === 'list'} + {#if node.tag === 'ol'} +
    + {:else} +
    + {/if} + + {:else if node.type === 'listitem'} +
  • + + {:else if node.type === 'text'} + {#if isCode(node.format)} + {node.text} + {:else if isBold(node.format) && isItalic(node.format)} + {node.text} + {:else if isBold(node.format)} + {node.text} + {:else if isItalic(node.format)} + {node.text} + {:else if isStrikethrough(node.format)} + {node.text} + {:else if isUnderline(node.format)} + {node.text} + {:else} + {node.text} + {/if} + + {:else if node.type === 'linebreak'} +
    + + {:else if node.type === 'upload'} +
    + {node.value.alt +
    + {/if} +{/each} \ No newline at end of file diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/apps/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/web/src/lib/types/artikel.ts b/apps/web/src/lib/types/artikel.ts new file mode 100644 index 0000000..607312e --- /dev/null +++ b/apps/web/src/lib/types/artikel.ts @@ -0,0 +1,42 @@ +// src/lib/types/artikel.ts + +export type ArtikelPost = { + slug: string + title: string + publishedAt: string + excerpt?: string + featuredImage?: { url: string; alt?: string } | null +} + +export type ArtikelPostFull = ArtikelPost & { + content: { root: { children: any[] } } + author?: { firstName?: string; lastName?: string } + references?: { + source: { + id: string + slug: string + displayTitle: string + title: string + authors?: { firstName?: string; lastName?: string }[] + year?: number + type: string + publisher?: string + place?: string + journal?: string + volume?: string + issue?: string + pages?: string + doi?: string + institution?: string + thesisType?: string + series?: string + seriesNumber?: string + bookTitle?: string + archiveCollection?: string + externalUrl?: string + accessed?: string + } + pages?: string + note?: string + }[] +} \ No newline at end of file diff --git a/apps/web/src/lib/types/breadcrumbs.ts b/apps/web/src/lib/types/breadcrumbs.ts new file mode 100644 index 0000000..8d82a93 --- /dev/null +++ b/apps/web/src/lib/types/breadcrumbs.ts @@ -0,0 +1,12 @@ +export interface BreadcrumbItem { + label: string; + href: string; +} + +export interface BreadcrumbData { + crumbs: BreadcrumbItem[]; +} + +export type LayoutData = { + breadcrumbs?: BreadcrumbData; +}; diff --git a/apps/web/src/lib/types/neuigkeiten.ts b/apps/web/src/lib/types/neuigkeiten.ts new file mode 100644 index 0000000..a140b16 --- /dev/null +++ b/apps/web/src/lib/types/neuigkeiten.ts @@ -0,0 +1,12 @@ +export type NeuigkeitenPost = { + slug: string + title: string + publishedAt: string + excerpt?: string + featuredImage?: { url: string; alt?: string } | null +} + +export type NeuigkeitenPostFull = NeuigkeitenPost & { + content: { root: { children: any[] } } + author?: { firstName?: string; lastName?: string } +} \ No newline at end of file diff --git a/apps/web/src/lib/utils/payload.ts b/apps/web/src/lib/utils/payload.ts new file mode 100644 index 0000000..251d944 --- /dev/null +++ b/apps/web/src/lib/utils/payload.ts @@ -0,0 +1,86 @@ +import { getLocale } from '$lib/paraglide/runtime' +import qs from 'qs' + +const CMS_URL = import.meta.env.PUBLIC_CMS_URL ?? 'http://localhost:3000' + +type FetchOptions = { + locale?: string + depth?: number + where?: Record + select?: Record + limit?: number + page?: number + sort?: string +} + +// Base fetch with locale + error handling +async function payloadFetch( + path: string, + options: FetchOptions = {}, + fetchFn: typeof fetch = fetch +): Promise { + const locale = options.locale ?? getLocale() + + const query = qs.stringify( + { + locale, + 'fallback-locale': 'de', + depth: options.depth ?? 1, + ...(options.where && { where: options.where }), + ...(options.select && { select: options.select }), + ...(options.limit && { limit: options.limit }), + ...(options.page && { page: options.page }), + ...(options.sort && { sort: options.sort }), + }, + { addQueryPrefix: true } + ) + + const res = await fetchFn(`${CMS_URL}/api${path}${query}`) + + if (!res.ok) { + throw new Error(`Payload fetch failed: ${res.status} ${path}`) + } + + return res.json() +} + +// Collection — list +export async function getCollection( + slug: string, + options: FetchOptions = {}, + fetchFn?: typeof fetch +) { + return payloadFetch<{ docs: T[]; totalDocs: number; totalPages: number }>( + `/${slug}`, + options, + fetchFn + ) +} + +// Collection — single by slug +export async function getBySlug( + slug: string, + value: string, + options: FetchOptions = {}, + fetchFn?: typeof fetch +) { + const data = await getCollection( + slug, + { + ...options, + where: { slug: { equals: value } }, + limit: 1, + }, + fetchFn + ) + return data.docs[0] ?? null +} + +// Global +export async function getGlobal( + slug: string, + options: FetchOptions = {}, + fetchFn?: typeof fetch +) { + return payloadFetch(`/globals/${slug}`, options, fetchFn) +} \ No newline at end of file diff --git a/apps/web/src/posts/build-vs-buy-de.md b/apps/web/src/posts/build-vs-buy-de.md new file mode 100644 index 0000000..f1e7f70 --- /dev/null +++ b/apps/web/src/posts/build-vs-buy-de.md @@ -0,0 +1,71 @@ +--- +title: "Kaufen vs. Bauen: Wie wir Chaos durch ein internes PIM ersetzt haben" +date: 2026-04-19 +description: Warum wir ein internes Produktinformationssystem gebaut statt gekauft haben, und was mich diese Entscheidung über Pitching, Pragmatismus und Unabhängigkeit gelehrt hat. +lang: de +slug: kaufen-vs-bauen-pim +translationSlug: build-vs-buy-pim +tags: [Prozesse, Plattform, Business] +--- + +In den meisten Unternehmen wird die Frage, ob man ein Tool kaufen oder selbst bauen soll, auf die falsche Art beantwortet. Jemand findet eine SaaS-Lösung, die in einer Demo gut aussieht, holt die Genehmigung für die monatlichen Kosten ein, und drei Jahre später ist das Unternehmen an einen Anbieter gebunden, zahlt für Funktionen, die es nicht nutzt, und erledigt die Hälfte der Arbeit immer noch manuell. + +Dies ist die Geschichte, wie wir es anders gemacht haben, und warum die wichtigste Entscheidung, die wir getroffen haben, nichts mit Technologie zu tun hatte. + +## Das Problem, das niemand vollständig benannt hatte + +Ich war Teil des Website-Teams eines mittelgroßen E-Commerce-Unternehmens. Unsere Aufgabe war es unter anderem, neue Produkte für Kunden zum Kauf bereitzustellen. In der Theorie simpel. In der Praxis bedeutete es, das letzte Glied in einer Kette zu sein, die Einkauf, Produktentwicklung, Finanzen, Logistik und Marketing umfasste, jede Abteilung mit eigenen Tools, eigenen Tabellen und einer eigenen Definition von „bereit". + +Wenn eine neue Kollektion anstand, verbrachten wir viel Zeit mit der Suche: Wurden die Preise von der Finanzabteilung festgelegt? Hat der Einkauf die Lieferung bestätigt? Hat die Produktentwicklung die technischen Daten bereitgestellt? Die Informationen existierten, irgendwo, aber sie waren über eine fragmentierte Landschaft aus Google Sheets, geteilten Laufwerken und institutionellem Gedächtnis verstreut. + +Dazu kamen die Sheets selbst, die zunehmend zum Problem wurden. Große, komplexe Tabellenkalkulationen sind fehleranfällig. Sie frieren auf schwächeren Rechnern ein, brechen zusammen, wenn jemand die falsche Zelle bearbeitet, und skalieren schlecht, wenn Teams und Produktkataloge wachsen. Doppelerfassungen waren an der Tagesordnung. Fehler ebenso. Verantwortlichkeit hingegen nicht. + +Logistik und Einkauf hatten sich bereits auf ein ERP (Odoo) konsolidiert, aber alles andere wurde abteilungsweise in Sheets verwaltet. Das Ergebnis: Das Website-Team, mein Team, absorbierte am Ende jedes Launch-Zyklus den Großteil des Chaos. + +## Die Übergangslösung + +Bevor ich ein System vorschlug, unternahm ich einen einfacheren ersten Schritt: die Konsolidierung der Informationen in einem einzigen Google Sheet, das den gesamten Launch- und Kundensupport-Workflow abdeckt. Keine neuen Tools, keine Entwicklungsarbeit. Nur die Einigung auf eine einzige Quelle der Wahrheit. + +Die Stabilisierung dauerte etwa sechs bis acht Monate. Die Reibung wurde reduziert, aber das zugrundeliegende Problem wurde dadurch umso klarer. Eine Tabellenkalkulation, egal wie gut gepflegt, war nie das richtige Fundament für diesen strukturierten, abteilungsübergreifenden Datenfluss. Wir brauchten etwas Zweckgebautes. Das endlose Kopieren und Einfügen musste aufhören. Die vermeidbaren Fehler durch menschliche Irrtümer mussten ein Ende haben. + +## Der richtige Pitch + +Hier scheitern viele interne Tool-Projekte. Der Instinkt ist, mit einer Lösung in ein Pitch-Gespräch zu gehen: *„Ich habe diese tolle SaaS gefunden"* oder *„wir sollten ein PIM-System bauen."* Dieser Ansatz scheitert fast immer, weil er Entscheidungsträger bittet, eine Lösung zu bewerten, bevor sie sich über das Problem einig sind. + +Ich machte das Gegenteil. Ich dokumentierte die Schwachstellen in betriebswirtschaftlichen Begriffen: verlorene Zeit pro Launch-Zyklus, Fehlerquoten, die nachgelagerten Kosten fehlerhafter Daten auf der Website. Ich machte deutlich, warum das für das Unternehmen relevant ist, nicht als technologisches Problem, sondern als operatives. Die Lösung kam erst danach. + +Der Pitch ging an meinen Abteilungsleiter und von dort an die C-Ebene. Er wurde genehmigt. + +## Warum wir gebaut statt gekauft haben + +Sobald das Projekt genehmigt war, war die Frage „Kaufen oder Bauen?" relativ eindeutig, allerdings nicht aus den Gründen, die man üblicherweise nennt. + +Ja, ich habe mir SaaS-PIM-Lösungen angesehen. Sie waren deutlich teurer. Aber Kosten allein sind ein schwaches Argument, denn SaaS-Kosten sind vorhersehbar, Entwicklungskosten nicht. Die stärkeren Argumente lagen woanders. + +Erstens: **Passgenauigkeit**. Unsere Anforderungen waren spezifisch: Integration mit Google Sheets (wo unsere Teams bereits arbeiteten), ein Rich-Text-Editor für Texter, eine API-Anbindung an unser TMS für Übersetzungs-Workflows und ein CSV-Export für Magento 2. Kein Standardtool hätte das ohne erheblichen Konfigurationsaufwand abgedeckt, für den man so oder so zahlt. + +Zweitens: **Kontrolle**. Ein schlankes internes Tool muss keine langfristige Verpflichtung sein. Wir haben es so gebaut, dass es ersetzbar ist. Kein Lock-in, kein Migrationsrisiko, keine Anbieterabhängigkeit. Wenn sich der Tech-Stack des Unternehmens ändern würde, könnten wir sauber aussteigen. + +Wie sich herausstellte, war das wichtig. Das Unternehmen bewegte sich später in Richtung des Shopify-Ökosystems, was das PIM möglicherweise überflüssig machen wird. Weil wir ohne Lock-in gebaut haben, ist das kein Problem. Wir betrachten es als ein bewusstes Merkmal des ursprünglichen Designs, nicht als Zufall. + +Drittens: **Eigentümerschaft**. Produktinformationen lagen zuvor in verstreuten Drive-Dokumenten. Viel Erfolg beim Finden von irgendetwas in diesem Chaos! Das PIM gab uns eine PostgreSQL-Datenbank mit täglichem automatisiertem Backup auf dem Unternehmens-Drive. 99,99 % Uptime über fast ein Jahr Betrieb. Daten, die tatsächlich auffindbar und wartbar sind. + +## Was wir gebaut haben + +Das MVP benötigte etwa 150-200 Stunden mit einem zweiköpfigen Team. Meine Rolle umfasste Konzept, Architektur, CI/CD-Pipeline und Hosting-Setup. Ich habe das System entworfen und in Produktion gebracht. Mein Junior-Kollege übernahm den Großteil der Feature-Entwicklung. Die End-to-End-Verantwortung vom Whiteboard bis zum Deployment lag bei mir. Das System: + +- Nutzt SSO zur Authentifizierung +- Zieht technische und Finanzdaten aus Google Sheets, wo die Mitarbeiter bereits arbeiten +- Bietet einen Rich-Text-Editor für Texter, isoliert von technischer Komplexität +- Fordert Produktübersetzungen über die TMS-API (Smartling) an und empfängt sie +- Exportiert eine Magento-2-fertige CSV, die den manuellen Kopieraufwand eliminiert, der zuvor etwa 70 % der Launch-Zeit des Website-Teams beanspruchte +- Läuft auf einem *sehr* erschwinglichen, europäischen VPS via Docker, deployed über Bitbucket Pipelines +- Sichert die Datenbank täglich per Cronjob auf Google Drive + +Im Laufe des vergangenen Jahres wurden kontinuierlich Patches und Erweiterungen eingespielt. Die Gesamtkosten, Entwicklung, Wartung, Hosting, liegen nach wie vor 80-90 % unter dem, was vergleichbare SaaS-Lösungen gekostet hätten. + +## Das Prinzip dahinter + +Ich habe in operativen Rollen genug Zeit verbracht, um eine Sache klar zu wissen: Die beste Lösung ist selten die technisch eleganteste, und nie die ideologisch reinste. Es ist die Lösung, die das eigentliche Problem löst, zu den tatsächlichen Rahmenbedingungen passt und ohne Drama übergeben, gewartet oder verworfen werden kann. + +Man kann nicht mit der Lösung anfangen. Man beginnt mit dem Problem, erarbeitet den Business Case und lässt die Lösung daraus folgen. Alles andere ist nur Technologie. \ No newline at end of file diff --git a/apps/web/src/posts/build-vs-buy-en.md b/apps/web/src/posts/build-vs-buy-en.md new file mode 100644 index 0000000..25308e9 --- /dev/null +++ b/apps/web/src/posts/build-vs-buy-en.md @@ -0,0 +1,71 @@ +--- +title: "Build vs. Buy: How We Replaced Chaos with a Custom PIM" +date: 2026-04-19 +description: Why we built an internal product information system instead of buying one, and what that decision taught me about pitching, pragmatism, and avoiding lock-in. +lang: en +slug: build-vs-buy-pim +translationSlug: kaufen-vs-bauen-pim +tags: [Process, Platform, Business] +--- + +At most companies, the question of whether to build or buy a tool gets answered the wrong way. Someone finds a SaaS that looks good in a demo, gets approval on the monthly cost, and three years later the company is locked into a vendor, paying for features it doesn't use, and still doing half the work manually anyway. + +This is the story of how we did it differently and why the most important decision we made had nothing to do with technology. + +## The Problem Nobody Had Fully Named + +I was part of the website team at a mid-sized e-commerce company. Our job, among other things, was to get new products live for customers to purchase. Simple enough in theory. In practice, it meant being the last link in a chain that involved procurement, product development, finance, logistics, and marketing, each of which had their own tools, their own spreadsheets, and their own definition of "ready." + +When a new collection was due to launch, we would spend significant time hunting: Are the prices set by finance? Has procurement confirmed delivery? Did product development provide the technical specifications? The information existed - somewhere - but it was scattered across a fragmented landscape of Google Sheets, shared drives, and institutional memory. + +On top of that, the Sheets themselves were becoming a liability. Large, complex spreadsheets are brittle. They freeze on underpowered machines, break when someone edits the wrong cell, and don't scale well as teams and product catalogues grow. Double-entry was common. Errors were common. Accountability was not. + +Logistics and procurement had already consolidated onto an ERP (Odoo), but everything else was managed on a team-by-team basis. The result was that the website team (my team) absorbed most of the chaos at the end of every launch cycle. + +## The Interim Fix + +Before proposing any system, I took a simpler first step: consolidate the information into a single Google Sheet covering the full launch and customer support workflow. No new tools, no development work. Just agreement on one source of truth. + +This took around six to eight months to stabilise. It reduced friction, but it also made the underlying problem clearer. A spreadsheet, however well-maintained, was never going to be the right substrate for this kind of structured, multi-department data flow. We needed something purpose-built. The endless copy-pasting had to go. The avoidable errors from human mistakes had to stop. + +## Pitching It Right + +Here is where a lot of internal tool projects go wrong. The instinct is to walk into a pitch with a solution: *"I've found this great SaaS"* or *"we should build a PIM system."* That approach almost always fails, because it asks decision-makers to evaluate a solution before they've agreed on the problem. + +I did the opposite. I documented the pain points in business terms: time lost per launch cycle, error rates, the downstream cost of bad data reaching the website. I made the case for why this mattered to the bottom line, not as a technology problem, but as an operational one. The solution came second. + +The pitch went to my department head and from there to C-level. It was approved. + +## Why We Built Instead of Bought + +Once the project was green-lit, the build vs. buy question was relatively straightforward, though not for the reasons people usually cite. + +Yes, I looked at SaaS PIM solutions. They were significantly more expensive. But cost alone is a weak argument, because SaaS costs are predictable and development costs are not. The stronger arguments were elsewhere. + +First, **fit**. Our requirements were specific: integration with Google Sheets (where our teams already worked), a rich text editor for copywriters, an API connection to our TMS for translation workflows, and a CSV export pipeline into Magento 2. No off-the-shelf tool was going to cover this without significant configuration work, which you're paying for either way. + +Second, **control**. A lightweight internal tool doesn't need to be a long-term commitment. We built it to be replaceable. No lock-in, no migration risk, no vendor dependency. If the company's tech stack changed, we could walk away cleanly. + +As it turned out, that mattered. The company later moved toward the Shopify ecosystem, which may eventually make the PIM redundant. Because we built without lock-in, that's fine. We treat it as a feature of the original design, not an accident. + +Third, **ownership**. Product information had previously lived in scattered Drive documents. Good luck finding anything in that chaos! The PIM gave us a PostgreSQL database with a daily automated backup to the company Drive. 99.99% uptime over nearly a year of operation. Data that's actually findable and maintainable. + +## What We Built + +The MVP took roughly 150–200 hours across a two-person team. My role was concept, architecture, CI/CD pipeline, and hosting setup. I designed the system and got it running in production. My junior colleague handled the bulk of feature development. End-to-end ownership from whiteboard to deployment was mine. The system: + +- Uses SSO for authentication +- Pulls technical and finance data from Google Sheets, where employees already work +- Provides a rich text editor for copywriters, isolated from technical complexity +- Requests and receives product translations via the TMS (Smartling) API +- Exports a Magento 2-ready CSV, eliminating the manual copy-paste work that previously consumed around 70% of launch time for the website team +- Runs on a *very* affordable, European VPS via Docker, deployed through Bitbucket Pipelines +- Backs up the database to Google Drive daily via a cron job + +Continuous patches and extensions have been applied over the past year as needs evolved. The total cost - development, maintenance, hosting - remains 80–90% below what comparable SaaS solutions would have cost. + +## The Principle Behind It + +I've spent enough time in operational roles to have learned one thing clearly: the best solution is rarely the most technically elegant one, and never the most ideologically pure one. It's the one that solves the actual problem, fits the actual constraints, and can be handed over, maintained, or discarded without drama. + +You can't start with the solution. You start with the problem, you build the business case, and you let the solution follow from that. Everything else is just technology. \ No newline at end of file diff --git a/apps/web/src/posts/science-and-open-source-de.md b/apps/web/src/posts/science-and-open-source-de.md new file mode 100644 index 0000000..f1b9824 --- /dev/null +++ b/apps/web/src/posts/science-and-open-source-de.md @@ -0,0 +1,45 @@ +--- +title: Wissenschaft und Open Source +date: 2024-06-11 +description: Warum Akademiker sorgfältig über die Software nachdenken müssen, die sie verwenden und lehren. +lang: de +slug: wissenschaft-und-open-source +translationSlug: science-and-open-source +featuredImage: /images/neuigkeiten/science_and_open_source.avif +imageCaption: © Lukas / Pexels +tags: [Open Source, Wissenschaft] +--- + +Während meiner akademischen Abenteuer wurde ich häufig mit der Verwendung von proprietärer Software konfrontiert. In der Tat sind verschiedene Fakultäten an der Universität Wien von unterschiedlichen Softwarelösungen abhängig. Soziologiestudenten wird die Verwendung von IBM SPSS für statistische Auswertungen beigebracht. In der Politikwissenschaft ist es Stata. Geographiestudenten müssen ArcGIS für die raumbezogene Analyse verwenden. + +Microsoft Office ist natürlich kostenlos und wird befürwortet. Offene Standards und Open Source Software (OSS) werden nur am Rande erwähnt. Universitätsmitarbeiter geben Kursmaterialien oft nur in proprietären, nicht standardisierten Formaten wie .pptx und .docx weiter. Die Auswirkungen und die ethischen Aspekte der Verwendung proprietärer Software werden nie diskutiert. Kurse, die auf OSS-Alternativen basieren, werden nur selten angeboten. + +Als Akademiker müssen wir zwingend auf die Konsequenzen und Auswirkungen der Software, die wir verwenden und lehren, eingehen. + +## Was ist "proprietär" und was ist "Open Source"? + +Der Hauptunterschied zwischen den beiden Begriffen besteht darin, ob der Quellcode der Software öffentlich eingesehen werden kann. Proprietäre Software wird in der Regel von einem einzigen Unternehmen kontrolliert, wie Microsoft Office. Die genaue Funktionsweise der Software ist für die Öffentlichkeit nicht einsehbar. OSS wie LibreOffice hingegen ist Eigentum der Öffentlichkeit oder NGOs und macht ihren gesamten Code öffentlich zugänglich. Er kann von jedem untersucht werden, der einen Blick darauf werfen möchte. + +Ein weiteres Unterscheidungsmerkmal ist die Lizenzierung. OSS ist in der Regel kostenlos verfügbar und kann von allen verändert und weitergegeben werden. Proprietäre Software erfordert oft regelmäßige Zahlungen für die neuesten Versionen. Die Weitergabe der Software oder ihre Modifizierung ist streng verboten. + +Proprietäre Software versucht ihre Benutzer zu kontrollieren und einzuschließen (sog. vendor lock-in). OSS fördert die Zusammenarbeit, Transparenz und Verantwortlichkeit — Ideen, die wir als gute wissenschaftliche Praxis verstehen. + +## Ethik und die gute wissenschaftliche Praxis + +Junge Studenten werden schon früh in ihrem Studium mit Ethik und guter wissenschaftlicher Praxis konfrontiert. Die strenge Kontrolle und Auflistung unserer Quellen sowie der Schutz der Privatsphäre von Teilnehmern während der Forschung sind eine Selbstverständlichkeit. Wer sich nicht an diese Regeln hält, wird berechtigt stark kritisiert. Wir wenden sie jedoch oft ausschließlich auf unsere Methodik an und nicht auf die Instrumente, die wir zur Verarbeitung unserer Daten verwenden. + +Während die von uns verwendete Software in der Vergangenheit von geringer Bedeutung war, ist es heute wichtig, die von uns verwendeten Werkzeuge sorgfältig auszuwählen. + +## Rechtliche Grauzone + +Es ist allgemein bekannt, dass große Unternehmen alle möglichen Daten über uns an allen möglichen Stellen sammeln. Das ist nichts Neues. Aber nur wenige wissen, dass die Datensammlungspraktiken von Microsoft so weit gehen, dass sie jedes Wort und jede Zahl analysieren, die in ihre Office Suite eingegeben wird. Auch wenn Forschungsteilnehmer der Datenschutzrichtlinie zugestimmt haben, haben sie **auch der von Microsoft** ausdrücklich zugestimmt? + +Die DSGVO ist eine relativ neue Verordnung, und diese Frage ist schwer zu beantworten, da es wenig bis gar keine Präzedenzfälle gibt. + +## Alternativen + +Sie fragen sich vielleicht, welche Software aufgrund der obigen Ausführungen als problematisch angesehen werden könnte und welche Alternativen es gibt, um Ihre Forschung legal und mit gutem Gewissen fortzusetzen. Die folgende, unvollständige Liste soll den Einstieg erleichtern: + +* ❌ Microsoft Office → ✅ LibreOffice *(Bitte beachten Sie, dass das weithin bekannte "OpenOffice" seit vielen Jahren keine Updates mehr erhält und nicht mehr aktiv weiterentwickelt wird)* +* ❌ SPSS, Stata → ✅ R, Python *(sehr empfehlenswerter Umstieg, da SPSS und Stata in der Praxis kaum genutzt werden)* +* ❌ ArcGIS → ✅ QGIS \ No newline at end of file diff --git a/apps/web/src/posts/science-and-open-source-en.md b/apps/web/src/posts/science-and-open-source-en.md new file mode 100644 index 0000000..5bc4d6f --- /dev/null +++ b/apps/web/src/posts/science-and-open-source-en.md @@ -0,0 +1,45 @@ +--- +title: Science and Open Source +date: 2024-06-11 +description: Why academics need to think carefully about the software they use and teach. +lang: en +slug: science-and-open-source +translationSlug: wissenschaft-und-open-source +featuredImage: /images/neuigkeiten/science_and_open_source.avif +imageCaption: © Lukas / Pexels +tags: [Open Source, Science] +--- + +During my academic adventures, I was frequently confronted with the use of proprietary software. Indeed, different faculties at the University of Vienna are reliant on different software. Sociology Students are taught the use of IBM SPSS for statistical evaluation. For Political Science it's Stata. Geography Students must use ArcGIS for geospatial analysis. + +Microsoft Office is, of course, free of charge and encouraged. Open Standards and Open Source Software (OSS) are only mentioned on the side. University staff often only shares material in proprietary, non-standard formats like .pptx and .docx. The effects and ethics of using proprietary software are never discussed. Courses based on OSS alternatives are only rarely offered. + +As academics, it is imperative that we understand the consequences and implications of what software we use and teach. + +## What is "proprietary" and what is "open source" + +The key difference between the two terms is whether the software's code can be publicly reviewed. Proprietary software is usually controlled by a single company, like Microsoft Office. The exact workings of the software are obscured from public view. OSS like LibreOffice on the other hand is owned by communities or NGOs and makes all of its code publicly available. It can be scrutinized by anyone willing to take a look. + +Another differentiator is licensing. OSS is usually available free of charge, and can be modified and redistributed by anyone. Proprietary software often requires frequent payments for the latest versions. Redistributing the software or modifying it is strictly forbidden. + +Proprietary software seeks to control and lock-in its users. OSS fosters cooperation, transparency and accountability, things we consider to be good scientific practice. + +## Ethics and good scientific practice + +Young students are confronted with ethics and good scientific practice in academia early in their studies. Controlling and listing our sources as well as protecting the privacy of participants during research are a given. You will be heavily criticized if you do not abide by these rules. However we often exclusively apply them to our methodology, and not to the tools we use to process our data. + +While the software we used in the past was of little consequence, nowadays it is important to carefully choose our tools of choice. + +## Legal grey area + +It is widely known that large companies collect all kinds of data about us in all kinds of places. That is nothing new. But only few realize that Microsoft's data collection practices go as far as to analyze every word and every number inputted into their Office Suite. While your research participants might have agreed to **your** privacy policy, have they also explicitly consented to Microsoft's? + +The GDPR is fairly new regulation, and this question is hard to answer, since there is little to no precedence. + +## Alternatives + +You might be wondering, what software could be considered problematic based on the above, and what alternatives exist to carry on your research legally and with a good conscience. The following is a non-exhaustive list to get you started: + +* ❌ Microsoft Office → ✅ LibreOffice *(Please keep in mind that the widely known "OpenOffice" has not received updates in many years, and is not actively developed anymore)* +* ❌ SPSS, Stata → ✅ R, Python *(highly recommended switch, as there is barely any real-world usage of SPSS and Stata)* +* ❌ ArcGIS → ✅ QGIS \ No newline at end of file diff --git a/apps/web/src/posts/technicalities-of-scientific-writing-de.md b/apps/web/src/posts/technicalities-of-scientific-writing-de.md new file mode 100644 index 0000000..e82ae00 --- /dev/null +++ b/apps/web/src/posts/technicalities-of-scientific-writing-de.md @@ -0,0 +1,122 @@ +--- +title: Wissanschaftliches Schreiben mit Software +date: 2025-10-19 +description: Ein praktischer Leitfaden zur Software und dem Formatierungs-Workflow beim Verfassen der ersten wissenschaftlichen Arbeit. +lang: de +slug: wissenschaftliches-schreiben-mit-software +translationSlug: technicalities-of-scientific-writing +featuredImage: /images/neuigkeiten/technicalities-of-scientific-writing/featured.avif +imageCaption: © William Fortunato / Pexels +tags: [Open Source, Wissenschaft] +--- + +Viele Studierende haben Schwierigkeiten mit den technischen Aspekten des Verfassens einer wissenschaftlichen Arbeit, insbesondere beim ersten Mal. Dieser Leitfaden wurde erstellt, um den Prozess etwas einfacher und schmerzfreier zu gestalten. + +## I. Welche Software brauche ich? + +### 1. Schreiben + +Jedes Textverarbeitungsprogramm, das in der Lage ist, ein Dokument zu formatieren, ein Inhaltsverzeichnis zu erstellen, Seiten zu nummerieren und Schriftart sowie Ränder anzupassen, wird funktionieren. Allerdings werden **LibreOffice** oder **Microsoft Office** aufgrund ihrer erweiterten Funktionen dringend empfohlen. + +Auch wenn Ihre Institution Ihnen kostenlose Microsoft-Lizenzen zur Verfügung stellt, würde ich Ihnen trotzdem raten, LibreOffice zu verwenden, da es... + +* ...frei und quelloffen ist – keine Lizenz erforderlich. +* ...Ihre Texte nicht zu kommerziellen Zwecken analysiert. +* ...ethisch vorzuziehen ist: Wissenschaftliche Arbeit sollte nicht von Unternehmen mit fragwürdigen Datenpraktiken abhängen. +* ...rechtliche Vorteile in der EU hat: Vermeidet Datenschutzprobleme bei der Speicherung von Teilnehmerdaten. + +### 2. Zitationen + +Technisch gesehen reicht ein Textverarbeitungsprogramm aus, um loszulegen. Wenn du jedoch vermeiden möchtest, dass dein Leben zu einem *endlosen Leiden* wird, solltest du *Zitationssoftware* verwenden. Diese ermöglicht es, das Einfügen und Formatieren von Zitaten und vor allem des Literaturverzeichnisses erheblich zu beschleunigen. Es macht vielleicht Spaß, alles manuell zu tun – bis man merkt, dass man mehr als nur zehn Quellen hat, die zudem alle unterschiedlichen Typen mit verschiedenen Zitierregeln sind. + +**Zotero** ist ein kostenloser und quelloffener Literaturverwaltungsdienst, der sich sowohl in LibreOffice als auch in Microsoft Office integrieren lässt. + +## II. Einrichtung + +Nach der Installation muss sichergestellt werden, dass Zotero mit deiner Office-Software integriert ist. In der Regel erkennt das Programm die Installation automatisch. In einigen seltenen Fällen (insbesondere unter Linux, wenn Sie Zotero über Snap oder Flatpak installiert haben) funktioniert die Integration jedoch nicht sofort, und muss über das Einstellungsmenü erneut installiert werden. Weitere Informationen: [offizielle Dokumentation](https://www.zotero.org/support/word_processor_integration). + +Wenn Zotero korrekt installiert ist, sollten die folgenden Symbole in Writer sichtbar sein. + +![Zotero-Symbole](/images/blog/technicalities-of-scientific-writing/zotero_toolbar.avif) + +### Literatur importieren + +Bei der Sammlung von Literatur, achte auf einfache Importmöglichkeiten. Zotero kann Referenzen im **BibTeX**- oder **RIS**-Format importieren, die häufig unter den Schaltflächen *Cite* oder *Citation* auf den Websites der Verlage zu finden sind. + +Nach dem Herunterladen gehst du in Zotero auf **Datei → Importieren**. Überprüfe und korrigiere eventuelle Fehler. **Achtung:** Diese Dateien sind nicht immer zu 100 % korrekt, daher sollten alle Angaben sorgfältig geprüft und gegebenenfalls manuell korrigiert werden. Du kannst Einträge auch manuell mit dem grünen Plus-Symbol hinzufügen. + +Verwende Zoteros *Collections*, um Referenzen zu gruppieren. Einträge können in mehreren Sammlungen erscheinen. Sichere die Bibliothek regelmäßig über **Datei → Bibliothek exportieren**. + +## III. Zitieren + +Um ein Zitat einzufügen, klickst du im Word Processor auf die Schaltfläche **Zitation einfügen** von Zotero. Wenn die Aufforderung kommt, einen Zitierstil auszuwählen, wähle einen Stil deiner Wahl (ich persönlich mag den Chicago Style). Wähle außerdem die richtige Sprache für das Dokument. Suche anschließend nach der entsprechenden Quelle und drücke die Eingabetaste. + +![Zotero-Pop-up](/images/blog/technicalities-of-scientific-writing/zotero_setup.avif) + +*Das Pop-up-Fenster, das beim Einfügen des ersten Zitats erscheint.* + +Nachdem mindestens ein Zitat eingefügt wurde, kann ein Literaturverzeichnis eingefügt werden. Klicke dazu einfach auf die richtige Schaltfläche (*Bibliographie hinzufügen/bearbeiten*). Beachte, dass das Literaturverzeichnis an der aktuellen Schreibposition eingefügt wird. + +## IV. Struktur des Dokuments + +Nachdem du das Zitieren gemeistert hast, konzentriere dich auf Formatierung und Kohärenz – nicht inhaltlich, sondern im Layout und Stil. + +### 1. Allgemeine Einstellungen in LibreOffice + +Aktiviere **Tab View** unter *View → User Interface…* und schalte anschließend die **Menüleiste** ein. Manchmal muss Zotero danach neugestartet werden. + +**Allgemeine Regeln:** + +* Schriftart: Sans Serif (Noto Sans, Arial, Liberation Sans usw.) +* Ausrichtung: Linksbündig (kein Blocksatz, da er die Lesbarkeit für Menschen mit Behinderungen erschwert) +* Schriftgröße: 10–12 pt für den Fließtext (abhängig von der gewählten Schriftart) +* Zeilenabstand: 1,5 +* Ränder: 1,5–2,5 cm +* Papierformat: A4 +* Seitennummerierung: Beginnt auf Seite 2 (oder 3/4, wenn das Inhaltsverzeichnis nicht mitgezählt werden soll) + +Um die Seitenränder und das Seitenformat zu konfigurieren, klicke mit der rechten Maustaste irgendwo im Dokument und wähle „Page Style…" aus der Liste. Im neuen Fenster wählst du den Tab „Page", wo die Ränder und das Format angepasst werden können. + +![Fenster Seitenvorlage](/images/blog/technicalities-of-scientific-writing/writer_page_style.avif) + +*Fenster Seitenvorlage* + +### 2. Dokumentstruktur + +Ihr wissenschaftlicher Artikel benötigt eine bestimmte Struktur: + +* Titelseite +* Inhaltsverzeichnis +* Einleitung +* Kapitel +* Schlussfolgerung +* Literaturverzeichnis +* Anhänge + +Um ein **Deckblatt** zu erstellen, positioniere den Cursor vor dem ersten Buchstaben des Dokuments und drücke Strg+Eingabe. Dadurch wird ein Seitenumbruch eingefügt. Auf der neuen Seite können Titel, Name, Matrikelnummer, der Name des Dozenten/Betreuers, der Name der Lehrveranstaltung, das Semester und der Studiengang eingefügt werden. *(Hinweis: Nicht alle dieser Angaben sind immer erforderlich.)* + +Zur Formatierung des Dokuments solltest du **Styles** anstelle manueller Formatierung verwenden. Styles sind Vorlagen, die man selbst konfigurieren kann. Erstelle eigene Styles über **Styles → New Style from Selection**. Wähle Abstände und Schriftarten für Titel, Untertitel und Fließtext. Dies ist auch wichtig, um später ein Inhaltsverzeichnis generieren zu können. + +**Kopf- & Fußzeilen** + +Einige Elemente müssen auf allen Seiten eingefügt werden, z. B. die Seitennummerierung. Du kannst die Kopf- und Fußzeileneinstellungen im Menü „Page Style" bearbeiten. Achte darauf, „Same content on first page" zu deaktivieren. + +![Fußzeileneinstellungen](/images/blog/technicalities-of-scientific-writing/writer_footer_setup.avif) + +*Fußzeileneinstellungen* + +**Seitennummerierung** + +Nachdem die Fußzeile aktiviert wurde, kann die Seitennummerierung über *Insert → Page Number…* eingefügt werden. Nun sollten die Seitennummern ab der zweiten Seite beginnen. + +![Menü Seitennummer einfügen](/images/blog/technicalities-of-scientific-writing/writer_page_number_wizard.avif) + +*Menü Seitennummer einfügen* + +**Inhaltsverzeichnis** + +Füge einen Seitenumbruch ein und wähle dann *Insert → Table of Contents and Index → Table of Contents, Index or Bibliography…*. Aktiviere unter dem Reiter **Type** die Option *Additional Styles* und ordne deine benutzerdefinierten Styles den Überschriftenebenen zu. Sobald das Inhaltsverzeichnis erstellt wurde, kann es mit einem Rechtsklick aktualisiert werden. + +## V. Fazit + +Der erste Umgang mit diesen Werkzeugen kann mühsam sein, aber mit der Zeit wird es einfacher. Um den Einstieg zu erleichtern, kannst du [dieses Beispiel-**.odt**-Dokument herunterladen](/images/blog/technicalities-of-scientific-writing/Example.odt). \ No newline at end of file diff --git a/apps/web/src/posts/technicalities-of-scientific-writing-en.md b/apps/web/src/posts/technicalities-of-scientific-writing-en.md new file mode 100644 index 0000000..61672a5 --- /dev/null +++ b/apps/web/src/posts/technicalities-of-scientific-writing-en.md @@ -0,0 +1,122 @@ +--- +title: Technicalities of Scientific Writing +date: 2025-10-19 +description: A practical guide to the software and formatting workflow behind writing your first scientific paper. +lang: en +slug: technicalities-of-scientific-writing +translationSlug: wissenschaftliches-schreiben-mit-software +featuredImage: /images/neuigkeiten/technicalities-of-scientific-writing/featured.avif +imageCaption: © William Fortunato / Pexels +tags: [Open Source, Science] +--- + +Many students struggle with the technicalities of writing a scientific paper for the first time. This guide was made to make the process a bit more straightforward and pain-free. + +## I. What software do you need? + +### 1. Writing + +Any word processor capable of styling a document, generating a table of contents, numbering pages, and adjusting typeface and margins will work. However, **LibreOffice** or **Microsoft Office** are highly recommended for their advanced features. + +Even if your institution provides you with free Microsoft licenses, I would still urge you to use LibreOffice instead, because it... + +* ...is free and open-source — no license required. +* ...does not analyze your writing for commercial purposes. +* ...is ethically preferable: scientific work should not depend on companies with questionable data practices. +* ...has legal advantages in the EU: avoids privacy issues when storing participant data. + +### 2. Citations + +Technically speaking, a word-processor is all you need to get started. However, if you don't want your life to be *eternal suffering*, then you're going to use *citation software*. They enable you to speed up the insertion and styling of citations and, more importantly, that of the bibliography. It's all fun and games to do it manually, until you realise that you have more than just 10 pieces of literature and that they're all a different type, with different citation rules. + +**Zotero** is a free and open-source citation manager that can integrate with LibreOffice and Microsoft Office alike. + +## II. Setting Up + +After installation, ensure Zotero integrates with your office software. It usually detects the program automatically. However, in some rare cases (especially on Linux, if you installed Zotero from Snap or Flatpak) it might not integrate straight away, so you'll need to quickly reinstall it from the preferences menu. For more details see the [official documentation](https://www.zotero.org/support/word_processor_integration). + +If Zotero is installed correctly, you should be able to see the following icons in Writer. + +![Zotero Icons](/images/blog/technicalities-of-scientific-writing/zotero_toolbar.avif) + +### Importing Literature + +When you're collecting literature, be on the lookout for easy importing possibilities. Zotero can import references in **BibTeX** or **RIS** format, commonly found under *Cite* or *Citation* buttons on publishers' websites. + +After downloading, go to **File → Import** in Zotero. Review and correct any errors. **Beware** that these files aren't correct 100% of the time, so make sure to double-check and correct mistakes by hand. You can also add entries manually using the green plus icon. + +Use Zotero's *Collections* to group references. Items can appear in multiple collections. Back up your library occasionally with **File → Export Library**. + +## III. Citing + +To insert a citation, click Zotero's **Insert Citation** button in your word processor. If it prompts you to choose a citation style, choose whichever you prefer (I personally really like Chicago Style). Also choose the right language for your document. Search for the relevant source and press Enter. + +![Zotero Pop-up](/images/blog/technicalities-of-scientific-writing/zotero_setup.avif) + +*The pop-up you get when inserting the first citation.* + +After you have inserted at least 1 citation, it will become possible to insert a bibliography. For this, simply press the right button (*Add/Edit Bibliography*). Note that the bibliography will be inserted at the current writing position. + +## IV. Structuring the Document + +After mastering citation, focus on formatting and coherence — not in argumentation, but in layout and style. + +### 1. General Setup in LibreOffice + +Enable **Tabbed Interface** under *View → User Interface…*, then activate the **Menubar**. Re-enable Zotero afterward if needed. + +**General rules:** + +* Typeface: Sans Serif (Noto Sans, Arial, Liberation Sans, etc.) +* Alignment: Left (don't use justified as it makes the text difficult to read for people with disabilities) +* Font sizes: 10–12 pt for body text (this can greatly vary based on the font you use) +* Line spacing: 1.5 +* Margins: 1.5–2.5 cm +* Paper format: A4 +* Page numbers: Start on page 2 (or page 3/4, if you don't want to count the table of contents) + +To configure the page margins and the page format, simply right-click anywhere on the document and choose "Page Style…" from the list. In the new window choose the "Page" tab where you can adjust the margins and choose the format. + +![Page Style Pop-up](/images/blog/technicalities-of-scientific-writing/writer_page_style.avif) + +*Page Style Pop-up* + +### 2. Document structure + +Your scientific article is going to need a specific structure: + +* Cover Page +* Table of Contents +* Introduction +* Your Chapters +* Conclusion +* Bibliography +* Attachments + +To create a **Cover Page**, first position your cursor before the first letter in your document and hit Ctrl+Enter. This inserts a Page Break. On the new page write down the title of your paper, your name, your matriculation number, your lecturer's/mentor's name, the name of the course, the semester and the field of study. *(Note: you may not need all of these)* + +To format your document, use **Styles** instead of manual formatting. Styles are presets that you can configure yourself. Create custom styles via **Styles → New Style from Selection**. Assign spacing and font settings for titles, subtitles, and main text. This is also important to be able to generate a Table of Contents. + +**Header & Footer** + +Some elements need to be inserted on all pages, like the Page Numbering. You can edit the Header and Footer settings in the Page Style menu mentioned above. Make sure to untick "Same content on first page" for both. + +![Footer Settings](/images/blog/technicalities-of-scientific-writing/writer_footer_setup.avif) + +*Footer Settings* + +**Page Numbering** + +After enabling the footer, insert the page numbering via *Insert → Page Number...*. Now you should have page numbers starting with 2 from your second page. + +![Page Number Insertion Menu](/images/blog/technicalities-of-scientific-writing/writer_page_number_wizard.avif) + +*Page Number Insertion Menu* + +**Table of Contents** + +Insert a Page Break then *Insert → Table of Contents and Index → Table of Contents, Index or Bibliography…*. Under the **Type** tab, enable *Additional Styles* and assign your custom styles to heading levels. Once your ToC is generated, you can right click on it and choose update to refresh it. + +## V. Conclusion + +The first time you navigate these tools will be tedious, but it gets easier. To help you get started, you can [download the example **.odt** document](/images/blog/technicalities-of-scientific-writing/Example.odt). \ No newline at end of file diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte new file mode 100644 index 0000000..e4216b3 --- /dev/null +++ b/apps/web/src/routes/+layout.svelte @@ -0,0 +1,31 @@ + + + + + + +
    + +
    +
    + +
    + + {@render children()} +
    + +