[
  {
    "path": ".cursor/rules/mintlify.mdc",
    "content": "---\nalwaysApply: true\n---\n\n# Mintlify technical writing rule\n\nYou are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices.\n\n## Core writing principles\n\n### Language and style requirements\n\n- Use clear, direct language appropriate for technical audiences\n- Write in second person (\"you\") for instructions and procedures\n- Use active voice over passive voice\n- Employ present tense for current states, future tense for outcomes\n- Avoid jargon unless necessary and define terms when first used\n- Maintain consistent terminology throughout all documentation\n- Keep sentences concise while providing necessary context\n- Use parallel structure in lists, headings, and procedures\n\n### Content organization standards\n\n- Lead with the most important information (inverted pyramid structure)\n- Use progressive disclosure: basic concepts before advanced ones\n- Break complex procedures into numbered steps\n- Include prerequisites and context before instructions\n- Provide expected outcomes for each major step\n- Use descriptive, keyword-rich headings for navigation and SEO\n- Group related information logically with clear section breaks\n\n### User-centered approach\n\n- Focus on user goals and outcomes rather than system features\n- Anticipate common questions and address them proactively\n- Include troubleshooting for likely failure points\n- Write for scannability with clear headings, lists, and white space\n- Include verification steps to confirm success\n\n## Mintlify component reference\n\n### docs.json\n\n- Refer to the [docs.json schema](https://mintlify.com/docs.json) when building the docs.json file and site navigation\n\n### Callout components\n\n#### Note - Additional helpful information\n\n<Note>\nSupplementary information that supports the main content without interrupting flow\n</Note>\n\n#### Tip - Best practices and pro tips\n\n<Tip>\nExpert advice, shortcuts, or best practices that enhance user success\n</Tip>\n\n#### Warning - Important cautions\n\n<Warning>\nCritical information about potential issues, breaking changes, or destructive actions\n</Warning>\n\n#### Info - Neutral contextual information\n\n<Info>\nBackground information, context, or neutral announcements\n</Info>\n\n#### Check - Success confirmations\n\n<Check>\nPositive confirmations, successful completions, or achievement indicators\n</Check>\n\n### Code components\n\n#### Single code block\n\nExample of a single code block:\n\n```javascript config.js\nconst apiConfig = {\n  baseURL: 'https://api.example.com',\n  timeout: 5000,\n  headers: {\n    'Authorization': `Bearer ${process.env.API_TOKEN}`\n  }\n};\n```\n\n#### Code group with multiple languages\n\nExample of a code group:\n\n<CodeGroup>\n```javascript Node.js\nconst response = await fetch('/api/endpoint', {\n  headers: { Authorization: `Bearer ${apiKey}` }\n});\n```\n\n```python Python\nimport requests\nresponse = requests.get('/api/endpoint', \n  headers={'Authorization': f'Bearer {api_key}'})\n```\n\n```curl cURL\ncurl -X GET '/api/endpoint' \\\n  -H 'Authorization: Bearer YOUR_API_KEY'\n```\n</CodeGroup>\n\n#### Request/response examples\n\nExample of request/response documentation:\n\n<RequestExample>\n```bash cURL\ncurl -X POST 'https://api.example.com/users' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\": \"John Doe\", \"email\": \"john@example.com\"}'\n```\n</RequestExample>\n\n<ResponseExample>\n```json Success\n{\n  \"id\": \"user_123\",\n  \"name\": \"John Doe\", \n  \"email\": \"john@example.com\",\n  \"created_at\": \"2024-01-15T10:30:00Z\"\n}\n```\n</ResponseExample>\n\n### Structural components\n\n#### Steps for procedures\n\nExample of step-by-step instructions:\n\n<Steps>\n<Step title=\"Install dependencies\">\n  Run `npm install` to install required packages.\n  \n  <Check>\n  Verify installation by running `npm list`.\n  </Check>\n</Step>\n\n<Step title=\"Configure environment\">\n  Create a `.env` file with your API credentials.\n  \n  ```bash\n  API_KEY=your_api_key_here\n  ```\n  \n  <Warning>\n  Never commit API keys to version control.\n  </Warning>\n</Step>\n</Steps>\n\n#### Tabs for alternative content\n\nExample of tabbed content:\n\n<Tabs>\n<Tab title=\"macOS\">\n  ```bash\n  brew install node\n  npm install -g package-name\n  ```\n</Tab>\n\n<Tab title=\"Windows\">\n  ```powershell\n  choco install nodejs\n  npm install -g package-name\n  ```\n</Tab>\n\n<Tab title=\"Linux\">\n  ```bash\n  sudo apt install nodejs npm\n  npm install -g package-name\n  ```\n</Tab>\n</Tabs>\n\n#### Accordions for collapsible content\n\nExample of accordion groups:\n\n<AccordionGroup>\n<Accordion title=\"Troubleshooting connection issues\">\n  - **Firewall blocking**: Ensure ports 80 and 443 are open\n  - **Proxy configuration**: Set HTTP_PROXY environment variable\n  - **DNS resolution**: Try using 8.8.8.8 as DNS server\n</Accordion>\n\n<Accordion title=\"Advanced configuration\">\n  ```javascript\n  const config = {\n    performance: { cache: true, timeout: 30000 },\n    security: { encryption: 'AES-256' }\n  };\n  ```\n</Accordion>\n</AccordionGroup>\n\n### Cards and columns for emphasizing information\n\nExample of cards and card groups:\n\n<Card title=\"Getting started guide\" icon=\"rocket\" href=\"/quickstart\">\nComplete walkthrough from installation to your first API call in under 10 minutes.\n</Card>\n\n<CardGroup cols={2}>\n<Card title=\"Authentication\" icon=\"key\" href=\"/auth\">\n  Learn how to authenticate requests using API keys or JWT tokens.\n</Card>\n\n<Card title=\"Rate limiting\" icon=\"clock\" href=\"/rate-limits\">\n  Understand rate limits and best practices for high-volume usage.\n</Card>\n</CardGroup>\n\n### API documentation components\n\n#### Parameter fields\n\nExample of parameter documentation:\n\n<ParamField path=\"user_id\" type=\"string\" required>\nUnique identifier for the user. Must be a valid UUID v4 format.\n</ParamField>\n\n<ParamField body=\"email\" type=\"string\" required>\nUser's email address. Must be valid and unique within the system.\n</ParamField>\n\n<ParamField query=\"limit\" type=\"integer\" default=\"10\">\nMaximum number of results to return. Range: 1-100.\n</ParamField>\n\n<ParamField header=\"Authorization\" type=\"string\" required>\nBearer token for API authentication. Format: `Bearer YOUR_API_KEY`\n</ParamField>\n\n#### Response fields\n\nExample of response field documentation:\n\n<ResponseField name=\"user_id\" type=\"string\" required>\nUnique identifier assigned to the newly created user.\n</ResponseField>\n\n<ResponseField name=\"created_at\" type=\"timestamp\">\nISO 8601 formatted timestamp of when the user was created.\n</ResponseField>\n\n<ResponseField name=\"permissions\" type=\"array\">\nList of permission strings assigned to this user.\n</ResponseField>\n\n#### Expandable nested fields\n\nExample of nested field documentation:\n\n<ResponseField name=\"user\" type=\"object\">\nComplete user object with all associated data.\n\n<Expandable title=\"User properties\">\n  <ResponseField name=\"profile\" type=\"object\">\n  User profile information including personal details.\n  \n  <Expandable title=\"Profile details\">\n    <ResponseField name=\"first_name\" type=\"string\">\n    User's first name as entered during registration.\n    </ResponseField>\n    \n    <ResponseField name=\"avatar_url\" type=\"string | null\">\n    URL to user's profile picture. Returns null if no avatar is set.\n    </ResponseField>\n  </Expandable>\n  </ResponseField>\n</Expandable>\n</ResponseField>\n\n### Media and advanced components\n\n#### Frames for images\n\nWrap all images in frames:\n\n<Frame>\n<img src=\"/images/dashboard.png\" alt=\"Main dashboard showing analytics overview\" />\n</Frame>\n\n<Frame caption=\"The analytics dashboard provides real-time insights\">\n<img src=\"/images/analytics.png\" alt=\"Analytics dashboard with charts\" />\n</Frame>\n\n#### Videos\n\nUse the HTML video element for self-hosted video content:\n\n<video\n  controls\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"link-to-your-video.com\"\n></video>\n\nEmbed YouTube videos using iframe elements:\n\n<iframe\n  className=\"w-full aspect-video rounded-xl\"\n  src=\"https://www.youtube.com/embed/4KzFe50RQkQ\"\n  title=\"YouTube video player\"\n  frameBorder=\"0\"\n  allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n  allowFullScreen\n></iframe>\n\n#### Tooltips\n\nExample of tooltip usage:\n\n<Tooltip tip=\"Application Programming Interface - protocols for building software\">\nAPI\n</Tooltip>\n\n#### Updates\n\nUse updates for changelogs:\n\n<Update label=\"Version 2.1.0\" description=\"Released March 15, 2024\">\n## New features\n- Added bulk user import feature\n- Improved error messages with actionable suggestions\n\n## Bug fixes\n- Fixed pagination issue with large datasets\n- Resolved authentication timeout problems\n</Update>\n\n## Required page structure\n\nEvery documentation page must begin with YAML frontmatter:\n\n```yaml\n---\ntitle: \"Clear, specific, keyword-rich title\"\ndescription: \"Concise description explaining page purpose and value\"\n---\n```\n\n## Content quality standards\n\n### Code examples requirements\n\n- Always include complete, runnable examples that users can copy and execute\n- Show proper error handling and edge case management\n- Use realistic data instead of placeholder values\n- Include expected outputs and results for verification\n- Test all code examples thoroughly before publishing\n- Specify language and include filename when relevant\n- Add explanatory comments for complex logic\n- Never include real API keys or secrets in code examples\n\n### API documentation requirements\n\n- Document all parameters including optional ones with clear descriptions\n- Show both success and error response examples with realistic data\n- Include rate limiting information with specific limits\n- Provide authentication examples showing proper format\n- Explain all HTTP status codes and error handling\n- Cover complete request/response cycles\n\n### Accessibility requirements\n\n- Include descriptive alt text for all images and diagrams\n- Use specific, actionable link text instead of \"click here\"\n- Ensure proper heading hierarchy starting with H2\n- Provide keyboard navigation considerations\n- Use sufficient color contrast in examples and visuals\n- Structure content for easy scanning with headers and lists\n\n## Component selection logic\n\n- Use **Steps** for procedures and sequential instructions\n- Use **Tabs** for platform-specific content or alternative approaches\n- Use **CodeGroup** when showing the same concept in multiple programming languages\n- Use **Accordions** for progressive disclosure of information\n- Use **RequestExample/ResponseExample** specifically for API endpoint documentation\n- Use **ParamField** for API parameters, **ResponseField** for API responses\n- Use **Expandable** for nested object properties or hierarchical information"
  },
  {
    "path": ".cursor/rules/ultracite.mdc",
    "content": "---\ndescription: Ultracite Rules - AI-Ready Formatter and Linter\nglobs: \"**/*.{ts,tsx,js,jsx}\"\nalwaysApply: true\n---\n\n# Project Context\nUltracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter.\n\n## Key Principles\n- Zero configuration required\n- Subsecond performance\n- Maximum type safety\n- AI-friendly code generation\n- Always use pnpm as the package manager\n\n## Before Writing Code\n1. Analyze existing patterns in the codebase\n2. Consider edge cases and error scenarios\n3. Follow the rules below strictly\n4. Validate accessibility requirements\n\n## Rules\n\n### Accessibility (a11y)\n- Don't use `accessKey` attribute on any HTML element.\n- Don't set `aria-hidden=\"true\"` on focusable elements.\n- Don't add ARIA roles, states, and properties to elements that don't support them.\n- Don't use distracting elements like `<marquee>` or `<blink>`.\n- Only use the `scope` prop on `<th>` elements.\n- Don't assign non-interactive ARIA roles to interactive HTML elements.\n- Make sure label elements have text content and are associated with an input.\n- Don't assign interactive ARIA roles to non-interactive HTML elements.\n- Don't assign `tabIndex` to non-interactive HTML elements.\n- Don't use positive integers for `tabIndex` property.\n- Don't include \"image\", \"picture\", or \"photo\" in img alt prop.\n- Don't use explicit role property that's the same as the implicit/default role.\n- Make static elements with click handlers use a valid role attribute.\n- Always include a `title` element for SVG elements.\n- Give all elements requiring alt text meaningful information for screen readers.\n- Make sure anchors have content that's accessible to screen readers.\n- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`.\n- Include all required ARIA attributes for elements with ARIA roles.\n- Make sure ARIA properties are valid for the element's supported roles.\n- Always include a `type` attribute for button elements.\n- Make elements with interactive roles and handlers focusable.\n- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`).\n- Always include a `lang` attribute on the html element.\n- Always include a `title` attribute for iframe elements.\n- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`.\n- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`.\n- Include caption tracks for audio and video elements.\n- Use semantic elements instead of role attributes in JSX.\n- Make sure all anchors are valid and navigable.\n- Ensure all ARIA properties (`aria-*`) are valid.\n- Use valid, non-abstract ARIA roles for elements with ARIA roles.\n- Use valid ARIA state and property values.\n- Use valid values for the `autocomplete` attribute on input elements.\n- Use correct ISO language/country codes for the `lang` attribute.\n\n### Code Complexity and Quality\n- Don't use consecutive spaces in regular expression literals.\n- Don't use the `arguments` object.\n- Don't use primitive type aliases or misleading types.\n- Don't use the comma operator.\n- Don't use empty type parameters in type aliases and interfaces.\n- Don't write functions that exceed a given Cognitive Complexity score.\n- Don't nest describe() blocks too deeply in test files.\n- Don't use unnecessary boolean casts.\n- Don't use unnecessary callbacks with flatMap.\n- Use for...of statements instead of Array.forEach.\n- Don't create classes that only have static members (like a static namespace).\n- Don't use this and super in static contexts.\n- Don't use unnecessary catch clauses.\n- Don't use unnecessary constructors.\n- Don't use unnecessary continue statements.\n- Don't export empty modules that don't change anything.\n- Don't use unnecessary escape sequences in regular expression literals.\n- Don't use unnecessary fragments.\n- Don't use unnecessary labels.\n- Don't use unnecessary nested block statements.\n- Don't rename imports, exports, and destructured assignments to the same name.\n- Don't use unnecessary string or template literal concatenation.\n- Don't use String.raw in template literals when there are no escape sequences.\n- Don't use useless case statements in switch statements.\n- Don't use ternary operators when simpler alternatives exist.\n- Don't use useless `this` aliasing.\n- Don't use any or unknown as type constraints.\n- Don't initialize variables to undefined.\n- Don't use the void operators (they're not familiar).\n- Use arrow functions instead of function expressions.\n- Use Date.now() to get milliseconds since the Unix Epoch.\n- Use .flatMap() instead of map().flat() when possible.\n- Use literal property access instead of computed property access.\n- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work.\n- Use concise optional chaining instead of chained logical expressions.\n- Use regular expression literals instead of the RegExp constructor when possible.\n- Don't use number literal object member names that aren't base 10 or use underscore separators.\n- Remove redundant terms from logical expressions.\n- Use while loops instead of for loops when you don't need initializer and update expressions.\n- Don't pass children as props.\n- Don't reassign const variables.\n- Don't use constant expressions in conditions.\n- Don't use `Math.min` and `Math.max` to clamp values when the result is constant.\n- Don't return a value from a constructor.\n- Don't use empty character classes in regular expression literals.\n- Don't use empty destructuring patterns.\n- Don't call global object properties as functions.\n- Don't declare functions and vars that are accessible outside their block.\n- Make sure builtins are correctly instantiated.\n- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors.\n- Don't use variables and function parameters before they're declared.\n- Don't use 8 and 9 escape sequences in string literals.\n- Don't use literal numbers that lose precision.\n\n### React and JSX Best Practices\n- Don't use the return value of React.render.\n- Make sure all dependencies are correctly specified in React hooks.\n- Make sure all React hooks are called from the top level of component functions.\n- Don't forget key props in iterators and collection literals.\n- Don't destructure props inside JSX components in Solid projects.\n- Don't define React components inside other components.\n- Don't use event handlers on non-interactive elements.\n- Don't assign to React component props.\n- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element.\n- Don't use dangerous JSX props.\n- Don't use Array index in keys.\n- Don't insert comments as text nodes.\n- Don't assign JSX properties multiple times.\n- Don't add extra closing tags for components without children.\n- Use `<>...</>` instead of `<Fragment>...</Fragment>`.\n- Watch out for possible \"wrong\" semicolons inside JSX elements.\n\n### Correctness and Safety\n- Don't assign a value to itself.\n- Don't return a value from a setter.\n- Don't compare expressions that modify string case with non-compliant values.\n- Don't use lexical declarations in switch clauses.\n- Don't use variables that haven't been declared in the document.\n- Don't write unreachable code.\n- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass.\n- Don't use control flow statements in finally blocks.\n- Don't use optional chaining where undefined values aren't allowed.\n- Don't have unused function parameters.\n- Don't have unused imports.\n- Don't have unused labels.\n- Don't have unused private class members.\n- Don't have unused variables.\n- Make sure void (self-closing) elements don't have children.\n- Don't return a value from a function with the return type 'void'\n- Use isNaN() when checking for NaN.\n- Make sure \"for\" loop update clauses move the counter in the right direction.\n- Make sure typeof expressions are compared to valid values.\n- Make sure generator functions contain yield.\n- Don't use await inside loops.\n- Don't use bitwise operators.\n- Don't use expressions where the operation doesn't change the value.\n- Make sure Promise-like statements are handled appropriately.\n- Don't use __dirname and __filename in the global scope.\n- Prevent import cycles.\n- Don't use configured elements.\n- Don't hardcode sensitive data like API keys and tokens.\n- Don't let variable declarations shadow variables from outer scopes.\n- Don't use the TypeScript directive @ts-ignore.\n- Prevent duplicate polyfills from Polyfill.io.\n- Don't use useless backreferences in regular expressions that always match empty strings.\n- Don't use unnecessary escapes in string literals.\n- Don't use useless undefined.\n- Make sure getters and setters for the same property are next to each other in class and object definitions.\n- Make sure object literals are declared consistently (defaults to explicit definitions).\n- Use static Response methods instead of new Response() constructor when possible.\n- Make sure switch-case statements are exhaustive.\n- Make sure the `preconnect` attribute is used when using Google Fonts.\n- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item.\n- Make sure iterable callbacks return consistent values.\n- Use `with { type: \"json\" }` for JSON module imports.\n- Use numeric separators in numeric literals.\n- Use object spread instead of `Object.assign()` when constructing new objects.\n- Always use the radix argument when using `parseInt()`.\n- Make sure JSDoc comment lines start with a single asterisk, except for the first one.\n- Include a description parameter for `Symbol()`.\n- Don't use spread (`...`) syntax on accumulators.\n- Don't use the `delete` operator.\n- Don't access namespace imports dynamically.\n- Don't use namespace imports.\n- Declare regex literals at the top level.\n- Don't use `target=\"_blank\"` without `rel=\"noopener\"`.\n\n### TypeScript Best Practices\n- Don't use TypeScript enums.\n- Don't export imported variables.\n- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions.\n- Don't use TypeScript namespaces.\n- Don't use non-null assertions with the `!` postfix operator.\n- Don't use parameter properties in class constructors.\n- Don't use user-defined types.\n- Use `as const` instead of literal types and type annotations.\n- Use either `T[]` or `Array<T>` consistently.\n- Initialize each enum member value explicitly.\n- Use `export type` for types.\n- Use `import type` for types.\n- Make sure all enum members are literal values.\n- Don't use TypeScript const enum.\n- Don't declare empty interfaces.\n- Don't let variables evolve into any type through reassignments.\n- Don't use the any type.\n- Don't misuse the non-null assertion operator (!) in TypeScript files.\n- Don't use implicit any type on variable declarations.\n- Don't merge interfaces and classes unsafely.\n- Don't use overload signatures that aren't next to each other.\n- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces.\n\n### Style and Consistency\n- Don't use global `eval()`.\n- Don't use callbacks in asynchronous tests and hooks.\n- Don't use negation in `if` statements that have `else` clauses.\n- Don't use nested ternary expressions.\n- Don't reassign function parameters.\n- This rule lets you specify global variable names you don't want to use in your application.\n- Don't use specified modules when loaded by import or require.\n- Don't use constants whose value is the upper-case version of their name.\n- Use `String.slice()` instead of `String.substr()` and `String.substring()`.\n- Don't use template literals if you don't need interpolation or special-character handling.\n- Don't use `else` blocks when the `if` block breaks early.\n- Don't use yoda expressions.\n- Don't use Array constructors.\n- Use `at()` instead of integer index access.\n- Follow curly brace conventions.\n- Use `else if` instead of nested `if` statements in `else` clauses.\n- Use single `if` statements instead of nested `if` clauses.\n- Use `new` for all builtins except `String`, `Number`, and `Boolean`.\n- Use consistent accessibility modifiers on class properties and methods.\n- Use `const` declarations for variables that are only assigned once.\n- Put default function parameters and optional function parameters last.\n- Include a `default` clause in switch statements.\n- Use the `**` operator instead of `Math.pow`.\n- Use `for-of` loops when you need the index to extract an item from the iterated array.\n- Use `node:assert/strict` over `node:assert`.\n- Use the `node:` protocol for Node.js builtin modules.\n- Use Number properties instead of global ones.\n- Use assignment operator shorthand where possible.\n- Use function types instead of object types with call signatures.\n- Use template literals over string concatenation.\n- Use `new` when throwing an error.\n- Don't throw non-Error values.\n- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`.\n- Use standard constants instead of approximated literals.\n- Don't assign values in expressions.\n- Don't use async functions as Promise executors.\n- Don't reassign exceptions in catch clauses.\n- Don't reassign class members.\n- Don't compare against -0.\n- Don't use labeled statements that aren't loops.\n- Don't use void type outside of generic or return types.\n- Don't use console.\n- Don't use control characters and escape sequences that match control characters in regular expression literals.\n- Don't use debugger.\n- Don't assign directly to document.cookie.\n- Use `===` and `!==`.\n- Don't use duplicate case labels.\n- Don't use duplicate class members.\n- Don't use duplicate conditions in if-else-if chains.\n- Don't use two keys with the same name inside objects.\n- Don't use duplicate function parameter names.\n- Don't have duplicate hooks in describe blocks.\n- Don't use empty block statements and static blocks.\n- Don't let switch clauses fall through.\n- Don't reassign function declarations.\n- Don't allow assignments to native objects and read-only global variables.\n- Use Number.isFinite instead of global isFinite.\n- Use Number.isNaN instead of global isNaN.\n- Don't assign to imported bindings.\n- Don't use irregular whitespace characters.\n- Don't use labels that share a name with a variable.\n- Don't use characters made with multiple code points in character class syntax.\n- Make sure to use new and constructor properly.\n- Don't use shorthand assign when the variable appears on both sides.\n- Don't use octal escape sequences in string literals.\n- Don't use Object.prototype builtins directly.\n- Don't redeclare variables, functions, classes, and types in the same scope.\n- Don't have redundant \"use strict\".\n- Don't compare things where both sides are exactly the same.\n- Don't let identifiers shadow restricted names.\n- Don't use sparse arrays (arrays with holes).\n- Don't use template literal placeholder syntax in regular strings.\n- Don't use the then property.\n- Don't use unsafe negation.\n- Don't use var.\n- Don't use with statements in non-strict contexts.\n- Make sure async functions actually use await.\n- Make sure default clauses in switch statements come last.\n- Make sure to pass a message value when creating a built-in error.\n- Make sure get methods always return a value.\n- Use a recommended display strategy with Google Fonts.\n- Make sure for-in loops include an if statement.\n- Use Array.isArray() instead of instanceof Array.\n- Make sure to use the digits argument with Number#toFixed().\n- Make sure to use the \"use strict\" directive in script files.\n\n### Next.js Specific Rules\n- Don't use `<img>` elements in Next.js projects.\n- Don't use `<head>` elements in Next.js projects.\n- Don't import next/document outside of pages/_document.jsx in Next.js projects.\n- Don't use the next/head module in pages/_document.js on Next.js projects.\n\n### Phosphor Icons\n- Always use the Phosphor Icons package.\n- Always use icon as the suffix for the icon component e.g. `UploadSimpleIcon` instead of `UploadSimple`.\n\n### Testing Best Practices\n- Don't use export or module.exports in test files.\n- Don't use focused tests.\n- Make sure the assertion function, like expect, is placed inside an it() function call.\n- Don't use disabled tests.\n\n## Common Tasks\n- `npx ultracite init` - Initialize Ultracite in your project\n- `npx ultracite fix` - Format and fix code automatically\n- `npx ultracite check` - Check for issues without fixing\n\n## Example: Error Handling\n```typescript\n// ✅ Good: Comprehensive error handling\ntry {\n  const result = await fetchData();\n  return { success: true, data: result };\n} catch (error) {\n  console.error('API call failed:', error);\n  return { success: false, error: error.message };\n}\n\n// ❌ Bad: Swallowing errors\ntry {\n  return await fetchData();\n} catch (e) {\n  console.log(e);\n}\n```"
  },
  {
    "path": ".dockerignore",
    "content": ".git\nnode_modules\npnpm-store\n**/.next\n**/.turbo\n**/dist\n**/.output\n**/.vercel\n.env\n.env.*\n!.env.example\n!**/.env.example\ncoverage\ncoverage/**\nplaywright-report\nplaywright-report/**\n*.log\n*.swp\n*.DS_Store\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# Contributing to Marble\n\nThanks for your interest in contributing! This guide explains how to get Marble running locally and how to submit high-quality contributions.\n\n## Prerequisites\n\nBefore you start, make sure you have the following installed or available:\n\n- **Node.js** ≥ 20.x\n- **pnpm** ≥ 10.x (install with `npm i -g pnpm`)\n- **PostgreSQL** database (we use [Neon](https://neon.tech) in examples)\n- **Redis** database (we use [Upstash](https://upstash.com))\n- **Google** and **GitHub** OAuth apps (for authentication)\n- **Cloudflare** account with R2 enabled (for media uploads)\n- **Optional**: [Polar](https://sandbox.polar.sh) sandbox account if you want to test payments\n\n---\n\n## Structure\n\nThis repository is a monorepo and is structured as follows:\n\n```text\n/\n├── apps/\n│   ├── api/      → Hono REST API\n│   ├── cms/      → Next.js dashboard\n│   ├── docs/     → Mintlify documentation\n│   └── web/      → Astro marketing site\n├── packages/\n│   ├── db/       → Prisma schema + client (shared by api & cms)\n│   ├── editor/   → Tiptap-based rich text editor\n│   ├── email/    → Email templates\n│   ├── parser/   → Content parsing utilities\n│   ├── tsconfig/ → Shared TypeScript configurations\n│   ├── ui/       → shadcn/ui components (shared UI library)\n│   └── utils/    → Shared utilities\n├── .npmrc\n├── package.json\n├── pnpm-workspace.yaml\n├── README.md\n└── turbo.json\n```\n\n### Apps\n\nThis directory contains the source code for all related applications:\n\n- **api**: [Hono](https://hono.dev) REST API for content delivery\n- **cms**: [Next.js](https://nextjs.org) app for the dashboard\n- **docs**: [Mintlify](https://mintlify.com) documentation site\n- **web**: [Astro](https://astro.build) app for the marketing website\n\n### Packages\n\nPackages contain internal shared modules used across different applications:\n\n- **db**: Prisma schema and client shared between the `api` and `cms` apps\n- **editor**: Tiptap-based rich text editor used in the CMS\n- **email**: Email templates for notifications and transactional emails\n- **parser**: Content parsing utilities\n- **tsconfig**: TypeScript configurations shared across the monorepo\n- **ui**: shadcn/ui components used in the `cms` app\n- **utils**: Shared utilities used across apps and packages\n\n## Getting Started\n\n1. [Fork](https://github.com/usemarble/marble/fork/) this repository to your own account\n\n   - Visit Marble repository\n\n   - Click the \"Fork\" button in the top right\n\n   - [Clone](https://help.github.com/articles/cloning-a-repository/) the fork to your local device.\n\n   ```bash\n   git clone https://github.com/YOUR-USERNAME/marble.git\n   cd marble\n   ```\n\n   - add the original repo as upstream\n\n   ```bash\n   git remote add upstream https://github.com/usemarble/marble.git\n   ```\n\n2. Install Dependencies\n\n    ```bash\n   pnpm install\n   ```\n\n3. Configure Environment Variables\n\n   Each app/package that uses environment variables has an example env file. You’ll need to copy and fill those out:\n\n   ```bash\n   cp apps/api/.dev.vars.example apps/api/.dev.vars\n   cp apps/cms/.env.example apps/cms/.env\n   cp apps/web/.env.example apps/web/.env\n   cp packages/db/.env.example packages/db/.env\n   ```\n\n   You'll need:\n\n   - A Postgres connection string (either neon or use docker to self host)\n\n   - Redis credentials from Upstash (see below)\n\n   - Google and GitHub OAuth credentials (how to get these)\n\n   - A BetterAuth secret\n\n   - Cloudflare R2 credentials for file uploads (see below)\n\n   - Optional: If you want to test payments, set up a [Polar](https://sandbox.polar.sh) sandbox account and fill in the POLAR_* variables.\n\n4. Database Setup\n\n   ### Option 1: Use Neon (Hosted)\n\n   We use Neon for the database. Create a Neon project and copy your connection string for Prisma\n   (ensure it includes `sslmode=require`).\n\n   - Paste it into the relevant env files:\n   Example:\n   ```bash\n   DATABASE_URL=\"postgresql://<user>:<password>@<host>/<db>?sslmode=require\"\n   ```\n\n   - Paste it into the relevant env files:\n   - `apps/api/.dev.vars` → `DATABASE_URL=<YOUR_STRING_HERE>`\n   - `apps/cms/.env` → `DATABASE_URL=<YOUR_STRING_HERE>`\n   - `packages/db/.env` → `DATABASE_URL=<YOUR_STRING_HERE>`\n\n   - Run migrations:\n\n      ```bash\n      pnpm db:migrate\n      ```\n\n   ### Option 2: Use Docker (Local)\n\n   Prerequisites: Docker Desktop (macOS/Windows) or Docker Engine + Docker Compose v2 (Linux).\n\n   Start a local Postgres and run migrations:\n\n   ```bash\n   # from repo root\n   pnpm docker:up\n   # wait for the DB to be healthy (one of):\n   #   pnpm docker:logs    # watch for \"database system is ready to accept connections\"\n   #   docker compose ps   # ensure STATUS is \"healthy\"\n   ## Alternatively, if your Docker Compose version supports it:\n   # docker compose up -d --wait\n   pnpm db:migrate\n   ```\n\n   If you’re using the local Docker DB, set `DATABASE_URL` in these env files:\n   - `apps/api/.dev.vars`\n   - `apps/cms/.env`\n   - `packages/db/.env`\n\n   Example:\n   ```bash\n   DATABASE_URL=postgresql://usemarble:justusemarble@localhost:5432/marble\n   ```\n   Note: These credentials are for local development only. Do not use them in production.\n\nThis will:\n\n- Build (if needed) and start the Postgres container defined in `docker-compose.yml`.\n- Expose Postgres on port `5432` using the credentials from the compose file.\n- Persist data in the `marble_pgdata` Docker volume.\n- Note: If you already have a local Postgres on port 5432, stop it or adjust the port mapping in `docker-compose.yml`.\n\n   Useful commands:\n\n   ```bash\n   pnpm docker:logs    # follow DB logs\n   pnpm docker:down    # stop containers\n   pnpm docker:clean   # stop and remove volumes (DESTROYS local data)\n   ```\n\n### Google OAuth\n\n   Create a project in the Google Cloud Console.\n   Follow [the first step](https://www.better-auth.com/docs/authentication/google) in the Better Auth documentation to set up Google OAuth and set the values for `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`.\n\n### GitHub OAuth\n\n   If you would rather use github you can follow [the first step](https://www.better-auth.com/docs/authentication/github#get-your-github-credentials) in the better auth docs to setup Github oAuth and set the environment values for `GITHUB_ID` and `GITHUB_SECRET`\n\n### Set up Cloudflare R2 for media uploads\n\n   To use media uploads in Marble, you’ll need to set up a Cloudflare R2 bucket. Here's a step-by-step guide to help you configure everything properly:\n\n- Go to your Cloudflare dashboard\n\n- Select your account and navigate to R2 from the sidebar\n\n- Click \"Create Bucket\"\n\n- Name your bucket (e.g. marble-media)\n\n- Hit 'Create\"\n\n- switch to the settings tab and enable \"public development url\"\n\n- copy the value to `CLOUDFLARE_PUBLIC_URL`\n\n- Set your bucket name to `CLOUDFLARE_BUCKET_NAME`\n\n- Go back to your R2 buckets overview and click \"API\"\n\n- from the dropdown select \"Use r2 with apis\"\n\n- then copy the api url and set to `CLOUDFLARE_S3_ENDPOINT`\n\n- Below the url click \"Create api Tokens\"\n\n- Select \"Create user API Token\"\n\n- For permissions select \"admin read and write\"\n\n- Leave everything else as default and click \"Create user API Token\"\n\n- Copy the values to `CLOUDFLARE_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCESS_KEY_ID` respectively\n\n## Set up Redis\n\n### Option 1: Use Upstash Redis\n\n   Marble uses Redis for rate limiting, session caching, and analytics. We use [Upstash](https://upstash.com) for serverless Redis. Here's how to set it up:\n\n- Go to [Upstash Console](https://console.upstash.com) and sign in or create an account\n\n- Click \"Create Database\"\n\n- Give your database a name (e.g. marble-redis)\n\n- Select a region close to your primary deployment region\n\n- Leave the type as \"Regional\" (free tier)\n\n- Click \"Create\"\n\n- Once created, scroll down to the \"REST API\" section\n\n- Copy the \"UPSTASH_REDIS_REST_URL\" value and set it to `REDIS_URL` in your environment files\n\n- Copy the \"UPSTASH_REDIS_REST_TOKEN\" value and set it to `REDIS_TOKEN` in your environment files\n\n   You'll need to add these to:\n   - `apps/api/.dev.vars` → `REDIS_URL=<YOUR_URL_HERE>` and `REDIS_TOKEN=<YOUR_TOKEN_HERE>`\n   - `apps/cms/.env` → `REDIS_URL=<YOUR_URL_HERE>` and `REDIS_TOKEN=<YOUR_TOKEN_HERE>`\n\n### Option 2: Docker (Local)\n\nUse the repository's Docker Compose services to run Redis locally (native Redis + Upstash-compatible HTTP bridge):\n\n```bash\n# from repo root\npnpm docker:up\npnpm docker:ps\n```\n\nExpected Redis services:\n- `redis` on `localhost:6379`\n- `serverless-redis-http` on `localhost:8079`\n\nSet these in your env files:\n- `apps/api/.dev.vars` → `REDIS_URL=http://localhost:8079` and `REDIS_TOKEN=justusemarble`\n- `apps/cms/.env` → `REDIS_URL=http://localhost:8079` and `REDIS_TOKEN=justusemarble`\n\nThese values match the local defaults in:\n- `apps/api/.dev.vars.example`\n- `apps/cms/.env.example`\n- `docker-compose.yml` (`SRH_TOKEN=justusemarble`, `8079:80`, `6379:6379`)\n\nStop services when done:\n\n```bash\npnpm docker:down\n```\n\n## Running the Apps\n\nFrom the root you can run all apps\n\n```bash\npnpm dev\n```\n\nor just one\n\n```bash\npnpm cms:dev\npnpm api:dev\npnpm web:dev\npnpm docs:dev\n```\n\n## Contributing to docs\n\nThe documentation lives in `apps/docs` and is built with [Mintlify](https://mintlify.com). To contribute:\n\n### Prerequisites\n\n- Node.js v20.17.0+ (same as the main project)\n- No database, Redis, or OAuth setup required\n\n### Option A: From the monorepo root (recommended)\n\nAfter `pnpm install`, run:\n\n```bash\npnpm docs:dev\n```\n\nThis uses the Mintlify CLI from the workspace—no global install needed. Docs preview at `http://localhost:3000`.\n\n### Option B: Manual setup\n\nIf you prefer the CLI globally:\n\n```bash\npnpm add -g mint\ncd apps/docs\nmint dev\n```\n\n### Port conflicts\n\nIf port 3000 is in use (e.g. by another app), use a different port:\n\n```bash\nmint dev --port 3333\n```\n\n### Useful commands\n\nFrom `apps/docs`:\n\n- `mint broken-links` — Find broken internal links\n- `mint a11y` — Check accessibility (contrast, alt text)\n- `mint validate` — Validate the build (useful for CI)\n\n### Project structure\n\nSee [apps/docs/README.md](../apps/docs/README.md) for the docs file layout.\n\n## Agent skills (optional)\n\nThe repo root has a [`skills-lock.json`](../skills-lock.json) file that pins [Agent Skills](https://skills.sh/) (for example Cloudflare, Tiptap, Vercel labs, and Resend-related packages). Installed skill files live under gitignored paths (for example `.agents/skills/`, `.cursor/skills/`, and `.claude/skills/`) so they are not committed.\n\nAfter cloning, you can restore the same skills from the lockfile from the repository root:\n\n```bash\nnpx skills experimental_install\n```\n\n`experimental_install` is the current CLI command that reads `skills-lock.json`. Adding a package updates the lockfile — for example:\n\n```bash\nnpx skills add https://github.com/cloudflare/skills\nnpx skills add ueberdosis/tiptap\nnpx skills add vercel-labs/agent-skills\nnpx skills add resend/email-best-practices\n```\n\nIf you add or change skills for the team, commit the updated `skills-lock.json`.\n\n## Making changes\n\n1. Create a new branch for your changes\n\n   ```bash\n   git checkout -b feature/your-feature\n   ```\n\n2. Before committing your changes make sure to run the lint and format commands (via ultracite/Biome) to catch any issues\n\n   ```bash\n   pnpm format\n   ```\n\n   or if you would rather fix issues yourself, run the following to list problems without fixing\n\n   ```bash\n   pnpm lint\n   ```\n\n3. Test your changes and make sure they work and run a build\n\n   ```bash\n   pnpm build\n   ```\n\n4. If your build succeeds you can go ahead and make a commit using conventional [commit messages](https://www.conventionalcommits.org/en/v1.0.0/)\n\n```bash\ngit commit -m \"fix(cms): fix sidebar overflow issue\"\n```\n\n## Pull Request Guidelines\n\n- Your PR should reference an issue (if applicable) or clearly describe its impact on the project. [see how to Link a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue)\n- Include a clear description of the changes\n- Keep PRs small and focused. Large PRs are harder to review and may be rejected or delayed.\n- Ensure consistency with the existing codebase. Use ultracite (Biome) for linting and formatting.\n- Include tests if applicable\n- Update documentation if your changes affect usage or API behavior.\n\n## Code Style\n\n- Follow the existing code formatting in the project (use ultracite/Biome for consistency).\n- Write clear, self-documenting code\n- Add comments only when necessary to explain complex logic\n- Use meaningful variable and function names\n\n## Reporting Issues\n\n- Use the GitHub issue tracker\n- Check if the issue already exists before creating a new one\n- Provide a clear description of the issue\n- Include steps to reproduce the issue\n\n## Need Help?\n\nFeel free to open an issue for questions or join our [discord](https://discord.gg/gU44Pmwqkx).\n\nThank you for contributing!\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: [usemarble]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n - Version [e.g. 22]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Description\n\n<!--- Clearly describe what this PR changes. Include relevant details. -->\n\n## Motivation and Context\n\n<!--- Why is this change needed? What problem does it solve? -->\n<!--- If applicable, link to related GitHub issues with `Closes #issue_number` -->\n\n## How to Test\n\n<!--- Provide step-by-step instructions on how to verify your changes work as expected. -->\n<!--- Include any setup or test cases if needed. -->\n\n## Screenshots (if applicable)\n\n<!--- Add screenshots to illustrate UI changes. -->\n\n## Video Demo (if applicable)\n\n<!--- Show screen recordings of the issue or feature. -->\n\n## Types of Changes\n\n<!--- Mark all that apply with an `x` -->\n\n- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)\n- [ ] ✨ New feature (non-breaking change that adds functionality)\n- [ ] ⚠️ Breaking change (fix or feature that alters existing functionality)\n- [ ] 🎨 UI/UX Improvements\n- [ ] ⚡ Performance Enhancement\n- [ ] 📖 Documentation (updates to README, docs, or comments)\n"
  },
  {
    "path": ".github/workflows/code-quality.yml",
    "content": "name: Code quality\n\non:\n  push:\n  pull_request:\njobs:\n  quality:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n\n      - uses: pnpm/action-setup@v4\n        name: Install pnpm\n\n      - name: Install dependencies\n        run: pnpm install --ignore-scripts\n\n      - name: Run lint\n        run: pnpm lint\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non:\n  push:\n  pull_request:\n  workflow_dispatch:\njobs:\n  tests:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v5\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n\n      - uses: pnpm/action-setup@v4\n        name: Install pnpm\n\n      - name: Install dependencies\n        run: pnpm install --ignore-scripts\n\n      - name: Run tests\n        run: pnpm test\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# Dependencies\nnode_modules\n.pnp\n.pnp.js\n\n# Local env files\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# Testing\ncoverage\n\n# Turbo\n.turbo\n\n# Vercel\n.vercel\n\n# Cloudflare\n.wrangler\n\n# Build Outputs\n.next/\nout/\nbuild\ndist\npackages/db/src/generated/\n.idea\n\n# Debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Misc\n.DS_Store\n*.pem\n\n# Agent skills (install locally via npx skills add)\n.agents/skills/\n.agent/skills/\n.cursor/skills/\n.claude/skills/\n\ndocs\n\n# Local only references\nschool/\n"
  },
  {
    "path": ".husky/commit-msg",
    "content": "pnpm exec commitlint --edit \"$1\""
  },
  {
    "path": ".npmrc",
    "content": "public-hoist-pattern[]=*prisma*"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"biomejs.biome\",\n    \"astro-build.astro-vscode\",\n    \"bradlc.vscode-tailwindcss\",\n    \"Prisma.prisma\"\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"tailwindCSS.experimental.configFile\": {\n    \"./apps/web/src/styles/globals.css\": [\"./apps/web/src/**\"],\n    \"./packages/ui/src/styles/globals.css\": [\n      \"./packages/ui/src/**\",\n      \"./packages/editor/src/**\",\n      \"./apps/cms/src/**\"\n    ]\n  },\n  \"tailwindCSS.includeLanguages\": {\n    \"astro\": \"html\"\n  },\n  \"tailwindCSS.classFunctions\": [\"cn\", \"clsx\"],\n  \"files.associations\": {\n    \"*.css\": \"tailwindcss\"\n  },\n  \"tailwindCSS.experimental.classRegex\": [\n    [\n      \"(?:export\\\\s+)?const\\\\s+[A-Za-z0-9_$]+\\\\s*=\\\\s*([\\\\s\\\\S]*?);\",\n      \"[\\\"'`]([^\\\"'`]*)[\\\"'`]\"\n    ],\n    \"return\\\\s+[\\\"'`]([^\\\"'`]*)[\\\"'`]\"\n  ],\n  \"editor.defaultFormatter\": \"biomejs.biome\",\n  \"[javascript]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"editor.codeActionsOnSave\": {\n    \"source.organizeImports.biome\": \"explicit\",\n    \"source.fixAll.biome\": \"explicit\"\n  },\n  \"editor.formatOnSave\": true,\n  \"editor.formatOnPaste\": true,\n  \"search.exclude\": {\n    \"**/node_modules\": true\n  },\n  \"[prisma]\": {\n    \"editor.defaultFormatter\": \"Prisma.prisma\"\n  },\n  \"[javascriptreact]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[json]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[jsonc]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[css]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"[graphql]\": {\n    \"editor.defaultFormatter\": \"biomejs.biome\"\n  },\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"emmet.showExpandedAbbreviation\": \"never\"\n}\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nsupport@marblecms.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">Marble</h1>\n\n<p align=\"center\">\n  <a href=\"https://vercel.com/oss\">\n    <img alt=\"Vercel OSS Program\" src=\"https://vercel.com/oss/program-badge.svg\" />\n  </a>\n</p>\n\n<p align=\"center\"><em>Super simple way to publish articles, product updates and changelogs to your site</em></p>\n\n---\n\n## ✨ Features\n\n- Create, edit, and manage posts in a beautiful editor\n- Organise content with **tags** and **categories**\n- Upload and embed **images** and **videos** in posts\n- **Readability insights** and AI-powered writing suggestions in the editor\n- **Realtime webhooks** to trigger workflows when content changes\n- Fetch your content anywhere through the **REST API**\n\n## 🛠 Local Development & Contributing\n\nWant to run Marble locally or contribute a feature? Check out our\n[Contributing Guide](./.github/CONTRIBUTING.md) for a step-by-step guide covering setup,\ndatabase configuration, and pull-request guidelines.\n\n## Community & Support\n\nHave questions or feedback?\n\n- Join the [Discord](https://discord.gg/gU44Pmwqkx)\n- Follow us on [Twitter](https://twitter.com/usemarblecms)\n\nFeel free to open an issue for bugs or feature requests.\n\n## License\n\nMarble is released under the [GNU Affero General Public License v3.0](./LICENSE.md).\n"
  },
  {
    "path": "apps/api/.gitignore",
    "content": "# prod\ndist/\n\n# dev\n.yarn/\n!.yarn/releases\n.vscode/*\n!.vscode/launch.json\n!.vscode/*.code-snippets\n.idea/workspace.xml\n.idea/usage.statistics.xml\n.idea/shelf\n\n# deps\nnode_modules/\n.wrangler\n\n# env\n.env\n.env.production\n.dev.vars\n\n# logs\nlogs/\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\n# misc\n.DS_Store\n"
  },
  {
    "path": "apps/api/README.md",
    "content": "# API\n\nAPI endpoints users can fetch data from.\n"
  },
  {
    "path": "apps/api/package.json",
    "content": "{\n  \"name\": \"api\",\n  \"version\": \"0.1.0\",\n  \"scripts\": {\n    \"dev\": \"wrangler dev\",\n    \"deploy\": \"wrangler deploy --minify\",\n    \"cf-typegen\": \"wrangler types --env-interface CloudflareBindings\"\n  },\n  \"dependencies\": {\n    \"@hono/zod-openapi\": \"^1.3.0\",\n    \"@hono/zod-validator\": \"^0.7.6\",\n    \"@marble/db\": \"workspace:*\",\n    \"@marble/email\": \"workspace:*\",\n    \"@marble/events\": \"workspace:*\",\n    \"@marble/utils\": \"workspace:*\",\n    \"@polar-sh/sdk\": \"^0.42.5\",\n    \"@upstash/ratelimit\": \"^2.0.8\",\n    \"@upstash/redis\": \"^1.36.4\",\n    \"hono\": \"^4.12.14\",\n    \"image-size\": \"^2.0.2\",\n    \"node-html-markdown\": \"^2.0.0\",\n    \"resend\": \"^6.12.3\",\n    \"sanitize-html\": \"^2.17.1\",\n    \"zod\": \"^4.3.6\"\n  },\n  \"devDependencies\": {\n    \"@cloudflare/workers-types\": \"^4.20260508.1\",\n    \"@types/sanitize-html\": \"^2.16.1\",\n    \"wrangler\": \"^4.90.0\"\n  }\n}\n"
  },
  {
    "path": "apps/api/src/app.ts",
    "content": "import { OpenAPIHono } from \"@hono/zod-openapi\";\nimport { Hono } from \"hono\";\nimport { cors } from \"hono/cors\";\nimport { trimTrailingSlash } from \"hono/trailing-slash\";\nimport { FRAMER_PLUGIN_PATTERN, ROUTES } from \"./lib/constants\";\nimport { analytics } from \"./middleware/analytics\";\nimport { authorization } from \"./middleware/authorization\";\nimport { cache } from \"./middleware/cache\";\nimport { keyAuthorization } from \"./middleware/key-authorization\";\nimport { legacyAnalytics } from \"./middleware/legacy-analytics\";\nimport { ratelimit } from \"./middleware/ratelimit\";\nimport { systemAuth } from \"./middleware/system\";\nimport authorsRoutes from \"./routes/authors\";\nimport cacheRoutes from \"./routes/cache\";\nimport categoriesRoutes from \"./routes/categories\";\nimport eventsRoutes from \"./routes/events\";\nimport invalidateRoutes from \"./routes/invalidate\";\nimport mediaRoutes from \"./routes/media\";\nimport postsRoutes from \"./routes/posts\";\nimport tagsRoutes from \"./routes/tags\";\nimport type { ApiKeyApp, Env } from \"./types/env\";\n\nconst app = new OpenAPIHono<{ Bindings: Env }>();\n\n// Global middleware — CORS must be first so preflight and cross-origin responses work\napp.use(\n  \"*\",\n  cors({\n    origin: (origin) => {\n      if (!origin) {\n        return \"*\";\n      }\n      if (FRAMER_PLUGIN_PATTERN.test(origin)) {\n        return origin;\n      }\n      return \"*\";\n    },\n    allowHeaders: [\"Content-Type\", \"Authorization\"],\n    allowMethods: [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"],\n  })\n);\n\napp.use(\"*\", cache());\napp.use(trimTrailingSlash());\n\n// Internal System Routes (no API key, no analytics)\napp.use(\"/cache/invalidate\", systemAuth());\napp.route(\"/cache/invalidate\", cacheRoutes);\n\napp.use(\"/internal/events\", systemAuth());\napp.route(\"/internal/events\", eventsRoutes);\n\n// Legacy Workspace ID Routes (/v1/:workspaceId/*)\n// MUST be registered BEFORE apiKeyV1 to intercept workspace ID routes\n// Using standard Hono since these are deprecated and don't need to be in the spec\nconst legacyV1 = new Hono<{ Bindings: Env }>();\nlegacyV1.use(\"/:workspaceId/*\", ratelimit(\"workspace\"));\nlegacyV1.use(\"/:workspaceId/*\", authorization());\nlegacyV1.use(\"/:workspaceId/*\", legacyAnalytics());\n\nlegacyV1.route(\"/:workspaceId/tags\", tagsRoutes);\nlegacyV1.route(\"/:workspaceId/categories\", categoriesRoutes);\nlegacyV1.route(\"/:workspaceId/posts\", postsRoutes);\nlegacyV1.route(\"/:workspaceId/authors\", authorsRoutes);\n\n// Mount legacy routes dispatcher - intercepts /v1/:workspaceId/* BEFORE apiKeyV1\napp.use(\"/v1/:workspaceId/*\", async (c, next) => {\n  const path = c.req.path;\n  const workspaceId = c.req.param(\"workspaceId\");\n\n  // Check if this is a legacy workspace route (workspaceId is not a known resource)\n  if (!ROUTES.includes(workspaceId)) {\n    // Rewrite path (strip /v1 prefix) for legacy router\n    const newPath = path.replace(\"/v1\", \"\");\n    const newUrl = new URL(c.req.url);\n    newUrl.pathname = newPath;\n    const newRequest = new Request(newUrl.toString(), c.req.raw);\n    return legacyV1.fetch(newRequest, c.env, c.executionCtx);\n  }\n\n  // If workspaceId is actually a resource name (posts, tags, etc.), skip this middleware\n  // and let Hono continue to the next matching route (apiKeyV1)\n  return next();\n});\n\n// API Key Routes (/v1/posts, /v1/tags, etc.)\n// Using OpenAPIHono to properly merge specs\nconst apiKeyV1 = new OpenAPIHono<ApiKeyApp>();\napiKeyV1.use(\"*\", ratelimit(\"apiKey\"));\napiKeyV1.use(\"*\", keyAuthorization());\napiKeyV1.use(\"*\", analytics());\n\n// Mount routes with proper OpenAPIHono to enable spec merging\napiKeyV1.route(\"/posts\", postsRoutes);\napiKeyV1.route(\"/categories\", categoriesRoutes);\napiKeyV1.route(\"/tags\", tagsRoutes);\napiKeyV1.route(\"/authors\", authorsRoutes);\napiKeyV1.route(\"/media\", mediaRoutes);\napiKeyV1.route(\"/cache/invalidate\", invalidateRoutes);\n\n// Mount apiKeyV1 under /v1 to automatically merge OpenAPI specs\napp.route(\"/v1\", apiKeyV1);\n\n// Redirect non-versioned routes to v1\napp.use(\"/:workspaceId/*\", async (c, next) => {\n  const path = c.req.path;\n  const workspaceId = c.req.param(\"workspaceId\");\n  if (\n    path.startsWith(\"/v1/\") ||\n    path === \"/\" ||\n    path === \"/status\" ||\n    path === \"/openapi.json\"\n  ) {\n    return next();\n  }\n\n  const isWorkspaceRoute = ROUTES.some(\n    (route) =>\n      path === `/${workspaceId}/${route}` ||\n      path.startsWith(`/${workspaceId}/${route}/`)\n  );\n\n  if (isWorkspaceRoute) {\n    const url = new URL(c.req.url);\n    url.pathname = `/v1${path}`;\n    return Response.redirect(url.toString(), 308);\n  }\n  return next();\n});\n\n// Redirect non-versioned API routes to v1 (e.g., /posts -> /v1/posts)\napp.use(\"/*\", async (c, next) => {\n  const path = c.req.path;\n  const firstSegment = path.split(\"/\").filter(Boolean)[0];\n\n  if (firstSegment && ROUTES.includes(firstSegment)) {\n    const url = new URL(c.req.url);\n    url.pathname = `/v1${path}`;\n    return Response.redirect(url.toString(), 308);\n  }\n  return next();\n});\n\napp.get(\"/\", (c) => c.text(\"Hello from marble\"));\napp.get(\"/status\", (c) => c.json({ status: \"ok\" }));\n\napp.doc(\"/openapi.json\", {\n  openapi: \"3.1.0\",\n  info: {\n    title: \"Marble API\",\n    version: \"1.0.0\",\n    description:\n      \"A headless CMS API for managing and delivering content programmatically.\",\n  },\n  servers: [{ url: \"https://api.marblecms.com\", description: \"Production\" }],\n  security: [{ apiKey: [] }],\n});\n\napp.openAPIRegistry.registerComponent(\"securitySchemes\", \"apiKey\", {\n  type: \"apiKey\",\n  in: \"header\",\n  name: \"Authorization\",\n  description: \"Your Marble API key\",\n});\n\nexport default app;\n"
  },
  {
    "path": "apps/api/src/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: \"required\" */\nexport { default } from \"./app\";\n"
  },
  {
    "path": "apps/api/src/lib/cache.ts",
    "content": "import { Redis } from \"@upstash/redis/cloudflare\";\n\n/** Default cache TTL in seconds (1 hour) */\nconst DEFAULT_TTL = 3600;\n\n/** Cache key prefix for all cached data */\nconst CACHE_PREFIX = \"cache\";\n\n/** Skip caching values larger than 8MB to stay under Upstash's 10MB request limit */\nconst MAX_CACHE_VALUE_BYTES = 8 * 1024 * 1024;\n\nexport type CacheClient = ReturnType<typeof createCacheClient>;\n\n/**\n * Create a cache client with helper methods for the cache-aside pattern.\n * Uses Upstash Redis for storage.\n */\nexport function createCacheClient(url: string, token: string) {\n  const redis = new Redis({ url, token });\n\n  return {\n    /**\n     * Get a cached value by key\n     */\n    async get<T>(key: string): Promise<T | null> {\n      try {\n        const value = await redis.get<T>(key);\n        if (value !== null) {\n          console.log(`[Cache] HIT: ${key}`);\n        }\n        return value;\n      } catch (error) {\n        console.error(`[Cache] GET error for ${key}:`, error);\n        return null;\n      }\n    },\n\n    /**\n     * Set a cached value with optional TTL\n     */\n    async set<T>(key: string, value: T, ttl = DEFAULT_TTL): Promise<void> {\n      try {\n        const serialized = JSON.stringify(value);\n        if (serialized.length > MAX_CACHE_VALUE_BYTES) {\n          console.warn(\n            `[Cache] SKIP SET: ${key} is ${serialized.length} bytes, exceeds ${MAX_CACHE_VALUE_BYTES} byte limit`\n          );\n          return;\n        }\n        await redis.set(key, value, { ex: ttl });\n        console.log(`[Cache] SET: ${key} (TTL: ${ttl}s)`);\n      } catch (error) {\n        console.error(`[Cache] SET error for ${key}:`, error);\n      }\n    },\n\n    /**\n     * Cache-aside pattern: get from cache or fetch and cache\n     */\n    async getOrSet<T>(\n      key: string,\n      fetcher: () => Promise<T>,\n      ttl = DEFAULT_TTL\n    ): Promise<T> {\n      try {\n        const cached = await redis.get<T>(key);\n        if (cached !== null) {\n          console.log(`[Cache] HIT: ${key}`);\n          return cached;\n        }\n\n        console.log(`[Cache] MISS: ${key}`);\n        const fresh = await fetcher();\n        const serialized = JSON.stringify(fresh);\n        if (serialized.length > MAX_CACHE_VALUE_BYTES) {\n          console.warn(\n            `[Cache] SKIP SET: ${key} is ${serialized.length} bytes, exceeds ${MAX_CACHE_VALUE_BYTES} byte limit`\n          );\n          return fresh;\n        }\n        await redis.set(key, fresh, { ex: ttl });\n        return fresh;\n      } catch (error) {\n        console.error(`[Cache] getOrSet error for ${key}:`, error);\n        return fetcher();\n      }\n    },\n\n    /**\n     * Cache-aside pattern for count queries\n     * Optimized for caching numeric count values\n     */\n    async getOrSetCount<T extends number>(\n      key: string,\n      fetcher: () => Promise<T>,\n      ttl = DEFAULT_TTL\n    ): Promise<T> {\n      try {\n        const cached = await redis.get<T>(key);\n        if (cached !== null) {\n          console.log(`[Cache] HIT (count): ${key}`);\n          return cached;\n        }\n\n        console.log(`[Cache] MISS (count): ${key}`);\n        const fresh = await fetcher();\n        await redis.set(key, fresh, { ex: ttl });\n        return fresh;\n      } catch (error) {\n        console.error(`[Cache] getOrSetCount error for ${key}:`, error);\n        return fetcher();\n      }\n    },\n\n    /**\n     * Invalidate cache keys matching a pattern\n     * Uses SCAN to find keys iteratively, then DEL to remove them in batches\n     */\n    async invalidate(pattern: string): Promise<number> {\n      try {\n        let cursor: string | number = \"0\";\n        const allKeys: string[] = [];\n        const batchSize = 100;\n\n        // Use SCAN to iterate through keys matching the pattern\n        do {\n          const [nextCursor, keys]: [string | number, string[]] =\n            await redis.scan(cursor, {\n              match: pattern,\n              count: batchSize,\n            });\n          cursor = nextCursor;\n          if (Array.isArray(keys)) {\n            allKeys.push(...keys);\n          }\n        } while (String(cursor) !== \"0\");\n\n        // Delete keys in batches to avoid large argument lists\n        let deletedCount = 0;\n        for (let i = 0; i < allKeys.length; i += batchSize) {\n          const batch = allKeys.slice(i, i + batchSize);\n          if (batch.length > 0) {\n            const deleted = await redis.del(...batch);\n            deletedCount += deleted;\n          }\n        }\n\n        if (deletedCount > 0) {\n          console.log(`[Cache] INVALIDATE: ${pattern} (${deletedCount} keys)`);\n        }\n        return deletedCount;\n      } catch (error) {\n        console.error(`[Cache] INVALIDATE error for ${pattern}:`, error);\n        return 0;\n      }\n    },\n\n    /**\n     * Invalidate all cache for a specific workspace\n     */\n    async invalidateWorkspace(workspaceId: string): Promise<number> {\n      return this.invalidate(`${CACHE_PREFIX}:${workspaceId}:*`);\n    },\n\n    /**\n     * Invalidate cache for a specific resource type in a workspace\n     */\n    async invalidateResource(\n      workspaceId: string,\n      resource: \"posts\" | \"categories\" | \"tags\" | \"authors\" | \"media\"\n    ): Promise<number> {\n      return this.invalidate(`${CACHE_PREFIX}:${workspaceId}:${resource}:*`);\n    },\n  };\n}\n\n/**\n * Generate a cache key for a workspace resource\n */\nexport function cacheKey(\n  workspaceId: string,\n  resource: string,\n  ...parts: string[]\n): string {\n  return [CACHE_PREFIX, workspaceId, resource, ...parts].join(\":\");\n}\n\n/**\n * Generate a hash from query parameters for cache key uniqueness\n * Uses a polynomial rolling hash that works in both Node and Workers\n */\nexport function hashQueryParams(params: Record<string, unknown>): string {\n  const sorted = Object.keys(params)\n    .sort()\n    .reduce(\n      (acc, key) => {\n        const value = params[key];\n        if (value !== undefined && value !== null && value !== \"\") {\n          acc[key] = value;\n        }\n        return acc;\n      },\n      {} as Record<string, unknown>\n    );\n\n  // Use a polynomial rolling hash for consistent cross-platform hashing\n  const str = JSON.stringify(sorted);\n  const hash = Array.from(str).reduce((acc, char) => {\n    const code = char.charCodeAt(0);\n    return Math.abs((acc * 31 + code) % 2_147_483_647);\n  }, 0);\n\n  return hash.toString(36);\n}\n"
  },
  {
    "path": "apps/api/src/lib/constants.ts",
    "content": "/**\n * Available API route resources.\n * Used for route dispatching and redirect logic.\n */\nexport const ROUTES = [\n  \"posts\",\n  \"categories\",\n  \"tags\",\n  \"authors\",\n  \"cache\",\n  \"media\",\n];\n\nexport const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;\nexport const DEFAULT_CDN_URL = \"https://cdn.marblecms.com\";\n\nexport const ALLOWED_IMAGE_MIME_TYPES = [\n  \"image/jpeg\",\n  \"image/png\",\n  \"image/gif\",\n  \"image/webp\",\n  \"image/avif\",\n  \"image/svg+xml\",\n] as const;\n\nexport const ALLOWED_VIDEO_MIME_TYPES = [\n  \"video/mp4\",\n  \"video/webm\",\n  \"video/ogg\",\n  \"video/quicktime\",\n] as const;\n\nexport const ALLOWED_AUDIO_MIME_TYPES = [\n  \"audio/mpeg\",\n  \"audio/mp4\",\n  \"audio/ogg\",\n  \"audio/wav\",\n  \"audio/webm\",\n  \"audio/aac\",\n  \"audio/flac\",\n] as const;\n\nexport const ALLOWED_DOCUMENT_MIME_TYPES = [\n  \"application/pdf\",\n  \"text/plain\",\n  \"text/csv\",\n  \"application/json\",\n  \"application/zip\",\n  \"application/msword\",\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n  \"application/vnd.ms-excel\",\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n  \"application/vnd.ms-powerpoint\",\n  \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\n] as const;\n\nexport const ALLOWED_MEDIA_MIME_TYPES = [\n  ...ALLOWED_IMAGE_MIME_TYPES,\n  ...ALLOWED_VIDEO_MIME_TYPES,\n  ...ALLOWED_AUDIO_MIME_TYPES,\n  ...ALLOWED_DOCUMENT_MIME_TYPES,\n] as const;\n\nexport type AllowedMediaMimeType = (typeof ALLOWED_MEDIA_MIME_TYPES)[number];\n\nexport const FRAMER_PLUGIN_ID = \"4pj5owtk2qcexo6c1yt9kicye\";\nexport const FRAMER_PLUGIN_PATTERN = new RegExp(\n  `^https://${FRAMER_PLUGIN_ID}(-[a-zA-Z0-9]+)?\\\\.plugins\\\\.framercdn\\\\.com$`\n);\n"
  },
  {
    "path": "apps/api/src/lib/crypto.ts",
    "content": "/**\n * Web Crypto API utilities for Cloudflare Workers\n * These functions use the native Web Crypto API instead of Node.js crypto\n * to avoid WASM polyfill issues in the Workers runtime\n */\n\n/**\n * Hash an API key using SHA-256 via Web Crypto API\n * @param key - The plaintext API key to hash\n * @returns The SHA-256 hash of the key as a hex string (64 characters)\n */\nexport async function hashApiKey(key: string): Promise<string> {\n  const encoder = new TextEncoder();\n  const data = encoder.encode(key);\n  const hashBuffer = await crypto.subtle.digest(\"SHA-256\", data);\n  const hashArray = Array.from(new Uint8Array(hashBuffer));\n  return hashArray.map((b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n"
  },
  {
    "path": "apps/api/src/lib/db.ts",
    "content": "import { createClient as createHyperdriveClient } from \"@marble/db/hyperdrive\";\nimport { createClient as createWorkersClient } from \"@marble/db/workers\";\nimport type { Env } from \"@/types/env\";\n\n/**\n * Get the database connection string.\n * In development, uses DATABASE_URL directly to bypass Hyperdrive's local proxy\n * (which can have compatibility issues with Neon's serverless driver).\n * In production, uses Hyperdrive for connection pooling and latency optimization.\n */\nexport function getConnectionString(env: Env): string {\n  // if (env.ENVIRONMENT === \"development\" && env.DATABASE_URL) {\n  //   return env.DATABASE_URL;\n  // }\n  if (!env.HYPERDRIVE?.connectionString) {\n    throw new Error(\n      \"Database configuration error: no connection string available\"\n    );\n  }\n  return env.HYPERDRIVE.connectionString;\n}\n\n/**\n * Create a Prisma client with the correct adapter for the current env.\n * - DATABASE_URL (dev or BYPASS_HYPERDRIVE): Neon serverless driver\n * - HYPERDRIVE: pg-worker driver (standard Postgres, Hyperdrive-compatible)\n */\nexport type DbClient = ReturnType<typeof createDbClient>;\n\nexport function createDbClient(env: Env) {\n  // const useDirect = env.ENVIRONMENT === \"development\";\n  // if (useDirect && env.DATABASE_URL) {\n  //   return createWorkersClient(env.DATABASE_URL);\n  // }\n  if (!env.HYPERDRIVE?.connectionString) {\n    throw new Error(\n      \"Database configuration error: no connection string available\"\n    );\n  }\n  return createHyperdriveClient(env.HYPERDRIVE.connectionString);\n}\n"
  },
  {
    "path": "apps/api/src/lib/events.ts",
    "content": "import type { createDbClient } from \"@/lib/db\";\nimport type { JsonObject } from \"@/validations/json\";\nimport type {\n  WORKSPACE_EVENT_ACTOR_TYPES,\n  WORKSPACE_EVENT_RESOURCE_TYPES,\n  WORKSPACE_EVENT_SOURCES,\n  WORKSPACE_EVENT_TYPES,\n} from \"@/validations/misc\";\n\ninterface EmitEventOptions {\n  type: (typeof WORKSPACE_EVENT_TYPES)[number];\n  workspaceId: string;\n  resourceType: (typeof WORKSPACE_EVENT_RESOURCE_TYPES)[number];\n  resourceId: string;\n  source?: (typeof WORKSPACE_EVENT_SOURCES)[number];\n  actorType?: (typeof WORKSPACE_EVENT_ACTOR_TYPES)[number];\n  actorId?: string;\n  payload?: JsonObject;\n}\n\nexport async function emitEvent(\n  db: ReturnType<typeof createDbClient>,\n  queue: Queue,\n  options: EmitEventOptions\n) {\n  const event = await db.workspaceEvent.create({\n    data: {\n      type: options.type,\n      workspaceId: options.workspaceId,\n      source: options.source ?? \"api\",\n      resourceType: options.resourceType,\n      resourceId: options.resourceId,\n      actorType: options.actorType,\n      actorId: options.actorId,\n      payload: options.payload ?? {},\n    },\n  });\n\n  await queue.send({ eventId: event.id });\n\n  return event;\n}\n"
  },
  {
    "path": "apps/api/src/lib/media.ts",
    "content": "import { imageSize } from \"image-size\";\nimport { DEFAULT_CDN_URL } from \"./constants\";\n\nexport type MediaType = \"image\" | \"video\" | \"audio\" | \"document\";\n\nexport interface MediaRecord {\n  id: string;\n  name: string;\n  url: string;\n  alt: string | null;\n  size: number;\n  mimeType: string | null;\n  width: number | null;\n  height: number | null;\n  duration: number | null;\n  blurHash: string | null;\n  type: MediaType;\n  createdAt: Date;\n  updatedAt: Date;\n}\n\nexport function serializeMedia(item: MediaRecord) {\n  return {\n    id: item.id,\n    name: item.name,\n    url: item.url,\n    alt: item.alt,\n    size: item.size,\n    mimeType: item.mimeType,\n    width: item.width,\n    height: item.height,\n    duration: item.duration,\n    blurHash: item.blurHash,\n    type: item.type,\n    createdAt: item.createdAt.toISOString(),\n    updatedAt: item.updatedAt.toISOString(),\n  };\n}\n\nexport function getMediaType(mimeType: string): MediaType {\n  if (mimeType.startsWith(\"image/\")) {\n    return \"image\";\n  }\n  if (mimeType.startsWith(\"video/\")) {\n    return \"video\";\n  }\n  if (mimeType.startsWith(\"audio/\")) {\n    return \"audio\";\n  }\n  return \"document\";\n}\n\nexport function extensionFromFile(file: File) {\n  const filename = file.name.trim();\n  const filenameExtension = filename.includes(\".\")\n    ? filename.split(\".\").pop()\n    : undefined;\n  if (filenameExtension) {\n    return filenameExtension.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n  }\n  return file.type.split(\"/\")[1]?.split(\"+\")[0] || \"bin\";\n}\n\nexport function publicUrl(envUrl: string | undefined, key: string) {\n  const base = (envUrl || DEFAULT_CDN_URL).replace(/\\/$/, \"\");\n  return `${base}/${key}`;\n}\n\nexport function objectKeyFromUrl(url: string) {\n  try {\n    return new URL(url).pathname.replace(/^\\/+/, \"\");\n  } catch {\n    return null;\n  }\n}\n\nexport function getImageDimensions(buffer: ArrayBuffer) {\n  try {\n    const dimensions = imageSize(new Uint8Array(buffer));\n    return {\n      width: dimensions.width,\n      height: dimensions.height,\n    };\n  } catch (error) {\n    console.warn(\"Failed to read image dimensions:\", error);\n    return {};\n  }\n}\n"
  },
  {
    "path": "apps/api/src/lib/polar.ts",
    "content": "import { Polar } from \"@polar-sh/sdk\";\n\nexport function createPolarClient(accessToken: string, isProduction = false) {\n  return new Polar({\n    server: isProduction ? \"production\" : \"sandbox\",\n    accessToken,\n  });\n}\n"
  },
  {
    "path": "apps/api/src/lib/posts.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\n\nexport const buildStatusFilter = (status: \"published\" | \"draft\" | \"all\") =>\n  status === \"all\"\n    ? { status: { in: [\"published\", \"draft\"] as (\"published\" | \"draft\")[] } }\n    : { status };\n\nfunction castFieldValue(\n  value: string,\n  type: string\n): string | number | boolean | string[] | null {\n  switch (type) {\n    case \"number\": {\n      const num = Number.parseFloat(value);\n      return Number.isNaN(num) ? null : num;\n    }\n    case \"boolean\":\n      return value === \"true\";\n    case \"multiselect\":\n      try {\n        return z.array(z.string()).parse(JSON.parse(value));\n      } catch {\n        return null;\n      }\n    default:\n      return value;\n  }\n}\n\nexport function buildFieldsObject(\n  fieldValues: Array<{\n    value: string;\n    field: { key: string; type: string };\n  }>,\n  allFields?: Array<{ key: string; type: string }>\n): Record<string, string | number | boolean | string[] | null> {\n  const result: Record<string, string | number | boolean | string[] | null> =\n    {};\n\n  if (allFields) {\n    for (const field of allFields) {\n      result[field.key] = null;\n    }\n  }\n\n  for (const fieldValue of fieldValues) {\n    result[fieldValue.field.key] = castFieldValue(\n      fieldValue.value,\n      fieldValue.field.type\n    );\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "apps/api/src/lib/redis.ts",
    "content": "import { Redis } from \"@upstash/redis/cloudflare\";\n\nexport function createRedisClient(url: string, token: string): Redis {\n  return new Redis({ url, token });\n}\n"
  },
  {
    "path": "apps/api/src/lib/sanitize.ts",
    "content": "import sanitize, { defaults } from \"sanitize-html\";\n\n/**\n * Sanitize HTML content to prevent XSS attacks.\n * Uses the same configuration as the CMS editor to ensure consistency.\n *\n * - Strips `<script>` tags and `on*` event handlers\n * - Whitelists safe HTML tags and attributes\n * - Only allows safe URL schemes (blocks `javascript:` in hrefs)\n * - Restricts iframe sources to YouTube only\n */\nexport const sanitizeHtml = (content: string): string => {\n  return sanitize(content, {\n    allowedTags: [\n      \"b\",\n      \"i\",\n      \"em\",\n      \"strong\",\n      \"a\",\n      \"img\",\n      \"video\",\n      \"track\",\n      \"h1\",\n      \"h2\",\n      \"h3\",\n      \"h4\",\n      \"h5\",\n      \"h6\",\n      \"code\",\n      \"pre\",\n      \"p\",\n      \"li\",\n      \"ul\",\n      \"ol\",\n      \"blockquote\",\n      \"td\",\n      \"th\",\n      \"table\",\n      \"tr\",\n      \"tbody\",\n      \"thead\",\n      \"tfoot\",\n      \"small\",\n      \"div\",\n      \"iframe\",\n      \"input\",\n      \"label\",\n      \"figure\",\n      \"figcaption\",\n      \"span\",\n      \"mark\",\n      \"s\",\n      \"u\",\n      \"sub\",\n      \"sup\",\n      \"hr\",\n    ],\n    allowedAttributes: {\n      ...defaults.allowedAttributes,\n      \"*\": [\"style\"],\n      code: [\"class\"],\n      a: [\"href\", \"target\"],\n      iframe: [\"src\", \"allowfullscreen\", \"style\", \"width\", \"height\"],\n      input: [\"type\", \"checked\"],\n      figure: [\n        \"src\",\n        \"alt\",\n        \"data-width\",\n        \"caption\",\n        \"data-align\",\n        \"data-type\",\n      ],\n      video: [\"src\", \"controls\", \"preload\", \"muted\", \"loop\", \"playsinline\"],\n      track: [\"kind\", \"src\", \"srclang\", \"label\"],\n      div: [\"data-twitter\", \"data-src\", \"data-youtube-video\"],\n      span: [\"style\", \"data-color\"],\n      mark: [\"style\", \"data-color\"],\n    },\n    allowedStyles: {\n      \"*\": {\n        color: [\n          /^#[\\da-fA-F]{3,6}$/,\n          /^rgb\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*\\)$/,\n          /^rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*[\\d.]+\\s*\\)$/,\n          /^[a-zA-Z]+$/,\n        ],\n        \"background-color\": [\n          /^#[\\da-fA-F]{3,6}$/,\n          /^rgb\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*\\)$/,\n          /^rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*[\\d.]+\\s*\\)$/,\n          /^[a-zA-Z]+$/,\n        ],\n        \"text-decoration\": [/^line-through$/, /^underline$/, /^none$/],\n      },\n    },\n    allowedSchemes: [\"http\", \"https\", \"ftp\", \"mailto\"],\n    allowedSchemesByTag: {\n      img: [\"http\", \"https\", \"data\"],\n      video: [\"http\", \"https\"],\n      a: [\"http\", \"https\", \"ftp\", \"mailto\"],\n      iframe: [\"https\"],\n    },\n    allowedIframeHostnames: [\"www.youtube.com\", \"www.youtube-nocookie.com\"],\n    exclusiveFilter: (frame) => {\n      if (frame.tag === \"script\") {\n        return true;\n      }\n      if (frame.tag === \"input\" && frame.attribs?.type !== \"checkbox\") {\n        return true;\n      }\n      if (frame.attribs) {\n        for (const attr in frame.attribs) {\n          if (/^on/i.test(attr)) {\n            return true;\n          }\n        }\n      }\n      return false;\n    },\n  });\n};\n"
  },
  {
    "path": "apps/api/src/lib/usage.ts",
    "content": "import { sendUsageLimitEmail } from \"@marble/email\";\nimport { getWorkspacePlan, PLAN_LIMITS, type PlanType } from \"@marble/utils\";\nimport { Redis } from \"@upstash/redis/cloudflare\";\nimport { Resend } from \"resend\";\nimport type { createDbClient } from \"@/lib/db\";\n\ntype DbClient = ReturnType<typeof createDbClient>;\n\nconst USAGE_KEY_PREFIX = \"usage:api\";\nconst USAGE_META_PREFIX = \"usage:meta\";\n\nconst META_TTL = 300;\n\ninterface UsageMeta {\n  limit: number;\n  plan: PlanType;\n  periodEnd: string;\n}\n\ninterface BillingPeriod {\n  start: Date;\n  end: Date;\n}\n\nasync function getBillingPeriod(\n  db: DbClient,\n  workspaceId: string\n): Promise<BillingPeriod> {\n  const workspace = await db.organization.findUnique({\n    where: { id: workspaceId },\n    select: {\n      createdAt: true,\n      subscriptions: {\n        where: { status: { in: [\"active\", \"trialing\", \"canceled\"] } },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          status: true,\n          cancelAtPeriodEnd: true,\n          currentPeriodStart: true,\n          currentPeriodEnd: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    const now = new Date();\n    return {\n      start: new Date(now.getFullYear(), now.getMonth(), 1),\n      end: new Date(now.getFullYear(), now.getMonth() + 1, 1),\n    };\n  }\n\n  const subscription = workspace.subscriptions[0];\n  const isValid =\n    subscription &&\n    (subscription.status === \"active\" ||\n      subscription.status === \"trialing\" ||\n      (subscription.status === \"canceled\" &&\n        subscription.cancelAtPeriodEnd &&\n        subscription.currentPeriodEnd &&\n        subscription.currentPeriodEnd > new Date()));\n\n  if (\n    isValid &&\n    subscription.currentPeriodStart &&\n    subscription.currentPeriodEnd\n  ) {\n    return {\n      start: subscription.currentPeriodStart,\n      end: subscription.currentPeriodEnd,\n    };\n  }\n\n  const dayOfMonth = workspace.createdAt.getDate();\n  const now = new Date();\n\n  const getValidDate = (year: number, month: number, day: number) => {\n    const lastDay = new Date(year, month + 1, 0).getDate();\n    return new Date(year, month, Math.min(day, lastDay));\n  };\n\n  let periodStart = getValidDate(now.getFullYear(), now.getMonth(), dayOfMonth);\n  if (periodStart > now) {\n    periodStart = getValidDate(\n      now.getFullYear(),\n      now.getMonth() - 1,\n      dayOfMonth\n    );\n  }\n\n  const periodEnd = getValidDate(\n    periodStart.getFullYear(),\n    periodStart.getMonth() + 1,\n    dayOfMonth\n  );\n\n  return { start: periodStart, end: periodEnd };\n}\n\nexport interface UsageCheckResult {\n  allowed: boolean;\n  currentUsage: number;\n  limit: number;\n  percentage: number;\n  plan: PlanType;\n  thresholdCrossed?: 75 | 90 | 100;\n}\n\nasync function getUsageMeta(\n  redis: Redis,\n  db: DbClient,\n  workspaceId: string\n): Promise<UsageMeta> {\n  const metaKey = `${USAGE_META_PREFIX}:${workspaceId}`;\n  const cached = await redis.get<UsageMeta>(metaKey);\n  if (cached) {\n    return cached;\n  }\n\n  const workspace = await db.organization.findUnique({\n    where: { id: workspaceId },\n    select: {\n      subscriptions: {\n        where: { status: { in: [\"active\", \"trialing\", \"canceled\"] } },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          plan: true,\n          status: true,\n          cancelAtPeriodEnd: true,\n          currentPeriodEnd: true,\n        },\n      },\n    },\n  });\n\n  const subscription = workspace?.subscriptions[0];\n  const plan = getWorkspacePlan(subscription);\n  const limit = PLAN_LIMITS[plan].maxApiRequests;\n  const period = await getBillingPeriod(db, workspaceId);\n\n  const meta: UsageMeta = {\n    limit,\n    plan,\n    periodEnd: period.end.toISOString(),\n  };\n\n  await redis.set(metaKey, meta, { ex: META_TTL });\n  return meta;\n}\n\nasync function seedUsageCounter(\n  redis: Redis,\n  db: DbClient,\n  workspaceId: string,\n  periodEnd: Date\n): Promise<number> {\n  const period = await getBillingPeriod(db, workspaceId);\n  const count = await db.usageEvent.count({\n    where: {\n      workspaceId,\n      type: \"api_request\",\n      createdAt: { gte: period.start, lt: period.end },\n    },\n  });\n\n  const counterKey = `${USAGE_KEY_PREFIX}:${workspaceId}`;\n  const ttl = Math.max(\n    1,\n    Math.floor((periodEnd.getTime() - Date.now()) / 1000)\n  );\n  await redis.set(counterKey, count, { ex: ttl });\n\n  return count;\n}\n\nexport async function checkApiUsage(\n  db: DbClient,\n  workspaceId: string,\n  redisCredentials?: { url: string; token: string }\n): Promise<UsageCheckResult> {\n  if (!redisCredentials) {\n    return checkApiUsageFromDb(db, workspaceId);\n  }\n\n  const redis = new Redis({\n    url: redisCredentials.url,\n    token: redisCredentials.token,\n  });\n\n  try {\n    const meta = await getUsageMeta(redis, db, workspaceId);\n    const counterKey = `${USAGE_KEY_PREFIX}:${workspaceId}`;\n\n    const exists = await redis.exists(counterKey);\n    let currentUsage: number;\n\n    if (exists) {\n      currentUsage = await redis.incr(counterKey);\n      currentUsage -= 1;\n    } else {\n      currentUsage = await seedUsageCounter(\n        redis,\n        db,\n        workspaceId,\n        new Date(meta.periodEnd)\n      );\n    }\n\n    const percentage = meta.limit > 0 ? (currentUsage / meta.limit) * 100 : 0;\n\n    let thresholdCrossed: 75 | 90 | 100 | undefined;\n    const nextPercentage =\n      meta.limit > 0 ? ((currentUsage + 1) / meta.limit) * 100 : 0;\n\n    if (nextPercentage >= 100 && percentage < 100) {\n      thresholdCrossed = 100;\n    } else if (nextPercentage >= 90 && percentage < 90) {\n      thresholdCrossed = 90;\n    } else if (nextPercentage >= 75 && percentage < 75) {\n      thresholdCrossed = 75;\n    }\n\n    return {\n      allowed: currentUsage < meta.limit,\n      currentUsage,\n      limit: meta.limit,\n      percentage,\n      plan: meta.plan,\n      thresholdCrossed,\n    };\n  } catch (err) {\n    console.error(\"[ApiUsage] Redis error, falling back to DB:\", err);\n    return checkApiUsageFromDb(db, workspaceId);\n  }\n}\n\nasync function checkApiUsageFromDb(\n  db: DbClient,\n  workspaceId: string\n): Promise<UsageCheckResult> {\n  const workspace = await db.organization.findUnique({\n    where: { id: workspaceId },\n    select: {\n      subscriptions: {\n        where: { status: { in: [\"active\", \"trialing\", \"canceled\"] } },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          plan: true,\n          status: true,\n          cancelAtPeriodEnd: true,\n          currentPeriodEnd: true,\n        },\n      },\n    },\n  });\n\n  const subscription = workspace?.subscriptions[0];\n  const plan = getWorkspacePlan(subscription);\n  const limit = PLAN_LIMITS[plan].maxApiRequests;\n\n  const period = await getBillingPeriod(db, workspaceId);\n  const currentUsage = await db.usageEvent.count({\n    where: {\n      workspaceId,\n      type: \"api_request\",\n      createdAt: { gte: period.start, lt: period.end },\n    },\n  });\n\n  const percentage = limit > 0 ? (currentUsage / limit) * 100 : 0;\n\n  let thresholdCrossed: 75 | 90 | 100 | undefined;\n  const nextPercentage = limit > 0 ? ((currentUsage + 1) / limit) * 100 : 0;\n\n  if (nextPercentage >= 100 && percentage < 100) {\n    thresholdCrossed = 100;\n  } else if (nextPercentage >= 90 && percentage < 90) {\n    thresholdCrossed = 90;\n  } else if (nextPercentage >= 75 && percentage < 75) {\n    thresholdCrossed = 75;\n  }\n\n  return {\n    allowed: currentUsage < limit,\n    currentUsage,\n    limit,\n    percentage,\n    plan,\n    thresholdCrossed,\n  };\n}\n\nexport async function notifyApiUsageThreshold(\n  resendApiKey: string,\n  db: DbClient,\n  workspaceId: string,\n  threshold: 75 | 90 | 100,\n  currentUsage: number,\n  limit: number\n): Promise<void> {\n  const owner = await db.member.findFirst({\n    where: { organizationId: workspaceId, role: \"owner\" },\n    select: {\n      user: { select: { email: true, name: true } },\n    },\n  });\n\n  if (!owner?.user) {\n    console.warn(\n      `[ApiUsage] No owner found for workspace ${workspaceId}, skipping notification`\n    );\n    return;\n  }\n\n  try {\n    const resend = new Resend(resendApiKey);\n    await sendUsageLimitEmail(resend, {\n      userEmail: owner.user.email,\n      userName: owner.user.name,\n      featureName: \"API Requests\",\n      usageAmount: currentUsage,\n      limitAmount: limit,\n      workspaceId,\n    });\n    console.log(\n      `[ApiUsage] Sent ${threshold}% threshold email for workspace ${workspaceId}`\n    );\n  } catch (error) {\n    console.error(\"[ApiUsage] Failed to send threshold notification:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/api/src/lib/workspace.ts",
    "content": "import type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\n\n/**\n * Get the workspace ID from either context (API key routes) or URL params (legacy routes)\n * This allows route handlers to work with both authentication methods\n * @returns workspaceId or undefined if not found\n */\nexport const getWorkspaceId = (c: Context): string | undefined => {\n  // Try context first (set by keyAuthorization middleware for API key routes)\n  const contextWorkspaceId = c.get(\"workspaceId\") as string | undefined;\n  if (contextWorkspaceId) {\n    return contextWorkspaceId;\n  }\n\n  // Fall back to URL param (legacy workspace ID routes)\n  return c.req.param(\"workspaceId\");\n};\n\n/**\n * Get the workspace ID or throw if not found.\n * Use this in route handlers to ensure workspaceId exists before database queries.\n * @throws HTTPException 400 if workspaceId is missing\n */\nexport const requireWorkspaceId = (c: Context): string => {\n  const workspaceId = getWorkspaceId(c);\n  if (!workspaceId) {\n    throw new HTTPException(400, {\n      message: \"Workspace ID is required\",\n    });\n  }\n  return workspaceId;\n};\n"
  },
  {
    "path": "apps/api/src/middleware/analytics.ts",
    "content": "import type { Context, MiddlewareHandler } from \"hono\";\nimport { createDbClient, type DbClient } from \"@/lib/db\";\nimport { createPolarClient } from \"@/lib/polar\";\nimport {\n  checkApiUsage,\n  notifyApiUsageThreshold,\n  type UsageCheckResult,\n} from \"@/lib/usage\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\ninterface AnalyticsTaskParams {\n  db: ReturnType<typeof createDbClient>;\n  workspaceId: string;\n  endpoint: string | null;\n  method: string;\n  status: number;\n  usageResult: UsageCheckResult | null;\n  resendApiKey?: string;\n  polarAccessToken?: string;\n  environment?: string;\n  apiKeyType?: string;\n}\n\nexport async function runAnalyticsTask({\n  db,\n  workspaceId,\n  endpoint,\n  method,\n  status,\n  usageResult,\n  resendApiKey,\n  polarAccessToken,\n  environment,\n  apiKeyType,\n}: AnalyticsTaskParams): Promise<void> {\n  try {\n    await db.usageEvent.create({\n      data: {\n        type: \"api_request\",\n        workspaceId,\n        endpoint,\n      },\n    });\n\n    if (resendApiKey && usageResult?.thresholdCrossed) {\n      try {\n        await notifyApiUsageThreshold(\n          resendApiKey,\n          db,\n          workspaceId,\n          usageResult.thresholdCrossed,\n          usageResult.currentUsage + 1,\n          usageResult.limit\n        );\n      } catch (usageError) {\n        console.error(\n          \"[Analytics] Error sending usage threshold email:\",\n          usageError\n        );\n      }\n    }\n\n    let customerId = workspaceId;\n    const organization = await db.organization.findFirst({\n      where: { id: workspaceId },\n      select: {\n        members: {\n          where: { role: \"owner\" },\n          select: { userId: true },\n        },\n      },\n    });\n\n    if (organization?.members[0]?.userId) {\n      customerId = organization.members[0].userId;\n    }\n\n    if (polarAccessToken) {\n      const isProduction = environment === \"production\";\n      const polar = createPolarClient(polarAccessToken, isProduction);\n      try {\n        await polar.events.ingest({\n          events: [\n            {\n              name: \"api_request\",\n              externalCustomerId: customerId,\n              metadata: {\n                ...(endpoint && { endpoint }),\n                method,\n                status,\n                ...(apiKeyType && { apiKeyType }),\n              },\n            },\n          ],\n        });\n      } catch (polarError) {\n        if (polarError instanceof Error) {\n          console.error(\"[Analytics] Polar error:\", polarError.message);\n        }\n      }\n    } else {\n      console.log(\n        \"[Analytics] Skipping Polar: POLAR_ACCESS_TOKEN not configured\"\n      );\n    }\n  } catch (err) {\n    console.error(\"[Analytics] Error in analytics task:\", err);\n  }\n}\n\nasync function checkUsage(\n  c: Context,\n  workspaceId: string\n): Promise<UsageCheckResult | null> {\n  const { REDIS_URL, REDIS_TOKEN } = c.env;\n\n  if (!workspaceId) {\n    return null;\n  }\n\n  try {\n    const db = createDbClient(c.env);\n    const redis =\n      REDIS_URL && REDIS_TOKEN\n        ? { url: REDIS_URL, token: REDIS_TOKEN }\n        : undefined;\n    const result = await checkApiUsage(db, workspaceId, redis);\n\n    if (!result.allowed) {\n      return result;\n    }\n\n    return result;\n  } catch (err) {\n    console.error(\"[Analytics] Error checking usage limits:\", err);\n    return null;\n  }\n}\n\n/**\n * Analytics middleware for API key authenticated routes.\n * Checks usage limits before the request and logs analytics after.\n */\nexport const analytics = (): MiddlewareHandler<ApiKeyApp> => {\n  return async (c, next) => {\n    const method = c.req.method;\n    const workspaceId = c.get(\"workspaceId\");\n\n    let usageResult: UsageCheckResult | null = null;\n\n    if (workspaceId && method !== \"OPTIONS\") {\n      usageResult = await checkUsage(c, workspaceId);\n\n      if (usageResult && !usageResult.allowed) {\n        return c.json(\n          {\n            error: \"Usage limit exceeded\",\n            message:\n              \"You have reached your API request limit for this billing period. Please upgrade your plan or wait until your usage resets.\",\n          },\n          429\n        );\n      }\n    }\n\n    await next();\n\n    const { RESEND_API_KEY, POLAR_ACCESS_TOKEN, ENVIRONMENT } = c.env;\n\n    let db: DbClient;\n    try {\n      db = createDbClient(c.env);\n    } catch {\n      console.error(\"[Analytics] Database configuration error\");\n      return;\n    }\n\n    const apiKeyType = c.get(\"apiKeyType\");\n    const status = c.res.status ?? 200;\n\n    if (!workspaceId || method === \"OPTIONS\" || status >= 400) {\n      return;\n    }\n\n    const path = c.req.path;\n    const pathParts = path.split(\"/\").filter(Boolean);\n    const endpoint = pathParts.length >= 1 ? `/${pathParts.join(\"/\")}` : null;\n\n    c.executionCtx?.waitUntil(\n      runAnalyticsTask({\n        db,\n        workspaceId,\n        endpoint,\n        method,\n        status,\n        usageResult,\n        resendApiKey: RESEND_API_KEY,\n        polarAccessToken: POLAR_ACCESS_TOKEN,\n        environment: ENVIRONMENT,\n        apiKeyType,\n      })\n    );\n  };\n};\n"
  },
  {
    "path": "apps/api/src/middleware/authorization.ts",
    "content": "import type { Context, MiddlewareHandler, Next } from \"hono\";\nimport { createDbClient, type DbClient } from \"@/lib/db\";\n\nexport const authorization =\n  (): MiddlewareHandler => async (c: Context, next: Next) => {\n    let db: DbClient;\n    try {\n      db = createDbClient(c.env);\n    } catch {\n      console.error(\"[Authorization] Database configuration error\");\n      return c.json({ error: \"Internal server error\" }, 500);\n    }\n\n    const workspaceId: string | null = c.req.param(\"workspaceId\") ?? null;\n    if (!workspaceId) {\n      console.error(\"[Authorization] Workspace ID not found\");\n      return c.json({ error: \"Workspace ID is required\" }, 400);\n    }\n\n    try {\n      const workspace = await db.organization.findUnique({\n        where: {\n          id: workspaceId,\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!workspace) {\n        return c.json(\n          {\n            error: \"Invalid workspace\",\n            message: \"The provided workspace key is invalid or does not exist\",\n          },\n          404\n        );\n      }\n\n      await next();\n    } catch (error) {\n      console.error(\"[Authorization] Error validating workspace:\", error);\n      return c.json({ error: \"Failed to validate workspace\" }, 500);\n    }\n  };\n"
  },
  {
    "path": "apps/api/src/middleware/cache.ts",
    "content": "import type { MiddlewareHandler } from \"hono\";\n\n/**\n * Default stale-if-error time in seconds.\n * This tells CDNs/browsers to serve stale content if the origin returns an error.\n */\nconst DEFAULT_STALE_IF_ERROR = 3600; // 1 hour\n\nexport interface CacheOptions {\n  /**\n   * Time in seconds for stale-if-error directive.\n   * When the origin returns an error, CDNs can serve cached content for this duration.\n   * @default 3600 (1 hour)\n   */\n  staleIfError?: number;\n}\n\n/**\n * Cache Control Middleware\n *\n * Automatically adds cache-related headers to successful GET/HEAD responses.\n * Currently adds `stale-if-error` directive to allow CDNs to serve stale content\n * when the origin returns errors.\n *\n * This middleware runs AFTER the route handler (post-processing) to inspect\n * the response status and existing headers before adding cache directives.\n *\n * @example\n * ```ts\n * // Use with default options (1 hour stale-if-error)\n * app.use(\"*\", cache());\n *\n * // Use with custom stale-if-error time\n * app.use(\"*\", cache({ staleIfError: 7200 })); // 2 hours\n * ```\n *\n * @param options - Configuration options for cache behavior\n * @returns Hono middleware handler\n */\nexport const cache = (options: CacheOptions = {}): MiddlewareHandler => {\n  const staleIfError = options.staleIfError ?? DEFAULT_STALE_IF_ERROR;\n\n  return async (c, next) => {\n    await next();\n\n    const method = c.req.method;\n\n    // Only apply cache headers to GET and HEAD requests\n    if (method !== \"GET\" && method !== \"HEAD\") {\n      return;\n    }\n\n    // Only apply to successful responses (2xx and 3xx)\n    const status = c.res.status ?? 200;\n    if (status < 200 || status >= 400) {\n      return;\n    }\n\n    const existingCacheControl = c.res.headers.get(\"Cache-Control\") ?? \"\";\n\n    // Skip if response explicitly opts out of caching\n    if (/\\bno-store\\b/i.test(existingCacheControl)) {\n      return;\n    }\n\n    // Skip if stale-if-error is already set\n    if (/\\bstale-if-error\\s*=\\s*\\d+\\b/i.test(existingCacheControl)) {\n      return;\n    }\n\n    // Append stale-if-error to existing Cache-Control header\n    const newValue = existingCacheControl\n      ? `${existingCacheControl}, stale-if-error=${staleIfError}`\n      : `stale-if-error=${staleIfError}`;\n\n    c.header(\"Cache-Control\", newValue);\n  };\n};\n"
  },
  {
    "path": "apps/api/src/middleware/key-authorization.ts",
    "content": "import type { MiddlewareHandler } from \"hono\";\nimport { hashApiKey } from \"@/lib/crypto\";\nimport { createDbClient, type DbClient } from \"@/lib/db\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\n/**\n * API Key Authorization Middleware\n * Verifies API keys from Authorization header or ?key= query parameter\n * Sets workspaceId and apiKeyId in context for downstream use\n */\nexport const keyAuthorization =\n  (): MiddlewareHandler<ApiKeyApp> => async (c, next) => {\n    let db: DbClient;\n    try {\n      db = createDbClient(c.env);\n    } catch {\n      console.error(\"[KeyAuth] Database configuration error\");\n      return c.json({ error: \"Internal server error\" }, 500);\n    }\n\n    let apiKey: string | null = null;\n\n    const authHeader = c.req.header(\"Authorization\");\n    if (authHeader) {\n      if (authHeader.startsWith(\"Bearer \")) {\n        apiKey = authHeader.substring(7);\n      } else {\n        apiKey = authHeader;\n      }\n    }\n\n    if (!apiKey) {\n      apiKey = c.req.query(\"key\") ?? null;\n    }\n\n    if (!apiKey) {\n      return c.json(\n        {\n          error: \"Unauthorized\",\n          message:\n            \"API key required. Provide via Authorization header or ?key= query parameter\",\n        },\n        401\n      );\n    }\n\n    try {\n      const hashedKey = await hashApiKey(apiKey);\n\n      const key = await db.apiKey.findUnique({\n        where: { key: hashedKey },\n        select: {\n          id: true,\n          workspaceId: true,\n          type: true,\n          scopes: true,\n          enabled: true,\n          expiresAt: true,\n        },\n      });\n\n      if (!key) {\n        return c.json(\n          {\n            error: \"Unauthorized\",\n            message: \"Invalid API key\",\n          },\n          401\n        );\n      }\n\n      if (!key.enabled) {\n        return c.json(\n          {\n            error: \"Unauthorized\",\n            message: \"API key is disabled\",\n          },\n          401\n        );\n      }\n\n      if (key.expiresAt && key.expiresAt < new Date()) {\n        return c.json(\n          {\n            error: \"Unauthorized\",\n            message: \"API key has expired\",\n          },\n          401\n        );\n      }\n\n      c.executionCtx?.waitUntil(\n        db.apiKey.update({\n          where: { id: key.id },\n          data: { lastUsed: new Date(), requestCount: { increment: 1 } },\n        })\n      );\n\n      c.set(\"workspaceId\", key.workspaceId);\n      c.set(\"apiKeyId\", key.id);\n      c.set(\"apiKeyType\", key.type);\n\n      if (c.req.method !== \"GET\" && key.type !== \"private\") {\n        return c.json(\n          {\n            error: \"Forbidden\",\n            message:\n              \"Write operations require a private API key (msk_...). Public keys are read-only.\",\n          },\n          403\n        );\n      }\n\n      await next();\n    } catch (error) {\n      console.error(\"[KeyAuth] Error verifying API key:\", error);\n      return c.json({ error: \"Failed to verify API key\" }, 500);\n    }\n  };\n"
  },
  {
    "path": "apps/api/src/middleware/legacy-analytics.ts",
    "content": "import type { Context, MiddlewareHandler, Next } from \"hono\";\nimport { createDbClient, type DbClient } from \"@/lib/db\";\nimport { checkApiUsage, type UsageCheckResult } from \"@/lib/usage\";\nimport { runAnalyticsTask } from \"./analytics\";\n\n/**\n * Legacy analytics middleware for workspace ID authenticated routes.\n * Same as analytics() but reads workspaceId from URL params instead of context.\n */\nexport const legacyAnalytics = (): MiddlewareHandler => {\n  return async (c: Context, next: Next) => {\n    const method = c.req.method;\n    const workspaceId: string | null = c.req.param(\"workspaceId\") ?? null;\n\n    const { REDIS_URL, REDIS_TOKEN } = c.env;\n\n    let usageResult: UsageCheckResult | null = null;\n\n    if (workspaceId && method !== \"OPTIONS\") {\n      try {\n        const db = createDbClient(c.env);\n        const redis =\n          REDIS_URL && REDIS_TOKEN\n            ? { url: REDIS_URL, token: REDIS_TOKEN }\n            : undefined;\n        usageResult = await checkApiUsage(db, workspaceId, redis);\n\n        if (!usageResult.allowed) {\n          return c.json(\n            {\n              error: \"Usage limit exceeded\",\n              message:\n                \"You have reached your API request limit for this billing period. Please upgrade your plan or wait until your usage resets.\",\n            },\n            429\n          );\n        }\n      } catch (err) {\n        console.error(\"[LegacyAnalytics] Error checking usage limits:\", err);\n      }\n    }\n\n    await next();\n\n    let db: DbClient;\n    try {\n      db = createDbClient(c.env);\n    } catch {\n      console.error(\"[LegacyAnalytics] Database configuration error\");\n      return;\n    }\n\n    const status = c.res.status ?? 200;\n\n    if (!workspaceId || method === \"OPTIONS\" || status >= 400) {\n      return;\n    }\n\n    const path = c.req.path;\n    const pathParts = path.split(\"/\").filter(Boolean);\n    const endpoint =\n      pathParts.length >= 3 ? `/${pathParts.slice(2).join(\"/\")}` : null;\n\n    const { RESEND_API_KEY, POLAR_ACCESS_TOKEN, ENVIRONMENT } = c.env;\n\n    c.executionCtx?.waitUntil(\n      runAnalyticsTask({\n        db,\n        workspaceId,\n        endpoint,\n        method,\n        status,\n        usageResult,\n        resendApiKey: RESEND_API_KEY,\n        polarAccessToken: POLAR_ACCESS_TOKEN,\n        environment: ENVIRONMENT,\n      })\n    );\n  };\n};\n"
  },
  {
    "path": "apps/api/src/middleware/ratelimit.ts",
    "content": "import { Ratelimit } from \"@upstash/ratelimit\";\nimport type { Context, MiddlewareHandler, Next } from \"hono\";\nimport { createRedisClient } from \"@/lib/redis\";\n\nexport interface RateLimit {\n  limit: number;\n  remaining: number;\n  reset: number;\n  success: boolean;\n}\n\nconst cache = new Map();\n\ntype RateLimitMode = \"workspace\" | \"apiKey\";\n\n/**\n * Rate limiting middleware using Upstash Redis\n * @param mode - \"workspace\" for legacy routes (IP + workspaceId), \"apiKey\" for API key routes\n */\nexport const ratelimit =\n  (mode: RateLimitMode = \"workspace\"): MiddlewareHandler =>\n  async (c: Context, next: Next) => {\n    try {\n      const redisClient = createRedisClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n      const clientIp =\n        c.req.header(\"x-forwarded-for\") ||\n        c.req.header(\"cf-connecting-ip\") ||\n        \"anonymous\";\n\n      let identifier: string;\n      let limitConfig: ReturnType<typeof Ratelimit.slidingWindow>;\n\n      if (mode === \"apiKey\") {\n        // For API key routes, we rate limit by IP initially\n        // After keyAuthorization runs, we could enhance this\n        // For now: IP-based with higher limits since API keys are trusted\n        identifier = `apikey:${clientIp}`;\n        limitConfig = Ratelimit.slidingWindow(200, \"10 s\");\n      } else {\n        // Legacy workspace mode: IP + workspaceId\n        const workspaceId: string | null = c.req.param(\"workspaceId\") ?? null;\n        identifier = workspaceId\n          ? `${clientIp}:workspace:${workspaceId}`\n          : clientIp;\n        limitConfig = workspaceId\n          ? Ratelimit.slidingWindow(200, \"10 s\")\n          : Ratelimit.slidingWindow(10, \"10 s\");\n      }\n\n      const rateLimiter = new Ratelimit({\n        redis: redisClient,\n        limiter: limitConfig,\n        ephemeralCache: cache,\n      });\n\n      const result = await rateLimiter.limit(identifier);\n      c.executionCtx.waitUntil(result.pending);\n\n      c.header(\"X-RateLimit-Limit\", String(result.limit));\n      c.header(\"X-RateLimit-Remaining\", String(result.remaining));\n      c.header(\"X-RateLimit-Reset\", String(result.reset));\n\n      if (!result.success) {\n        return c.json({ error: \"Too many requests\" }, 429);\n      }\n\n      await next();\n    } catch (error) {\n      console.error(\"Rate limiting error:\", error);\n      await next();\n    }\n  };\n"
  },
  {
    "path": "apps/api/src/middleware/system.ts",
    "content": "import type { MiddlewareHandler } from \"hono\";\nimport type { Env } from \"@/types/env\";\n\n/**\n * System Secret Authentication Middleware\n * Validates X-System-Secret header for internal cache invalidation requests\n */\nexport const systemAuth =\n  (): MiddlewareHandler<{ Bindings: Env }> => async (c, next) => {\n    const systemSecret = c.env.SYSTEM_SECRET;\n    const providedSecret = c.req.header(\"X-System-Secret\");\n\n    if (!systemSecret) {\n      console.error(\"[SystemAuth] SYSTEM_SECRET not configured\");\n      return c.json({ error: \"Internal server error\" }, 500);\n    }\n\n    if (!providedSecret || providedSecret !== systemSecret) {\n      return c.json(\n        {\n          error: \"Unauthorized\",\n          message: \"Invalid or missing system secret\",\n        },\n        401\n      );\n    }\n\n    await next();\n  };\n"
  },
  {
    "path": "apps/api/src/routes/authors.ts",
    "content": "import { createRoute, OpenAPIHono, z } from \"@hono/zod-openapi\";\nimport { toAuthorPayload, withChanges } from \"@marble/events\";\nimport { cacheKey, createCacheClient, hashQueryParams } from \"@/lib/cache\";\nimport { createDbClient } from \"@/lib/db\";\nimport { emitEvent } from \"@/lib/events\";\nimport { requireWorkspaceId } from \"@/lib/workspace\";\nimport {\n  AuthorResponseSchema,\n  AuthorsListResponseSchema,\n  CreateAuthorBodySchema,\n  CreateAuthorResponseSchema,\n  UpdateAuthorBodySchema,\n} from \"@/schemas/authors\";\nimport {\n  ConflictSchema,\n  DeleteResponseSchema,\n  ErrorSchema,\n  ForbiddenSchema,\n  LimitQuerySchema,\n  NotFoundSchema,\n  PageNotFoundSchema,\n  PageQuerySchema,\n  ServerErrorSchema,\n} from \"@/schemas/common\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\nconst authors = new OpenAPIHono<ApiKeyApp>();\n\nconst AuthorsQuerySchema = z.object({\n  limit: LimitQuerySchema,\n  page: PageQuerySchema,\n});\n\nconst AuthorParamsSchema = z.object({\n  identifier: z.string().openapi({\n    param: { name: \"identifier\", in: \"path\" },\n    example: \"john-doe\",\n    description: \"Author ID or slug\",\n  }),\n});\n\nconst listAuthorsRoute = createRoute({\n  method: \"get\",\n  path: \"/\",\n  tags: [\"Authors\"],\n  summary: \"List authors\",\n  description: \"Get a paginated list of authors who have published posts\",\n  request: {\n    query: AuthorsQuerySchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: AuthorsListResponseSchema } },\n      description: \"Paginated list of authors\",\n    },\n    400: {\n      content: {\n        \"application/json\": {\n          schema: z.union([ErrorSchema, PageNotFoundSchema]),\n        },\n      },\n      description: \"Invalid query parameters or page number\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst getAuthorRoute = createRoute({\n  method: \"get\",\n  path: \"/{identifier}\",\n  tags: [\"Authors\"],\n  summary: \"Get author\",\n  description: \"Get a single author by ID or slug\",\n  request: {\n    params: AuthorParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: AuthorResponseSchema } },\n      description: \"The requested author\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Author not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nauthors.openapi(listAuthorsRoute, async (c) => {\n  const workspaceId = requireWorkspaceId(c);\n  const db = createDbClient(c.env);\n  const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n  const { limit, page } = c.req.valid(\"query\");\n\n  // Generate cache key for count (exclude page - it doesn't affect count)\n  const countCacheKey = cacheKey(\n    workspaceId,\n    \"authors\",\n    \"list\",\n    hashQueryParams({ limit }),\n    \"count\"\n  );\n\n  // Cache count query separately (1 hour TTL, invalidated with posts)\n  const totalAuthors = await cache.getOrSetCount(countCacheKey, () =>\n    db.author.count({\n      where: {\n        workspaceId,\n        coAuthoredPosts: {\n          some: {\n            status: \"published\",\n          },\n        },\n      },\n    })\n  );\n\n  // Generate cache key for data (includes page)\n  const listCacheKey = cacheKey(\n    workspaceId,\n    \"authors\",\n    \"list\",\n    hashQueryParams({ page, limit })\n  );\n\n  const totalPages = Math.ceil(totalAuthors / limit);\n  const prevPage = page > 1 ? page - 1 : null;\n  const nextPage = page < totalPages ? page + 1 : null;\n  const authorsToSkip = limit ? (page - 1) * limit : 0;\n\n  if (page > totalPages && totalAuthors > 0) {\n    return c.json(\n      {\n        error: \"Invalid page number\" as const,\n        details: {\n          message: `Page ${page} does not exist.`,\n          totalPages,\n          requestedPage: page,\n        },\n      },\n      400 as const\n    );\n  }\n\n  try {\n    const authorsList = await cache.getOrSet(listCacheKey, () =>\n      db.author.findMany({\n        where: {\n          workspaceId,\n          coAuthoredPosts: {\n            some: {\n              status: \"published\",\n            },\n          },\n        },\n        select: {\n          id: true,\n          name: true,\n          image: true,\n          slug: true,\n          bio: true,\n          role: true,\n          socials: {\n            select: {\n              url: true,\n              platform: true,\n            },\n          },\n          _count: {\n            select: {\n              coAuthoredPosts: {\n                where: {\n                  status: \"published\",\n                },\n              },\n            },\n          },\n        },\n        orderBy: [{ name: \"asc\" }],\n        take: limit,\n        skip: authorsToSkip,\n      })\n    );\n\n    // because I dont want prisma's ugly _count\n    const transformedAuthors = authorsList.map((author) => {\n      const { _count, ...rest } = author;\n      return {\n        ...rest,\n        count: {\n          posts: _count.coAuthoredPosts,\n        },\n      };\n    });\n\n    return c.json(\n      {\n        authors: transformedAuthors,\n        pagination: {\n          limit,\n          currentPage: page,\n          nextPage,\n          previousPage: prevPage,\n          totalPages,\n          totalItems: totalAuthors,\n        },\n      },\n      200 as const\n    );\n  } catch (_error) {\n    return c.json({ error: \"Failed to fetch authors\" }, 500 as const);\n  }\n});\n\nauthors.openapi(getAuthorRoute, async (c) => {\n  const workspaceId = requireWorkspaceId(c);\n  const { identifier } = c.req.valid(\"param\");\n  const db = createDbClient(c.env);\n  const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n  try {\n    // Cache by identifier (slug or id)\n    const singleCacheKey = cacheKey(workspaceId, \"authors\", identifier);\n\n    const author = await cache.getOrSet(singleCacheKey, () =>\n      db.author.findFirst({\n        where: {\n          workspaceId,\n          OR: [{ id: identifier }, { slug: identifier }],\n        },\n        select: {\n          id: true,\n          name: true,\n          image: true,\n          slug: true,\n          bio: true,\n          role: true,\n          socials: {\n            select: {\n              url: true,\n              platform: true,\n            },\n          },\n        },\n      })\n    );\n\n    if (!author) {\n      return c.json(\n        {\n          error: \"Author not found\",\n          message: \"The requested author does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    return c.json({ author }, 200 as const);\n  } catch (_error) {\n    return c.json({ error: \"Failed to fetch author\" }, 500 as const);\n  }\n});\n\n// ─── POST /v1/authors ───\n\nconst createAuthorRoute = createRoute({\n  method: \"post\",\n  path: \"/\",\n  tags: [\"Authors\"],\n  summary: \"Create author\",\n  description:\n    \"Create a new author. Requires a private API key. Hobby plan is limited to 1 author.\",\n  request: {\n    body: {\n      content: { \"application/json\": { schema: CreateAuthorBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    201: {\n      content: { \"application/json\": { schema: CreateAuthorResponseSchema } },\n      description: \"Author created successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description:\n        \"Public API key used for write operation or plan limit reached\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Author with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nauthors.openapi(createAuthorRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const body = c.req.valid(\"json\");\n\n    // Check plan limits — hobby plan limited to 1 author\n    const workspace = await db.organization.findUnique({\n      where: { id: workspaceId },\n      select: {\n        subscriptions: {\n          where: {\n            OR: [\n              { status: \"active\" },\n              { status: \"trialing\" },\n              {\n                status: \"canceled\",\n                cancelAtPeriodEnd: true,\n                currentPeriodEnd: { gt: new Date() },\n              },\n            ],\n          },\n          orderBy: { createdAt: \"desc\" },\n          take: 1,\n          select: { plan: true },\n        },\n      },\n    });\n\n    const activeSub = workspace?.subscriptions[0];\n    const plan = activeSub?.plan?.toLowerCase() === \"pro\" ? \"pro\" : \"hobby\";\n\n    if (plan === \"hobby\") {\n      const existingCount = await db.author.count({\n        where: { workspaceId, isActive: true },\n      });\n\n      if (existingCount >= 1) {\n        return c.json(\n          {\n            error: \"Author limit reached\",\n            message:\n              \"Hobby plan is limited to 1 author. Upgrade to Pro to create more.\",\n          },\n          403 as const\n        );\n      }\n    }\n\n    // Check slug uniqueness\n    const existingAuthor = await db.author.findFirst({\n      where: { workspaceId, slug: body.slug },\n    });\n\n    if (existingAuthor) {\n      return c.json(\n        {\n          error: \"Slug already in use\",\n          message: \"An author with this slug already exists in this workspace\",\n        },\n        409 as const\n      );\n    }\n\n    const author = await db.author.create({\n      data: {\n        name: body.name,\n        slug: body.slug,\n        bio: body.bio ?? null,\n        role: body.role ?? null,\n        email: body.email ?? null,\n        image: body.image ?? null,\n        workspaceId,\n        ...(body.socials &&\n          body.socials.length > 0 && {\n            socials: {\n              create: body.socials.map((s) => ({\n                url: s.url,\n                platform: s.platform,\n              })),\n            },\n          }),\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        bio: true,\n        role: true,\n        image: true,\n        socials: {\n          select: { url: true, platform: true },\n        },\n      },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"authors\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"author_created\",\n        workspaceId,\n        resourceType: \"author\",\n        resourceId: author.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toAuthorPayload(author),\n      }).catch((error) => {\n        console.error(\"[authors.create] Failed to emit author_created:\", error);\n      })\n    );\n\n    return c.json({ author }, 201 as const);\n  } catch (error) {\n    console.error(\"Error creating author:\", error);\n    return c.json(\n      {\n        error: \"Failed to create author\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\n// ─── PATCH /v1/authors/{identifier} ───\n\nconst updateAuthorRoute = createRoute({\n  method: \"patch\",\n  path: \"/{identifier}\",\n  tags: [\"Authors\"],\n  summary: \"Update author\",\n  description:\n    \"Update an existing author by ID or slug. Requires a private API key.\",\n  request: {\n    params: AuthorParamsSchema,\n    body: {\n      content: { \"application/json\": { schema: UpdateAuthorBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: CreateAuthorResponseSchema } },\n      description: \"Author updated successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Author not found\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Author with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nauthors.openapi(updateAuthorRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n    const body = c.req.valid(\"json\");\n\n    const existingAuthor = await db.author.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ id: identifier }, { slug: identifier }],\n      },\n    });\n\n    if (!existingAuthor) {\n      return c.json(\n        {\n          error: \"Author not found\",\n          message: \"The requested author does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // If slug is being changed, check uniqueness\n    if (body.slug && body.slug !== existingAuthor.slug) {\n      const slugConflict = await db.author.findFirst({\n        where: {\n          slug: body.slug,\n          workspaceId,\n          id: { not: existingAuthor.id },\n        },\n      });\n\n      if (slugConflict) {\n        return c.json(\n          {\n            error: \"Slug already in use\",\n            message:\n              \"An author with this slug already exists in this workspace\",\n          },\n          409 as const\n        );\n      }\n    }\n\n    const updatedAuthor = await db.author.update({\n      where: { id: existingAuthor.id },\n      data: {\n        ...(body.name !== undefined && { name: body.name }),\n        ...(body.slug !== undefined && { slug: body.slug }),\n        ...(body.bio !== undefined && { bio: body.bio }),\n        ...(body.role !== undefined && { role: body.role }),\n        ...(body.email !== undefined && { email: body.email || null }),\n        ...(body.image !== undefined && { image: body.image }),\n        // Socials: delete all existing and recreate (same pattern as CMS)\n        ...(body.socials !== undefined && {\n          socials: {\n            deleteMany: {},\n            ...(body.socials.length > 0 && {\n              create: body.socials.map((s) => ({\n                url: s.url,\n                platform: s.platform,\n              })),\n            }),\n          },\n        }),\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        bio: true,\n        role: true,\n        image: true,\n        socials: {\n          select: { url: true, platform: true },\n        },\n      },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"authors\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"author_updated\",\n        workspaceId,\n        resourceType: \"author\",\n        resourceId: updatedAuthor.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: withChanges(toAuthorPayload(updatedAuthor), Object.keys(body)),\n      }).catch((error) => {\n        console.error(\"[authors.update] Failed to emit author_updated:\", error);\n      })\n    );\n\n    return c.json({ author: updatedAuthor }, 200 as const);\n  } catch (error) {\n    console.error(\"Error updating author:\", error);\n    return c.json(\n      {\n        error: \"Failed to update author\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\n// ─── DELETE /v1/authors/{identifier} ───\n\nconst deleteAuthorRoute = createRoute({\n  method: \"delete\",\n  path: \"/{identifier}\",\n  tags: [\"Authors\"],\n  summary: \"Delete author\",\n  description: \"Delete an author by ID or slug. Requires a private API key.\",\n  request: {\n    params: AuthorParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: DeleteResponseSchema } },\n      description: \"Author deleted successfully\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Author not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nauthors.openapi(deleteAuthorRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n\n    const existingAuthor = await db.author.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ id: identifier }, { slug: identifier }],\n      },\n      include: {\n        socials: {\n          select: { url: true, platform: true },\n        },\n      },\n    });\n\n    if (!existingAuthor) {\n      return c.json(\n        {\n          error: \"Author not found\",\n          message: \"The requested author does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    await db.author.delete({\n      where: { id: existingAuthor.id },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"authors\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"author_deleted\",\n        workspaceId,\n        resourceType: \"author\",\n        resourceId: existingAuthor.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toAuthorPayload(existingAuthor),\n      }).catch((error) => {\n        console.error(\"[authors.delete] Failed to emit author_deleted:\", error);\n      })\n    );\n\n    return c.json({ id: existingAuthor.id }, 200 as const);\n  } catch (error) {\n    console.error(\"Error deleting author:\", error);\n    return c.json(\n      {\n        error: \"Failed to delete author\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nexport default authors;\n"
  },
  {
    "path": "apps/api/src/routes/cache.ts",
    "content": "import { Hono } from \"hono\";\nimport { createCacheClient } from \"@/lib/cache\";\nimport type { Env } from \"@/types/env\";\nimport { SystemCacheInvalidateSchema } from \"@/validations/misc\";\n\nconst cacheInvalidate = new Hono<{ Bindings: Env }>();\n\ncacheInvalidate.post(\"/\", async (c) => {\n  const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n  try {\n    const rawBody = await c.req.json();\n    const validation = SystemCacheInvalidateSchema.safeParse(rawBody);\n\n    if (!validation.success) {\n      return c.json(\n        {\n          error: \"Invalid request body\",\n          details: validation.error.issues.map((err) => ({\n            field: err.path.join(\".\"),\n            message: err.message,\n          })),\n        },\n        400\n      );\n    }\n\n    const { workspaceId, resource } = validation.data;\n\n    let invalidatedCount: number;\n\n    if (resource === \"usage\") {\n      const redis = new (await import(\"@upstash/redis/cloudflare\")).Redis({\n        url: c.env.REDIS_URL,\n        token: c.env.REDIS_TOKEN,\n      });\n      const deleted = await redis.del(`usage:meta:${workspaceId}`);\n      return c.json({\n        success: true,\n        message: `Invalidated usage cache${deleted ? \"\" : \" (was not cached)\"}`,\n        workspaceId,\n        resource,\n      });\n    }\n\n    if (resource) {\n      invalidatedCount = await cache.invalidateResource(workspaceId, resource);\n      return c.json({\n        success: true,\n        message: `Invalidated ${invalidatedCount} cache entries for ${resource}`,\n        workspaceId,\n        resource,\n      });\n    }\n\n    invalidatedCount = await cache.invalidateWorkspace(workspaceId);\n    return c.json({\n      success: true,\n      message: `Invalidated ${invalidatedCount} cache entries for workspace`,\n      workspaceId,\n    });\n  } catch (error) {\n    console.error(\"[Cache] Invalidation error:\", error);\n    return c.json(\n      {\n        error: \"Failed to invalidate cache\",\n        message: error instanceof Error ? error.message : \"Unknown error\",\n      },\n      500\n    );\n  }\n});\n\nexport default cacheInvalidate;\n"
  },
  {
    "path": "apps/api/src/routes/categories.ts",
    "content": "import { createRoute, OpenAPIHono, z } from \"@hono/zod-openapi\";\nimport { toCategoryPayload, withChanges } from \"@marble/events\";\nimport { cacheKey, createCacheClient, hashQueryParams } from \"@/lib/cache\";\nimport { createDbClient } from \"@/lib/db\";\nimport { emitEvent } from \"@/lib/events\";\nimport { requireWorkspaceId } from \"@/lib/workspace\";\nimport {\n  CategoriesListResponseSchema,\n  CategoryResponseSchema,\n  CreateCategoryBodySchema,\n  CreateCategoryResponseSchema,\n  UpdateCategoryBodySchema,\n} from \"@/schemas/categories\";\nimport {\n  ConflictSchema,\n  DeleteResponseSchema,\n  ErrorSchema,\n  ForbiddenSchema,\n  LimitQuerySchema,\n  NotFoundSchema,\n  PageNotFoundSchema,\n  PageQuerySchema,\n  ServerErrorSchema,\n} from \"@/schemas/common\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\nconst categories = new OpenAPIHono<ApiKeyApp>();\n\nconst CategoriesQuerySchema = z.object({\n  limit: LimitQuerySchema,\n  page: PageQuerySchema,\n});\n\nconst CategoryParamsSchema = z.object({\n  identifier: z.string().openapi({\n    param: { name: \"identifier\", in: \"path\" },\n    example: \"technology\",\n    description: \"Category ID or slug\",\n  }),\n});\n\nconst listCategoriesRoute = createRoute({\n  method: \"get\",\n  path: \"/\",\n  tags: [\"Categories\"],\n  summary: \"List categories\",\n  description: \"Get a paginated list of categories\",\n  request: {\n    query: CategoriesQuerySchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: CategoriesListResponseSchema } },\n      description: \"Paginated list of categories\",\n    },\n    400: {\n      content: {\n        \"application/json\": {\n          schema: z.union([ErrorSchema, PageNotFoundSchema]),\n        },\n      },\n      description: \"Invalid query parameters or page number\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst getCategoryRoute = createRoute({\n  method: \"get\",\n  path: \"/{identifier}\",\n  tags: [\"Categories\"],\n  summary: \"Get category\",\n  description: \"Get a single category by ID or slug\",\n  request: {\n    params: CategoryParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: CategoryResponseSchema } },\n      description: \"The requested category\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Category not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst createCategoryRoute = createRoute({\n  method: \"post\",\n  path: \"/\",\n  tags: [\"Categories\"],\n  summary: \"Create category\",\n  description: \"Create a new category. Requires a private API key.\",\n  request: {\n    body: {\n      content: { \"application/json\": { schema: CreateCategoryBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    201: {\n      content: {\n        \"application/json\": { schema: CreateCategoryResponseSchema },\n      },\n      description: \"Category created successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Category with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\ncategories.openapi(listCategoriesRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n    const { limit, page } = c.req.valid(\"query\");\n\n    // Generate cache key for count (exclude page - it doesn't affect count)\n    const countCacheKey = cacheKey(\n      workspaceId,\n      \"categories\",\n      \"list\",\n      hashQueryParams({ limit }),\n      \"count\"\n    );\n\n    // Cache count query separately (1 hour TTL, invalidated with posts)\n    const totalCategories = await cache.getOrSetCount(countCacheKey, () =>\n      db.category.count({\n        where: { workspaceId },\n      })\n    );\n\n    // Generate cache key for data (includes page)\n    const listCacheKey = cacheKey(\n      workspaceId,\n      \"categories\",\n      \"list\",\n      hashQueryParams({ page, limit })\n    );\n\n    const totalPages = Math.ceil(totalCategories / limit);\n    const prevPage = page > 1 ? page - 1 : null;\n    const nextPage = page < totalPages ? page + 1 : null;\n    const categoriesToSkip = limit ? (page - 1) * limit : 0;\n\n    // Validate page number\n    if (page > totalPages && totalCategories > 0) {\n      return c.json(\n        {\n          error: \"Invalid page number\" as const,\n          details: {\n            message: `Page ${page} does not exist.`,\n            totalPages,\n            requestedPage: page,\n          },\n        },\n        400 as const\n      );\n    }\n\n    const categoriesList = await cache.getOrSet(listCacheKey, () =>\n      db.category.findMany({\n        where: {\n          workspaceId,\n        },\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          description: true,\n          _count: {\n            select: {\n              posts: {\n                where: {\n                  status: \"published\",\n                },\n              },\n            },\n          },\n        },\n        take: limit,\n        skip: categoriesToSkip,\n      })\n    );\n\n    const transformedCategories = categoriesList.map((category) => {\n      const { _count, ...rest } = category;\n      return {\n        ...rest,\n        count: _count,\n      };\n    });\n\n    return c.json(\n      {\n        categories: transformedCategories,\n        pagination: {\n          limit,\n          currentPage: page,\n          nextPage,\n          previousPage: prevPage,\n          totalPages,\n          totalItems: totalCategories,\n        },\n      },\n      200 as const\n    );\n  } catch (error) {\n    console.error(\"Error fetching categories:\", error);\n    return c.json({ error: \"Failed to fetch categories\" }, 500 as const);\n  }\n});\n\ncategories.openapi(getCategoryRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const { identifier } = c.req.valid(\"param\");\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n    // Cache by identifier (slug or id)\n    const singleCacheKey = cacheKey(workspaceId, \"categories\", identifier);\n\n    const category = await cache.getOrSet(singleCacheKey, () =>\n      db.category.findFirst({\n        where: {\n          workspaceId,\n          OR: [{ id: identifier }, { slug: identifier }],\n        },\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          description: true,\n          _count: {\n            select: {\n              posts: {\n                where: {\n                  status: \"published\",\n                },\n              },\n            },\n          },\n        },\n      })\n    );\n\n    if (!category) {\n      return c.json(\n        {\n          error: \"Category not found\",\n          message: \"The requested category does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // Transform _count to count\n    const { _count, ...rest } = category;\n    const transformedCategory = {\n      ...rest,\n      count: _count,\n    };\n\n    return c.json({ category: transformedCategory }, 200 as const);\n  } catch (error) {\n    console.error(\"Error fetching category:\", error);\n    return c.json({ error: \"Failed to fetch category\" }, 500 as const);\n  }\n});\n\ncategories.openapi(createCategoryRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const body = c.req.valid(\"json\");\n\n    // Check for slug uniqueness within workspace\n    const existingCategory = await db.category.findFirst({\n      where: {\n        slug: body.slug,\n        workspaceId,\n      },\n    });\n\n    if (existingCategory) {\n      return c.json(\n        {\n          error: \"Slug already in use\",\n          message: \"A category with this slug already exists in this workspace\",\n        },\n        409 as const\n      );\n    }\n\n    const categoryCreated = await db.category.create({\n      data: {\n        name: body.name,\n        slug: body.slug,\n        description: body.description ?? null,\n        workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        description: true,\n      },\n    });\n\n    // Invalidate cache for categories and posts\n    c.executionCtx.waitUntil(\n      cache.invalidateResource(workspaceId, \"categories\")\n    );\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"category_created\",\n        workspaceId,\n        resourceType: \"category\",\n        resourceId: categoryCreated.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toCategoryPayload(categoryCreated),\n      }).catch((error) => {\n        console.error(\n          \"[categories.create] Failed to emit category_created:\",\n          error\n        );\n      })\n    );\n\n    return c.json({ category: categoryCreated }, 201 as const);\n  } catch (error) {\n    console.error(\"Error creating category:\", error);\n    return c.json(\n      {\n        error: \"Failed to create category\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nconst updateCategoryRoute = createRoute({\n  method: \"patch\",\n  path: \"/{identifier}\",\n  tags: [\"Categories\"],\n  summary: \"Update category\",\n  description:\n    \"Update an existing category by ID or slug. Requires a private API key.\",\n  request: {\n    params: CategoryParamsSchema,\n    body: {\n      content: { \"application/json\": { schema: UpdateCategoryBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    200: {\n      content: {\n        \"application/json\": { schema: CreateCategoryResponseSchema },\n      },\n      description: \"Category updated successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Category not found\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Category with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst deleteCategoryRoute = createRoute({\n  method: \"delete\",\n  path: \"/{identifier}\",\n  tags: [\"Categories\"],\n  summary: \"Delete category\",\n  description:\n    \"Delete a category by ID or slug. Requires a private API key. Cannot delete a category that has posts assigned to it.\",\n  request: {\n    params: CategoryParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: DeleteResponseSchema } },\n      description: \"Category deleted successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Category has posts assigned to it\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Category not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\ncategories.openapi(updateCategoryRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n    const body = c.req.valid(\"json\");\n\n    const existingCategory = await db.category.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ id: identifier }, { slug: identifier }],\n      },\n    });\n\n    if (!existingCategory) {\n      return c.json(\n        {\n          error: \"Category not found\",\n          message: \"The requested category does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // If slug is being changed, check uniqueness\n    if (body.slug && body.slug !== existingCategory.slug) {\n      const slugConflict = await db.category.findFirst({\n        where: {\n          slug: body.slug,\n          workspaceId,\n          id: { not: existingCategory.id },\n        },\n      });\n\n      if (slugConflict) {\n        return c.json(\n          {\n            error: \"Slug already in use\",\n            message:\n              \"A category with this slug already exists in this workspace\",\n          },\n          409 as const\n        );\n      }\n    }\n\n    const categoryUpdated = await db.category.update({\n      where: { id: existingCategory.id },\n      data: {\n        ...(body.name !== undefined && { name: body.name }),\n        ...(body.slug !== undefined && { slug: body.slug }),\n        ...(body.description !== undefined && {\n          description: body.description,\n        }),\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        description: true,\n      },\n    });\n\n    c.executionCtx.waitUntil(\n      cache.invalidateResource(workspaceId, \"categories\")\n    );\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"category_updated\",\n        workspaceId,\n        resourceType: \"category\",\n        resourceId: categoryUpdated.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: withChanges(\n          toCategoryPayload(categoryUpdated),\n          Object.keys(body)\n        ),\n      }).catch((error) => {\n        console.error(\n          \"[categories.update] Failed to emit category_updated:\",\n          error\n        );\n      })\n    );\n\n    return c.json({ category: categoryUpdated }, 200 as const);\n  } catch (error) {\n    console.error(\"Error updating category:\", error);\n    return c.json(\n      {\n        error: \"Failed to update category\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\ncategories.openapi(deleteCategoryRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n\n    const existingCategory = await db.category.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ id: identifier }, { slug: identifier }],\n      },\n      include: {\n        _count: { select: { posts: true } },\n      },\n    });\n\n    if (!existingCategory) {\n      return c.json(\n        {\n          error: \"Category not found\",\n          message: \"The requested category does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // Prevent deleting a category that has posts\n    if (existingCategory._count.posts > 0) {\n      return c.json(\n        {\n          error: \"Category has posts\",\n          message: `This category has ${existingCategory._count.posts} post(s) assigned to it. Reassign or delete them before deleting this category.`,\n        },\n        400 as const\n      );\n    }\n\n    await db.category.delete({\n      where: { id: existingCategory.id },\n    });\n\n    c.executionCtx.waitUntil(\n      cache.invalidateResource(workspaceId, \"categories\")\n    );\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"category_deleted\",\n        workspaceId,\n        resourceType: \"category\",\n        resourceId: existingCategory.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toCategoryPayload(existingCategory),\n      }).catch((error) => {\n        console.error(\n          \"[categories.delete] Failed to emit category_deleted:\",\n          error\n        );\n      })\n    );\n\n    return c.json({ id: existingCategory.id }, 200 as const);\n  } catch (error) {\n    console.error(\"Error deleting category:\", error);\n    return c.json(\n      {\n        error: \"Failed to delete category\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nexport default categories;\n"
  },
  {
    "path": "apps/api/src/routes/events.ts",
    "content": "import { Hono } from \"hono\";\nimport { createDbClient } from \"@/lib/db\";\nimport type { Env } from \"@/types/env\";\nimport { InternalEventSchema } from \"@/validations/misc\";\n\nconst events = new Hono<{ Bindings: Env }>();\n\nevents.post(\"/\", async (c) => {\n  let rawBody: unknown;\n\n  try {\n    rawBody = await c.req.json();\n  } catch {\n    return c.json({ error: \"Invalid JSON body\" }, 400);\n  }\n\n  const validation = InternalEventSchema.safeParse(rawBody);\n\n  if (!validation.success) {\n    return c.json(\n      {\n        error: \"Invalid event payload\",\n        details: validation.error.issues.map((err) => ({\n          field: err.path.join(\".\"),\n          message: err.message,\n        })),\n      },\n      400\n    );\n  }\n\n  const body = validation.data;\n\n  const db = createDbClient(c.env);\n\n  const workspace = await db.organization.findUnique({\n    where: { id: body.workspaceId },\n    select: { id: true },\n  });\n\n  if (!workspace) {\n    return c.json({ error: \"Workspace not found\" }, 404);\n  }\n\n  try {\n    const event = await db.workspaceEvent.create({\n      data: {\n        type: body.type,\n        workspaceId: body.workspaceId,\n        source: body.source,\n        resourceType: body.resourceType,\n        resourceId: body.resourceId,\n        actorType: body.actorType,\n        actorId: body.actorId,\n        payload: body.payload ?? {},\n      },\n    });\n\n    await c.env.EVENT_QUEUE.send({\n      eventId: event.id,\n      targetWebhookEndpointId: body.targetWebhookEndpointId,\n      isTest: body.isTest,\n    });\n\n    return c.json({ ok: true, eventId: event.id });\n  } catch (error) {\n    console.error(\"[InternalEvents] Failed to create event:\", error);\n    return c.json(\n      {\n        error: \"Failed to create event\",\n        message: error instanceof Error ? error.message : \"Unknown error\",\n      },\n      500\n    );\n  }\n});\n\nexport default events;\n"
  },
  {
    "path": "apps/api/src/routes/invalidate.ts",
    "content": "import { Redis } from \"@upstash/redis/cloudflare\";\nimport { Hono } from \"hono\";\nimport { createCacheClient } from \"@/lib/cache\";\nimport type { ApiKeyApp } from \"@/types/env\";\nimport { CacheInvalidateSchema } from \"@/validations/misc\";\n\nconst invalidate = new Hono<ApiKeyApp>();\n\n/**\n * Cache invalidation endpoint\n * Allows CMS or admin to invalidate cached data when content changes\n *\n * POST /v1/cache/invalidate\n * - Invalidates all cache for workspace if no resource specified\n * - Invalidates specific resource cache if resource is provided\n *\n * Requires API key authentication (private key recommended)\n */\ninvalidate.post(\"/\", async (c) => {\n  const workspaceId = c.get(\"workspaceId\");\n\n  if (!workspaceId) {\n    return c.json({ error: \"Workspace ID is required\" }, 400);\n  }\n\n  const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n  try {\n    const rawBody = await c.req.json();\n    const validation = CacheInvalidateSchema.safeParse(rawBody);\n\n    if (!validation.success) {\n      return c.json(\n        {\n          error: \"Invalid request body\",\n          details: validation.error.issues.map((err) => ({\n            field: err.path.join(\".\"),\n            message: err.message,\n          })),\n        },\n        400\n      );\n    }\n\n    const { resource } = validation.data;\n\n    let invalidatedCount: number;\n\n    if (resource === \"usage\") {\n      const redis = new Redis({\n        url: c.env.REDIS_URL,\n        token: c.env.REDIS_TOKEN,\n      });\n      const deleted = await redis.del(`usage:meta:${workspaceId}`);\n      return c.json({\n        success: true,\n        message: `Invalidated usage cache${deleted ? \"\" : \" (was not cached)\"}`,\n        workspaceId,\n        resource,\n      });\n    }\n\n    if (resource) {\n      // Invalidate specific resource\n      invalidatedCount = await cache.invalidateResource(workspaceId, resource);\n      return c.json({\n        success: true,\n        message: `Invalidated ${invalidatedCount} cache entries for ${resource}`,\n        workspaceId,\n        resource,\n      });\n    }\n\n    // Invalidate all workspace cache\n    invalidatedCount = await cache.invalidateWorkspace(workspaceId);\n    return c.json({\n      success: true,\n      message: `Invalidated ${invalidatedCount} cache entries for workspace`,\n      workspaceId,\n    });\n  } catch (error) {\n    console.error(\"[Cache] Invalidation error:\", error);\n    return c.json(\n      {\n        error: \"Failed to invalidate cache\",\n        message: error instanceof Error ? error.message : \"Unknown error\",\n      },\n      500\n    );\n  }\n});\n\nexport default invalidate;\n"
  },
  {
    "path": "apps/api/src/routes/media.ts",
    "content": "import { createRoute, OpenAPIHono } from \"@hono/zod-openapi\";\nimport { toMediaPayload, withChanges } from \"@marble/events\";\nimport { cacheKey, createCacheClient, hashQueryParams } from \"@/lib/cache\";\nimport { ALLOWED_MEDIA_MIME_TYPES, MAX_UPLOAD_SIZE } from \"@/lib/constants\";\nimport { createDbClient } from \"@/lib/db\";\nimport { emitEvent } from \"@/lib/events\";\nimport {\n  extensionFromFile,\n  getImageDimensions,\n  getMediaType,\n  objectKeyFromUrl,\n  publicUrl,\n  serializeMedia,\n} from \"@/lib/media\";\nimport { requireWorkspaceId } from \"@/lib/workspace\";\nimport {\n  DeleteResponseSchema,\n  ErrorSchema,\n  ForbiddenSchema,\n  NotFoundSchema,\n  PageNotFoundSchema,\n  ServerErrorSchema,\n} from \"@/schemas/common\";\nimport {\n  MediaListResponseSchema,\n  MediaParamsSchema,\n  MediaQuerySchema,\n  MediaResponseSchema,\n  UpdateMediaBodySchema,\n  UploadMediaBodySchema,\n} from \"@/schemas/media\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\nconst media = new OpenAPIHono<ApiKeyApp>();\n\nfunction isUploadedFile(value: unknown): value is File {\n  return value !== null && typeof value !== \"string\";\n}\n\nconst listMediaRoute = createRoute({\n  method: \"get\",\n  path: \"/\",\n  tags: [\"Media\"],\n  summary: \"List media assets\",\n  description: \"Retrieve media assets for the authenticated workspace.\",\n  request: { query: MediaQuerySchema },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: MediaListResponseSchema } },\n      description: \"Media assets retrieved successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid query parameters\",\n    },\n    404: {\n      content: { \"application/json\": { schema: PageNotFoundSchema } },\n      description: \"Page number does not exist\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst getMediaRoute = createRoute({\n  method: \"get\",\n  path: \"/{id}\",\n  tags: [\"Media\"],\n  summary: \"Get media asset\",\n  description: \"Retrieve a single media asset by ID.\",\n  request: { params: MediaParamsSchema },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: MediaResponseSchema } },\n      description: \"Media asset retrieved successfully\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Media asset not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst updateMediaRoute = createRoute({\n  method: \"patch\",\n  path: \"/{id}\",\n  tags: [\"Media\"],\n  summary: \"Update media asset\",\n  description: \"Update media asset metadata. Requires a private API key.\",\n  request: {\n    params: MediaParamsSchema,\n    body: {\n      content: { \"application/json\": { schema: UpdateMediaBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: MediaResponseSchema } },\n      description: \"Media asset updated successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Media asset not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst deleteMediaRoute = createRoute({\n  method: \"delete\",\n  path: \"/{id}\",\n  tags: [\"Media\"],\n  summary: \"Delete media asset\",\n  description:\n    \"Delete a media asset and its R2 object. Requires a private API key.\",\n  request: { params: MediaParamsSchema },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: DeleteResponseSchema } },\n      description: \"Media asset deleted successfully\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Media asset not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst uploadMediaRoute = createRoute({\n  method: \"post\",\n  path: \"/upload\",\n  tags: [\"Media\"],\n  summary: \"Upload media asset\",\n  description:\n    \"Upload a media file and create a media asset. Requires a private API key. Maximum file size is 5 MiB.\",\n  request: {\n    body: {\n      content: { \"multipart/form-data\": { schema: UploadMediaBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    201: {\n      content: { \"application/json\": { schema: MediaResponseSchema } },\n      description: \"Media asset uploaded successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid upload request\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    413: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"File exceeds the upload size limit\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nmedia.openapi(listMediaRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const query = c.req.valid(\"query\");\n    const { limit, page, order, type } = query;\n    const skip = (page - 1) * limit;\n\n    const where = {\n      workspaceId,\n      ...(type ? { type } : {}),\n      ...(query.query\n        ? {\n            OR: [\n              { name: { contains: query.query, mode: \"insensitive\" as const } },\n              { alt: { contains: query.query, mode: \"insensitive\" as const } },\n              { url: { contains: query.query, mode: \"insensitive\" as const } },\n              {\n                mimeType: {\n                  contains: query.query,\n                  mode: \"insensitive\" as const,\n                },\n              },\n            ],\n          }\n        : {}),\n    };\n\n    const key = cacheKey(workspaceId, \"media\", \"list\", hashQueryParams(query));\n\n    const response = await cache.getOrSet(key, async () => {\n      const [items, totalItems] = await Promise.all([\n        db.media.findMany({\n          where,\n          orderBy: { createdAt: order },\n          skip,\n          take: limit,\n        }),\n        db.media.count({ where }),\n      ]);\n\n      const totalPages = Math.ceil(totalItems / limit);\n      return {\n        media: items.map(serializeMedia),\n        pagination: {\n          limit,\n          currentPage: page,\n          nextPage: page < totalPages ? page + 1 : null,\n          previousPage: page > 1 ? page - 1 : null,\n          totalPages,\n          totalItems,\n        },\n      };\n    });\n\n    if (\n      page > response.pagination.totalPages &&\n      response.pagination.totalItems > 0\n    ) {\n      return c.json(\n        {\n          error: \"Invalid page number\" as const,\n          details: {\n            message: `Page ${page} does not exist.`,\n            totalPages: response.pagination.totalPages,\n            requestedPage: page,\n          },\n        },\n        404 as const\n      );\n    }\n\n    return c.json(response, 200 as const);\n  } catch (error) {\n    console.error(\"Error fetching media:\", error);\n    return c.json(\n      {\n        error: \"Failed to fetch media\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nmedia.openapi(getMediaRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const { id } = c.req.valid(\"param\");\n\n    const item = await db.media.findFirst({ where: { id, workspaceId } });\n    if (!item) {\n      return c.json(\n        {\n          error: \"Media not found\",\n          message: \"The requested media asset does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    return c.json({ media: serializeMedia(item) }, 200 as const);\n  } catch (error) {\n    console.error(\"Error fetching media asset:\", error);\n    return c.json(\n      {\n        error: \"Failed to fetch media\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nmedia.openapi(updateMediaRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { id } = c.req.valid(\"param\");\n    const body = c.req.valid(\"json\");\n\n    const existing = await db.media.findFirst({ where: { id, workspaceId } });\n    if (!existing) {\n      return c.json(\n        {\n          error: \"Media not found\",\n          message: \"The requested media asset does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    const updated = await db.media.update({\n      where: { id },\n      data: {\n        ...(body.name !== undefined ? { name: body.name } : {}),\n        ...(body.alt !== undefined ? { alt: body.alt } : {}),\n      },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"media\"));\n\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"media_updated\",\n        workspaceId,\n        resourceType: \"media\",\n        resourceId: updated.id,\n        actorType: \"api_key\",\n        actorId: c.get(\"apiKeyId\"),\n        payload: withChanges(toMediaPayload(updated), Object.keys(body)),\n      }).catch((error) => {\n        console.error(\"[media.update] Failed to emit media_updated:\", error);\n      })\n    );\n\n    return c.json({ media: serializeMedia(updated) }, 200 as const);\n  } catch (error) {\n    console.error(\"Error updating media asset:\", error);\n    return c.json(\n      {\n        error: \"Failed to update media\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nmedia.openapi(deleteMediaRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { id } = c.req.valid(\"param\");\n\n    const existing = await db.media.findFirst({ where: { id, workspaceId } });\n    if (!existing) {\n      return c.json(\n        {\n          error: \"Media not found\",\n          message: \"The requested media asset does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    await db.media.delete({ where: { id } });\n\n    const key = objectKeyFromUrl(existing.url);\n    if (key) {\n      c.executionCtx.waitUntil(c.env.STORAGE.delete(key));\n    }\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"media\"));\n\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"media_deleted\",\n        workspaceId,\n        resourceType: \"media\",\n        resourceId: id,\n        actorType: \"api_key\",\n        actorId: c.get(\"apiKeyId\"),\n        payload: toMediaPayload(existing),\n      }).catch((error) => {\n        console.error(\"[media.delete] Failed to emit media_deleted:\", error);\n      })\n    );\n\n    return c.json({ id }, 200 as const);\n  } catch (error) {\n    console.error(\"Error deleting media asset:\", error);\n    return c.json(\n      {\n        error: \"Failed to delete media\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nmedia.openapi(uploadMediaRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const formData = await c.req.formData();\n    const file = formData.get(\"file\");\n\n    if (!isUploadedFile(file)) {\n      return c.json(\n        {\n          error: \"Invalid upload request\",\n          message: \"A file field is required\",\n        },\n        400 as const\n      );\n    }\n    if (file.size > MAX_UPLOAD_SIZE) {\n      return c.json(\n        {\n          error: \"File too large\",\n          message: `Media uploads are limited to ${MAX_UPLOAD_SIZE / 1024 / 1024} MiB`,\n        },\n        413 as const\n      );\n    }\n\n    const contentType = file.type || \"application/octet-stream\";\n    if (\n      !(ALLOWED_MEDIA_MIME_TYPES as readonly string[]).includes(contentType)\n    ) {\n      return c.json(\n        {\n          error: \"Unsupported file type\",\n          message: `File type ${contentType} is not allowed`,\n        },\n        400 as const\n      );\n    }\n\n    const fileBuffer = await file.arrayBuffer();\n    const dimensions = contentType.startsWith(\"image/\")\n      ? getImageDimensions(fileBuffer)\n      : {};\n    const extension = extensionFromFile(file);\n    const id = crypto.randomUUID();\n    const key = `media/${workspaceId}/${id}.${extension}`;\n    const nameField = formData.get(\"name\");\n    const altField = formData.get(\"alt\");\n    const name =\n      typeof nameField === \"string\" && nameField.trim()\n        ? nameField.trim()\n        : file.name || `media-${id}`;\n    const alt =\n      typeof altField === \"string\" && altField.trim() ? altField.trim() : null;\n\n    await c.env.STORAGE.put(key, fileBuffer, {\n      httpMetadata: { contentType },\n      customMetadata: {\n        workspaceId,\n        originalFilename: file.name,\n      },\n    });\n\n    let created: Awaited<ReturnType<typeof db.media.create>>;\n    try {\n      created = await db.media.create({\n        data: {\n          name,\n          alt,\n          url: publicUrl(c.env.STORAGE_PUBLIC_URL, key),\n          size: file.size,\n          mimeType: contentType,\n          width: dimensions.width,\n          height: dimensions.height,\n          type: getMediaType(contentType),\n          workspaceId,\n        },\n      });\n    } catch (error) {\n      await c.env.STORAGE.delete(key);\n      throw error;\n    }\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"media\"));\n\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"media_uploaded\",\n        workspaceId,\n        resourceType: \"media\",\n        resourceId: created.id,\n        actorType: \"api_key\",\n        actorId: c.get(\"apiKeyId\"),\n        payload: toMediaPayload(created),\n      }).catch((error) => {\n        console.error(\"[media.upload] Failed to emit media_uploaded:\", error);\n      })\n    );\n\n    return c.json({ media: serializeMedia(created) }, 201 as const);\n  } catch (error) {\n    console.error(\"Error uploading media asset:\", error);\n    return c.json(\n      {\n        error: \"Failed to upload media\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nexport default media;\n"
  },
  {
    "path": "apps/api/src/routes/posts.ts",
    "content": "import { createRoute, OpenAPIHono, z } from \"@hono/zod-openapi\";\nimport { toPostPayload, withChanges } from \"@marble/events\";\nimport { NodeHtmlMarkdown } from \"node-html-markdown\";\nimport { cacheKey, createCacheClient, hashQueryParams } from \"@/lib/cache\";\nimport { createDbClient } from \"@/lib/db\";\nimport { emitEvent } from \"@/lib/events\";\nimport { buildFieldsObject, buildStatusFilter } from \"@/lib/posts\";\nimport { sanitizeHtml } from \"@/lib/sanitize\";\nimport { requireWorkspaceId } from \"@/lib/workspace\";\nimport {\n  ConflictSchema,\n  DeleteResponseSchema,\n  ErrorSchema,\n  ForbiddenSchema,\n  NotFoundSchema,\n  PageNotFoundSchema,\n  ServerErrorSchema,\n} from \"@/schemas/common\";\nimport {\n  CreatePostBodySchema,\n  CreatePostResponseSchema,\n  PostParamsSchema,\n  PostResponseSchema,\n  PostsListResponseSchema,\n  PostsQuerySchema,\n  SinglePostQuerySchema,\n  UpdatePostBodySchema,\n  UpdatePostResponseSchema,\n} from \"@/schemas/posts\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\nconst posts = new OpenAPIHono<ApiKeyApp>();\n\nconst listPostsRoute = createRoute({\n  method: \"get\",\n  path: \"/\",\n  tags: [\"Posts\"],\n  summary: \"List posts\",\n  description:\n    \"Get a paginated list of published posts with optional filtering\",\n  request: {\n    query: PostsQuerySchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: PostsListResponseSchema } },\n      description: \"Paginated list of posts\",\n    },\n    400: {\n      content: {\n        \"application/json\": {\n          schema: z.union([ErrorSchema, PageNotFoundSchema]),\n        },\n      },\n      description: \"Invalid query parameters or page number\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst getPostRoute = createRoute({\n  method: \"get\",\n  path: \"/{identifier}\",\n  tags: [\"Posts\"],\n  summary: \"Get post\",\n  description:\n    \"Get a single post by ID or slug, with optional status filtering\",\n  request: {\n    params: PostParamsSchema,\n    query: SinglePostQuerySchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: PostResponseSchema } },\n      description: \"The requested post\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Post not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst createPostRoute = createRoute({\n  method: \"post\",\n  path: \"/\",\n  tags: [\"Posts\"],\n  summary: \"Create post\",\n  description:\n    \"Create a new post. Requires a private API key. Category is required. If authors are not provided, the first workspace author is used.\",\n  request: {\n    body: {\n      content: { \"application/json\": { schema: CreatePostBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    201: {\n      content: { \"application/json\": { schema: CreatePostResponseSchema } },\n      description: \"Post created successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body or referenced resources not found\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Post with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nposts.openapi(listPostsRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n    const {\n      limit: rawLimit,\n      page,\n      order,\n      categories,\n      excludeCategories,\n      tags,\n      excludeTags,\n      query,\n      format,\n      featured,\n      status,\n    } = c.req.valid(\"query\");\n\n    const categoryFilter: Record<string, unknown> = {};\n    if (categories.length > 0) {\n      categoryFilter.in = categories;\n    }\n    if (excludeCategories.length > 0) {\n      categoryFilter.notIn = excludeCategories;\n    }\n\n    const tagFilter: Record<string, unknown> = {};\n    if (tags.length > 0) {\n      tagFilter.some = { slug: { in: tags } };\n    }\n    if (excludeTags.length > 0) {\n      tagFilter.none = { slug: { in: excludeTags } };\n    }\n\n    const statusFilter = buildStatusFilter(status);\n\n    // Build the where clause\n    const where = {\n      workspaceId,\n      ...statusFilter,\n      ...(Object.keys(categoryFilter).length > 0\n        ? { category: { slug: categoryFilter } }\n        : {}),\n      ...(Object.keys(tagFilter).length > 0 ? { tags: tagFilter } : {}),\n      ...(query && {\n        OR: [{ title: { contains: query } }, { content: { contains: query } }],\n      }),\n      ...(featured !== undefined && { featured: featured === \"true\" }),\n    };\n\n    // Generate cache key for count (exclude page and format - they don't affect count)\n    const countCacheKey = cacheKey(\n      workspaceId,\n      \"posts\",\n      \"list\",\n      hashQueryParams({\n        limit: rawLimit,\n        order,\n        categories,\n        excludeCategories,\n        tags,\n        excludeTags,\n        query,\n        featured,\n        status,\n      }),\n      \"count\"\n    );\n\n    // Cache count query separately (1 hour TTL, same as data)\n    const totalPosts = await cache.getOrSetCount(countCacheKey, () =>\n      db.post.count({ where })\n    );\n\n    // Generate cache key for data (includes page and format)\n    const listCacheKey = cacheKey(\n      workspaceId,\n      \"posts\",\n      \"list\",\n      hashQueryParams({\n        page,\n        limit: rawLimit,\n        order,\n        categories,\n        excludeCategories,\n        tags,\n        excludeTags,\n        query,\n        format,\n        featured,\n        status,\n      })\n    );\n\n    // Handle pagination\n    const limit = rawLimit;\n    const totalPages = Math.ceil(totalPosts / limit);\n\n    // Validate page number\n    if (page > totalPages && totalPosts > 0) {\n      return c.json(\n        {\n          error: \"Invalid page number\" as const,\n          details: {\n            message: `Page ${page} does not exist.`,\n            totalPages,\n            requestedPage: page,\n          },\n        },\n        400 as const\n      );\n    }\n\n    // Infer some additional stuff\n    const postsToSkip = (page - 1) * limit;\n    const prevPage = page > 1 ? page - 1 : null;\n    const nextPage = page < totalPages ? page + 1 : null;\n\n    const postSelect = {\n      id: true,\n      slug: true,\n      title: true,\n      status: true,\n      content: true,\n      featured: true,\n      coverImage: true,\n      description: true,\n      publishedAt: true,\n      updatedAt: true,\n      authors: {\n        select: {\n          id: true,\n          name: true,\n          image: true,\n          bio: true,\n          role: true,\n          slug: true,\n          socials: {\n            select: {\n              url: true,\n              platform: true,\n            },\n          },\n        },\n      },\n      category: {\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          description: true,\n        },\n      },\n      tags: {\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          description: true,\n        },\n      },\n      fieldValues: {\n        select: {\n          value: true,\n          field: {\n            select: {\n              key: true,\n              type: true,\n            },\n          },\n        },\n      },\n    } as const;\n\n    const findManyArgs = {\n      where,\n      orderBy: { publishedAt: order },\n      take: limit,\n      skip: postsToSkip,\n      select: postSelect,\n    };\n\n    const [postsData, workspaceFields] = await Promise.all([\n      cache.getOrSet(listCacheKey, () => db.post.findMany(findManyArgs)),\n      db.field.findMany({\n        where: { workspaceId },\n        select: {\n          key: true,\n          type: true,\n        },\n      }),\n    ]);\n\n    const formattedPosts =\n      format === \"markdown\"\n        ? postsData.map((post) => ({\n            ...post,\n            content: NodeHtmlMarkdown.translate(post.content || \"\"),\n          }))\n        : postsData;\n\n    const postsWithFields = formattedPosts.map((post) => {\n      const { fieldValues, ...rest } = post as typeof post & {\n        fieldValues: Array<{\n          value: string;\n          field: { key: string; type: string };\n        }>;\n      };\n      return {\n        ...rest,\n        fields: buildFieldsObject(fieldValues || [], workspaceFields),\n      };\n    });\n\n    const paginationInfo = {\n      limit,\n      currentPage: page,\n      nextPage,\n      previousPage: prevPage,\n      totalPages,\n      totalItems: totalPosts,\n    };\n\n    return c.json(\n      {\n        posts: postsWithFields,\n        pagination: paginationInfo,\n      },\n      200 as const\n    );\n  } catch (error) {\n    console.error(\"Error fetching posts:\", error);\n    return c.json(\n      {\n        error: \"Failed to fetch posts\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nposts.openapi(getPostRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const { identifier } = c.req.valid(\"param\");\n    const { format, status } = c.req.valid(\"query\");\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n    const statusFilter = buildStatusFilter(status);\n\n    // Cache by identifier (slug or id), format, and status\n    const singleCacheKey = cacheKey(\n      workspaceId,\n      \"posts\",\n      identifier,\n      hashQueryParams({ format, status })\n    );\n\n    const post = await cache.getOrSet(singleCacheKey, () =>\n      db.post.findFirst({\n        where: {\n          workspaceId,\n          OR: [{ slug: identifier }, { id: identifier }],\n          ...statusFilter,\n        },\n        select: {\n          id: true,\n          slug: true,\n          title: true,\n          status: true,\n          content: true,\n          featured: true,\n          coverImage: true,\n          description: true,\n          publishedAt: true,\n          updatedAt: true,\n          authors: {\n            select: {\n              id: true,\n              name: true,\n              image: true,\n              bio: true,\n              role: true,\n              slug: true,\n              socials: {\n                select: {\n                  url: true,\n                  platform: true,\n                },\n              },\n            },\n          },\n          category: {\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n              description: true,\n            },\n          },\n          tags: {\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n              description: true,\n            },\n          },\n          fieldValues: {\n            select: {\n              value: true,\n              field: {\n                select: {\n                  key: true,\n                  type: true,\n                },\n              },\n            },\n          },\n        },\n      })\n    );\n\n    if (!post) {\n      return c.json(\n        {\n          error: \"Post not found\",\n          message:\n            \"The requested post does not exist or does not match the requested status\",\n        },\n        404 as const\n      );\n    }\n\n    const workspaceFields = await db.field.findMany({\n      where: { workspaceId },\n      select: {\n        key: true,\n        type: true,\n      },\n    });\n\n    // Format post based on requested format\n    const formattedPost =\n      format === \"markdown\"\n        ? {\n            ...post,\n            content: NodeHtmlMarkdown.translate(post.content || \"\"),\n          }\n        : post;\n\n    const { fieldValues, ...postRest } =\n      formattedPost as typeof formattedPost & {\n        fieldValues: Array<{\n          value: string;\n          field: { key: string; type: string };\n        }>;\n      };\n    const postWithFields = {\n      ...postRest,\n      fields: buildFieldsObject(fieldValues || [], workspaceFields),\n    };\n\n    return c.json({ post: postWithFields }, 200 as const);\n  } catch (_error) {\n    return c.json({ error: \"Failed to fetch post\" }, 500 as const);\n  }\n});\n\nposts.openapi(createPostRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const body = c.req.valid(\"json\");\n\n    // 1. Check slug uniqueness within workspace\n    const existingPost = await db.post.findFirst({\n      where: {\n        slug: body.slug,\n        workspaceId,\n      },\n    });\n\n    if (existingPost) {\n      return c.json(\n        {\n          error: \"Slug already in use\",\n          message: \"A post with this slug already exists in this workspace\",\n        },\n        409 as const\n      );\n    }\n\n    // 2. Validate category exists in workspace\n    const category = await db.category.findFirst({\n      where: {\n        id: body.categoryId,\n        workspaceId,\n      },\n    });\n\n    if (!category) {\n      return c.json(\n        {\n          error: \"Invalid category\",\n          message:\n            \"The specified category does not exist in this workspace. Use GET /v1/categories to list available categories.\",\n        },\n        400 as const\n      );\n    }\n\n    // 3. Validate tags if provided\n    let validTagIds: string[] = [];\n    if (body.tags && body.tags.length > 0) {\n      const validTags = await db.tag.findMany({\n        where: {\n          id: { in: body.tags },\n          workspaceId,\n        },\n        select: { id: true },\n      });\n\n      validTagIds = validTags.map((t) => t.id);\n\n      // Check if any provided tag IDs were invalid\n      const invalidTagIds = body.tags.filter((id) => !validTagIds.includes(id));\n      if (invalidTagIds.length > 0) {\n        return c.json(\n          {\n            error: \"Invalid tags\",\n            message: `The following tag IDs do not exist in this workspace: ${invalidTagIds.join(\", \")}. Use GET /v1/tags to list available tags.`,\n          },\n          400 as const\n        );\n      }\n    }\n\n    // 4. Resolve authors\n    let authorIds: string[];\n\n    if (body.authors && body.authors.length > 0) {\n      // Validate provided author IDs\n      const validAuthors = await db.author.findMany({\n        where: {\n          id: { in: body.authors },\n          workspaceId,\n          isActive: true,\n        },\n        select: { id: true },\n      });\n\n      if (validAuthors.length === 0) {\n        return c.json(\n          {\n            error: \"Invalid authors\",\n            message:\n              \"None of the provided author IDs exist in this workspace. Use GET /v1/authors to list available authors.\",\n          },\n          400 as const\n        );\n      }\n\n      const invalidAuthorIds = body.authors.filter(\n        (id) => !validAuthors.map((a) => a.id).includes(id)\n      );\n      if (invalidAuthorIds.length > 0) {\n        return c.json(\n          {\n            error: \"Invalid authors\",\n            message: `The following author IDs do not exist in this workspace: ${invalidAuthorIds.join(\", \")}. Use GET /v1/authors to list available authors.`,\n          },\n          400 as const\n        );\n      }\n\n      const validAuthorIdSet = new Set(validAuthors.map((a) => a.id));\n      authorIds = body.authors.filter((id) => validAuthorIdSet.has(id));\n    } else {\n      // Fallback: use the first workspace author\n      const firstAuthor = await db.author.findFirst({\n        where: {\n          workspaceId,\n          isActive: true,\n        },\n        orderBy: { createdAt: \"asc\" },\n        select: { id: true },\n      });\n\n      if (!firstAuthor) {\n        return c.json(\n          {\n            error: \"No authors available\",\n            message:\n              \"This workspace has no authors. Please create an author in the dashboard before creating posts via the API.\",\n          },\n          400 as const\n        );\n      }\n\n      authorIds = [firstAuthor.id];\n    }\n\n    // The first author in the list becomes the primary author\n    const primaryAuthorId = authorIds[0];\n\n    // 5. Determine publishedAt\n    const publishedAt = body.publishedAt\n      ? new Date(body.publishedAt)\n      : new Date();\n\n    // 6. Create the post\n    const postCreated = await db.post.create({\n      data: {\n        title: body.title,\n        content: sanitizeHtml(body.content),\n        contentJson: {}, // API users send HTML, not TipTap JSON\n        description: body.description,\n        slug: body.slug,\n        categoryId: body.categoryId,\n        status: body.status,\n        featured: body.featured ?? false,\n        coverImage: body.coverImage ?? null,\n        publishedAt,\n        workspaceId,\n        primaryAuthorId,\n        tags:\n          validTagIds.length > 0\n            ? { connect: validTagIds.map((id) => ({ id })) }\n            : undefined,\n        authors: {\n          connect: authorIds.map((id) => ({ id })),\n        },\n      },\n      select: {\n        id: true,\n        slug: true,\n        title: true,\n        status: true,\n        featured: true,\n        publishedAt: true,\n        createdAt: true,\n      },\n    });\n\n    // 7. Invalidate cache\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"tags\"));\n    c.executionCtx.waitUntil(\n      cache.invalidateResource(workspaceId, \"categories\")\n    );\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"authors\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    const eventType =\n      postCreated.status === \"published\" ? \"post_published\" : \"post_created\";\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: eventType,\n        workspaceId,\n        resourceType: \"post\",\n        resourceId: postCreated.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toPostPayload(postCreated),\n      }).catch((error) => {\n        console.error(`[posts.create] Failed to emit ${eventType}:`, error);\n      })\n    );\n\n    return c.json({ post: postCreated }, 201 as const);\n  } catch (error) {\n    console.error(\"Error creating post:\", error);\n    return c.json(\n      {\n        error: \"Failed to create post\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nconst updatePostRoute = createRoute({\n  method: \"patch\",\n  path: \"/{identifier}\",\n  tags: [\"Posts\"],\n  summary: \"Update post\",\n  description:\n    \"Update an existing post by ID or slug. All fields are optional — only provided fields are updated. Requires a private API key.\",\n  request: {\n    params: PostParamsSchema,\n    body: {\n      content: { \"application/json\": { schema: UpdatePostBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: UpdatePostResponseSchema } },\n      description: \"Post updated successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body or referenced resources not found\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Post not found\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Post with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst deletePostRoute = createRoute({\n  method: \"delete\",\n  path: \"/{identifier}\",\n  tags: [\"Posts\"],\n  summary: \"Delete post\",\n  description: \"Delete a post by ID or slug. Requires a private API key.\",\n  request: {\n    params: PostParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: DeleteResponseSchema } },\n      description: \"Post deleted successfully\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Post not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nposts.openapi(updatePostRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n    const body = c.req.valid(\"json\");\n\n    // 1. Find the existing post\n    const existingPost = await db.post.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ slug: identifier }, { id: identifier }],\n      },\n    });\n\n    if (!existingPost) {\n      return c.json(\n        {\n          error: \"Post not found\",\n          message: \"The requested post does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // 2. If slug is being changed, check uniqueness\n    if (body.slug && body.slug !== existingPost.slug) {\n      const slugConflict = await db.post.findFirst({\n        where: {\n          slug: body.slug,\n          workspaceId,\n          id: { not: existingPost.id },\n        },\n      });\n\n      if (slugConflict) {\n        return c.json(\n          {\n            error: \"Slug already in use\",\n            message: \"A post with this slug already exists in this workspace\",\n          },\n          409 as const\n        );\n      }\n    }\n\n    // 3. Validate category if provided\n    if (body.categoryId) {\n      const category = await db.category.findFirst({\n        where: { id: body.categoryId, workspaceId },\n      });\n\n      if (!category) {\n        return c.json(\n          {\n            error: \"Invalid category\",\n            message:\n              \"The specified category does not exist in this workspace. Use GET /v1/categories to list available categories.\",\n          },\n          400 as const\n        );\n      }\n    }\n\n    // 4. Validate tags if provided\n    let tagUpdate: { set: { id: string }[] } | undefined;\n    if (body.tags !== undefined) {\n      if (body.tags.length > 0) {\n        const validTags = await db.tag.findMany({\n          where: { id: { in: body.tags }, workspaceId },\n          select: { id: true },\n        });\n\n        const validTagIds = validTags.map((t) => t.id);\n        const invalidTagIds = body.tags.filter(\n          (id) => !validTagIds.includes(id)\n        );\n\n        if (invalidTagIds.length > 0) {\n          return c.json(\n            {\n              error: \"Invalid tags\",\n              message: `The following tag IDs do not exist in this workspace: ${invalidTagIds.join(\", \")}. Use GET /v1/tags to list available tags.`,\n            },\n            400 as const\n          );\n        }\n\n        tagUpdate = { set: validTagIds.map((id) => ({ id })) };\n      } else {\n        // Empty array = remove all tags\n        tagUpdate = { set: [] };\n      }\n    }\n\n    // 5. Validate authors if provided\n    let authorUpdate: { set: { id: string }[] } | undefined;\n    let primaryAuthorId: string | undefined;\n    if (body.authors !== undefined) {\n      if (body.authors.length === 0) {\n        return c.json(\n          {\n            error: \"Invalid authors\",\n            message:\n              \"Authors array cannot be empty. At least one author is required.\",\n          },\n          400 as const\n        );\n      }\n\n      const validAuthors = await db.author.findMany({\n        where: { id: { in: body.authors }, workspaceId, isActive: true },\n        select: { id: true },\n      });\n\n      const invalidAuthorIds = body.authors.filter(\n        (id) => !validAuthors.map((a) => a.id).includes(id)\n      );\n\n      if (invalidAuthorIds.length > 0) {\n        return c.json(\n          {\n            error: \"Invalid authors\",\n            message: `The following author IDs do not exist in this workspace: ${invalidAuthorIds.join(\", \")}. Use GET /v1/authors to list available authors.`,\n          },\n          400 as const\n        );\n      }\n\n      const validAuthorIdSet = new Set(validAuthors.map((a) => a.id));\n      const orderedAuthorIds = body.authors.filter((id) =>\n        validAuthorIdSet.has(id)\n      );\n      authorUpdate = { set: orderedAuthorIds.map((id) => ({ id })) };\n      primaryAuthorId = orderedAuthorIds[0];\n    }\n\n    // 6. Build update data\n    const updateData: Record<string, unknown> = {};\n    if (body.title !== undefined) {\n      updateData.title = body.title;\n    }\n    if (body.content !== undefined) {\n      updateData.content = sanitizeHtml(body.content);\n    }\n    if (body.description !== undefined) {\n      updateData.description = body.description;\n    }\n    if (body.slug !== undefined) {\n      updateData.slug = body.slug;\n    }\n    if (body.categoryId !== undefined) {\n      updateData.categoryId = body.categoryId;\n    }\n    if (body.status !== undefined) {\n      updateData.status = body.status;\n    }\n    if (body.featured !== undefined) {\n      updateData.featured = body.featured;\n    }\n    if (body.coverImage !== undefined) {\n      updateData.coverImage = body.coverImage;\n    }\n    if (body.publishedAt !== undefined) {\n      updateData.publishedAt = new Date(body.publishedAt);\n    }\n    if (tagUpdate) {\n      updateData.tags = tagUpdate;\n    }\n    if (authorUpdate) {\n      updateData.authors = authorUpdate;\n    }\n    if (primaryAuthorId) {\n      updateData.primaryAuthorId = primaryAuthorId;\n    }\n\n    const postUpdated = await db.post.update({\n      where: { id: existingPost.id },\n      data: updateData,\n      select: {\n        id: true,\n        slug: true,\n        title: true,\n        status: true,\n        featured: true,\n        publishedAt: true,\n        updatedAt: true,\n      },\n    });\n\n    // 7. Invalidate cache\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"tags\"));\n    c.executionCtx.waitUntil(\n      cache.invalidateResource(workspaceId, \"categories\")\n    );\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"authors\"));\n\n    // 8. Emit events\n    const apiKeyId = c.get(\"apiKeyId\");\n    let eventType: \"post_published\" | \"post_unpublished\" | \"post_updated\";\n\n    if (\n      existingPost.status !== \"published\" &&\n      postUpdated.status === \"published\"\n    ) {\n      eventType = \"post_published\";\n    } else if (\n      existingPost.status === \"published\" &&\n      postUpdated.status !== \"published\"\n    ) {\n      eventType = \"post_unpublished\";\n    } else {\n      eventType = \"post_updated\";\n    }\n\n    const payload =\n      eventType === \"post_updated\"\n        ? withChanges(toPostPayload(postUpdated), Object.keys(body))\n        : toPostPayload(postUpdated);\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: eventType,\n        workspaceId,\n        resourceType: \"post\",\n        resourceId: postUpdated.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload,\n      }).catch((error) => {\n        console.error(`[posts.update] Failed to emit ${eventType}:`, error);\n      })\n    );\n\n    return c.json({ post: postUpdated }, 200 as const);\n  } catch (error) {\n    console.error(\"Error updating post:\", error);\n    return c.json(\n      {\n        error: \"Failed to update post\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nposts.openapi(deletePostRoute, async (c) => {\n  try {\n    const workspaceId = requireWorkspaceId(c);\n    const db = createDbClient(c.env);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n\n    const existingPost = await db.post.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ slug: identifier }, { id: identifier }],\n      },\n    });\n\n    if (!existingPost) {\n      return c.json(\n        {\n          error: \"Post not found\",\n          message: \"The requested post does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    await db.post.delete({\n      where: { id: existingPost.id },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"tags\"));\n    c.executionCtx.waitUntil(\n      cache.invalidateResource(workspaceId, \"categories\")\n    );\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"authors\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"post_deleted\",\n        workspaceId,\n        resourceType: \"post\",\n        resourceId: existingPost.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toPostPayload(existingPost),\n      }).catch((error) => {\n        console.error(\"[posts.delete] Failed to emit post_deleted:\", error);\n      })\n    );\n\n    return c.json({ id: existingPost.id }, 200 as const);\n  } catch (error) {\n    console.error(\"Error deleting post:\", error);\n    return c.json(\n      {\n        error: \"Failed to delete post\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nexport default posts;\n"
  },
  {
    "path": "apps/api/src/routes/tags.ts",
    "content": "import { createRoute, OpenAPIHono, z } from \"@hono/zod-openapi\";\nimport { toTagPayload, withChanges } from \"@marble/events\";\nimport { cacheKey, createCacheClient, hashQueryParams } from \"@/lib/cache\";\nimport { createDbClient } from \"@/lib/db\";\nimport { emitEvent } from \"@/lib/events\";\nimport { requireWorkspaceId } from \"@/lib/workspace\";\nimport {\n  ConflictSchema,\n  DeleteResponseSchema,\n  ErrorSchema,\n  ForbiddenSchema,\n  LimitQuerySchema,\n  NotFoundSchema,\n  PageNotFoundSchema,\n  PageQuerySchema,\n  ServerErrorSchema,\n} from \"@/schemas/common\";\nimport {\n  CreateTagBodySchema,\n  CreateTagResponseSchema,\n  TagResponseSchema,\n  TagsListResponseSchema,\n  UpdateTagBodySchema,\n} from \"@/schemas/tags\";\nimport type { ApiKeyApp } from \"@/types/env\";\n\nconst tags = new OpenAPIHono<ApiKeyApp>();\n\nconst TagsQuerySchema = z.object({\n  limit: LimitQuerySchema,\n  page: PageQuerySchema,\n});\n\nconst TagParamsSchema = z.object({\n  identifier: z.string().openapi({\n    param: { name: \"identifier\", in: \"path\" },\n    example: \"javascript\",\n    description: \"Tag ID or slug\",\n  }),\n});\n\nconst listTagsRoute = createRoute({\n  method: \"get\",\n  path: \"/\",\n  tags: [\"Tags\"],\n  summary: \"List tags\",\n  description: \"Get a paginated list of tags\",\n  request: {\n    query: TagsQuerySchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: TagsListResponseSchema } },\n      description: \"Paginated list of tags\",\n    },\n    400: {\n      content: {\n        \"application/json\": {\n          schema: z.union([ErrorSchema, PageNotFoundSchema]),\n        },\n      },\n      description: \"Invalid query parameters or page number\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst getTagRoute = createRoute({\n  method: \"get\",\n  path: \"/{identifier}\",\n  tags: [\"Tags\"],\n  summary: \"Get tag\",\n  description: \"Get a single tag by ID or slug\",\n  request: {\n    params: TagParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: TagResponseSchema } },\n      description: \"The requested tag\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Tag not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst createTagRoute = createRoute({\n  method: \"post\",\n  path: \"/\",\n  tags: [\"Tags\"],\n  summary: \"Create tag\",\n  description: \"Create a new tag. Requires a private API key.\",\n  request: {\n    body: {\n      content: { \"application/json\": { schema: CreateTagBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    201: {\n      content: { \"application/json\": { schema: CreateTagResponseSchema } },\n      description: \"Tag created successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Tag with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\ntags.openapi(listTagsRoute, async (c) => {\n  const db = createDbClient(c.env);\n  const workspaceId = requireWorkspaceId(c);\n  const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n  const { limit, page } = c.req.valid(\"query\");\n\n  // Generate cache key for count (exclude page - it doesn't affect count)\n  const countCacheKey = cacheKey(\n    workspaceId,\n    \"tags\",\n    \"list\",\n    hashQueryParams({ limit }),\n    \"count\"\n  );\n\n  // Cache count query separately (1 hour TTL, invalidated with posts)\n  const totalTags = await cache.getOrSetCount(countCacheKey, () =>\n    db.tag.count({\n      where: {\n        workspaceId,\n      },\n    })\n  );\n\n  // Generate cache key for data (includes page)\n  const listCacheKey = cacheKey(\n    workspaceId,\n    \"tags\",\n    \"list\",\n    hashQueryParams({ page, limit })\n  );\n\n  const totalPages = Math.ceil(totalTags / limit);\n  const prevPage = page > 1 ? page - 1 : null;\n  const nextPage = page < totalPages ? page + 1 : null;\n  const tagsToSkip = limit ? (page - 1) * limit : 0;\n\n  // Validate page number\n  if (page > totalPages && totalTags > 0) {\n    return c.json(\n      {\n        error: \"Invalid page number\" as const,\n        details: {\n          message: `Page ${page} does not exist.`,\n          totalPages,\n          requestedPage: page,\n        },\n      },\n      400 as const\n    );\n  }\n\n  const tagsList = await cache.getOrSet(listCacheKey, () =>\n    db.tag.findMany({\n      where: {\n        workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        description: true,\n        _count: {\n          select: {\n            posts: {\n              where: {\n                status: \"published\",\n              },\n            },\n          },\n        },\n      },\n      take: limit,\n      skip: tagsToSkip,\n    })\n  );\n\n  // because I dont want prisma's ugly _count\n  const transformedTags = tagsList.map((tag) => {\n    const { _count, ...rest } = tag;\n    return {\n      ...rest,\n      count: _count,\n    };\n  });\n\n  return c.json(\n    {\n      tags: transformedTags,\n      pagination: {\n        limit,\n        currentPage: page,\n        nextPage,\n        previousPage: prevPage,\n        totalPages,\n        totalItems: totalTags,\n      },\n    },\n    200 as const\n  );\n});\n\ntags.openapi(getTagRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const { identifier } = c.req.valid(\"param\");\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n\n    // Cache by identifier (slug or id)\n    const singleCacheKey = cacheKey(workspaceId, \"tags\", identifier);\n\n    // First get the tag\n    const tag = await cache.getOrSet(singleCacheKey, () =>\n      db.tag.findFirst({\n        where: {\n          workspaceId,\n          OR: [{ id: identifier }, { slug: identifier }],\n        },\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          description: true,\n          _count: {\n            select: {\n              posts: {\n                where: {\n                  status: \"published\",\n                },\n              },\n            },\n          },\n        },\n      })\n    );\n\n    if (!tag) {\n      return c.json(\n        {\n          error: \"Tag not found\",\n          message: \"The requested tag does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // Transform _count to count\n    const { _count, ...rest } = tag;\n    const transformedTag = {\n      ...rest,\n      count: _count,\n    };\n\n    return c.json({ tag: transformedTag }, 200 as const);\n  } catch (error) {\n    console.error(\"Error fetching tag:\", error);\n    return c.json({ error: \"Failed to fetch tag\" }, 500 as const);\n  }\n});\n\ntags.openapi(createTagRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const body = c.req.valid(\"json\");\n\n    // Check for slug uniqueness within workspace\n    const existingTag = await db.tag.findFirst({\n      where: {\n        slug: body.slug,\n        workspaceId,\n      },\n    });\n\n    if (existingTag) {\n      return c.json(\n        {\n          error: \"Slug already in use\",\n          message: \"A tag with this slug already exists in this workspace\",\n        },\n        409 as const\n      );\n    }\n\n    const tagCreated = await db.tag.create({\n      data: {\n        name: body.name,\n        slug: body.slug,\n        description: body.description ?? null,\n        workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        description: true,\n      },\n    });\n\n    // Invalidate cache for tags and posts\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"tags\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"tag_created\",\n        workspaceId,\n        resourceType: \"tag\",\n        resourceId: tagCreated.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toTagPayload(tagCreated),\n      }).catch((error) => {\n        console.error(\"[tags.create] Failed to emit tag_created:\", error);\n      })\n    );\n\n    return c.json({ tag: tagCreated }, 201 as const);\n  } catch (error) {\n    console.error(\"Error creating tag:\", error);\n    return c.json(\n      {\n        error: \"Failed to create tag\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nconst updateTagRoute = createRoute({\n  method: \"patch\",\n  path: \"/{identifier}\",\n  tags: [\"Tags\"],\n  summary: \"Update tag\",\n  description:\n    \"Update an existing tag by ID or slug. Requires a private API key.\",\n  request: {\n    params: TagParamsSchema,\n    body: {\n      content: { \"application/json\": { schema: UpdateTagBodySchema } },\n      required: true,\n    },\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: CreateTagResponseSchema } },\n      description: \"Tag updated successfully\",\n    },\n    400: {\n      content: { \"application/json\": { schema: ErrorSchema } },\n      description: \"Invalid request body\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Tag not found\",\n    },\n    409: {\n      content: { \"application/json\": { schema: ConflictSchema } },\n      description: \"Tag with this slug already exists\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\nconst deleteTagRoute = createRoute({\n  method: \"delete\",\n  path: \"/{identifier}\",\n  tags: [\"Tags\"],\n  summary: \"Delete tag\",\n  description: \"Delete a tag by ID or slug. Requires a private API key.\",\n  request: {\n    params: TagParamsSchema,\n  },\n  responses: {\n    200: {\n      content: { \"application/json\": { schema: DeleteResponseSchema } },\n      description: \"Tag deleted successfully\",\n    },\n    403: {\n      content: { \"application/json\": { schema: ForbiddenSchema } },\n      description: \"Public API key used for write operation\",\n    },\n    404: {\n      content: { \"application/json\": { schema: NotFoundSchema } },\n      description: \"Tag not found\",\n    },\n    500: {\n      content: { \"application/json\": { schema: ServerErrorSchema } },\n      description: \"Server error\",\n    },\n  },\n});\n\ntags.openapi(updateTagRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n    const body = c.req.valid(\"json\");\n\n    // Find the tag first\n    const existingTag = await db.tag.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ id: identifier }, { slug: identifier }],\n      },\n    });\n\n    if (!existingTag) {\n      return c.json(\n        {\n          error: \"Tag not found\",\n          message: \"The requested tag does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    // If slug is being changed, check uniqueness\n    if (body.slug && body.slug !== existingTag.slug) {\n      const slugConflict = await db.tag.findFirst({\n        where: {\n          slug: body.slug,\n          workspaceId,\n          id: { not: existingTag.id },\n        },\n      });\n\n      if (slugConflict) {\n        return c.json(\n          {\n            error: \"Slug already in use\",\n            message: \"A tag with this slug already exists in this workspace\",\n          },\n          409 as const\n        );\n      }\n    }\n\n    const tagUpdated = await db.tag.update({\n      where: { id: existingTag.id },\n      data: {\n        ...(body.name !== undefined && { name: body.name }),\n        ...(body.slug !== undefined && { slug: body.slug }),\n        ...(body.description !== undefined && {\n          description: body.description,\n        }),\n      },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        description: true,\n      },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"tags\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"tag_updated\",\n        workspaceId,\n        resourceType: \"tag\",\n        resourceId: tagUpdated.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: withChanges(toTagPayload(tagUpdated), Object.keys(body)),\n      }).catch((error) => {\n        console.error(\"[tags.update] Failed to emit tag_updated:\", error);\n      })\n    );\n\n    return c.json({ tag: tagUpdated }, 200 as const);\n  } catch (error) {\n    console.error(\"Error updating tag:\", error);\n    return c.json(\n      {\n        error: \"Failed to update tag\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\ntags.openapi(deleteTagRoute, async (c) => {\n  try {\n    const db = createDbClient(c.env);\n    const workspaceId = requireWorkspaceId(c);\n    const cache = createCacheClient(c.env.REDIS_URL, c.env.REDIS_TOKEN);\n    const { identifier } = c.req.valid(\"param\");\n\n    const existingTag = await db.tag.findFirst({\n      where: {\n        workspaceId,\n        OR: [{ id: identifier }, { slug: identifier }],\n      },\n    });\n\n    if (!existingTag) {\n      return c.json(\n        {\n          error: \"Tag not found\",\n          message: \"The requested tag does not exist\",\n        },\n        404 as const\n      );\n    }\n\n    await db.tag.delete({\n      where: { id: existingTag.id },\n    });\n\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"tags\"));\n    c.executionCtx.waitUntil(cache.invalidateResource(workspaceId, \"posts\"));\n\n    const apiKeyId = c.get(\"apiKeyId\");\n    c.executionCtx.waitUntil(\n      emitEvent(db, c.env.EVENT_QUEUE, {\n        type: \"tag_deleted\",\n        workspaceId,\n        resourceType: \"tag\",\n        resourceId: existingTag.id,\n        actorType: \"api_key\",\n        actorId: apiKeyId,\n        payload: toTagPayload(existingTag),\n      }).catch((error) => {\n        console.error(\"[tags.delete] Failed to emit tag_deleted:\", error);\n      })\n    );\n\n    return c.json({ id: existingTag.id }, 200 as const);\n  } catch (error) {\n    console.error(\"Error deleting tag:\", error);\n    return c.json(\n      {\n        error: \"Failed to delete tag\",\n        message: \"An unexpected error occurred\",\n      },\n      500 as const\n    );\n  }\n});\n\nexport default tags;\n"
  },
  {
    "path": "apps/api/src/schemas/authors.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\nimport { PaginationSchema } from \"./common\";\n\nexport const SocialSchema = z\n  .object({\n    url: z.url().openapi({ example: \"https://twitter.com/johndoe\" }),\n    platform: z.string().openapi({ example: \"twitter\" }),\n  })\n  .openapi(\"Social\");\n\nexport const AuthorSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp3456lm06xfpzcgl0\" }),\n    name: z.string().openapi({ example: \"John Doe\" }),\n    image: z\n      .string()\n      .nullable()\n      .openapi({ example: \"https://media.marblecms.com/avatar.jpg\" }),\n    slug: z.string().openapi({ example: \"john-doe\" }),\n    bio: z\n      .string()\n      .nullable()\n      .openapi({ example: \"Technical writer and developer\" }),\n    role: z.string().nullable().openapi({ example: \"Editor\" }),\n    socials: z.array(SocialSchema),\n    count: z\n      .object({\n        posts: z.number().int().openapi({ example: 12 }),\n      })\n      .optional()\n      .openapi({ description: \"Number of published posts by this author\" }),\n  })\n  .openapi(\"Author\");\n\nexport const AuthorsListResponseSchema = z\n  .object({\n    authors: z.array(AuthorSchema),\n    pagination: PaginationSchema,\n  })\n  .openapi(\"AuthorsListResponse\");\n\nexport const AuthorResponseSchema = z\n  .object({\n    author: AuthorSchema,\n  })\n  .openapi(\"AuthorResponse\");\n\nexport const SocialInputSchema = z\n  .object({\n    platform: z\n      .enum([\n        \"x\",\n        \"twitter\",\n        \"github\",\n        \"facebook\",\n        \"instagram\",\n        \"youtube\",\n        \"tiktok\",\n        \"linkedin\",\n        \"website\",\n        \"onlyfans\",\n        \"discord\",\n        \"bluesky\",\n      ])\n      .openapi({ example: \"x\" }),\n    url: z\n      .url(\"Must be a valid URL\")\n      .openapi({ example: \"https://x.com/johndoe\" }),\n  })\n  .openapi(\"SocialInput\");\n\nexport const CreateAuthorBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name is required\")\n      .openapi({ example: \"John Doe\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug is required\")\n      .openapi({ example: \"john-doe\" }),\n    bio: z\n      .string()\n      .nullable()\n      .optional()\n      .openapi({ example: \"Technical writer and developer\" }),\n    role: z.string().nullable().optional().openapi({ example: \"Editor\" }),\n    email: z\n      .email()\n      .nullable()\n      .optional()\n      .openapi({ example: \"john@example.com\" }),\n    image: z\n      .url()\n      .nullable()\n      .optional()\n      .openapi({ example: \"https://media.marblecms.com/avatar.jpg\" }),\n    socials: z\n      .array(SocialInputSchema)\n      .optional()\n      .openapi({ description: \"Social media links for this author\" }),\n  })\n  .openapi(\"CreateAuthorBody\");\n\nexport const CreateAuthorResponseSchema = z\n  .object({\n    author: z.object({\n      id: z.string().openapi({ example: \"cryitfjp3456lm06xfpzcgl0\" }),\n      name: z.string().openapi({ example: \"John Doe\" }),\n      slug: z.string().openapi({ example: \"john-doe\" }),\n      bio: z\n        .string()\n        .nullable()\n        .openapi({ example: \"Technical writer and developer\" }),\n      role: z.string().nullable().openapi({ example: \"Editor\" }),\n      image: z\n        .string()\n        .nullable()\n        .openapi({ example: \"https://media.marblecms.com/avatar.jpg\" }),\n      socials: z.array(SocialSchema),\n    }),\n  })\n  .openapi(\"CreateAuthorResponse\");\n\nexport const UpdateAuthorBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name cannot be empty\")\n      .optional()\n      .openapi({ example: \"John Doe\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .optional()\n      .openapi({ example: \"john-doe\" }),\n    bio: z.string().nullable().optional().openapi({ example: \"Updated bio\" }),\n    role: z\n      .string()\n      .nullable()\n      .optional()\n      .openapi({ example: \"Senior Editor\" }),\n    email: z\n      .email()\n      .nullable()\n      .optional()\n      .openapi({ example: \"john@example.com\" }),\n    image: z\n      .url()\n      .nullable()\n      .optional()\n      .openapi({ example: \"https://media.marblecms.com/new-avatar.jpg\" }),\n    socials: z.array(SocialInputSchema).optional().openapi({\n      description:\n        \"Social media links. Replaces all existing socials when provided.\",\n    }),\n  })\n  .openapi(\"UpdateAuthorBody\");\n"
  },
  {
    "path": "apps/api/src/schemas/categories.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\nimport { PaginationSchema } from \"./common\";\n\nexport const CategorySchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp2345kl05weoybfk9\" }),\n    name: z.string().openapi({ example: \"Technology\" }),\n    slug: z.string().openapi({ example: \"technology\" }),\n    description: z\n      .string()\n      .nullable()\n      .openapi({ example: \"Tech news and tutorials\" }),\n    count: z\n      .object({\n        posts: z.number().int().openapi({ example: 15 }),\n      })\n      .openapi({ description: \"Number of published posts in this category\" }),\n  })\n  .openapi(\"Category\");\n\nexport const CategoriesListResponseSchema = z\n  .object({\n    categories: z.array(CategorySchema),\n    pagination: PaginationSchema,\n  })\n  .openapi(\"CategoriesListResponse\");\n\nexport const CategoryResponseSchema = z\n  .object({\n    category: CategorySchema,\n  })\n  .openapi(\"CategoryResponse\");\n\nexport const CreateCategoryBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name cannot be empty\")\n      .openapi({ example: \"Technology\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .openapi({ example: \"technology\" }),\n    description: z\n      .string()\n      .optional()\n      .openapi({ example: \"Tech news and tutorials\" }),\n  })\n  .openapi(\"CreateCategoryBody\");\n\nexport const CreateCategoryResponseSchema = z\n  .object({\n    category: z.object({\n      id: z.string().openapi({ example: \"cryitfjp2345kl05weoybfk9\" }),\n      name: z.string().openapi({ example: \"Technology\" }),\n      slug: z.string().openapi({ example: \"technology\" }),\n      description: z\n        .string()\n        .nullable()\n        .openapi({ example: \"Tech news and tutorials\" }),\n    }),\n  })\n  .openapi(\"CreateCategoryResponse\");\n\nexport const UpdateCategoryBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name cannot be empty\")\n      .optional()\n      .openapi({ example: \"Engineering\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .optional()\n      .openapi({ example: \"engineering\" }),\n    description: z\n      .string()\n      .nullable()\n      .optional()\n      .openapi({ example: \"Engineering articles and tutorials\" }),\n  })\n  .openapi(\"UpdateCategoryBody\");\n"
  },
  {
    "path": "apps/api/src/schemas/common.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\n\nexport const PaginationSchema = z\n  .object({\n    limit: z.number().int().positive().openapi({ example: 10 }),\n    currentPage: z.number().int().positive().openapi({ example: 1 }),\n    nextPage: z.number().int().nullable().openapi({ example: 2 }),\n    previousPage: z.number().int().nullable().openapi({ example: null }),\n    totalPages: z.number().int().openapi({ example: 5 }),\n    totalItems: z.number().int().openapi({ example: 42 }),\n  })\n  .openapi(\"Pagination\");\n\nexport const ErrorDetailSchema = z.object({\n  field: z.string().openapi({ example: \"limit\" }),\n  message: z.string().openapi({ example: \"Expected number, received string\" }),\n});\n\n// Used for 400 Bad Request (validation errors)\nexport const ErrorSchema = z\n  .object({\n    error: z.string().openapi({ example: \"Invalid query parameters\" }),\n    details: z.array(ErrorDetailSchema).optional(),\n    message: z.string().optional().openapi({ example: \"Validation failed\" }),\n  })\n  .openapi(\"Error\");\n\n// Used for 500 Internal Server Error\nexport const ServerErrorSchema = z\n  .object({\n    error: z.string().openapi({ example: \"Internal server error\" }),\n    message: z\n      .string()\n      .optional()\n      .openapi({ example: \"Failed to fetch resource\" }),\n  })\n  .openapi(\"ServerError\");\n\nexport const NotFoundSchema = z\n  .object({\n    error: z.string().openapi({ example: \"Post not found\" }),\n    message: z\n      .string()\n      .openapi({ example: \"The requested resource does not exist\" }),\n  })\n  .openapi(\"NotFound\");\n\nexport const ForbiddenSchema = z\n  .object({\n    error: z.string().openapi({ example: \"Forbidden\" }),\n    message: z.string().openapi({\n      example:\n        \"Write operations require a private API key (msk_...). Public keys are read-only.\",\n    }),\n  })\n  .openapi(\"Forbidden\");\n\nexport const ConflictSchema = z\n  .object({\n    error: z.string().openapi({ example: \"Slug already in use\" }),\n    message: z.string().optional().openapi({\n      example: \"A resource with this slug already exists in this workspace\",\n    }),\n  })\n  .openapi(\"Conflict\");\n\nexport const DeleteResponseSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp5678mn09qrstuvwx\" }),\n  })\n  .openapi(\"DeleteResponse\");\n\nexport const PageNotFoundSchema = z\n  .object({\n    error: z.literal(\"Invalid page number\"),\n    details: z.object({\n      message: z.string().openapi({ example: \"Page 10 does not exist.\" }),\n      totalPages: z.number().int().openapi({ example: 5 }),\n      requestedPage: z.number().int().openapi({ example: 10 }),\n    }),\n  })\n  .openapi(\"PageNotFound\");\n\nexport const LimitQuerySchema = z.coerce\n  .number()\n  .int()\n  .min(1)\n  .max(100)\n  .default(10)\n  .openapi({\n    param: { name: \"limit\", in: \"query\" },\n    example: \"10\",\n    description: \"Number of items per page (1-100)\",\n  });\n\nexport const PageQuerySchema = z.coerce\n  .number()\n  .int()\n  .positive()\n  .default(1)\n  .openapi({\n    param: { name: \"page\", in: \"query\" },\n    example: \"1\",\n    description: \"Page number\",\n  });\n\nexport const IdentifierParamSchema = z.string().openapi({\n  param: { name: \"identifier\", in: \"path\" },\n  example: \"my-post-slug\",\n  description: \"ID or slug of the resource\",\n});\n\nexport const ContentFormatSchema = z\n  .enum([\"html\", \"markdown\"])\n  .openapi(\"ContentFormat\");\n"
  },
  {
    "path": "apps/api/src/schemas/media.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\nimport { LimitQuerySchema, PageQuerySchema, PaginationSchema } from \"./common\";\n\nexport const MediaTypeSchema = z\n  .enum([\"image\", \"video\", \"audio\", \"document\"])\n  .openapi(\"MediaType\");\n\nexport const MediaSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp1234jl04vdnycek8\" }),\n    name: z.string().openapi({ example: \"Hero image\" }),\n    url: z\n      .url()\n      .openapi({ example: \"https://cdn.marblecms.com/media/hero.jpg\" }),\n    alt: z.string().nullable().openapi({ example: \"A dashboard screenshot\" }),\n    size: z.number().int().openapi({\n      example: 382_019,\n      description: \"File size in bytes\",\n    }),\n    mimeType: z.string().nullable().openapi({ example: \"image/jpeg\" }),\n    width: z.number().int().nullable().openapi({ example: 1600 }),\n    height: z.number().int().nullable().openapi({ example: 900 }),\n    duration: z\n      .number()\n      .int()\n      .nullable()\n      .openapi({ example: 12_000, description: \"Duration in milliseconds\" }),\n    blurHash: z\n      .string()\n      .nullable()\n      .openapi({ example: \"LEHV6nWB2yk8pyo0adR*.7kCMdnj\" }),\n    type: MediaTypeSchema,\n    createdAt: z.iso.datetime().openapi({ example: \"2024-01-15T10:00:00Z\" }),\n    updatedAt: z.iso.datetime().openapi({ example: \"2024-01-16T12:00:00Z\" }),\n  })\n  .openapi(\"Media\");\n\nexport const MediaListResponseSchema = z\n  .object({\n    media: z.array(MediaSchema),\n    pagination: PaginationSchema,\n  })\n  .openapi(\"MediaListResponse\");\n\nexport const MediaResponseSchema = z\n  .object({\n    media: MediaSchema,\n  })\n  .openapi(\"MediaResponse\");\n\nexport const MediaQuerySchema = z.object({\n  limit: LimitQuerySchema,\n  page: PageQuerySchema,\n  query: z\n    .string()\n    .optional()\n    .openapi({\n      param: { name: \"query\", in: \"query\" },\n      example: \"hero\",\n      description: \"Search media by name, alt text, URL, or MIME type\",\n    }),\n  type: MediaTypeSchema.optional().openapi({\n    param: { name: \"type\", in: \"query\" },\n    example: \"image\",\n    description: \"Filter by inferred media type\",\n  }),\n  order: z\n    .enum([\"asc\", \"desc\"])\n    .optional()\n    .default(\"desc\")\n    .openapi({\n      param: { name: \"order\", in: \"query\" },\n      example: \"desc\",\n      description: \"Sort order by creation date\",\n    }),\n});\n\nexport const MediaParamsSchema = z.object({\n  id: z.string().openapi({\n    param: { name: \"id\", in: \"path\" },\n    example: \"cryitfjp1234jl04vdnycek8\",\n    description: \"Media asset ID\",\n  }),\n});\n\nexport const UpdateMediaBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name cannot be empty\")\n      .optional()\n      .openapi({ example: \"Updated hero image\" }),\n    alt: z\n      .string()\n      .nullable()\n      .optional()\n      .openapi({ example: \"Dashboard with a post editor open\" }),\n  })\n  .openapi(\"UpdateMediaBody\");\n\nexport const UploadMediaBodySchema = z\n  .object({\n    file: z.any().openapi({\n      type: \"string\",\n      format: \"binary\",\n      description: \"Media file to upload. Maximum size: 5 MiB.\",\n    }),\n    name: z.string().optional().openapi({ example: \"Hero image\" }),\n    alt: z.string().optional().openapi({ example: \"Dashboard screenshot\" }),\n  })\n  .openapi(\"UploadMediaBody\");\n"
  },
  {
    "path": "apps/api/src/schemas/posts.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\nimport { ContentFormatSchema, PaginationSchema } from \"./common\";\n\nexport const SocialRefSchema = z\n  .object({\n    url: z.url().openapi({ example: \"https://twitter.com/johndoe\" }),\n    platform: z.string().openapi({ example: \"twitter\" }),\n  })\n  .openapi(\"SocialRef\");\n\nexport const AuthorRefSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp1234jl04vdnycek8\" }),\n    name: z.string().openapi({ example: \"John Doe\" }),\n    image: z\n      .string()\n      .nullable()\n      .openapi({ example: \"https://media.marblecms.com/avatar.jpg\" }),\n    bio: z\n      .string()\n      .nullable()\n      .openapi({ example: \"Technical writer and developer\" }),\n    role: z.string().nullable().openapi({ example: \"Editor\" }),\n    slug: z.string().openapi({ example: \"john-doe\" }),\n    socials: z.array(SocialRefSchema),\n  })\n  .openapi(\"AuthorRef\");\n\nexport const CategoryRefSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp1234jl04vdnycek8\" }),\n    name: z.string().openapi({ example: \"Technology\" }),\n    slug: z.string().openapi({ example: \"technology\" }),\n    description: z\n      .string()\n      .nullable()\n      .openapi({ example: \"Tech news and tutorials\" }),\n  })\n  .openapi(\"CategoryRef\");\n\nexport const TagRefSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp1234jl04vdnycek8\" }),\n    name: z.string().openapi({ example: \"JavaScript\" }),\n    slug: z.string().openapi({ example: \"javascript\" }),\n    description: z\n      .string()\n      .nullable()\n      .openapi({ example: \"JavaScript tutorials\" }),\n  })\n  .openapi(\"TagRef\");\n\nexport const PostSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp5678mn09qrstuvwx\" }),\n    slug: z.string().openapi({ example: \"getting-started-with-nextjs\" }),\n    title: z.string().openapi({ example: \"Getting Started with Next.js\" }),\n    status: z.enum([\"published\", \"draft\"]).openapi({ example: \"published\" }),\n    featured: z.boolean().openapi({ example: false }),\n    coverImage: z\n      .string()\n      .nullable()\n      .openapi({ example: \"https://media.marblecms.com/cover.jpg\" }),\n    description: z\n      .string()\n      .openapi({ example: \"A beginner's guide to Next.js\" }),\n    publishedAt: z.iso.datetime().openapi({ example: \"2024-01-15T10:00:00Z\" }),\n    updatedAt: z.iso.datetime().openapi({ example: \"2024-01-16T12:00:00Z\" }),\n    authors: z.array(AuthorRefSchema),\n    category: CategoryRefSchema,\n    tags: z.array(TagRefSchema),\n    fields: z\n      .record(\n        z.string(),\n        z.union([\n          z.string(),\n          z.number(),\n          z.boolean(),\n          z.array(z.string()),\n          z.null(),\n        ])\n      )\n      .openapi({\n        description: \"Custom field values keyed by field key\",\n        example: {\n          release_date: \"2024-01-15\",\n          priority_score: 5,\n          hashtags: [\"#javascript\", \"#nextjs\"],\n        },\n      }),\n    content: z.string().openapi({ example: \"<p>Hello world</p>\" }),\n  })\n  .openapi(\"Post\");\n\nexport const PostsListResponseSchema = z\n  .object({\n    posts: z.array(PostSchema),\n    pagination: PaginationSchema,\n  })\n  .openapi(\"PostsListResponse\");\n\nexport const PostResponseSchema = z\n  .object({\n    post: PostSchema,\n  })\n  .openapi(\"PostResponse\");\n\nexport const PostsQuerySchema = z.object({\n  limit: z.coerce\n    .number()\n    .int()\n    .min(1)\n    .max(100)\n    .optional()\n    .default(10)\n    .openapi({\n      param: { name: \"limit\", in: \"query\" },\n      type: \"integer\",\n      example: 10,\n      description: \"Number of posts per page (1-100)\",\n    }),\n  page: z.coerce\n    .number()\n    .int()\n    .min(1)\n    .optional()\n    .default(1)\n    .openapi({\n      param: { name: \"page\", in: \"query\" },\n      type: \"integer\",\n      example: 1,\n      description: \"Page number\",\n    }),\n  order: z\n    .enum([\"asc\", \"desc\"])\n    .optional()\n    .default(\"desc\")\n    .openapi({\n      param: { name: \"order\", in: \"query\" },\n      example: \"desc\",\n      description: \"Sort order by publishedAt\",\n    }),\n  categories: z\n    .preprocess(\n      (val) =>\n        typeof val === \"string\"\n          ? val\n              .split(\",\")\n              .map((s) => s.trim())\n              .filter(Boolean)\n          : val,\n      z.array(z.string()).optional().default([])\n    )\n    .openapi({\n      param: { name: \"categories\", in: \"query\", style: \"form\", explode: false },\n      type: \"array\",\n      items: { type: \"string\" },\n      example: [\"tech\", \"news\"],\n      description: \"Category slugs to include\",\n    }),\n  excludeCategories: z\n    .preprocess(\n      (val) =>\n        typeof val === \"string\"\n          ? val\n              .split(\",\")\n              .map((s) => s.trim())\n              .filter(Boolean)\n          : val,\n      z.array(z.string()).optional().default([])\n    )\n    .openapi({\n      param: {\n        name: \"excludeCategories\",\n        in: \"query\",\n        style: \"form\",\n        explode: false,\n      },\n      type: \"array\",\n      items: { type: \"string\" },\n      example: [\"changelog\"],\n      description: \"Category slugs to exclude\",\n    }),\n  tags: z\n    .preprocess(\n      (val) =>\n        typeof val === \"string\"\n          ? val\n              .split(\",\")\n              .map((s) => s.trim())\n              .filter(Boolean)\n          : val,\n      z.array(z.string()).optional().default([])\n    )\n    .openapi({\n      param: { name: \"tags\", in: \"query\", style: \"form\", explode: false },\n      type: \"array\",\n      items: { type: \"string\" },\n      example: [\"javascript\", \"react\"],\n      description: \"Tag slugs to include\",\n    }),\n  excludeTags: z\n    .preprocess(\n      (val) =>\n        typeof val === \"string\"\n          ? val\n              .split(\",\")\n              .map((s) => s.trim())\n              .filter(Boolean)\n          : val,\n      z.array(z.string()).optional().default([])\n    )\n    .openapi({\n      param: {\n        name: \"excludeTags\",\n        in: \"query\",\n        style: \"form\",\n        explode: false,\n      },\n      type: \"array\",\n      items: { type: \"string\" },\n      example: [\"outdated\"],\n      description: \"Tag slugs to exclude\",\n    }),\n  query: z\n    .string()\n    .optional()\n    .openapi({\n      param: { name: \"query\", in: \"query\" },\n      example: \"nextjs\",\n      description: \"Search query for title and content\",\n    }),\n  format: ContentFormatSchema.optional().openapi({\n    param: { name: \"format\", in: \"query\" },\n    example: \"html\",\n    description: \"Content format (html or markdown)\",\n  }),\n  featured: z\n    .enum([\"true\", \"false\"])\n    .optional()\n    .openapi({\n      param: { name: \"featured\", in: \"query\" },\n      example: \"true\",\n      description: \"Filter by featured status\",\n    }),\n  status: z\n    .enum([\"published\", \"draft\", \"all\"])\n    .optional()\n    .default(\"published\")\n    .openapi({\n      param: { name: \"status\", in: \"query\" },\n      example: \"published\",\n      description:\n        \"Filter by post status. Use 'published' for live posts, 'draft' for unpublished posts, or 'all' for both.\",\n    }),\n});\n\nexport const PostParamsSchema = z.object({\n  identifier: z.string().openapi({\n    param: { name: \"identifier\", in: \"path\" },\n    example: \"my-post-slug\",\n    description: \"Post ID or slug\",\n  }),\n});\n\nexport const SinglePostQuerySchema = z.object({\n  format: ContentFormatSchema.optional().openapi({\n    param: { name: \"format\", in: \"query\" },\n    example: \"html\",\n    description: \"Content format (html or markdown)\",\n  }),\n  status: z\n    .enum([\"published\", \"draft\", \"all\"])\n    .optional()\n    .default(\"published\")\n    .openapi({\n      param: { name: \"status\", in: \"query\" },\n      example: \"published\",\n      description:\n        \"Filter by post status. Use 'published' for live posts, 'draft' for unpublished posts, or 'all' for both.\",\n    }),\n});\n\nexport const CreatePostBodySchema = z\n  .object({\n    title: z\n      .string()\n      .min(1, \"Title cannot be empty\")\n      .openapi({ example: \"Getting Started with Next.js\" }),\n    content: z\n      .string()\n      .min(1, \"Content cannot be empty\")\n      .openapi({ example: \"<p>Hello world</p>\" }),\n    description: z\n      .string()\n      .min(1, \"Description cannot be empty\")\n      .openapi({ example: \"A beginner's guide to Next.js\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .openapi({ example: \"getting-started-with-nextjs\" }),\n    categoryId: z\n      .string()\n      .min(1, \"Category ID is required\")\n      .openapi({ example: \"cryitfjp2345kl05weoybfk9\" }),\n    status: z.enum([\"published\", \"draft\"]).openapi({ example: \"draft\" }),\n    tags: z\n      .array(z.string())\n      .optional()\n      .openapi({\n        example: [\"cryitfjp4567no07ygqadhm1\"],\n        description: \"Array of tag IDs to attach to the post\",\n      }),\n    authors: z\n      .array(z.string())\n      .optional()\n      .openapi({\n        example: [\"cryitfjp3456lm06xfpzcgl0\"],\n        description:\n          \"Array of author IDs. If omitted, the first workspace author is used.\",\n      }),\n    featured: z.boolean().optional().default(false).openapi({ example: false }),\n    coverImage: z\n      .url()\n      .nullable()\n      .optional()\n      .openapi({ example: \"https://media.marblecms.com/cover.jpg\" }),\n    publishedAt: z.iso.datetime().optional().openapi({\n      example: \"2024-01-15T10:00:00Z\",\n      description: \"ISO 8601 datetime. Defaults to current time if omitted.\",\n    }),\n  })\n  .openapi(\"CreatePostBody\");\n\nexport const CreatePostResponseSchema = z\n  .object({\n    post: z.object({\n      id: z.string().openapi({ example: \"cryitfjp5678mn09qrstuvwx\" }),\n      slug: z.string().openapi({ example: \"getting-started-with-nextjs\" }),\n      title: z.string().openapi({ example: \"Getting Started with Next.js\" }),\n      status: z.string().openapi({ example: \"draft\" }),\n      featured: z.boolean().openapi({ example: false }),\n      publishedAt: z.string().openapi({ example: \"2024-01-15T10:00:00.000Z\" }),\n      createdAt: z.string().openapi({ example: \"2024-01-15T10:00:00.000Z\" }),\n    }),\n  })\n  .openapi(\"CreatePostResponse\");\n\nexport const UpdatePostBodySchema = z\n  .object({\n    title: z\n      .string()\n      .min(1, \"Title cannot be empty\")\n      .optional()\n      .openapi({ example: \"Updated Post Title\" }),\n    content: z\n      .string()\n      .min(1, \"Content cannot be empty\")\n      .optional()\n      .openapi({ example: \"<p>Updated content</p>\" }),\n    description: z\n      .string()\n      .min(1, \"Description cannot be empty\")\n      .optional()\n      .openapi({ example: \"Updated description\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .optional()\n      .openapi({ example: \"updated-post-slug\" }),\n    categoryId: z\n      .string()\n      .min(1, \"Category ID is required\")\n      .optional()\n      .openapi({ example: \"cryitfjp2345kl05weoybfk9\" }),\n    status: z\n      .enum([\"published\", \"draft\"])\n      .optional()\n      .openapi({ example: \"published\" }),\n    tags: z\n      .array(z.string())\n      .optional()\n      .openapi({\n        example: [\"cryitfjp4567no07ygqadhm1\"],\n        description:\n          \"Array of tag IDs. Replaces all existing tags when provided.\",\n      }),\n    authors: z\n      .array(z.string())\n      .optional()\n      .openapi({\n        example: [\"cryitfjp3456lm06xfpzcgl0\"],\n        description:\n          \"Array of author IDs. Replaces all existing authors when provided.\",\n      }),\n    featured: z.boolean().optional().openapi({ example: true }),\n    coverImage: z\n      .url()\n      .nullable()\n      .optional()\n      .openapi({ example: \"https://media.marblecms.com/new-cover.jpg\" }),\n    publishedAt: z.iso\n      .datetime()\n      .optional()\n      .openapi({ example: \"2024-02-01T10:00:00Z\" }),\n  })\n  .openapi(\"UpdatePostBody\");\n\nexport const UpdatePostResponseSchema = z\n  .object({\n    post: z.object({\n      id: z.string().openapi({ example: \"cryitfjp5678mn09qrstuvwx\" }),\n      slug: z.string().openapi({ example: \"updated-post-slug\" }),\n      title: z.string().openapi({ example: \"Updated Post Title\" }),\n      status: z.string().openapi({ example: \"published\" }),\n      featured: z.boolean().openapi({ example: true }),\n      publishedAt: z.string().openapi({ example: \"2024-02-01T10:00:00.000Z\" }),\n      updatedAt: z.string().openapi({ example: \"2024-02-01T12:00:00.000Z\" }),\n    }),\n  })\n  .openapi(\"UpdatePostResponse\");\n"
  },
  {
    "path": "apps/api/src/schemas/tags.ts",
    "content": "import { z } from \"@hono/zod-openapi\";\nimport { PaginationSchema } from \"./common\";\n\nexport const TagSchema = z\n  .object({\n    id: z.string().openapi({ example: \"cryitfjp4567no07ygqadhm1\" }),\n    name: z.string().openapi({ example: \"JavaScript\" }),\n    slug: z.string().openapi({ example: \"javascript\" }),\n    description: z\n      .string()\n      .nullable()\n      .openapi({ example: \"JavaScript tutorials\" }),\n    count: z\n      .object({\n        posts: z.number().int().openapi({ example: 8 }),\n      })\n      .openapi({ description: \"Number of published posts with this tag\" }),\n  })\n  .openapi(\"Tag\");\n\nexport const TagsListResponseSchema = z\n  .object({\n    tags: z.array(TagSchema),\n    pagination: PaginationSchema,\n  })\n  .openapi(\"TagsListResponse\");\n\nexport const TagResponseSchema = z\n  .object({\n    tag: TagSchema,\n  })\n  .openapi(\"TagResponse\");\n\nexport const CreateTagBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name cannot be empty\")\n      .openapi({ example: \"JavaScript\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .openapi({ example: \"javascript\" }),\n    description: z\n      .string()\n      .optional()\n      .openapi({ example: \"JavaScript tutorials and guides\" }),\n  })\n  .openapi(\"CreateTagBody\");\n\nexport const CreateTagResponseSchema = z\n  .object({\n    tag: z.object({\n      id: z.string().openapi({ example: \"cryitfjp4567no07ygqadhm1\" }),\n      name: z.string().openapi({ example: \"JavaScript\" }),\n      slug: z.string().openapi({ example: \"javascript\" }),\n      description: z\n        .string()\n        .nullable()\n        .openapi({ example: \"JavaScript tutorials and guides\" }),\n    }),\n  })\n  .openapi(\"CreateTagResponse\");\n\nexport const UpdateTagBodySchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, \"Name cannot be empty\")\n      .optional()\n      .openapi({ example: \"TypeScript\" }),\n    slug: z\n      .string()\n      .slugify()\n      .min(1, \"Slug cannot be empty\")\n      .optional()\n      .openapi({ example: \"typescript\" }),\n    description: z\n      .string()\n      .nullable()\n      .optional()\n      .openapi({ example: \"TypeScript tutorials and guides\" }),\n  })\n  .openapi(\"UpdateTagBody\");\n"
  },
  {
    "path": "apps/api/src/types/env.ts",
    "content": "export interface Env {\n  DATABASE_URL: string;\n  HYPERDRIVE: { connectionString: string };\n  STORAGE: R2Bucket;\n  STORAGE_PUBLIC_URL?: string;\n  REDIS_URL: string;\n  REDIS_TOKEN: string;\n  POLAR_ACCESS_TOKEN: string;\n  ENVIRONMENT?: string;\n  SYSTEM_SECRET: string;\n  RESEND_API_KEY: string;\n  EVENT_QUEUE: Queue;\n}\n\n// Context variables set by keyAuthorization middleware\nexport interface ApiKeyVariables {\n  workspaceId?: string;\n  apiKeyId?: string;\n  apiKeyType?: \"public\" | \"private\";\n}\n\n// Hono app type for API key authenticated routes\nexport interface ApiKeyApp {\n  Bindings: Env;\n  Variables: ApiKeyVariables;\n}\n"
  },
  {
    "path": "apps/api/src/validations/authors.ts",
    "content": "import { z } from \"zod\";\n\nexport const AuthorsQuerySchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 10 : Math.max(1, Math.min(100, num));\n    })\n    .default(10),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n\nexport const AuthorQuerySchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 20 : Math.max(1, Math.min(100, num));\n    })\n    .default(20),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n"
  },
  {
    "path": "apps/api/src/validations/categories.ts",
    "content": "import { z } from \"zod\";\n\nexport const CategoriesQuerySchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 10 : Math.max(1, Math.min(100, num));\n    })\n    .default(10),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n\nexport const CategoryQuerySchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 20 : Math.max(1, Math.min(100, num));\n    })\n    .default(20),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n"
  },
  {
    "path": "apps/api/src/validations/json.ts",
    "content": "import { z } from \"zod\";\n\nconst JsonLiteralSchema = z.union([\n  z.string(),\n  z.number(),\n  z.boolean(),\n  z.null(),\n]);\n\nexport type JsonValue =\n  | z.infer<typeof JsonLiteralSchema>\n  | JsonObject\n  | JsonValue[];\n\nexport interface JsonObject {\n  [key: string]: JsonValue;\n}\n\nexport const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>\n  z.union([\n    JsonLiteralSchema,\n    z.array(JsonValueSchema),\n    z.record(z.string(), JsonValueSchema),\n  ])\n);\n\nexport const JsonObjectSchema: z.ZodType<JsonObject> = z.record(\n  z.string(),\n  JsonValueSchema\n);\n"
  },
  {
    "path": "apps/api/src/validations/misc.ts",
    "content": "import {\n  WORKSPACE_EVENT_ACTOR_TYPES as EVENT_ACTOR_TYPES,\n  WORKSPACE_EVENT_RESOURCE_TYPES as EVENT_RESOURCE_TYPES,\n  WORKSPACE_EVENT_SOURCES as EVENT_SOURCES,\n  WORKSPACE_EVENT_TYPES as EVENT_TYPES,\n} from \"@marble/events\";\nimport { z } from \"zod\";\nimport { JsonObjectSchema } from \"@/validations/json\";\n\nexport const WORKSPACE_EVENT_TYPES = EVENT_TYPES;\nexport const WORKSPACE_EVENT_SOURCES = EVENT_SOURCES;\nexport const WORKSPACE_EVENT_ACTOR_TYPES = EVENT_ACTOR_TYPES;\nexport const WORKSPACE_EVENT_RESOURCE_TYPES = EVENT_RESOURCE_TYPES;\n\nexport const InternalEventSchema = z\n  .object({\n    type: z.enum(EVENT_TYPES),\n    workspaceId: z.string().min(1),\n    source: z.enum(EVENT_SOURCES).optional().default(\"dashboard\"),\n    resourceType: z.enum(EVENT_RESOURCE_TYPES).optional(),\n    resourceId: z.string().min(1).optional(),\n    actorType: z.enum(EVENT_ACTOR_TYPES).optional(),\n    actorId: z.string().min(1).optional(),\n    payload: JsonObjectSchema.optional().default({}),\n    isTest: z.boolean().optional().default(false),\n    targetWebhookEndpointId: z.string().min(1).optional(),\n  })\n  .refine(\n    (event) => Boolean(event.resourceType) === Boolean(event.resourceId),\n    {\n      message: \"resourceType and resourceId must be provided together\",\n      path: [\"resourceId\"],\n    }\n  );\n\nexport const BasicPaginationSchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 10 : Math.max(1, Math.min(100, num));\n    })\n    .default(10),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n\nexport const CacheInvalidateSchema = z.object({\n  resource: z\n    .enum([\"posts\", \"categories\", \"tags\", \"authors\", \"usage\"])\n    .optional(),\n});\n\nexport const SystemCacheInvalidateSchema = z.object({\n  workspaceId: z.string(),\n  resource: z\n    .enum([\"posts\", \"categories\", \"tags\", \"authors\", \"usage\"])\n    .optional(),\n});\n"
  },
  {
    "path": "apps/api/src/validations/posts.ts",
    "content": "import { z } from \"zod\";\n\nexport const OrderSchema = z.enum([\"asc\", \"desc\"]).default(\"desc\");\n\nexport const PostsQuerySchema = z.object({\n  limit: z\n    .union([\n      z.literal(\"all\"),\n      z.string().transform((val) => {\n        const num = Number.parseInt(val, 10);\n        return Number.isNaN(num) ? 10 : Math.max(1, num);\n      }),\n      z.number().positive(),\n    ])\n    .default(10),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n  order: OrderSchema,\n  author: z.string().optional(),\n  categories: z\n    .string()\n    .transform((val) =>\n      val\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean)\n    )\n    .optional(),\n  excludeCategories: z\n    .string()\n    .transform((val) =>\n      val\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean)\n    )\n    .optional(),\n  tags: z\n    .string()\n    .transform((val) =>\n      val\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean)\n    )\n    .optional(),\n  excludeTags: z\n    .string()\n    .transform((val) =>\n      val\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean)\n    )\n    .optional(),\n  query: z.string().optional(),\n});\n\nexport const PostQuerySchema = z.object({\n  include: z\n    .string()\n    .transform((val) =>\n      val\n        .split(\",\")\n        .map((s) => s.trim())\n        .filter(Boolean)\n    )\n    .optional(),\n});\n"
  },
  {
    "path": "apps/api/src/validations/tags.ts",
    "content": "import { z } from \"zod\";\n\nexport const TagsQuerySchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 10 : Math.max(1, Math.min(100, num));\n    })\n    .default(10),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n\nexport const TagQuerySchema = z.object({\n  limit: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 20 : Math.max(1, Math.min(100, num));\n    })\n    .default(20),\n  page: z\n    .string()\n    .transform((val) => {\n      const num = Number.parseInt(val, 10);\n      return Number.isNaN(num) ? 1 : Math.max(1, num);\n    })\n    .default(1),\n});\n"
  },
  {
    "path": "apps/api/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"lib\": [\"ESNext\"],\n    \"types\": [\"@cloudflare/workers-types\"],\n    \"jsx\": \"react-jsx\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/api/wrangler.jsonc",
    "content": "{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"name\": \"marble-api\",\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2026-05-02\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"keep_vars\": true,\n  \"placement\": { \"mode\": \"smart\" },\n  \"observability\": {\n    \"enabled\": true,\n    \"head_sampling_rate\": 1\n  },\n  \"hyperdrive\": [\n    { \"binding\": \"HYPERDRIVE\", \"id\": \"c0eea431cc454c9b96589cd52f70a009\" }\n  ],\n  \"r2_buckets\": [{ \"binding\": \"STORAGE\", \"bucket_name\": \"marblecms\" }],\n  \"queues\": {\n    \"producers\": [\n      {\n        \"binding\": \"EVENT_QUEUE\",\n        \"queue\": \"marble-events\"\n      }\n    ]\n  },\n  \"env\": {\n    \"development\": {\n      \"hyperdrive\": [\n        {\n          \"binding\": \"HYPERDRIVE\",\n          \"id\": \"d20494d3ce894268b67505bf684a2395\"\n        }\n      ],\n      \"r2_buckets\": [\n        {\n          \"binding\": \"STORAGE\",\n          \"bucket_name\": \"marbledev\",\n          \"remote\": true\n        }\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/cms/.env.example",
    "content": "# POSTGRES CONNECTION STRING\nDATABASE_URL=postgresql://usemarble:justusemarble@localhost:5432/marble\n\n# AUTH\nBETTER_AUTH_SECRET=your-better-auth-secret\nBETTER_AUTH_URL=http://localhost:3000\n\n# GOOGLE OAUTH\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n\n# GITHUB OAUTH\nGITHUB_ID=\nGITHUB_SECRET=\n\n# RESEND\nRESEND_API_KEY=\n\n# POLAR\nPOLAR_ACCESS_TOKEN=\nPOLAR_WEBHOOK_SECRET=\nPOLAR_SUCCESS_URL=http://localhost:3000/api/polar/success?checkout_id={CHECKOUT_ID}\n\n# POLAR PRODUCT ID's\nPOLAR_HOBBY_PRODUCT_ID=\nPOLAR_PRO_PRODUCT_ID=\nPOLAR_PRO_YEARLY_PRODUCT_ID=\n\n# NEXT\nNEXT_PUBLIC_APP_URL=http://localhost:3000\nNEXT_PUBLIC_SITE_URL=https://marblecms.com\n\n# SYSTEM\nMARBLE_API_URL=\nSYSTEM_SECRET=\n\n# R2\nCLOUDFLARE_SECRET_ACCESS_KEY=\nCLOUDFLARE_ACCESS_KEY_ID=\n\nCLOUDFLARE_BUCKET_NAME=\nCLOUDFLARE_PUBLIC_URL=\n\nCLOUDFLARE_TOKEN=\nCLOUDFLARE_S3_ENDPOINT=\n\n# Redis\nREDIS_URL=http://localhost:8079\nREDIS_TOKEN=justusemarble\n\nAI_GATEWAY_API_KEY=\n"
  },
  {
    "path": "apps/cms/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n!.env.example\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n"
  },
  {
    "path": "apps/cms/README.md",
    "content": "# CMS\n\nDashboard for content management.\n"
  },
  {
    "path": "apps/cms/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"../../packages/ui/src/styles/globals.css\",\n    \"baseColor\": \"zinc\",\n    \"cssVariables\": true\n  },\n  \"iconLibrary\": \"phosphor\",\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"hooks\": \"@/hooks\",\n    \"lib\": \"@/lib\",\n    \"utils\": \"@marble/ui/lib/utils\",\n    \"ui\": \"@marble/ui/components\"\n  }\n}\n"
  },
  {
    "path": "apps/cms/next.config.ts",
    "content": "import path from \"node:path\";\nimport type { NextConfig } from \"next\";\n\nconst nextConfig: NextConfig = {\n  reactStrictMode: true,\n  transpilePackages: [\n    \"@marble/db\",\n    \"@marble/ui\",\n    \"@marble/parser\",\n    \"@marble/email\",\n  ],\n  reactCompiler: true,\n  experimental: {\n    optimizePackageImports: [\"@phosphor-icons/react\"],\n  },\n  turbopack: {\n    root: path.resolve(__dirname, \"../..\"),\n  },\n  async redirects() {\n    return [\n      {\n        source: \"/settings\",\n        destination: \"/settings/general\",\n        permanent: true,\n      },\n      {\n        source: \"/settings/\",\n        destination: \"/settings/general\",\n        permanent: true,\n      },\n    ];\n  },\n  images: {\n    remotePatterns: [\n      {\n        protocol: \"https\",\n        hostname: \"avatars.githubusercontent.com\",\n      },\n      {\n        protocol: \"https\",\n        hostname: \"lh3.googleusercontent.com\",\n      },\n      {\n        protocol: \"https\",\n        hostname: \"images.marblecms.com\",\n      },\n      {\n        protocol: \"https\",\n        hostname: \"media.marblecms.com\",\n      },\n    ],\n    qualities: [20, 40, 60, 80, 100],\n  },\n};\n\nexport default nextConfig;\n"
  },
  {
    "path": "apps/cms/package.json",
    "content": "{\n  \"name\": \"cms\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\"\n  },\n  \"dependencies\": {\n    \"@ai-sdk/react\": \"^2.0.47\",\n    \"@aws-sdk/client-s3\": \"^3.758.0\",\n    \"@aws-sdk/lib-storage\": \"^3.758.0\",\n    \"@aws-sdk/s3-request-presigner\": \"^3.758.0\",\n    \"@better-fetch/fetch\": \"^1.1.21\",\n    \"@databuddy/sdk\": \"^2.1.75\",\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@hugeicons/core-free-icons\": \"^3.1.1\",\n    \"@hugeicons/react\": \"^1.1.4\",\n    \"@marble/db\": \"workspace:*\",\n    \"@marble/editor\": \"workspace:*\",\n    \"@marble/email\": \"workspace:*\",\n    \"@marble/events\": \"workspace:*\",\n    \"@marble/parser\": \"workspace:*\",\n    \"@marble/ui\": \"workspace:*\",\n    \"@marble/utils\": \"workspace:*\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@polar-sh/better-auth\": \"^1.8.3\",\n    \"@polar-sh/sdk\": \"^0.46.4\",\n    \"@tanstack/react-query\": \"^5.85.5\",\n    \"@tanstack/react-query-devtools\": \"^5.85.5\",\n    \"@tanstack/react-table\": \"^8.20.5\",\n    \"@tanstack/react-virtual\": \"^3.13.12\",\n    \"@upstash/ratelimit\": \"^2.0.5\",\n    \"@upstash/redis\": \"^1.35.3\",\n    \"@vvo/tzdb\": \"^6.176.0\",\n    \"ai\": \"^5.0.108\",\n    \"axios\": \"^1.16.0\",\n    \"better-auth\": \"1.5.6\",\n    \"blurhash\": \"^2.0.5\",\n    \"canvas-confetti\": \"^1.9.3\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"croner\": \"^9.1.0\",\n    \"date-fns\": \"^4.1.0\",\n    \"gray-matter\": \"^4.0.3\",\n    \"input-otp\": \"^1.4.2\",\n    \"lowlight\": \"^3.3.0\",\n    \"motion\": \"^11.15.0\",\n    \"nanoid\": \"^5.0.9\",\n    \"next\": \"16.2.6\",\n    \"next-themes\": \"^0.4.6\",\n    \"node-html-markdown\": \"^1.3.0\",\n    \"nuqs\": \"^2.8.4\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-dropzone\": \"^14.3.8\",\n    \"react-hook-form\": \"^7.72.0\",\n    \"react-textarea-autosize\": \"^8.5.9\",\n    \"recharts\": \"^2.15.3\",\n    \"resend\": \"^6.12.3\",\n    \"sanitize-html\": \"^2.17.0\",\n    \"server-only\": \"^0.0.1\",\n    \"sharp\": \"^0.33.5\",\n    \"shiki\": \"^3.12.2\",\n    \"sonner\": \"^1.7.1\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"usehooks-ts\": \"^3.1.1\",\n    \"vaul\": \"^1.1.2\",\n    \"zod\": \"^4.3.5\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"@tailwindcss/postcss\": \"^4.2.1\",\n    \"@tailwindcss/typography\": \"^0.5.19\",\n    \"@types/canvas-confetti\": \"^1.9.0\",\n    \"@types/node\": \"^22.9.0\",\n    \"@types/react\": \"19.2.14\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"@types/sanitize-html\": \"^2.16.0\",\n    \"babel-plugin-react-compiler\": \"^1.0.0\",\n    \"postcss\": \"^8.5.6\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "apps/cms/postcss.config.mjs",
    "content": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    \"@tailwindcss/postcss\": {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "apps/cms/public/manifest.json",
    "content": "{\n  \"name\": \"Marble\",\n  \"short_name\": \"Marble\",\n  \"icons\": [\n    {\n      \"src\": \"/web-app-manifest-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/web-app-manifest-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"theme_color\": \"#ffffff\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/join/[id]/page-client.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { buttonVariants } from \"@marble/ui/components/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  ArrowArcLeftIcon,\n  CheckIcon,\n  CircleNotchIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { organization } from \"@/lib/auth/client\";\n\ninterface PageClientProps {\n  id: string;\n  user: {\n    id: string;\n    email: string;\n    emailVerified: boolean;\n    name: string;\n    createdAt: Date;\n    updatedAt: Date;\n    image?: string | null | undefined | undefined;\n  };\n}\n\ninterface GetOrganizationResponse {\n  organizationName: string;\n  organizationSlug: string;\n  inviterEmail: string;\n  id: string;\n  status: \"pending\" | \"accepted\" | \"rejected\" | \"canceled\";\n  email: string;\n  expiresAt: Date;\n  organizationId: string;\n  role: string;\n  inviterId: string;\n}\n\ntype InviteStatus = \"pending\" | \"accepted\" | \"rejected\";\n\nfunction PageClient({ id, user }: PageClientProps) {\n  const [inviteStatus, setInviteStatus] = useState<InviteStatus>(\"pending\");\n  const [actionError, setActionError] = useState<string | null>(null);\n  const [accepting, setAccepting] = useState(false);\n  const [rejecting, setRejecting] = useState(false);\n  const router = useRouter();\n\n  const {\n    data: invitation,\n    error: fetchError,\n    isLoading,\n  } = useQuery({\n    queryKey: [\"invitation\", id],\n    queryFn: async () => {\n      const res = await organization.getInvitation({ query: { id } });\n      if (res.error) {\n        throw new Error(res.error.message || \"An error occurred\");\n      }\n      return res.data as GetOrganizationResponse;\n    },\n  });\n\n  const error = actionError || fetchError?.message || null;\n\n  const handleAccept = async () => {\n    setAccepting(true);\n    setActionError(null);\n    try {\n      const res = await organization.acceptInvitation({\n        invitationId: id,\n      });\n\n      if (res.error) {\n        setActionError(res.error.message || \"Failed to accept invitation\");\n      } else {\n        setInviteStatus(\"accepted\");\n        router.push(`/${invitation?.organizationSlug}`);\n      }\n    } catch (error) {\n      console.error(\"Error accepting invitation:\", error);\n      setActionError(\n        error instanceof Error\n          ? error.message\n          : \"An unexpected error occurred. Please try again.\"\n      );\n    }\n    setAccepting(false);\n  };\n\n  const handleReject = async () => {\n    setRejecting(true);\n    setActionError(null);\n    try {\n      const res = await organization.rejectInvitation({\n        invitationId: id,\n      });\n\n      if (res.error) {\n        setActionError(res.error.message || \"Failed to reject invitation\");\n      } else {\n        setInviteStatus(\"rejected\");\n      }\n    } catch (error) {\n      console.error(\"Error rejecting invitation:\", error);\n      setActionError(\n        error instanceof Error\n          ? error.message\n          : \"An unexpected error occurred. Please try again.\"\n      );\n    }\n    setRejecting(false);\n  };\n\n  return (\n    <div className=\"flex items-center justify-center\">\n      {invitation ? (\n        <Card className=\"max-w-md rounded-[24px] px-5 py-7\">\n          <CardHeader\n            className={cn(\n              \"items-center\",\n              inviteStatus !== \"pending\" && \"sr-only\"\n            )}\n          >\n            <CardTitle className=\"font-medium\">Invitation</CardTitle>\n            <CardDescription>\n              You've been invited to join a workspace\n            </CardDescription>\n          </CardHeader>\n          <CardContent>\n            {inviteStatus === \"pending\" && (\n              <div className=\"mt-5 flex flex-col gap-8\">\n                <div className=\"flex items-center justify-center gap-4\">\n                  <Avatar className=\"size-14\">\n                    <AvatarImage src={user.image || \"\"} />\n                    <AvatarFallback>XQ</AvatarFallback>\n                  </Avatar>\n                  <svg\n                    className=\"size-6\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={1}\n                    viewBox=\"0 0 24 24\"\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                  >\n                    <title>X</title>\n                    <path\n                      d=\"M17.25 8.25 21 12m0 0-3.75 3.75M21 12H3\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                  </svg>\n                  <Avatar className=\"size-14\">\n                    <AvatarImage src=\"\" />\n                    <AvatarFallback>MAB</AvatarFallback>\n                  </Avatar>\n                </div>\n                <p className=\"text-center text-sm\">\n                  <strong>{invitation?.inviterEmail}</strong> has invited you to\n                  join <strong>{invitation?.organizationName}</strong>.\n                </p>\n                {/* <p className=\"text-sm text-center\">\n                  This invitation was sent to{\" \"}\n                  <strong>{invitation?.email}</strong>.\n                </p> */}\n              </div>\n            )}\n            {inviteStatus === \"accepted\" && (\n              <div className=\"space-y-4 pt-8 pb-4\">\n                <div className=\"mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-100\">\n                  <CheckIcon className=\"h-8 w-8 text-green-600\" />\n                </div>\n                <h2 className=\"text-center font-medium text-2xl\">\n                  Welcome to {invitation?.organizationName}!\n                </h2>\n                <p className=\"text-center\">\n                  We're excited to have you on board!\n                </p>\n              </div>\n            )}\n            {inviteStatus === \"rejected\" && (\n              <div className=\"space-y-4 pt-8 pb-4\">\n                <div className=\"mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100\">\n                  <XIcon className=\"h-8 w-8 text-red-600\" />\n                </div>\n                <h2 className=\"text-center font-medium text-2xl\">Declined</h2>\n                <p className=\"text-center text-muted-foreground\">\n                  You&lsquo;ve declined the invitation to join{\" \"}\n                  {invitation?.organizationName}.\n                </p>\n                <div className=\"flex items-center justify-center\">\n                  <Link\n                    className={buttonVariants({\n                      variant: \"outline\",\n                      className: \"flex items-center gap-2\",\n                    })}\n                    href=\"/\"\n                  >\n                    <ArrowArcLeftIcon className=\"size-4\" />\n                    <span>Back home</span>\n                  </Link>\n                </div>\n              </div>\n            )}\n          </CardContent>\n          {error && inviteStatus === \"pending\" && (\n            <div className=\"mt-4 rounded-sm border border-destructive bg-destructive/10 p-3\">\n              <ErrorMessage className=\"text-center text-sm\">\n                {error}\n              </ErrorMessage>\n            </div>\n          )}\n          {inviteStatus === \"pending\" && (\n            <CardFooter className=\"mt-4 grid grid-cols-2 gap-6\">\n              <AsyncButton\n                isLoading={rejecting}\n                onClick={handleReject}\n                variant=\"outline\"\n              >\n                Reject\n              </AsyncButton>\n              <AsyncButton\n                isLoading={accepting}\n                onClick={handleAccept}\n                variant=\"outline\"\n              >\n                Accept\n              </AsyncButton>\n            </CardFooter>\n          )}\n        </Card>\n      ) : error && !isLoading ? (\n        <InviteError />\n      ) : isLoading ? (\n        <InviteLoading />\n      ) : null}\n    </div>\n  );\n}\n\nexport default PageClient;\n\nfunction InviteError() {\n  return (\n    <Card className=\"w-full max-w-md rounded-[24px] px-5 py-7\">\n      <CardHeader className=\"text-center\">\n        <CardTitle className=\"font-medium\">Invalid Invite</CardTitle>\n        <CardDescription className=\"sr-only\">\n          This invite is invalid or you don't have the correct permissions.\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <div className=\"flex flex-col items-center gap-6\">\n          <p className=\"text-center text-muted-foreground\">\n            The invitation you're trying to access is either invalid or you\n            don't have the correct permissions. Please check your email for a\n            valid invitation or contact the sender.\n          </p>\n          <Link\n            className={buttonVariants({\n              variant: \"outline\",\n              className: \"flex items-center gap-2\",\n            })}\n            href=\"/\"\n          >\n            <ArrowArcLeftIcon className=\"size-4 text-muted-foreground\" />\n            <span>Back home</span>\n          </Link>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n\nfunction InviteLoading() {\n  return (\n    <Card className=\"grid h-80 max-w-md place-content-center rounded-[24px] p-6\">\n      <CardHeader className=\"sr-only\">\n        <CardTitle>Loading</CardTitle>\n        <CardDescription>\n          We're verifying your invite link, please hold on.\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <div className=\"flex flex-col items-center gap-4\">\n          <CircleNotchIcon className=\"size-5 animate-spin transition\" />\n          <p className=\"max-w-prose text-center text-muted-foreground\">\n            We're verifying your invite link. This might take a few seconds...\n          </p>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/join/[id]/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\nimport { Suspense } from \"react\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { getServerSession } from \"@/lib/auth/session\";\nimport PageClient from \"./page-client\";\n\nexport default async function InvitePage(props: {\n  params: Promise<{ id: string }>;\n}) {\n  const params = await props.params;\n  const { id } = params;\n\n  return (\n    <div className=\"grid h-screen w-full place-content-center bg-muted\">\n      <Suspense fallback={<PageLoader />}>\n        <InvitePageComponent code={id} />\n      </Suspense>\n    </div>\n  );\n}\n\nasync function InvitePageComponent({ code }: { code: string }) {\n  const session = await getServerSession({ allowUnverified: true });\n\n  if (!session || !session.user) {\n    return redirect(`/login/?from=/join/${code}`);\n  }\n\n  return <PageClient id={code} user={session.user} />;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/layout.tsx",
    "content": "export default function AuthLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return <div className=\"grid min-h-dvh\">{children}</div>;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/login/page.tsx",
    "content": "import { Separator } from \"@marble/ui/components/separator\";\nimport type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { Suspense } from \"react\";\nimport { LoginForm } from \"@/components/auth/login-form\";\nimport MarbleIcon from \"@/components/icons/marble\";\nimport { SITE_CONFIG } from \"@/utils/site\";\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(SITE_CONFIG.url),\n  title: \"Log In - Marble\",\n  description: \"Sign in to your Marble account to manage your blog.\",\n  alternates: {\n    canonical: \"/login\",\n  },\n};\n\ninterface PageProps {\n  params: Promise<{ slug: string }>;\n  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;\n}\n\nexport default async function LoginPage(props: PageProps) {\n  const searchParams = await props.searchParams;\n  const from = searchParams.from;\n\n  return (\n    <div className=\"h-screen w-full md:grid md:grid-cols-2\">\n      <section className=\"hidden flex-col justify-between bg-surface p-6 md:flex\">\n        <div>\n          <MarbleIcon />\n        </div>\n        <div className=\"flex items-center justify-between gap-4\">\n          <p className=\"font-medium text-lg\">\n            The easiest way to manage your blog.\n          </p>\n        </div>\n      </section>\n      <section className=\"flex h-full flex-col items-center justify-between p-6\">\n        <div className=\"self-start\">\n          <h1 className=\"sr-only\">Login to your account</h1>\n        </div>\n        <div className=\"flex min-w-[300px] flex-col gap-8 rounded-md p-6 lg:w-[384px] lg:px-8 lg:py-10\">\n          <div className=\"text-center\">\n            <p className=\"font-semibold text-xl lg:text-2xl\">Welcome back</p>\n            <p className=\"text-muted-foreground text-sm\">\n              Please sign-in to continue.\n            </p>\n          </div>\n\n          <Suspense>\n            <LoginForm />\n          </Suspense>\n\n          <div className=\"flex flex-col gap-4 px-8 text-center text-muted-foreground text-xs\">\n            <p>\n              <Link\n                className=\"underline underline-offset-2 hover:text-primary\"\n                href={\n                  from && from !== \"/reset\" ? `/reset?from=${from}` : \"/reset\"\n                }\n              >\n                Forgot password?\n              </Link>\n            </p>\n\n            <Separator />\n\n            <p>\n              Don&apos;t have an account?{\" \"}\n              <Link\n                className=\"underline underline-offset-2 hover:text-primary\"\n                href={\n                  from && from !== \"/\" ? `/register?from=${from}` : \"/register\"\n                }\n              >\n                Register\n              </Link>\n            </p>\n          </div>\n        </div>\n        <div>\n          <p className=\"px-8 text-center text-muted-foreground text-xs\">\n            By continuing, you agree to our{\" \"}\n            <Link\n              className=\"underline underline-offset-2 hover:text-primary\"\n              href=\"https://marblecms.com/terms\"\n              target=\"_blank\"\n            >\n              Terms of Service\n            </Link>{\" \"}\n            and{\" \"}\n            <Link\n              className=\"underline underline-offset-2 hover:text-primary\"\n              href=\"https://marblecms.com/privacy\"\n              target=\"_blank\"\n            >\n              Privacy Policy\n            </Link>\n            .\n          </p>\n        </div>\n      </section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/new/page-client.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { buttonVariants } from \"@marble/ui/components/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport Link from \"next/link\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { Suspense } from \"react\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { TimezoneSelector } from \"@/components/ui/timezone-selector\";\nimport { organization } from \"@/lib/auth/client\";\nimport { timezones } from \"@/lib/constants\";\nimport {\n  type CreateWorkspaceValues,\n  workspaceSchema,\n} from \"@/lib/validations/workspace\";\nimport { generateSlug } from \"@/utils/string\";\n\nfunction PageClientInner() {\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    formState: { errors, isSubmitting },\n  } = useForm<CreateWorkspaceValues>({\n    resolver: zodResolver(workspaceSchema),\n    defaultValues: {\n      name: \"\",\n      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    },\n  });\n\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const hasWorkspaces = searchParams.get(\"workspaces\") === \"true\";\n\n  async function onSubmit(payload: CreateWorkspaceValues) {\n    const { error } = await organization.checkSlug({\n      slug: payload.slug,\n    });\n\n    if (error) {\n      toast.error(error.message);\n      console.log(\"check slug error\", error);\n      return;\n    }\n\n    try {\n      const { data, error } = await organization.create({\n        name: payload.name,\n        slug: payload.slug,\n        timezone: payload.timezone,\n        logo: `https://api.dicebear.com/9.x/glass/svg?seed=${payload.name}`,\n      });\n      if (error) {\n        toast.error(error.message);\n        return;\n      }\n      if (data) {\n        console.log(\"setting active organization\", data.id);\n        await organization.setActive({\n          organizationId: data.id,\n        });\n        console.log(\"active organization set\", data.id);\n        router.push(`/${data.slug}`);\n        toast.success(\"Workspace created\");\n      }\n    } catch (error) {\n      console.error(\"Failed to create workspace\", error);\n      toast.error(\"Failed to create workspace\");\n    }\n  }\n  return (\n    <div className=\"grid h-screen place-items-center bg-surface dark:bg-background\">\n      <Card className=\"rounded-[20px] border-none bg-surface p-2 sm:w-[450px]\">\n        <div className=\"flex flex-col gap-6 rounded-[12px] bg-background p-6 shadow-xs\">\n          <CardHeader className=\"mb-0 items-center gap-0 text-center\">\n            <CardTitle className=\"font-medium text-2xl\">\n              New workspace\n            </CardTitle>\n            <CardDescription className=\"text-center\">\n              {hasWorkspaces\n                ? \"Set up your new workspace.\"\n                : \"You'll need a workspace to proceed.\"}\n            </CardDescription>\n          </CardHeader>\n          <CardContent className=\"p-0\">\n            <form\n              className=\"flex flex-col gap-10\"\n              onSubmit={handleSubmit(onSubmit)}\n            >\n              <div className=\"flex flex-col gap-4\">\n                <div className=\"flex flex-col gap-2\">\n                  <Label className=\"sr-only\" htmlFor=\"name\">\n                    Name\n                  </Label>\n\n                  <Input\n                    id=\"name\"\n                    placeholder=\"Name\"\n                    {...register(\"name\", {\n                      onChange: (e) => {\n                        setValue(\"slug\", generateSlug(e.target.value));\n                      },\n                    })}\n                  />\n                  {errors.name && (\n                    <ErrorMessage>{errors.name.message}</ErrorMessage>\n                  )}\n                </div>\n                <div className=\"grid flex-1 gap-2\">\n                  <Label className=\"sr-only\" htmlFor=\"slug\">\n                    Slug\n                  </Label>\n                  <div className=\"flex w-full overflow-hidden rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] placeholder:text-muted-foreground focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30\">\n                    <span className=\"border-r bg-muted p-2\">\n                      {process.env.NEXT_PUBLIC_APP_URL?.split(\"//\")[1]}/\n                    </span>\n\n                    <input\n                      id=\"slug\"\n                      placeholder=\"Slug\"\n                      {...register(\"slug\")}\n                      autoComplete=\"off\"\n                      className=\"w-full bg-transparent px-2 py-2 outline-none ring-0\"\n                    />\n                  </div>\n                  {errors.slug && (\n                    <ErrorMessage>{errors.slug.message}</ErrorMessage>\n                  )}\n                </div>\n                <div className=\"flex flex-col gap-2\">\n                  <Label className=\"sr-only\" htmlFor=\"timezone\">\n                    Timezone\n                  </Label>\n                  <Controller\n                    control={control}\n                    name=\"timezone\"\n                    render={({ field }) => (\n                      <TimezoneSelector\n                        onValueChange={field.onChange}\n                        placeholder=\"Select timezone...\"\n                        timezones={timezones}\n                        value={field.value}\n                      />\n                    )}\n                  />\n                  {errors.timezone && (\n                    <ErrorMessage>{errors.timezone.message}</ErrorMessage>\n                  )}\n                </div>\n              </div>\n              <div className=\"flex flex-col gap-4\">\n                <AsyncButton\n                  className=\"flex w-full cursor-pointer gap-2\"\n                  isLoading={isSubmitting}\n                  type=\"submit\"\n                >\n                  Create\n                </AsyncButton>\n                {hasWorkspaces && (\n                  <Link\n                    className={cn(\n                      buttonVariants({ variant: \"ghost\" }),\n                      \"w-full\"\n                    )}\n                    href=\"/\"\n                  >\n                    Back to dashboard\n                  </Link>\n                )}\n              </div>\n            </form>\n          </CardContent>\n        </div>\n      </Card>\n    </div>\n  );\n}\n\nfunction PageClient() {\n  return (\n    <Suspense>\n      <PageClientInner />\n    </Suspense>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/new/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport { Suspense } from \"react\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Create new workspace\",\n};\n\nasync function Page() {\n  return (\n    <Suspense>\n      <PageClient />\n    </Suspense>\n  );\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/register/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport Link from \"next/link\";\nimport { Suspense } from \"react\";\nimport { RegisterForm } from \"@/components/auth/register-form\";\nimport MarbleIcon from \"@/components/icons/marble\";\nimport { SITE_CONFIG } from \"@/utils/site\";\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(SITE_CONFIG.url),\n  title: \"Sign Up - Marble\",\n  description: \"Create a new Marble account to start managing your blog.\",\n  alternates: {\n    canonical: \"/register\",\n  },\n};\n\ninterface PageProps {\n  params: Promise<{ slug: string }>;\n  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;\n}\n\nexport default async function RegisterPage(props: PageProps) {\n  const searchParams = await props.searchParams;\n  const from = searchParams.from;\n\n  return (\n    <div className=\"h-screen w-full md:grid md:grid-cols-2\">\n      <section className=\"hidden flex-col justify-between bg-surface p-6 md:flex\">\n        <div>\n          <MarbleIcon />\n        </div>\n        <div className=\"flex items-center justify-between gap-4\">\n          <p className=\"font-medium text-lg\">\n            The easiest way to manage your blog.\n          </p>\n        </div>\n      </section>\n      <section className=\"flex h-full flex-col items-center justify-between p-6\">\n        <div className=\"self-start\">\n          <h1 className=\"sr-only\">Register for an account</h1>\n        </div>\n        <div className=\"flex min-w-[300px] flex-col gap-8 rounded-md p-6 lg:w-[384px] lg:px-8 lg:py-10\">\n          <div className=\"text-center\">\n            <p className=\"font-semibold text-xl lg:text-2xl\">Create Account</p>\n            <p className=\"text-muted-foreground text-sm\">\n              Sign up to get started.\n            </p>\n          </div>\n\n          <Suspense>\n            <RegisterForm />\n          </Suspense>\n\n          <p className=\"px-8 text-center text-muted-foreground text-xs\">\n            Already have an account?{\" \"}\n            <Link\n              className=\"underline underline-offset-2 hover:text-primary\"\n              href={from && from !== \"/\" ? `/login?from=${from}` : \"/login\"}\n            >\n              Login\n            </Link>\n          </p>\n        </div>\n        <div>\n          <p className=\"px-8 text-center text-muted-foreground text-xs\">\n            By continuing, you agree to our{\" \"}\n            <Link\n              className=\"underline underline-offset-2 hover:text-primary\"\n              href=\"https://marblecms.com/terms\"\n              target=\"_blank\"\n            >\n              Terms of Service\n            </Link>{\" \"}\n            and{\" \"}\n            <Link\n              className=\"underline underline-offset-2 hover:text-primary\"\n              href=\"https://marblecms.com/privacy\"\n              target=\"_blank\"\n            >\n              Privacy Policy\n            </Link>\n            .\n          </p>\n        </div>\n      </section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/reset/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport { Suspense } from \"react\";\nimport { ResetForm } from \"@/components/auth/reset/reset-form\";\nimport ResetRequestForm from \"@/components/auth/reset/reset-request-form\";\nimport PageLoader from \"@/components/shared/page-loader\";\n\nexport const metadata: Metadata = {\n  title: \"Reset Password\",\n};\n\ninterface PageProps {\n  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;\n}\n\nexport default async function ResetPage({ searchParams }: PageProps) {\n  let token = (await searchParams).token;\n\n  if (Array.isArray(token)) {\n    token = token[0];\n  }\n\n  // If token exists, show ResetForm\n  if (token) {\n    return (\n      <main className=\"flex min-h-screen items-center justify-center px-4\">\n        <Suspense fallback={<PageLoader />}>\n          <ResetForm callbackUrl=\"/login\" token={token} />\n        </Suspense>\n      </main>\n    );\n  }\n\n  // Otherwise show ResetRequestForm\n  return (\n    <main className=\"flex min-h-screen items-center justify-center px-4\">\n      <Suspense fallback={<PageLoader />}>\n        <ResetRequestForm />\n      </Suspense>\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(auth)/verify/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport { Suspense } from \"react\";\nimport { VerifyForm } from \"@/components/auth/verify-form\";\nimport PageLoader from \"@/components/shared/page-loader\";\n\nexport const metadata: Metadata = {\n  title: \"Verify Email\",\n};\n\ninterface PageProps {\n  params: Promise<{ slug: string }>;\n  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;\n}\n\nexport default async function VerifyPage({ searchParams }: PageProps) {\n  const s = await searchParams;\n  const from = (s.from as string) ?? \"/\";\n  const rawEmail = s.email as string;\n\n  const email = rawEmail ? decodeURIComponent(rawEmail) : \"\";\n\n  return (\n    <div>\n      <Suspense fallback={<PageLoader />}>\n        <VerifyForm callbackUrl={from} email={email} />\n      </Suspense>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/(home)/page-client.tsx",
    "content": "\"use client\";\n\nimport { useQuery } from \"@tanstack/react-query\";\nimport { ApiUsageCard } from \"@/components/home/api-usage-card\";\nimport { MediaUsageCard } from \"@/components/home/media-usage-card\";\nimport { PublishingActivityCard } from \"@/components/home/publishing-activity-card\";\nimport { WebhookUsageCard } from \"@/components/home/webhook-usage-card\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { UsageDashboardData } from \"@/types/dashboard\";\n\nexport default function PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n\n  const { data, isPending, isError } = useQuery({\n    queryKey: workspaceId\n      ? QUERY_KEYS.USAGE_DASHBOARD(workspaceId)\n      : [\"usage-dashboard\", \"disabled\"],\n    queryFn: async (): Promise<UsageDashboardData> => {\n      const response = await fetch(\"/api/metrics/usage\");\n      if (!response.ok) {\n        throw new Error(\"Failed to fetch usage metrics\");\n      }\n      return response.json();\n    },\n    enabled: Boolean(workspaceId) && !isFetchingWorkspace,\n    staleTime: 1000 * 60 * 10,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isPending) {\n    return <PageLoader />;\n  }\n\n  if (isError) {\n    return (\n      <div className=\"text-muted-foreground text-sm\">\n        Unable to load dashboard metrics right now.\n      </div>\n    );\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 pt-10 pb-16\" size=\"compact\">\n      <div className=\"flex w-full flex-col gap-6 md:grid md:gap-x-10 md:gap-y-8\">\n        <ApiUsageCard data={data?.api} isLoading={isPending} />\n        <div className=\"flex flex-col gap-6 lg:grid lg:grid-cols-2 lg:gap-8\">\n          <WebhookUsageCard data={data?.webhooks} isLoading={isPending} />\n          <MediaUsageCard data={data?.media} isLoading={isPending} />\n        </div>\n        <PublishingActivityCard />\n      </div>\n    </DashboardBody>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/(home)/page.tsx",
    "content": "import PageClient from \"./page-client\";\n\nexport const metadata = {\n  title: \"Home\",\n  description: \"Workspace overview and metrics\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/authors/page-client.tsx",
    "content": "\"use client\";\n\nimport { useQuery } from \"@tanstack/react-query\";\nimport { toast } from \"sonner\";\nimport { columns } from \"@/components/authors/columns\";\nimport { AuthorDataTable } from \"@/components/authors/data-table\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { Author } from \"@/types/author\";\n\nfunction PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n\n  const { data: authors, isLoading } = useQuery({\n    queryKey: workspaceId\n      ? QUERY_KEYS.AUTHORS(workspaceId)\n      : [\"authors\", \"disabled\"],\n    queryFn: async () => {\n      try {\n        const response = await fetch(\"/api/authors\");\n        if (!response.ok) {\n          throw new Error(\"Failed to fetch authors\");\n        }\n        const data: Author[] = await response.json();\n        return data;\n      } catch (error) {\n        toast.error(\n          error instanceof Error ? error.message : \"Failed to fetch authors\"\n        );\n      }\n    },\n    enabled: Boolean(workspaceId) && !isFetchingWorkspace,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 pt-10 pb-16\" size=\"compact\">\n      <div className=\"space-y-6\">\n        <AuthorDataTable columns={columns} data={authors || []} />\n      </div>\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/authors/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Authors\",\n  description: \"Manage your authors\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/categories/page-client.tsx",
    "content": "\"use client\";\n\nimport { Package01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { PlusIcon } from \"@phosphor-icons/react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport dynamic from \"next/dynamic\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { type Category, columns } from \"@/components/categories/columns\";\nimport { DataTable } from \"@/components/categories/data-table\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nconst CategoryModal = dynamic(() =>\n  import(\"@/components/categories/category-modals\").then(\n    (mod) => mod.CategoryModal\n  )\n);\n\nfunction PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n  const [showCreateModal, setShowCreateModal] = useState(false);\n\n  const { data: categories, isLoading } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.CATEGORIES(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      try {\n        const res = await fetch(\"/api/categories\");\n        if (!res.ok) {\n          throw new Error(\n            `Failed to fetch categories: ${res.status} ${res.statusText}`\n          );\n        }\n        const data: Category[] = await res.json();\n        return data;\n      } catch (error) {\n        toast.error(\n          error instanceof Error ? error.message : \"Failed to fetch categories\"\n        );\n      }\n    },\n    enabled: !!workspaceId && !isFetchingWorkspace,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  return (\n    <>\n      {categories && categories.length > 0 ? (\n        <DashboardBody\n          className=\"flex flex-col gap-8 pt-10 pb-16\"\n          size=\"compact\"\n        >\n          <DataTable columns={columns} data={categories} />\n        </DashboardBody>\n      ) : (\n        <DashboardBody\n          className=\"grid h-full place-content-center\"\n          size=\"compact\"\n        >\n          <div className=\"flex max-w-80 flex-col items-center gap-4\">\n            <div>\n              <HugeiconsIcon className=\"size-16\" icon={Package01Icon} />\n            </div>\n            <div className=\"flex flex-col items-center gap-4 text-center\">\n              <p className=\"text-muted-foreground text-sm\">\n                Categories help organize your content. Create your first\n                category to get started.\n              </p>\n              <Button onClick={() => setShowCreateModal(true)}>\n                <PlusIcon size={16} />\n                <span>Create Category</span>\n              </Button>\n            </div>\n          </div>\n        </DashboardBody>\n      )}\n      <CategoryModal\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n    </>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/categories/page.tsx",
    "content": "import PageClient from \"./page-client\";\n\nexport const metadata = {\n  title: \"Categories\",\n  description: \"Manage your categories\",\n};\n\nfunction Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/layout.tsx",
    "content": "import { SidebarInset, SidebarProvider } from \"@marble/ui/components/sidebar\";\nimport { AppSidebar } from \"@/components/nav/app-sidebar\";\nimport type { Workspace } from \"@/types/workspace\";\nimport { request } from \"@/utils/fetch/client\";\n\nconst _getWorkspaceData = async (workspace: string) => {\n  const res = await request<Workspace | null>(`workspaces/${workspace}`);\n  return res.data;\n};\n\nexport const metadata = {\n  title: {\n    template: \"%s | Marble\",\n    default: \"Marble\",\n  },\n  description: \"Manage your workspace\",\n};\n\nexport default async function DashboardLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <SidebarProvider\n      className=\"overflow-y-hidden\"\n      style={\n        {\n          \"--sidebar-width-icon\": \"3.5rem\",\n        } as React.CSSProperties\n      }\n    >\n      <AppSidebar />\n      <SidebarInset className=\"relative overflow-y-auto peer-data-[variant=inset]:border-l md:peer-data-[variant=inset]:shadow-none\">\n        {children}\n      </SidebarInset>\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/loading.tsx",
    "content": "import PageLoader from \"@/components/shared/page-loader\";\n\nexport default function Loading() {\n  return <PageLoader />;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/media/[id]/page-client.tsx",
    "content": "\"use client\";\n\nimport {\n  ArrowLeft02Icon,\n  Copy01Icon,\n  Download01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { format } from \"date-fns\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { HeaderSidebarTrigger } from \"@/components/layout/header-sidebar-trigger\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport { VideoPlayer } from \"@/components/media/video-player\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { blurhashToDataUrl } from \"@/lib/blurhash\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { Media } from \"@/types/media\";\nimport {\n  downloadMedia,\n  formatMediaDimensions,\n  formatMediaDuration,\n  formatMediaType,\n} from \"@/utils/media\";\nimport { formatBytes } from \"@/utils/string\";\n\ninterface MediaDetailPageProps {\n  id: string;\n  workspace: string;\n}\nexport default function MediaDetailPage({\n  id,\n  workspace,\n}: MediaDetailPageProps) {\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const {\n    data: media,\n    error,\n    isError,\n    isLoading,\n  } = useQuery({\n    queryKey: QUERY_KEYS.MEDIA_DETAIL(workspaceId ?? \"\", id),\n    queryFn: async (): Promise<Media> => {\n      const response = await fetch(`/api/media/${id}`);\n      if (!response.ok) {\n        const data = await response.json().catch(() => null);\n        throw new Error(data?.error || \"Failed to fetch media\");\n      }\n      return response.json();\n    },\n    enabled: Boolean(workspaceId),\n  });\n\n  const { isPending: isSaving, mutate: updateMedia } = useMutation({\n    mutationFn: async ({ alt, name }: { alt: string | null; name: string }) => {\n      const response = await fetch(`/api/media/${id}`, {\n        body: JSON.stringify({ alt, name }),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        method: \"PATCH\",\n      });\n\n      if (!response.ok) {\n        const data = await response.json().catch(() => null);\n        throw new Error(data?.error || \"Failed to update media\");\n      }\n\n      return response.json() as Promise<Media>;\n    },\n    onError: (error) => {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to update media\"\n      );\n    },\n    onSuccess: (updatedMedia) => {\n      if (!workspaceId) {\n        return;\n      }\n\n      queryClient.setQueryData(\n        QUERY_KEYS.MEDIA_DETAIL(workspaceId, id),\n        updatedMedia\n      );\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.MEDIA(workspaceId),\n      });\n      toast.success(\"Saved media details\");\n    },\n  });\n\n  const blurDataUrl = useMemo(() => {\n    if (!media?.blurHash || media.type !== \"image\") {\n      return undefined;\n    }\n\n    return blurhashToDataUrl(media.blurHash);\n  }, [media?.blurHash, media?.type]);\n\n  if (isLoading) {\n    return <PageLoader />;\n  }\n\n  if (isError || !media) {\n    return (\n      <DashboardBody showHeader={false}>\n        <div className=\"grid min-h-[calc(100vh-56px)] place-items-center p-8\">\n          <p className=\"text-muted-foreground text-sm\">\n            {error instanceof Error ? error.message : \"Could not load media.\"}\n          </p>\n        </div>\n      </DashboardBody>\n    );\n  }\n\n  const copyMediaUrl = async () => {\n    try {\n      await navigator.clipboard.writeText(media.url);\n      toast.success(\"Copied media URL\");\n    } catch {\n      toast.error(\"Could not copy media URL\");\n    }\n  };\n\n  return (\n    <DashboardBody\n      contextView={\n        <MediaDetailsPanel\n          isSaving={isSaving}\n          media={media}\n          onSave={(values) => updateMedia(values)}\n        />\n      }\n      flush\n      showHeader={false}\n    >\n      <div className=\"flex min-h-0 flex-1 flex-col bg-background\">\n        <div className=\"flex h-13 shrink-0 items-center justify-between gap-3 border-b border-dashed bg-background px-4\">\n          <div className=\"flex items-center gap-2\">\n            <HeaderSidebarTrigger />\n            <Tooltip>\n              <TooltipTrigger\n                delay={400}\n                render={\n                  <Button\n                    aria-label=\"Back to media\"\n                    nativeButton={false}\n                    render={<Link href={`/${workspace}/media`} />}\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                  />\n                }\n              >\n                <HugeiconsIcon className=\"size-4\" icon={ArrowLeft02Icon} />\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>Back to media</p>\n              </TooltipContent>\n            </Tooltip>\n          </div>\n          <p className=\"min-w-0 truncate font-medium text-sm\">{media.name}</p>\n          <div className=\"flex items-center gap-1\">\n            <Tooltip>\n              <TooltipTrigger\n                delay={400}\n                render={\n                  <Button\n                    onClick={copyMediaUrl}\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                  />\n                }\n              >\n                <HugeiconsIcon className=\"size-4\" icon={Copy01Icon} />\n                <span className=\"sr-only\">Copy URL</span>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>Copy URL</p>\n              </TooltipContent>\n            </Tooltip>\n            <Tooltip>\n              <TooltipTrigger\n                delay={400}\n                render={\n                  <Button\n                    onClick={() => {\n                      downloadMedia(media).catch(() => {\n                        toast.error(\"Could not download media\");\n                      });\n                    }}\n                    size=\"icon-sm\"\n                    type=\"button\"\n                    variant=\"ghost\"\n                  >\n                    <HugeiconsIcon className=\"size-4\" icon={Download01Icon} />\n                    <span className=\"sr-only\">Download media</span>\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                <p>Download file</p>\n              </TooltipContent>\n            </Tooltip>\n          </div>\n        </div>\n        <main className=\"min-h-0 flex-1 overflow-hidden bg-background p-4 md:p-8\">\n          <div className=\"grid h-full min-h-0 place-items-center\">\n            <div className=\"flex h-full min-h-0 w-full items-center justify-center\">\n              {media.type === \"image\" ? (\n                <Image\n                  alt={media.alt ?? media.name}\n                  blurDataURL={blurDataUrl}\n                  className=\"h-auto max-h-full w-auto max-w-[min(100%,_900px)] rounded-lg object-contain shadow-sm\"\n                  height={media.height ?? 900}\n                  placeholder={blurDataUrl ? \"blur\" : \"empty\"}\n                  priority\n                  sizes=\"(min-width: 1024px) min(900px, calc(100vw - 520px)), calc(100vw - 32px)\"\n                  src={media.url}\n                  unoptimized\n                  width={media.width ?? 1200}\n                />\n              ) : media.type === \"video\" ? (\n                <VideoPlayer\n                  className=\"max-h-full max-w-[min(100%,_900px)] rounded-lg shadow-sm\"\n                  controls\n                  preview={false}\n                  src={media.url}\n                />\n              ) : (\n                <div className=\"rounded-lg border border-dashed px-4 py-3 text-muted-foreground text-sm\">\n                  Preview unavailable\n                </div>\n              )}\n            </div>\n          </div>\n        </main>\n      </div>\n    </DashboardBody>\n  );\n}\n\nfunction MediaDetailsPanel({\n  isSaving,\n  media,\n  onSave,\n}: {\n  isSaving: boolean;\n  media: Media;\n  onSave: (values: { alt: string | null; name: string }) => void;\n}) {\n  const [name, setName] = useState(media.name);\n  const [alt, setAlt] = useState(media.alt ?? \"\");\n\n  useEffect(() => {\n    setName(media.name);\n    setAlt(media.alt ?? \"\");\n  }, [media.alt, media.name]);\n\n  const hasChanges = name !== media.name || alt !== (media.alt ?? \"\");\n\n  const handleSave = () => {\n    onSave({\n      alt: alt.trim() ? alt.trim() : null,\n      name: name.trim(),\n    });\n  };\n\n  return (\n    <div className=\"flex h-full min-h-0 flex-col\">\n      <div className=\"flex h-13 shrink-0 items-center justify-between gap-3 border-b border-dashed px-5\">\n        <h2 className=\"font-medium text-lg\">Details</h2>\n        <Button\n          disabled={!hasChanges || isSaving || !name.trim()}\n          onClick={handleSave}\n          size=\"sm\"\n        >\n          Save\n        </Button>\n      </div>\n      <div className=\"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-5\">\n        <div className=\"grid gap-2\">\n          <Label htmlFor=\"media-name\">Name</Label>\n          <Input\n            id=\"media-name\"\n            onChange={(event) => setName(event.target.value)}\n            value={name}\n          />\n        </div>\n        <div className=\"grid gap-2\">\n          <Label htmlFor=\"media-alt\">Alt text</Label>\n          <Textarea\n            id=\"media-alt\"\n            onChange={(event) => setAlt(event.target.value)}\n            placeholder=\"Describe the media for screen readers\"\n            rows={4}\n            value={alt}\n          />\n        </div>\n        <dl className=\"grid gap-3 text-sm\">\n          <DetailItem label=\"Type\" value={formatMediaType(media)} />\n          <DetailItem label=\"Size\" value={formatBytes(media.size)} />\n          <DetailItem label=\"Dimensions\" value={formatMediaDimensions(media)} />\n          <DetailItem\n            label=\"Duration\"\n            value={formatMediaDuration(media.duration)}\n          />\n          <DetailItem label=\"MIME type\" value={media.mimeType ?? \"-\"} />\n          <DetailItem\n            label=\"Uploaded\"\n            value={format(new Date(media.createdAt), \"MMM d, yyyy\")}\n          />\n        </dl>\n        <div className=\"grid gap-2\">\n          <h3 className=\"font-medium text-sm\">References</h3>\n          <p className=\"text-muted-foreground text-sm\">\n            Not used in any posts yet.\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction DetailItem({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"grid gap-1\">\n      <dt className=\"font-medium text-foreground\">{label}</dt>\n      <dd className=\"break-words text-muted-foreground\">{value}</dd>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/media/[id]/page.tsx",
    "content": "import MediaDetailPage from \"./page-client\";\n\nexport const metadata = {\n  title: \"Media\",\n  description: \"Manage your media\",\n};\n\nexport default async function Page(props: {\n  params: Promise<{ id: string; workspace: string }>;\n}) {\n  const { id, workspace } = await props.params;\n\n  return <MediaDetailPage id={id} workspace={workspace} />;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/media/page-client.tsx",
    "content": "\"use client\";\n\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { keepPreviousData, useQuery } from \"@tanstack/react-query\";\nimport { useState } from \"react\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport { MediaDataTable } from \"@/components/media/media-data-table\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useMediaActions } from \"@/hooks/use-media-actions\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { getMediaApiUrl, useMediaPageFilters } from \"@/lib/search-params\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type {\n  Media,\n  MediaPaginatedListResponse,\n  MediaQueryKey,\n} from \"@/types/media\";\nimport { toMediaType } from \"@/utils/media\";\n\nfunction PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n  const [{ page, perPage, search, sort, type }] = useMediaPageFilters();\n  const normalizedType = toMediaType(type);\n  const [isUploading, setIsUploading] = useState(false);\n  const [statusMessage, setStatusMessage] = useState(\"\");\n\n  const { data, error, isError, isLoading, isFetching } = useQuery({\n    queryKey: [\n      // biome-ignore lint/style/noNonNullAssertion: <>\n      ...QUERY_KEYS.MEDIA(workspaceId!),\n      { page, perPage, search, sort, type: normalizedType },\n    ],\n    queryFn: async () => {\n      try {\n        const url = getMediaApiUrl(\"/api/media\", {\n          page,\n          perPage,\n          search: search || null,\n          sort,\n          type: normalizedType,\n        });\n\n        const res = await fetch(url);\n        if (!res.ok) {\n          throw new Error(\n            `Failed to fetch media: ${res.status} ${res.statusText}`\n          );\n        }\n        const data: MediaPaginatedListResponse = await res.json();\n        return data;\n      } catch (error) {\n        toast.error(\n          error instanceof Error ? error.message : \"Failed to fetch media\"\n        );\n        throw error;\n      }\n    },\n    enabled: !!workspaceId && !isFetchingWorkspace,\n    placeholderData: keepPreviousData,\n    staleTime: 1000 * 60 * 5,\n    gcTime: 1000 * 60 * 30,\n  });\n\n  const mediaItems = data?.media ?? [];\n  const hasAnyMedia = data?.hasAnyMedia ?? mediaItems.length > 0;\n  const pageCount = data?.pageCount ?? 1;\n  const totalCount = data?.totalCount ?? mediaItems.length;\n\n  const mediaQueryKey: MediaQueryKey = [\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    ...QUERY_KEYS.MEDIA(workspaceId!),\n    { page, perPage, search, type: normalizedType, sort },\n  ];\n\n  const { handleUploadComplete } = useMediaActions(mediaQueryKey);\n\n  const handleFileUpload = async (files: FileList) => {\n    if (!files?.length) {\n      return;\n    }\n\n    setIsUploading(true);\n\n    const total = files.length;\n    let uploaded = 0;\n    let failed = 0;\n\n    const getUploadMessage = (current: number, totalFiles: number) => {\n      if (totalFiles === 1) {\n        return \"Uploading file...\";\n      }\n      return `Uploading ${current} of ${totalFiles} files...`;\n    };\n\n    const toastId = toast.loading(getUploadMessage(0, total));\n    setStatusMessage(getUploadMessage(0, total));\n\n    try {\n      const errors: Array<{ file: string; error: string }> = [];\n      for (const file of Array.from(files)) {\n        try {\n          await uploadFile({ file, type: \"media\" });\n          uploaded += 1;\n        } catch (error) {\n          errors.push({\n            file: file.name,\n            error: error instanceof Error ? error.message : \"Unknown error\",\n          });\n          failed += 1;\n        }\n\n        const message = getUploadMessage(uploaded, total);\n        toast.loading(message, { id: toastId });\n        setStatusMessage(message);\n      }\n\n      handleUploadComplete();\n\n      if (failed === 0) {\n        const successMsg = `Uploaded all ${uploaded} file${uploaded > 1 ? \"s\" : \"\"}!`;\n        toast.success(successMsg, { id: toastId });\n        setStatusMessage(successMsg);\n      } else {\n        const warnMsg = `Uploaded ${uploaded} file${uploaded > 1 ? \"s\" : \"\"}, ${failed} failed.`;\n        toast.warning(warnMsg, { id: toastId });\n        setStatusMessage(warnMsg);\n      }\n    } catch (err) {\n      toast.error(\"Unexpected upload error\", { id: toastId });\n      setStatusMessage(\"Unexpected upload error\");\n    }\n    setIsUploading(false);\n  };\n\n  if (isFetchingWorkspace || !workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  if (isError) {\n    return (\n      <DashboardBody className=\"grid min-h-[calc(100vh-56px)] place-items-center\">\n        <p className=\"text-muted-foreground text-sm\">\n          {error instanceof Error ? error.message : \"Could not load media.\"}\n        </p>\n      </DashboardBody>\n    );\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 pt-10 pb-16\" size=\"compact\">\n      <div aria-atomic=\"true\" aria-live=\"polite\" className=\"sr-only\">\n        {statusMessage}\n      </div>\n      <MediaDataTable\n        disabled={isFetching || isUploading}\n        hasAnyMedia={hasAnyMedia}\n        isUploading={isUploading}\n        media={mediaItems}\n        mediaQueryKey={mediaQueryKey}\n        onUpload={handleFileUpload}\n        pageCount={pageCount}\n        totalCount={totalCount}\n      />\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/media/page.tsx",
    "content": "import PageClient from \"./page-client\";\n\nexport const metadata = {\n  title: \"Media\",\n  description: \"Manage your media\",\n};\n\nfunction Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/posts/page-client.tsx",
    "content": "\"use client\";\n\nimport { Files01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { PlusIcon, UploadSimpleIcon } from \"@phosphor-icons/react\";\nimport { keepPreviousData, useQuery } from \"@tanstack/react-query\";\nimport dynamic from \"next/dynamic\";\nimport Link from \"next/link\";\nimport { useMemo, useState } from \"react\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport { columns, type Post } from \"@/components/posts/columns\";\nimport { PostDataView } from \"@/components/posts/data-view\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { getPostApiUrl, usePostPageFilters } from \"@/lib/search-params\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nconst PostsImportModal = dynamic(\n  () =>\n    import(\"@/components/posts/import-modal\").then((m) => m.PostsImportModal),\n  { ssr: false }\n);\n\nfunction PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { activeWorkspace, isFetchingWorkspace } = useWorkspace();\n  const [filters] = usePostPageFilters();\n  const apiFilters = useMemo(\n    () => ({\n      category: filters.category,\n      page: filters.page,\n      perPage: filters.perPage,\n      search: filters.search,\n      sort: filters.sort,\n      status: filters.status,\n    }),\n    [\n      filters.category,\n      filters.page,\n      filters.perPage,\n      filters.search,\n      filters.sort,\n      filters.status,\n    ]\n  );\n\n  const [importOpen, setImportOpen] = useState(false);\n\n  const { data, error, isError, isFetching, isLoading } = useQuery({\n    queryKey: workspaceId\n      ? [...QUERY_KEYS.POSTS(workspaceId), apiFilters]\n      : [\"posts\", \"disabled\"],\n    placeholderData: keepPreviousData,\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      const res = await fetch(getPostApiUrl(\"/api/posts\", apiFilters));\n      if (!res.ok) {\n        throw new Error(\"Failed to fetch posts\");\n      }\n      return (await res.json()) as {\n        hasAnyPosts: boolean;\n        pageCount: number;\n        posts: Post[];\n        totalCount: number;\n      };\n    },\n    enabled: Boolean(workspaceId) && !isFetchingWorkspace,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || (isLoading && !data)) {\n    return <PageLoader />;\n  }\n\n  if (isError && !data) {\n    return (\n      <DashboardBody className=\"grid min-h-[calc(100vh-56px)] place-items-center\">\n        <p className=\"text-muted-foreground text-sm\">\n          {error instanceof Error ? error.message : \"Could not load posts.\"}\n        </p>\n      </DashboardBody>\n    );\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 pt-10 pb-16\" size=\"compact\">\n      {data?.hasAnyPosts ? (\n        <PostDataView\n          columns={columns}\n          data={data.posts}\n          isFetching={isFetching}\n          pageCount={data.pageCount}\n          totalCount={data.totalCount}\n        />\n      ) : (\n        <>\n          <DashboardBody className=\"grid h-full place-content-center\">\n            <div className=\"flex max-w-80 flex-col items-center gap-4\">\n              <div className=\"p-2\">\n                <HugeiconsIcon className=\"size-16\" icon={Files01Icon} />\n              </div>\n              <div className=\"flex flex-col items-center gap-4 text-center\">\n                <p className=\"text-muted-foreground text-sm\">\n                  No posts yet. Click the button below to start writing.\n                </p>\n                <div className=\"flex gap-2\">\n                  <Link\n                    className={buttonVariants({ variant: \"default\" })}\n                    href={`/${activeWorkspace?.slug}/editor/p/new`}\n                  >\n                    <PlusIcon size={16} />\n                    <span>New Post</span>\n                  </Link>\n                  <Tooltip>\n                    <TooltipTrigger\n                      render={\n                        <Button\n                          aria-label=\"Upload\"\n                          onClick={() => setImportOpen(true)}\n                          variant=\"default\"\n                        >\n                          <UploadSimpleIcon className=\"size-4\" />\n                        </Button>\n                      }\n                    />\n                    <TooltipContent side=\"top\">Upload</TooltipContent>\n                  </Tooltip>\n                </div>\n              </div>\n            </div>\n          </DashboardBody>\n          <PostsImportModal open={importOpen} setOpen={setImportOpen} />\n        </>\n      )}\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/posts/page.tsx",
    "content": "import PageClient from \"./page-client\";\n\nexport const metadata = {\n  title: \"Posts\",\n  description: \"Manage your posts\",\n};\n\nfunction Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/account/page-client.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CircleNotchIcon,\n  ImageIcon,\n  UploadSimpleIcon,\n} from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useEffect, useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport { CropImageModal } from \"@/components/media/crop-image-modal\";\nimport { DeleteAccountModal } from \"@/components/settings/delete-account\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { MAX_AVATAR_FILE_SIZE } from \"@/lib/constants\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { type ProfileData, profileSchema } from \"@/lib/validations/settings\";\nimport { useUser } from \"@/providers/user\";\n\nfunction PageClient() {\n  const queryClient = useQueryClient();\n  const { user, updateUser, isUpdatingUser, isFetchingUser } = useUser();\n  const [pendingAvatarUrl, setPendingAvatarUrl] = useState<\n    string | undefined\n  >();\n  const avatarUrl = pendingAvatarUrl ?? user?.image ?? undefined;\n  const [file, setFile] = useState<File | null>(null);\n  const [cropOpen, setCropOpen] = useState(false);\n\n  const { mutate: uploadAvatar, isPending: isUploading } = useMutation({\n    mutationFn: (file: File) => uploadFile({ file, type: \"avatar\" }),\n    onSuccess: (data) => {\n      setPendingAvatarUrl(data.url);\n      updateUser({ image: data.url });\n      queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER });\n      setFile(null);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const handleUpdateUser = async (data: { name: string }) => {\n    try {\n      await updateUser(data);\n    } catch (error) {\n      toast.error(\"Failed to update user\");\n    }\n  };\n\n  const {\n    register,\n    handleSubmit,\n    reset,\n    watch,\n    formState: { errors, isSubmitting, isDirty },\n  } = useForm<ProfileData>({\n    resolver: zodResolver(profileSchema),\n    defaultValues: { name: user?.name ?? \"\", email: user?.email ?? \"\" },\n  });\n\n  const watchedName = watch(\"name\");\n  const hasNameChanges =\n    (watchedName ?? \"\").trim() !== (user?.name ?? \"\").trim() && isDirty;\n\n  useEffect(() => {\n    if (user) {\n      reset({ name: user.name ?? \"\", email: user.email ?? \"\" });\n    }\n  }, [user, reset]);\n\n  const onSubmit = (data: ProfileData) => {\n    if (!user?.id) {\n      return;\n    }\n    handleUpdateUser({ name: data.name });\n  };\n\n  const handleReset = () => {\n    setFile(null);\n  };\n\n  if (isFetchingUser) {\n    return <PageLoader />;\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 py-12\" size=\"compact\">\n      <SettingsSection\n        description=\"Change your profile picture. Square images work best.\"\n        title=\"Avatar\"\n      >\n        <div className=\"flex items-center gap-6 rounded-[14px] bg-background px-4 py-3.5\">\n          <Label\n            className={cn(\n              \"group relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-full\",\n              isUploading && \"pointer-events-none\"\n            )}\n            htmlFor=\"logo\"\n          >\n            <Avatar className=\"size-16\">\n              <AvatarImage src={avatarUrl || undefined} />\n              <AvatarFallback>\n                <ImageIcon className=\"size-4 text-muted-foreground\" />\n              </AvatarFallback>\n            </Avatar>\n\n            <input\n              accept=\"image/*\"\n              className=\"sr-only\"\n              id=\"logo\"\n              onChange={(e) => {\n                const selectedFile = e.target.files?.[0];\n                if (selectedFile && !isUploading) {\n                  setFile(selectedFile);\n                  setCropOpen(true);\n                }\n              }}\n              title=\"Upload avatar\"\n              type=\"file\"\n            />\n            <div\n              className={cn(\n                \"absolute inset-0 flex size-full items-center justify-center bg-background/50 backdrop-blur-xs transition-opacity duration-300\",\n                isUploading\n                  ? \"opacity-100\"\n                  : \"opacity-0 group-hover:opacity-100\"\n              )}\n            >\n              {isUploading ? (\n                <CircleNotchIcon className=\"size-4 animate-spin\" />\n              ) : (\n                <UploadSimpleIcon className=\"size-4\" />\n              )}\n            </div>\n          </Label>\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n            <Input readOnly value={avatarUrl || \"\"} />\n            <CopyButton\n              textToCopy={avatarUrl || \"\"}\n              toastMessage=\"Avatar URL copied to clipboard.\"\n            />\n          </div>\n        </div>\n      </SettingsSection>\n      <CropImageModal\n        aspect={1}\n        file={file}\n        maxImageSize={MAX_AVATAR_FILE_SIZE}\n        onCropped={(cropped) => {\n          setCropOpen(false);\n          uploadAvatar(cropped);\n        }}\n        onOpenChange={(open) => {\n          setCropOpen(open);\n          if (!open) {\n            setFile(null);\n          }\n        }}\n        open={cropOpen}\n        reset={() => {\n          handleReset();\n        }}\n      />\n\n      <SettingsSection\n        description=\"Your name will be displayed on your profile and in emails.\"\n        title=\"Full Name\"\n      >\n        <form\n          className=\"flex flex-col gap-2 rounded-[14px] bg-background px-4 py-3.5 sm:flex-row sm:items-start\"\n          onSubmit={handleSubmit(onSubmit)}\n        >\n          <div className=\"min-w-0 flex-1\">\n            <Label className=\"sr-only\" htmlFor=\"name\">\n              Name\n            </Label>\n            <Input {...register(\"name\")} />\n            {errors.name && <ErrorMessage>{errors.name.message}</ErrorMessage>}\n          </div>\n          <AsyncButton\n            className=\"w-20 self-end\"\n            disabled={!hasNameChanges}\n            isLoading={isSubmitting || isUpdatingUser}\n            type=\"submit\"\n          >\n            Save\n          </AsyncButton>\n        </form>\n      </SettingsSection>\n\n      <SettingsSection\n        description=\"Email associated with your account. This cannot be changed.\"\n        title=\"Email\"\n      >\n        <div className=\"rounded-[14px] bg-background px-4 py-3.5\">\n          <Label className=\"sr-only\" htmlFor=\"email\">\n            Email\n          </Label>\n          <Input defaultValue={user?.email} disabled readOnly />\n        </div>\n      </SettingsSection>\n\n      <SettingsSection\n        description=\"Permanently delete your account and all associated data.\"\n        title=\"Delete Account\"\n      >\n        <div className=\"flex flex-col gap-3 rounded-[14px] bg-background px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <div className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive\">\n              <HugeiconsIcon icon={Alert02Icon} size={18} strokeWidth={2} />\n            </div>\n            <p className=\"text-muted-foreground text-sm\">\n              Account deletion is permanent.\n            </p>\n          </div>\n          <DeleteAccountModal />\n        </div>\n      </SettingsSection>\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/account/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Account\",\n  description: \"Manage your account settings\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/appearance/page-client.tsx",
    "content": "\"use client\";\n\nimport { DesktopIcon } from \"@phosphor-icons/react\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { ThemeSwitch } from \"@/components/settings/theme\";\n\nfunction PageClient() {\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 py-12\" size=\"compact\">\n      <SettingsSection description=\"Choose your preferred theme.\" title=\"Theme\">\n        <div className=\"flex flex-col gap-3 rounded-[14px] bg-background px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <div className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n              <DesktopIcon className=\"size-4\" />\n            </div>\n            <p className=\"text-muted-foreground text-sm\">\n              Defaults to your device theme.\n            </p>\n          </div>\n          <ThemeSwitch />\n        </div>\n      </SettingsSection>\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/appearance/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Appearance\",\n  description: \"Customize how Marble looks for you\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/billing/page-client.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Card, CardDescription, CardTitle } from \"@marble/ui/components/card\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport { PRICING_PLANS } from \"@marble/utils\";\nimport { ArrowUpRightIcon, CheckIcon } from \"@phosphor-icons/react\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { invoiceTableColumns } from \"@/components/invoice/columns\";\nimport { InvoiceDataTable } from \"@/components/invoice/data-table\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { usePlan } from \"@/hooks/use-plan\";\nimport { authClient, checkout } from \"@/lib/auth/client\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nfunction PageClient() {\n  const [checkoutLoading, setCheckoutLoading] = useState<\n    \"pro\" | \"pro-yearly\" | \"hobby\" | null\n  >(null);\n  const [isYearly, setIsYearly] = useState(true);\n  const { activeWorkspace, isFetchingWorkspace, isOwner } = useWorkspace();\n  const { currentPlan, isProPlan } = usePlan();\n\n  const hobbyPlan = PRICING_PLANS.find((p) => p.id === \"hobby\");\n  const proPlan = PRICING_PLANS.find((p) => p.id === \"pro\");\n\n  const getPlanDisplayName = () => {\n    return currentPlan === \"pro\" ? \"Pro\" : \"Hobby\";\n  };\n\n  const monthlyPrice = \"$20\";\n  const yearlyPrice = \"$200\";\n\n  if (isFetchingWorkspace || !activeWorkspace) {\n    return <PageLoader />;\n  }\n\n  const handleCheckout = async (plan: \"pro\" | \"pro-yearly\" | \"hobby\") => {\n    if (!activeWorkspace?.id) {\n      return;\n    }\n\n    setCheckoutLoading(plan);\n\n    try {\n      await checkout({\n        slug: plan,\n        referenceId: activeWorkspace.id,\n      });\n    } catch (error) {\n      console.error(error);\n      toast.error(\"Failed to start checkout\");\n    }\n    setCheckoutLoading(null);\n  };\n\n  const redirectCustomerPortal = async () => {\n    try {\n      await authClient.customer.portal();\n    } catch (_e) {\n      toast.error(\"Failed to redirect to customer portal\");\n    }\n  };\n\n  const renderPlanButton = (planId: \"hobby\" | \"pro\") => {\n    const isCurrentPlan = currentPlan === planId;\n\n    if (isCurrentPlan) {\n      return (\n        <Button className=\"w-full\" disabled variant=\"outline\">\n          Current Plan\n        </Button>\n      );\n    }\n\n    if (planId === \"hobby\" && isProPlan) {\n      return (\n        <Button\n          className=\"w-full\"\n          onClick={() => redirectCustomerPortal()}\n          variant=\"outline\"\n        >\n          Manage Billing\n          <ArrowUpRightIcon className=\"ml-1 size-4\" />\n        </Button>\n      );\n    }\n\n    const isUpgrade = planId === \"pro\" && currentPlan === \"hobby\";\n    const checkoutSlug = isYearly ? \"pro-yearly\" : \"pro\";\n\n    return (\n      <AsyncButton\n        className=\"w-full\"\n        isLoading={checkoutLoading === checkoutSlug}\n        onClick={() => handleCheckout(checkoutSlug)}\n        variant=\"default\"\n      >\n        Upgrade to Pro\n      </AsyncButton>\n    );\n  };\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 py-12\" size=\"compact\">\n      {/* Current Plan Header */}\n      <Card className=\"gap-0 rounded-[20px] border-none bg-surface p-1\">\n        <div className=\"flex items-center justify-between rounded-[16px] bg-background p-6 shadow-xs\">\n          <div className=\"flex flex-col gap-1\">\n            <CardTitle className=\"font-medium text-lg\">Billing Plan</CardTitle>\n            <CardDescription>View and manage your billing plan</CardDescription>\n          </div>\n          <div className=\"flex items-center gap-4\">\n            {!isProPlan && (\n              <div className=\"flex items-center gap-2 rounded-full bg-surface px-4 py-2\">\n                <span className=\"text-muted-foreground text-sm\">\n                  Current plan:\n                </span>\n                <span className=\"font-medium\">{getPlanDisplayName()}</span>\n              </div>\n            )}\n            {isOwner && isProPlan && (\n              <Button\n                onClick={() => redirectCustomerPortal()}\n                variant=\"outline\"\n              >\n                Manage Billing\n                <ArrowUpRightIcon className=\"ml-1 size-4\" />\n              </Button>\n            )}\n          </div>\n        </div>\n      </Card>\n\n      <div className=\"flex flex-col gap-4\">\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <h2 className=\"font-medium text-lg\">Plans</h2>\n            <p className=\"text-muted-foreground text-sm\">\n              Upgrade or change your plan. Pro includes a 3 day free trial.\n            </p>\n          </div>\n          {/* Billing Period Toggle */}\n          <div className=\"flex items-center gap-3\">\n            <Label\n              className={isYearly ? \"text-muted-foreground\" : \"font-medium\"}\n              htmlFor=\"billing-toggle\"\n            >\n              Monthly\n            </Label>\n            <Switch\n              checked={isYearly}\n              id=\"billing-toggle\"\n              onCheckedChange={setIsYearly}\n            />\n            <Label\n              className={isYearly ? \"font-medium\" : \"text-muted-foreground\"}\n              htmlFor=\"billing-toggle\"\n            >\n              Yearly\n            </Label>\n          </div>\n        </div>\n\n        <section className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n          {hobbyPlan && (\n            <Card className=\"relative gap-0 rounded-[20px] border-none bg-surface p-1\">\n              <div className=\"flex h-full flex-col gap-6 rounded-[16px] bg-background p-6 shadow-xs\">\n                <div className=\"flex items-start justify-between\">\n                  <div>\n                    <h3 className=\"font-semibold text-xl\">{hobbyPlan.title}</h3>\n                    <p className=\"mt-1 text-muted-foreground text-sm\">\n                      {hobbyPlan.description}\n                    </p>\n                  </div>\n                  {currentPlan === \"hobby\" && (\n                    <Badge variant=\"secondary\">Current Plan</Badge>\n                  )}\n                </div>\n\n                <div>\n                  <span className=\"font-bold text-3xl\">\n                    {hobbyPlan.price.monthly}\n                  </span>\n                  <span className=\"text-muted-foreground\"> / month</span>\n                </div>\n\n                {isOwner && renderPlanButton(\"hobby\")}\n\n                <ul className=\"flex flex-col gap-2\">\n                  {hobbyPlan.features.map((feature) => (\n                    <li\n                      className=\"flex items-center gap-2 text-sm\"\n                      key={feature}\n                    >\n                      <CheckIcon className=\"size-4 text-green-500\" />\n                      <span>{feature}</span>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            </Card>\n          )}\n\n          {proPlan && (\n            <Card className=\"relative gap-0 rounded-[20px] border-none bg-surface p-1\">\n              <div className=\"flex h-full flex-col gap-6 rounded-[16px] bg-background p-6 shadow-xs\">\n                <div className=\"flex items-start justify-between gap-4\">\n                  <div>\n                    <h3 className=\"font-semibold text-xl\">{proPlan.title}</h3>\n                    <p className=\"mt-1 text-muted-foreground text-sm\">\n                      {proPlan.description}\n                    </p>\n                  </div>\n                  {currentPlan === \"pro\" ? (\n                    <Badge variant=\"secondary\">Current Plan</Badge>\n                  ) : null}\n                </div>\n\n                <div className=\"border-border border-t border-dashed pt-4\">\n                  <span className=\"font-bold text-3xl\">\n                    {isYearly ? yearlyPrice : monthlyPrice}\n                  </span>\n                  <span className=\"text-muted-foreground\">\n                    {isYearly ? \" / year\" : \" / month\"}\n                  </span>\n                </div>\n                {isOwner && renderPlanButton(\"pro\")}\n\n                <ul className=\"flex flex-col gap-2\">\n                  {proPlan.features.map((feature) => (\n                    <li\n                      className=\"flex items-center gap-2 text-sm\"\n                      key={feature}\n                    >\n                      <CheckIcon className=\"size-4 text-green-500\" />\n                      <span>{feature}</span>\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            </Card>\n          )}\n        </section>\n      </div>\n\n      <div className=\"flex flex-col gap-4\">\n        <div>\n          <h2 className=\"font-medium text-lg\">Invoices</h2>\n          <p className=\"text-muted-foreground text-sm\">\n            View your billing history and download receipts.\n          </p>\n        </div>\n        <InvoiceDataTable columns={invoiceTableColumns} data={[]} />\n      </div>\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/billing/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Billing\",\n  description: \"Manage your workspace billing settings\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/fields/page-client.tsx",
    "content": "\"use client\";\n\nimport { DatabaseIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { ArrowUpRightIcon, PlusIcon } from \"@phosphor-icons/react\";\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport dynamic from \"next/dynamic\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { CustomField } from \"@/types/fields\";\n\nconst CreateCustomFieldSheet = dynamic(\n  () => import(\"@/components/fields/create-custom-field\")\n);\n\nconst CustomFieldRow = dynamic(() =>\n  import(\"@/components/fields/custom-field-row\").then(\n    (mod) => mod.CustomFieldRow\n  )\n);\n\nconst fieldTypeLabels: Record<string, string> = {\n  text: \"Text\",\n  number: \"Number\",\n  boolean: \"Boolean\",\n  date: \"Date\",\n  richtext: \"Rich Text\",\n  select: \"Select\",\n  multiselect: \"Multi Select\",\n};\n\nexport function PageClient() {\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n  const docsHref = \"https://docs.marblecms.com/guides/features/custom-fields\";\n\n  const {\n    data: fields,\n    isLoading,\n    isError,\n    error,\n    refetch,\n  } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.CUSTOM_FIELDS(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      const res = await fetch(\"/api/fields\");\n      if (!res.ok) {\n        throw new Error(\n          `Failed to fetch custom fields: ${res.status} ${res.statusText}`\n        );\n      }\n      const data: CustomField[] = await res.json();\n      return data;\n    },\n    enabled: !!workspaceId,\n  });\n\n  if (!workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  if (isError) {\n    return (\n      <DashboardBody\n        className=\"grid h-full place-content-center\"\n        size=\"compact\"\n      >\n        <div className=\"flex max-w-96 flex-col items-center gap-4 text-center\">\n          <p className=\"font-medium\">Unable to load custom fields</p>\n          <p className=\"text-muted-foreground text-sm\">\n            {error instanceof Error\n              ? error.message\n              : \"Something went wrong while loading your workspace fields.\"}\n          </p>\n          <Button onClick={() => refetch()}>Retry</Button>\n        </div>\n      </DashboardBody>\n    );\n  }\n\n  if (fields?.length === 0) {\n    return (\n      <DashboardBody\n        className=\"grid h-full place-content-center\"\n        size=\"compact\"\n      >\n        <div className=\"flex max-w-80 flex-col items-center gap-4\">\n          <div className=\"p-2\">\n            <HugeiconsIcon className=\"size-16\" icon={DatabaseIcon} />\n          </div>\n          <div className=\"flex flex-col items-center gap-4 text-center\">\n            <p className=\"text-muted-foreground text-sm\">\n              Extend the default post schema with custom fields. Define text,\n              number, boolean, date, or rich text fields that can be set on\n              every post.\n            </p>\n            <div className=\"flex flex-wrap items-center justify-center gap-2\">\n              <CreateCustomFieldSheet>\n                <Button>\n                  <PlusIcon className=\"size-4\" />\n                  New Field\n                </Button>\n              </CreateCustomFieldSheet>\n              <a\n                className={cn(buttonVariants({ variant: \"outline\" }))}\n                href={docsHref}\n                rel=\"noopener noreferrer\"\n                target=\"_blank\"\n              >\n                Learn more\n                <ArrowUpRightIcon className=\"size-4\" />\n              </a>\n            </div>\n          </div>\n        </div>\n      </DashboardBody>\n    );\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 pt-10 pb-16\" size=\"compact\">\n      <div className=\"flex flex-col gap-6\">\n        <div className=\"flex items-center justify-between\">\n          <div />\n          <CreateCustomFieldSheet>\n            <Button>\n              <PlusIcon className=\"size-4\" />\n              New Field\n            </Button>\n          </CreateCustomFieldSheet>\n        </div>\n\n        <div className=\"rounded-lg border\">\n          <table className=\"w-full\">\n            <thead>\n              <tr className=\"border-b text-muted-foreground text-sm\">\n                <th className=\"px-4 py-3 text-left font-medium\">Name</th>\n                <th className=\"px-4 py-3 text-left font-medium\">Key</th>\n                <th className=\"px-4 py-3 text-left font-medium\">Type</th>\n                <th className=\"px-4 py-3 text-right font-medium\">\n                  <span className=\"sr-only\">Actions</span>\n                </th>\n              </tr>\n            </thead>\n            <tbody>\n              {fields?.map((field) => (\n                <CustomFieldRow\n                  field={field}\n                  fieldTypeLabels={fieldTypeLabels}\n                  key={field.id}\n                  onDelete={() => {\n                    if (workspaceId) {\n                      queryClient.invalidateQueries({\n                        queryKey: QUERY_KEYS.CUSTOM_FIELDS(workspaceId),\n                      });\n                    }\n                  }}\n                />\n              ))}\n            </tbody>\n          </table>\n        </div>\n      </div>\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/fields/page.tsx",
    "content": "import { PageClient } from \"./page-client\";\n\nexport const metadata = {\n  title: \"Custom Fields\",\n  description: \"Define custom fields to extend your post schema.\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/general/page-client.tsx",
    "content": "\"use client\";\n\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport { Delete } from \"@/components/settings/fields/delete\";\nimport { Id } from \"@/components/settings/fields/id\";\nimport { Logo } from \"@/components/settings/fields/logo\";\nimport { Name } from \"@/components/settings/fields/name\";\nimport { Slug } from \"@/components/settings/fields/slug\";\nimport { Timezone } from \"@/components/settings/fields/timezone\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nfunction PageClient() {\n  const { activeWorkspace, isFetchingWorkspace } = useWorkspace();\n\n  if (isFetchingWorkspace || !activeWorkspace) {\n    return <PageLoader />;\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 py-12\" size=\"compact\">\n      <Name />\n      <Slug />\n      <Logo />\n      <Timezone />\n      <Id />\n      <Delete />\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/general/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"General\",\n  description: \"Manage your workspace general settings\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/keys/page-client.tsx",
    "content": "\"use client\";\n\nimport { Key01Icon, PlusSignIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport dynamic from \"next/dynamic\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { type APIKey, columns } from \"@/components/keys/columns\";\nimport { DataTable } from \"@/components/keys/data-table\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nconst CreateKeyModal = dynamic(() =>\n  import(\"@/components/keys/api-key-modal\").then((mod) => mod.ApiKeyModal)\n);\n\nfunction PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n  const [showCreateModal, setShowCreateModal] = useState(false);\n  const { data: keys, isLoading } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.KEYS(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      try {\n        const res = await fetch(\"/api/keys\");\n        if (!res.ok) {\n          throw new Error(\n            `Failed to fetch keys: ${res.status} ${res.statusText}`\n          );\n        }\n        const data: APIKey[] = await res.json();\n        return data;\n      } catch (error) {\n        toast.error(\n          error instanceof Error ? error.message : \"Failed to fetch keys\"\n        );\n        return [];\n      }\n    },\n    enabled: !!workspaceId && !isFetchingWorkspace,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  return (\n    <>\n      {keys && keys.length > 0 ? (\n        <DashboardBody\n          className=\"flex flex-col gap-8 pt-10 pb-16\"\n          size=\"compact\"\n        >\n          <DataTable columns={columns} data={keys} />\n        </DashboardBody>\n      ) : (\n        <DashboardBody\n          className=\"grid h-full place-content-center\"\n          size=\"compact\"\n        >\n          <div className=\"flex max-w-80 flex-col items-center gap-4\">\n            <div className=\"p-2\">\n              <HugeiconsIcon className=\"size-16\" icon={Key01Icon} />\n            </div>\n            <div className=\"flex flex-col items-center gap-4 text-center\">\n              <div className=\"flex flex-col items-center gap-2\">\n                <p className=\"font-medium\">No API keys yet</p>\n                <p className=\"text-muted-foreground text-sm\">\n                  API keys let you interact with your workspace via the API.\n                </p>\n              </div>\n              <div className=\"mt-2 grid grid-cols-2 gap-2\">\n                <Button\n                  className=\"col-span-1\"\n                  onClick={() => setShowCreateModal(true)}\n                >\n                  <HugeiconsIcon\n                    icon={PlusSignIcon}\n                    size={16}\n                    strokeWidth={2}\n                  />\n                  <span>Create API Key</span>\n                </Button>\n                <Link\n                  className={cn(\n                    buttonVariants({\n                      variant: \"secondary\",\n                      className: \"col-span-1 w-full\",\n                    })\n                  )}\n                  href=\"https://docs.marblecms.com\"\n                  rel=\"noopener\"\n                  target=\"_blank\"\n                >\n                  Learn more\n                </Link>\n              </div>\n            </div>\n          </div>\n        </DashboardBody>\n      )}\n      <CreateKeyModal\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n    </>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/keys/page.tsx",
    "content": "import PageClient from \"./page-client\";\n\nexport const metadata = {\n  title: \"API Keys\",\n  description: \"Manage your API keys\",\n};\n\nfunction Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/members/page-client.tsx",
    "content": "\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport { useState } from \"react\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { columns, type TeamMemberRow } from \"@/components/team/columns\";\nimport { TeamDataTable } from \"@/components/team/data-table\";\nimport { InviteSection } from \"@/components/team/invite-section\";\nimport { useUser } from \"@/providers/user\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nconst InviteModal = dynamic(() =>\n  import(\"@/components/team/invite-modal\").then((mod) => mod.InviteModal)\n);\n\nconst LeaveWorkspaceModal = dynamic(() =>\n  import(\"@/components/team/leave-workspace\").then(\n    (mod) => mod.LeaveWorkspaceModal\n  )\n);\n\nfunction PageClient() {\n  const { user } = useUser();\n  const { activeWorkspace, isFetchingWorkspace, currentUserRole } =\n    useWorkspace();\n\n  const [showInviteModal, setShowInviteModal] = useState(false);\n  const [showLeaveWorkspaceModal, setShowLeaveWorkspaceModal] = useState(false);\n\n  if (isFetchingWorkspace || !activeWorkspace || !user) {\n    return <PageLoader />;\n  }\n\n  const data: TeamMemberRow[] = activeWorkspace.members.map((member) => ({\n    id: member.id,\n    type: \"member\" as const,\n    name: member.user.name || member.user.email,\n    email: member.user.email,\n    image: member.user.image || null,\n    role: member.role as \"owner\" | \"admin\" | \"member\",\n    status: \"accepted\" as const,\n    joinedAt: new Date(member.createdAt),\n    userId: member.userId,\n  }));\n\n  return (\n    <DashboardBody size=\"compact\">\n      <div className=\"space-y-6\">\n        <TeamDataTable\n          columns={columns}\n          currentUserId={user.id}\n          currentUserRole={\n            currentUserRole as \"owner\" | \"admin\" | \"member\" | undefined\n          }\n          data={data}\n          setShowInviteModal={setShowInviteModal}\n          setShowLeaveWorkspaceModal={setShowLeaveWorkspaceModal}\n        />\n\n        <InviteSection invitations={activeWorkspace.invitations || []} />\n      </div>\n\n      <InviteModal open={showInviteModal} setOpen={setShowInviteModal} />\n      <LeaveWorkspaceModal\n        id={activeWorkspace.id}\n        name={activeWorkspace.name}\n        open={showLeaveWorkspaceModal}\n        setOpen={setShowLeaveWorkspaceModal}\n      />\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/members/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Team Members\",\n  description: \"Manage your team members and invites\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/notifications/page-client.tsx",
    "content": "\"use client\";\n\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport {\n  DEFAULT_NOTIFICATION_PREFERENCES,\n  type NotificationPreferences,\n  type NotificationToggleItem,\n  USER_NOTIFICATION_ITEMS,\n  WORKSPACE_NOTIFICATION_ITEMS,\n} from \"@/lib/notifications\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { request } from \"@/utils/fetch/client\";\n\nfunction NotificationToggle({\n  item,\n  checked,\n  onToggle,\n  isPending,\n}: {\n  item: NotificationToggleItem;\n  checked: boolean;\n  onToggle: (item: NotificationToggleItem, value: boolean) => void;\n  isPending: boolean;\n}) {\n  return (\n    <div className=\"flex items-center justify-between gap-8 rounded-[14px] bg-background px-4 py-3.5\">\n      <div className=\"flex flex-col gap-0.5\">\n        <p className=\"font-medium text-sm\">{item.label}</p>\n        <p className=\"text-[13px] text-muted-foreground\">{item.description}</p>\n      </div>\n      <div className=\"flex shrink-0 items-center gap-2\">\n        <Switch\n          checked={checked}\n          disabled={isPending}\n          onCheckedChange={(value) => onToggle(item, value)}\n        />\n      </div>\n    </div>\n  );\n}\n\ninterface NotificationGroupProps {\n  title: string;\n  description: string;\n  items: NotificationToggleItem[];\n  getChecked: (item: NotificationToggleItem) => boolean;\n  isTogglePending: (item: NotificationToggleItem) => boolean;\n  onToggle: (item: NotificationToggleItem, value: boolean) => void;\n}\n\nfunction NotificationGroup({\n  title,\n  description,\n  items,\n  getChecked,\n  isTogglePending,\n  onToggle,\n}: NotificationGroupProps) {\n  return (\n    <div className=\"flex flex-col gap-1 rounded-[20px] bg-surface p-1.5\">\n      <div className=\"flex flex-col gap-0.5 px-4 py-2\">\n        <h2 className=\"font-medium text-sm\">{title}</h2>\n        <p className=\"text-[13px] text-muted-foreground\">{description}</p>\n      </div>\n      <div className=\"flex flex-col gap-1.5\">\n        {items.map((item) => (\n          <NotificationToggle\n            checked={getChecked(item)}\n            isPending={isTogglePending(item)}\n            item={item}\n            key={item.key}\n            onToggle={onToggle}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction PageClient() {\n  const queryClient = useQueryClient();\n\n  const { data: preferences, isLoading } = useQuery({\n    queryKey: QUERY_KEYS.NOTIFICATION_PREFERENCES,\n    queryFn: async () => {\n      const response =\n        await request<NotificationPreferences>(\"user/notifications\");\n      return response.data;\n    },\n  });\n\n  const {\n    mutate: toggle,\n    variables: pendingToggle,\n    isPending: isToggleMutationPending,\n  } = useMutation({\n    mutationFn: async ({\n      scope,\n      key,\n      value,\n    }: {\n      scope: \"user\" | \"workspace\";\n      key: string;\n      value: boolean;\n    }) => {\n      const response = await request(\"user/notifications\", \"PATCH\", {\n        scope,\n        key,\n        value,\n      });\n      return response.data;\n    },\n    onMutate: async ({ scope, key, value }) => {\n      await queryClient.cancelQueries({\n        queryKey: QUERY_KEYS.NOTIFICATION_PREFERENCES,\n      });\n\n      const previous = queryClient.getQueryData<NotificationPreferences>(\n        QUERY_KEYS.NOTIFICATION_PREFERENCES\n      );\n\n      const current = previous ?? DEFAULT_NOTIFICATION_PREFERENCES;\n\n      queryClient.setQueryData<NotificationPreferences>(\n        QUERY_KEYS.NOTIFICATION_PREFERENCES,\n        {\n          ...current,\n          [scope]: {\n            ...current[scope],\n            [key]: value,\n          },\n        }\n      );\n\n      return { previous };\n    },\n    onSuccess: (_data, variables) => {\n      toast.success(\n        `${variables.value ? \"Enabled\" : \"Disabled\"} notification preference`\n      );\n    },\n    onError: (_error, _variables, context) => {\n      if (context?.previous) {\n        queryClient.setQueryData(\n          QUERY_KEYS.NOTIFICATION_PREFERENCES,\n          context.previous\n        );\n      }\n      toast.error(\"Failed to update preference\");\n    },\n    onSettled: () => {\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.NOTIFICATION_PREFERENCES,\n      });\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.USER,\n      });\n    },\n  });\n\n  const handleToggle = (item: NotificationToggleItem, value: boolean) => {\n    toggle({ scope: item.scope, key: item.key, value });\n  };\n\n  const isPending = (item: NotificationToggleItem) =>\n    isToggleMutationPending &&\n    pendingToggle?.scope === item.scope &&\n    pendingToggle?.key === item.key;\n\n  const getChecked = (item: NotificationToggleItem): boolean => {\n    const source = preferences ?? DEFAULT_NOTIFICATION_PREFERENCES;\n    const scopePrefs = source[item.scope];\n    return (scopePrefs as Record<string, boolean>)[item.key] ?? false;\n  };\n\n  if (isLoading) {\n    return <PageLoader />;\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 py-12\" size=\"compact\">\n      <NotificationGroup\n        description=\"These apply to your account across all workspaces.\"\n        getChecked={getChecked}\n        isTogglePending={isPending}\n        items={USER_NOTIFICATION_ITEMS}\n        onToggle={handleToggle}\n        title=\"Personal\"\n      />\n\n      <NotificationGroup\n        description=\"Applies to your current workspace. Critical notifications like payment failures and security alerts are always sent.\"\n        getChecked={getChecked}\n        isTogglePending={isPending}\n        items={WORKSPACE_NOTIFICATION_ITEMS}\n        onToggle={handleToggle}\n        title=\"Workspace\"\n      />\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/notifications/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Notifications\",\n  description: \"Manage your notification preferences\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/webhooks/page-client.tsx",
    "content": "\"use client\";\n\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport {\n  WebhookDataTable,\n  WebhooksEmptyState,\n} from \"@/components/webhooks/webhook-data-table\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { Webhook } from \"@/types/webhook\";\n\nexport function PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n  const queryClient = useQueryClient();\n\n  const { data: webhooks, isLoading } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.WEBHOOKS(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      try {\n        const res = await fetch(\"/api/webhooks\");\n        if (!res.ok) {\n          throw new Error(\n            `Failed to fetch webhooks: ${res.status} ${res.statusText}`\n          );\n        }\n        const data: Webhook[] = await res.json();\n        return data;\n      } catch (error) {\n        toast.error(\n          error instanceof Error ? error.message : \"Failed to fetch webhooks\"\n        );\n      }\n    },\n    enabled: !!workspaceId && !isFetchingWorkspace,\n  });\n\n  const {\n    mutate: toggleWebhook,\n    variables: toggleVariables,\n    isPending: isToggling,\n  } = useMutation({\n    mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {\n      const response = await fetch(`/api/webhooks/${id}`, {\n        method: \"PATCH\",\n        body: JSON.stringify({ enabled }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to update webhook\");\n      }\n\n      return response.json();\n    },\n    onMutate: async (newWebhookData) => {\n      if (!workspaceId) {\n        return;\n      }\n\n      await queryClient.cancelQueries({\n        queryKey: QUERY_KEYS.WEBHOOKS(workspaceId),\n      });\n      const previousWebhooks = queryClient.getQueryData<Webhook[]>(\n        QUERY_KEYS.WEBHOOKS(workspaceId)\n      );\n\n      queryClient.setQueryData<Webhook[]>(\n        QUERY_KEYS.WEBHOOKS(workspaceId),\n        (old) =>\n          old?.map((webhook) =>\n            webhook.id === newWebhookData.id\n              ? { ...webhook, enabled: newWebhookData.enabled }\n              : webhook\n          ) ?? []\n      );\n\n      return { previousWebhooks };\n    },\n    onError: (_err, _newWebhook, context) => {\n      if (context?.previousWebhooks && workspaceId) {\n        queryClient.setQueryData(\n          QUERY_KEYS.WEBHOOKS(workspaceId),\n          context.previousWebhooks\n        );\n      }\n      toast.error(\"Failed to update\");\n    },\n    onSettled: () => {\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.WEBHOOKS(workspaceId),\n        });\n      }\n    },\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  if (webhooks?.length === 0) {\n    return (\n      <DashboardBody\n        className=\"grid h-full place-content-center\"\n        size=\"compact\"\n      >\n        <WebhooksEmptyState />\n      </DashboardBody>\n    );\n  }\n\n  return (\n    <DashboardBody className=\"flex flex-col gap-8 pt-10 pb-16\" size=\"compact\">\n      <WebhookDataTable\n        isToggling={isToggling}\n        onDelete={() => {\n          queryClient.invalidateQueries({\n            queryKey: QUERY_KEYS.WEBHOOKS(workspaceId),\n          });\n        }}\n        onToggle={toggleWebhook}\n        toggleVariables={toggleVariables}\n        webhooks={webhooks ?? []}\n      />\n    </DashboardBody>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/settings/webhooks/page.tsx",
    "content": "import { PageClient } from \"./page-client\";\n\nexport const metadata = {\n  title: \"Webhooks\",\n  description: \"Create webhooks to receive events from your workspace.\",\n};\n\nasync function Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/tags/page-client.tsx",
    "content": "\"use client\";\n\nimport { Tag01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { PlusIcon } from \"@phosphor-icons/react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport dynamic from \"next/dynamic\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { DashboardBody } from \"@/components/layout/wrapper\";\nimport PageLoader from \"@/components/shared/page-loader\";\nimport { columns, type Tag } from \"@/components/tags/columns\";\nimport { DataTable } from \"@/components/tags/data-table\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nconst TagModal = dynamic(() =>\n  import(\"@/components/tags/tag-modals\").then((mod) => mod.TagModal)\n);\n\nfunction PageClient() {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n  const [showCreateModal, setShowCreateModal] = useState(false);\n\n  const { data: tags, isLoading } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.TAGS(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      try {\n        const res = await fetch(\"/api/tags\");\n        if (!res.ok) {\n          throw new Error(\"Failed to fetch tags\");\n        }\n        const data: Tag[] = await res.json();\n        return data;\n      } catch (error) {\n        toast.error(\n          error instanceof Error ? error.message : \"Failed to fetch tags\"\n        );\n      }\n    },\n    enabled: !!workspaceId && !isFetchingWorkspace,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isLoading) {\n    return <PageLoader />;\n  }\n\n  return (\n    <>\n      {tags && tags.length > 0 ? (\n        <DashboardBody\n          className=\"flex flex-col gap-8 pt-10 pb-16\"\n          size=\"compact\"\n        >\n          <DataTable columns={columns} data={tags} />\n        </DashboardBody>\n      ) : (\n        <DashboardBody\n          className=\"grid h-full place-content-center\"\n          size=\"compact\"\n        >\n          <div className=\"flex max-w-80 flex-col items-center gap-4\">\n            <div className=\"p-2\">\n              <HugeiconsIcon className=\"size-16\" icon={Tag01Icon} />\n            </div>\n            <div className=\"flex flex-col items-center gap-4 text-center\">\n              <p className=\"text-muted-foreground text-sm\">\n                Tags help readers discover your content. Create your first tag\n                to get started.\n              </p>\n              <Button onClick={() => setShowCreateModal(true)}>\n                <PlusIcon size={16} />\n                <span>Create Tag</span>\n              </Button>\n            </div>\n          </div>\n        </DashboardBody>\n      )}\n      <TagModal\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n    </>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(dashboard)/tags/page.tsx",
    "content": "import PageClient from \"./page-client\";\n\nexport const metadata = {\n  title: \"Tags\",\n  description: \"Manage your tags\",\n};\n\nfunction Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(editor)/editor/p/[id]/page-client.tsx",
    "content": "\"use client\";\n\nimport { useParams } from \"next/navigation\";\nimport { EditorDataProvider } from \"@/components/editor/editor-data-provider\";\nimport EditorPage from \"@/components/editor/editor-page\";\n\nfunction PageClient() {\n  const params = useParams<{ id: string }>();\n\n  return (\n    <EditorDataProvider postId={params.id}>\n      <EditorPage />\n    </EditorDataProvider>\n  );\n}\n\nexport default PageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(editor)/editor/p/[id]/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport PageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Update Post - Marble\",\n};\n\nfunction Page() {\n  return <PageClient />;\n}\n\nexport default Page;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(editor)/editor/p/new/page-client.tsx",
    "content": "\"use client\";\n\nimport { EditorDataProvider } from \"@/components/editor/editor-data-provider\";\nimport EditorPage from \"@/components/editor/editor-page\";\n\nfunction NewPostPageClient() {\n  return (\n    <EditorDataProvider>\n      <EditorPage />\n    </EditorDataProvider>\n  );\n}\n\nexport default NewPostPageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(editor)/editor/p/new/page.tsx",
    "content": "import type { Metadata } from \"next\";\nimport NewPostPageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"New Post - Marble\",\n};\n\nexport default function Page() {\n  return <NewPostPageClient />;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/(editor)/layout.tsx",
    "content": "import { SidebarProvider } from \"@marble/ui/components/sidebar\";\n\nfunction EditorLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"bg-editor-background p-2\">\n      <SidebarProvider\n        className=\"h-[calc(100vh-1rem)] min-h-[calc(100vh-1rem)] overflow-y-hidden\"\n        style={\n          {\n            \"--sidebar-width\": \"400px\",\n            \"--sidebar-background\": \"var(--color-editor-sidebar-background)\",\n          } as React.CSSProperties\n        }\n      >\n        {children}\n      </SidebarProvider>\n    </div>\n  );\n}\n\nexport default EditorLayout;\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/layout.tsx",
    "content": "// app/(main)/[workspace]/layout.tsx\nimport { notFound } from \"next/navigation\";\nimport { setActiveWorkspace } from \"@/lib/auth/workspace\";\nimport {\n  getInitialWorkspaceData,\n  validateWorkspaceAccess,\n} from \"@/lib/queries/workspace\";\nimport { WorkspaceProvider } from \"@/providers/workspace\";\nimport { SetWorkspaceCookie } from \"./set-workspace-cookie\";\n\nexport default async function WorkspaceLayout({\n  children,\n  params,\n}: {\n  children: React.ReactNode;\n  params: Promise<{ workspace: string }>;\n}) {\n  const { workspace: workspaceSlug } = await params;\n\n  const workspaceExists = await validateWorkspaceAccess(workspaceSlug);\n  if (!workspaceExists) {\n    notFound();\n  }\n\n  await setActiveWorkspace(workspaceSlug);\n  const initialWorkspace = await getInitialWorkspaceData(workspaceSlug);\n  if (!initialWorkspace) {\n    notFound();\n  }\n\n  return (\n    <WorkspaceProvider\n      initialWorkspace={initialWorkspace}\n      workspaceSlug={workspaceSlug}\n    >\n      <SetWorkspaceCookie workspaceSlug={workspaceSlug} />\n      {children}\n    </WorkspaceProvider>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/loading.tsx",
    "content": "import PageLoader from \"@/components/shared/page-loader\";\n\nexport default function Loading() {\n  return (\n    <div className=\"h-screen w-screen\">\n      <PageLoader />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/[workspace]/set-workspace-cookie.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\nimport { setServerLastVisitedWorkspace } from \"@/utils/workspace/server\";\n\nexport function SetWorkspaceCookie({\n  workspaceSlug,\n}: {\n  workspaceSlug: string;\n}) {\n  useEffect(() => {\n    setServerLastVisitedWorkspace(workspaceSlug);\n  }, [workspaceSlug]);\n\n  return null;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(main)/layout.tsx",
    "content": "import { UserProvider } from \"@/providers/user\";\n\nexport default async function MainLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <UserProvider initialUser={null}>\n      <div>{children}</div>\n    </UserProvider>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/(share)/layout.tsx",
    "content": "export default function ShareLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return <div className=\"grid min-h-dvh\">{children}</div>;\n}\n"
  },
  {
    "path": "apps/cms/src/app/(share)/share/[token]/page-client.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { format } from \"date-fns\";\nimport Image from \"next/image\";\nimport Prose from \"@/components/share/prose\";\nimport { LinkExpired, LinkNotFound } from \"@/components/share/screens\";\nimport type { SharePageClientProps } from \"@/types/share\";\n\nfunction SharePageClient({ data, status }: SharePageClientProps) {\n  if (status === \"expired\") {\n    return <LinkExpired />;\n  }\n\n  if (!data) {\n    return <LinkNotFound />;\n  }\n\n  const { post } = data;\n\n  return (\n    <div className=\"relative flex min-h-dvh flex-col bg-background\">\n      <header className=\"border-b\">\n        <div className=\"container mx-auto flex items-center justify-between px-4 py-4\">\n          <div className=\"flex items-center gap-2\">\n            <Avatar className=\"size-8 border border-dashed\">\n              <AvatarImage src={post.workspace.logo || undefined} />\n              <AvatarFallback>\n                {(post.workspace.name.charAt(0) || \"W\").toUpperCase()}\n              </AvatarFallback>\n            </Avatar>\n            <span className=\"font-medium text-sm\">{post.workspace.name}</span>\n          </div>\n        </div>\n      </header>\n\n      <main className=\"mx-auto max-w-screen-md flex-1 py-14 max-sm:px-4 lg:py-20\">\n        <div className=\"mx-auto max-w-screen-md\">\n          <header className=\"mb-8\">\n            <h1 className=\"mb-4 font-semibold text-4xl leading-tight\">\n              {post.title}\n            </h1>\n\n            <div className=\"mb-6 border-y py-4\">\n              {post.authors[0] && (\n                <div className=\"flex items-center gap-2\">\n                  <Avatar className=\"size-9\">\n                    <AvatarImage src={post.authors[0].image || undefined} />\n                    <AvatarFallback>\n                      {(post.authors[0].name.charAt(0) || \"A\").toUpperCase()}\n                    </AvatarFallback>\n                  </Avatar>\n                  <div className=\"flex flex-col\">\n                    <span className=\"text-muted-foreground text-xs\">\n                      {post.authors[0].name}\n                    </span>\n                    <span className=\"text-muted-foreground text-xs\">\n                      {format(post.publishedAt, \"MMM d, yyyy\")}\n                    </span>\n                  </div>\n                </div>\n              )}\n            </div>\n\n            {post.coverImage && (\n              <div className=\"mb-8\">\n                <Image\n                  alt={post.title}\n                  className=\"w-full object-cover\"\n                  height={400}\n                  src={post.coverImage}\n                  width={800}\n                />\n              </div>\n            )}\n          </header>\n\n          <Prose\n            className=\"prose-iframe prose-img:rounded-none\"\n            html={post.content}\n          />\n        </div>\n      </main>\n\n      <footer className=\"border-t bg-muted/30\">\n        <div className=\"container mx-auto p-4\">\n          <p className=\"text-center text-muted-foreground text-sm\">\n            This is a shared draft from {post.workspace.name}\n          </p>\n        </div>\n      </footer>\n    </div>\n  );\n}\n\nexport default SharePageClient;\n"
  },
  {
    "path": "apps/cms/src/app/(share)/share/[token]/page.tsx",
    "content": "import { highlightContent } from \"@marble/utils\";\nimport type { Metadata } from \"next\";\nimport { notFound } from \"next/navigation\";\nimport type { ShareData, ShareStatus } from \"@/types/share\";\nimport SharePageClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"Shared Post\",\n  description: \"View a shared draft post\",\n};\n\ninterface SharePageProps {\n  params: Promise<{ token: string }>;\n}\n\nasync function fetchShareData(token: string) {\n  const response = await fetch(\n    `${process.env.NEXT_PUBLIC_APP_URL}/api/share/${token}`,\n    {\n      cache: \"no-store\",\n    }\n  );\n\n  if (response.status === 404) {\n    return null;\n  }\n\n  if (response.status === 410) {\n    return { status: \"expired\" as ShareStatus };\n  }\n\n  if (!response.ok) {\n    throw new Error(`Failed to fetch share data: ${response.statusText}`);\n  }\n\n  const data = await response.json();\n\n  const highlightedContent = await highlightContent(data.post.content);\n\n  return {\n    data: {\n      ...data,\n      post: {\n        ...data.post,\n        content: highlightedContent,\n      },\n    } as ShareData,\n  };\n}\n\nasync function SharePage(props: SharePageProps) {\n  const params = await props.params;\n  const { token } = params;\n\n  const result = await fetchShareData(token);\n\n  if (!result) {\n    notFound();\n  }\n\n  if (result.status === \"expired\") {\n    return <SharePageClient status=\"expired\" />;\n  }\n\n  return <SharePageClient data={result.data} />;\n}\n\nexport default SharePage;\n"
  },
  {
    "path": "apps/cms/src/app/api/accounts/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { getServerSession } from \"@/lib/auth/session\";\n\nexport async function DELETE(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const session = await getServerSession();\n\n  if (!session || !session.user) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  const { id } = await params;\n\n  await db.account.delete({\n    where: {\n      id,\n      userId: session.user.id,\n    },\n  });\n\n  return new NextResponse(null, { status: 204 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/accounts/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { getServerSession } from \"@/lib/auth/session\";\n\nexport async function GET() {\n  const sessionData = await getServerSession();\n\n  if (!sessionData) {\n    return NextResponse.json({ error: \"Not authenticated\" }, { status: 401 });\n  }\n\n  try {\n    const userAccountDetails = await db.account.findMany({\n      where: {\n        userId: sessionData.user.id,\n      },\n      select: {\n        id: true,\n        createdAt: true,\n        providerId: true,\n        accountId: true,\n        user: {\n          select: {\n            email: true,\n          },\n        },\n      },\n    });\n\n    const accountDetails = userAccountDetails.map((account) => ({\n      id: account.id,\n      createdAt: account.createdAt,\n      providerId: account.providerId,\n      accountId: account.accountId,\n      email: account.user.email,\n    }));\n\n    return NextResponse.json(accountDetails, { status: 200 });\n  } catch (error) {\n    console.error(\"Error fetching account details:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch account details\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/ai/suggestions/prompt.ts",
    "content": "export interface SystemPromptParams {\n  metrics: {\n    wordCount: number;\n    sentenceCount: number;\n    wordsPerSentence: number;\n    readingTime: number;\n    readabilityScore: number;\n  };\n}\n\nexport const systemPrompt = ({\n  metrics,\n}: SystemPromptParams) => `You are a professional writing coach specializing in readability improvement. Analyze the provided text and generate specific, actionable suggestions to improve its readability and clarity. Be extremely concise. Sacrifice grammar for the sake of concision. Decide on a type of the post e.g. blog post, article, changelog, etc. If you can't decide go with the default blog post. Make sure you use this type to generate the suggestions e.g. a changelog will include a list of changes rather than text. Determine the main tone of the post and use it to generate the suggestions e.g. not every tone needs a professional writing style.\n\n    <PROMPT>\n        ## ANALYSIS-CRITERIA\n        - Word count and content length optimization\n        - Sentence length and complexity (flag sentences >20 words)\n        - Word choice and vocabulary complexity  \n        - Passive vs active voice usage\n        - Paragraph structure and length\n        - Heading hierarchy validation (Markdown: #, ##-######)\n        - Overall tone consistency and appropriateness\n        - Text flow and logical progression\n        - Redundancy and wordiness\n        - Clarity and ambiguity issues\n\n        ## HEADING-STRUCTURE-RULES\n        - No main heading (#) should be used.\n        - Proper hierarchy: ### follows ##, #### follows ###, etc.\n        - No skipping levels (don't go from ## to ####)\n        - Check Markdown (##, ###) syntax\n        - Don't mention this syntax to the user use \"heading 2/3/4\" etc.\n\n        ## TEXT-METRICS-INPUT\n        - Word count: ${metrics.wordCount}\n        - Sentence count: ${metrics.sentenceCount}\n        - Average words per sentence: ${metrics.wordsPerSentence}\n        - Reading time: ${metrics.readingTime} minutes\n        - Readability score: ${metrics.readabilityScore}\n\n        ## RESPONSE-REQUIREMENTS\n        - Return maximum 8 suggestions\n        - Each suggestion \"text\" must be 1-2 sentences only\n        - Provide brief \"explanation\" when helpful (e.g., examples of complex words, specific improvements)\n        - Include \"textReference\" for specific text snippets that should be highlighted (optional)\n        - Only suggest actual issues - don't fabricate problems if text is good\n        - Be specific and concrete, not generic. Make sure the suggestions are actionable and specific.\n        - Focus on immediate improvements the writer can make\n        - Don't use an em dash in the suggestions\n\n        ## EXPLANATION-GUIDELINES\n        - Keep explanations to 1 sentence maximum\n        - Use when providing examples: \"Complex words like 'utilize' could be 'use'\"\n        - Use when clarifying metrics: \"Sentences averaging 20-25 words or more are considered hard to read\"\n        - Use when giving specific alternatives: \"Try 'shows' instead of 'demonstrates'\"\n\n        ## TEXT-REFERENCE-GUIDELINES\n        - Include exact text snippets that need attention (for highlighting)\n        - Only include if there's a specific problematic phrase or sentence\n        - Keep references short (5-15 words max)\n        - Examples: \"utilize advanced methodologies\", \"In conclusion, it is evident that\"\n\n        ## CRITICAL\n        Feel free to disable any of the rules above if you think one doesn't make sense for this tone/type of post. Only suggest real issues you can identify in the text. If the text is well-written, acknowledge it and provide minor refinement suggestions.\n    </PROMPT>`;\n"
  },
  {
    "path": "apps/cms/src/app/api/ai/suggestions/route.tsx",
    "content": "import { createHash } from \"node:crypto\";\nimport { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { NodeHtmlMarkdown } from \"node-html-markdown\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { aiSuggestionsRateLimiter, rateLimitHeaders } from \"@/lib/ratelimit\";\nimport { redis } from \"@/lib/redis\";\nimport {\n  aiReadabilityBodySchema,\n  aiReadabilityResponseSchema,\n} from \"@/lib/validations/editor\";\nimport { systemPrompt } from \"./prompt\";\n\nexport const maxDuration = 30;\n\nfunction createContentHash(\n  content: string,\n  metrics: {\n    wordCount: number;\n    sentenceCount: number;\n    wordsPerSentence: number;\n    readabilityScore: number;\n    readingTime: number;\n  }\n): string {\n  const contentHash = createHash(\"sha256\")\n    .update(content)\n    .digest(\"hex\")\n    .slice(0, 16);\n  const metricsHash = createHash(\"sha256\")\n    .update(\n      JSON.stringify({\n        w: metrics.wordCount,\n        s: metrics.sentenceCount,\n        wps: metrics.wordsPerSentence,\n        rs: metrics.readabilityScore,\n        rt: metrics.readingTime,\n      })\n    )\n    .digest(\"hex\")\n    .slice(0, 16);\n  return `${contentHash}:${metricsHash}`;\n}\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const bypassCache = request.headers.get(\"x-bypass-cache\") === \"true\";\n\n  const body = await request.json();\n  const parsedBody = aiReadabilityBodySchema.safeParse(body);\n\n  if (!parsedBody.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: parsedBody.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const postId = parsedBody.data.postId;\n\n  if (postId) {\n    const post = await db.post.findFirst({\n      where: {\n        id: postId,\n        workspaceId,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!post) {\n      return NextResponse.json(\n        { error: \"Post not found or does not belong to this workspace\" },\n        { status: 404 }\n      );\n    }\n  }\n\n  const cacheKey = postId\n    ? `ai:suggestions:${workspaceId}:${postId}`\n    : `ai:suggestions:${workspaceId}:${createContentHash(parsedBody.data.content, parsedBody.data.metrics)}`;\n\n  if (!bypassCache) {\n    const cached = await redis.get<string>(cacheKey);\n    if (cached) {\n      const cachedJson =\n        typeof cached === \"string\" ? cached : JSON.stringify(cached);\n      return new NextResponse(cachedJson, {\n        headers: { \"Content-Type\": \"application/json\" },\n      });\n    }\n  }\n\n  const { success, limit, remaining, reset } =\n    await aiSuggestionsRateLimiter.limit(workspaceId);\n\n  if (!success) {\n    return NextResponse.json(\n      { error: \"Too Many Requests\", remaining },\n      { status: 429, headers: rateLimitHeaders(limit, remaining, reset) }\n    );\n  }\n\n  const { generateObject } = await import(\"ai\");\n\n  const result = await generateObject({\n    model: \"openai/gpt-5.1-instant\",\n    messages: [\n      {\n        role: \"system\",\n        content: systemPrompt({ metrics: parsedBody.data.metrics }),\n      },\n      {\n        role: \"user\",\n        content: `\n        <CONTENT>\n        ${NodeHtmlMarkdown.translate(parsedBody.data.content)}\n        </CONTENT>\n        `,\n      },\n    ],\n    schema: aiReadabilityResponseSchema,\n  });\n\n  const resultJson = JSON.stringify(result.object);\n\n  await redis.set(cacheKey, resultJson, { ex: 1200 });\n\n  return new NextResponse(resultJson, {\n    headers: { \"Content-Type\": \"application/json\" },\n  });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/auth/[...all]/route.ts",
    "content": "import { toNextJsHandler } from \"better-auth/next-js\";\nimport { auth } from \"@/lib/auth/server\";\n\nexport const { POST, GET } = toNextJsHandler(auth);\n"
  },
  {
    "path": "apps/cms/src/app/api/authors/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toAuthorPayload, withChanges } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { authorSchema } from \"@/lib/validations/authors\";\n\nexport async function DELETE(\n  _request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n  const { id } = await params;\n\n  if (!id) {\n    return NextResponse.json(\n      { error: \"Author ID is required\" },\n      { status: 400 }\n    );\n  }\n\n  try {\n    const author = await db.author.findFirst({\n      where: {\n        id,\n        workspaceId,\n      },\n      include: {\n        socials: true,\n      },\n    });\n\n    if (!author) {\n      return NextResponse.json({ error: \"Author not found\" }, { status: 404 });\n    }\n\n    const deletedAuthor = await db.author.delete({\n      where: {\n        id,\n        workspaceId,\n      },\n    });\n\n    // Invalidate cache for authors and posts (authors affect posts)\n    invalidateCache(workspaceId, \"authors\");\n    invalidateCache(workspaceId, \"posts\");\n\n    await emitDashboardEvent({\n      type: \"author_deleted\",\n      workspaceId,\n      resourceType: \"author\",\n      resourceId: author.id,\n      actorId: sessionData.user.id,\n      payload: toAuthorPayload(author),\n    }).catch(logDashboardEventError);\n\n    return NextResponse.json(deletedAuthor.id, { status: 200 });\n  } catch (error) {\n    console.error(\"Failed to delete author:\", error);\n    return NextResponse.json(\n      { error: \"Failed to delete author\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function PATCH(\n  request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n  const { id } = await params;\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  try {\n    const body = await request.json();\n    const parsedBody = authorSchema.safeParse(body);\n\n    if (!parsedBody.success) {\n      return NextResponse.json(\n        { error: \"Invalid request body\", details: parsedBody.error.issues },\n        { status: 400 }\n      );\n    }\n\n    const { name, bio, role, email, image, userId, slug, socials } =\n      parsedBody.data;\n\n    const validEmail = email === \"\" ? null : email;\n    const validUserId = userId ? userId : null;\n\n    const author = await db.author.findFirst({\n      where: {\n        id,\n        workspaceId,\n      },\n    });\n\n    if (!author) {\n      return NextResponse.json({ error: \"Author not found\" }, { status: 404 });\n    }\n\n    const existingAuthorWithSlug = await db.author.findFirst({\n      where: {\n        slug,\n        workspaceId,\n        id: { not: id },\n      },\n    });\n\n    if (existingAuthorWithSlug) {\n      return NextResponse.json(\n        { error: \"Slug already in use\" },\n        { status: 409 }\n      );\n    }\n\n    const updatedAuthor = await db.author.update({\n      where: {\n        id,\n        workspaceId,\n      },\n      data: {\n        name,\n        bio,\n        role,\n        email: validEmail,\n        image,\n        slug,\n        userId: validUserId,\n        ...(typeof socials !== \"undefined\" && {\n          socials: {\n            deleteMany: {},\n            ...(socials.length > 0 && {\n              create: socials.map((social) => ({\n                url: social.url,\n                platform: social.platform,\n              })),\n            }),\n          },\n        }),\n      },\n      include: {\n        socials: true,\n      },\n    });\n\n    // Invalidate cache for authors and posts (authors affect posts)\n    invalidateCache(workspaceId, \"authors\");\n    invalidateCache(workspaceId, \"posts\");\n\n    await emitDashboardEvent({\n      type: \"author_updated\",\n      workspaceId,\n      resourceType: \"author\",\n      resourceId: updatedAuthor.id,\n      actorId: sessionData.user.id,\n      payload: withChanges(\n        toAuthorPayload(updatedAuthor),\n        Object.keys(parsedBody.data)\n      ),\n    }).catch(logDashboardEventError);\n\n    return NextResponse.json(updatedAuthor, { status: 200 });\n  } catch (error) {\n    console.error(\"Failed to update author:\", error);\n    return NextResponse.json(\n      { error: \"Failed to update author\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/authors/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toAuthorPayload } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { getWorkspacePlan } from \"@/lib/plans\";\nimport { authorSchema } from \"@/lib/validations/authors\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  try {\n    const authors = await db.author.findMany({\n      where: {\n        workspaceId,\n        isActive: true,\n      },\n      select: {\n        id: true,\n        name: true,\n        image: true,\n        role: true,\n        bio: true,\n        slug: true,\n        email: true,\n        userId: true,\n        isActive: true,\n        createdAt: true,\n        updatedAt: true,\n        socials: {\n          select: {\n            id: true,\n            url: true,\n            platform: true,\n          },\n        },\n      },\n      orderBy: {\n        name: \"asc\",\n      },\n    });\n\n    return NextResponse.json(authors, { status: 200 });\n  } catch (error) {\n    console.error(\"Failed to fetch authors:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch authors\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  try {\n    // Check plan limits for author creation (only hobby plan is limited to 1 author)\n    const workspace = await db.organization.findUnique({\n      where: { id: workspaceId },\n      select: {\n        subscriptions: {\n          where: {\n            OR: [\n              { status: \"active\" },\n              { status: \"trialing\" },\n              {\n                status: \"canceled\",\n                cancelAtPeriodEnd: true,\n                currentPeriodEnd: { gt: new Date() },\n              },\n            ],\n          },\n          orderBy: { createdAt: \"desc\" },\n          take: 1,\n          select: {\n            id: true,\n            status: true,\n            plan: true,\n            currentPeriodStart: true,\n            currentPeriodEnd: true,\n            cancelAtPeriodEnd: true,\n            canceledAt: true,\n          },\n        },\n      },\n    });\n\n    const activeSubscription = workspace?.subscriptions[0] || null;\n    const currentPlan = getWorkspacePlan(activeSubscription);\n\n    // Hobby plan is limited to 1 author\n    if (currentPlan === \"hobby\") {\n      const existingAuthorsCount = await db.author.count({\n        where: {\n          workspaceId,\n          isActive: true,\n        },\n      });\n\n      if (existingAuthorsCount >= 1) {\n        return NextResponse.json(\n          {\n            error:\n              \"Author limit reached. Upgrade to Pro plan to create more authors.\",\n          },\n          { status: 403 }\n        );\n      }\n    }\n\n    const body = await request.json();\n    const parsedBody = authorSchema.safeParse(body);\n\n    if (!parsedBody.success) {\n      return NextResponse.json(\n        { error: \"Invalid request body\", details: parsedBody.error.issues },\n        { status: 400 }\n      );\n    }\n\n    const { name, bio, role, email, image, slug, userId, socials } =\n      parsedBody.data;\n\n    const validEmail = email === \"\" ? null : email;\n\n    const validUserId = userId ? userId : null;\n\n    // const slug = generateSlug(name);\n\n    const existingAuthor = await db.author.findUnique({\n      where: {\n        workspaceId_slug: {\n          workspaceId,\n          slug,\n        },\n      },\n    });\n\n    if (existingAuthor) {\n      return NextResponse.json(\n        { error: \"Author with this name already exists\" },\n        { status: 409 }\n      );\n    }\n\n    const author = await db.author.create({\n      data: {\n        name,\n        slug,\n        bio,\n        role,\n        email: validEmail,\n        image,\n        workspaceId,\n        userId: validUserId,\n        ...(socials &&\n          socials.length > 0 && {\n            socials: {\n              create: socials.map((social) => ({\n                url: social.url,\n                platform: social.platform,\n              })),\n            },\n          }),\n      },\n      include: {\n        socials: true,\n      },\n    });\n\n    // Invalidate cache for authors and posts (authors affect posts)\n    invalidateCache(workspaceId, \"authors\");\n    invalidateCache(workspaceId, \"posts\");\n\n    await emitDashboardEvent({\n      type: \"author_created\",\n      workspaceId,\n      resourceType: \"author\",\n      resourceId: author.id,\n      actorId: sessionData.user.id,\n      payload: toAuthorPayload(author),\n    }).catch(logDashboardEventError);\n\n    return NextResponse.json(author, { status: 201 });\n  } catch (error) {\n    console.error(\"Failed to create author:\", error);\n    return NextResponse.json(\n      { error: \"Failed to create author\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/categories/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toCategoryPayload, withChanges } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { categorySchema } from \"@/lib/validations/workspace\";\n\nexport async function PATCH(\n  req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const json = await req.json();\n  const body = categorySchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existingCategoryWithSlug = await db.category.findFirst({\n    where: {\n      slug: body.data.slug,\n      workspaceId,\n      id: { not: id },\n    },\n  });\n\n  if (existingCategoryWithSlug) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  const updatedCategory = await db.category.update({\n    where: {\n      id,\n      workspaceId,\n    },\n    data: {\n      name: body.data.name,\n      slug: body.data.slug,\n      description: body.data.description,\n    },\n  });\n\n  await emitDashboardEvent({\n    type: \"category_updated\",\n    workspaceId,\n    resourceType: \"category\",\n    resourceId: updatedCategory.id,\n    actorId: sessionData.user.id,\n    payload: withChanges(\n      toCategoryPayload(updatedCategory),\n      Object.keys(body.data)\n    ),\n  }).catch(logDashboardEventError);\n\n  // Invalidate cache for categories and posts (categories affect posts)\n  invalidateCache(workspaceId, \"categories\");\n  invalidateCache(workspaceId, \"posts\");\n\n  return NextResponse.json(updatedCategory, { status: 200 });\n}\n\nexport async function DELETE(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const category = await db.category.findFirst({\n    where: { id, workspaceId },\n    select: { id: true, name: true, slug: true, description: true },\n  });\n\n  if (!category) {\n    return NextResponse.json({ error: \"Category not found\" }, { status: 404 });\n  }\n\n  const postsWithCategory = await db.post.findFirst({\n    where: {\n      categoryId: id,\n      workspaceId,\n    },\n    select: { id: true },\n  });\n\n  if (postsWithCategory) {\n    return NextResponse.json(\n      { error: \"Category is associated with existing posts\" },\n      { status: 400 }\n    );\n  }\n\n  try {\n    await db.category.delete({\n      where: {\n        id,\n        workspaceId,\n      },\n    });\n\n    await emitDashboardEvent({\n      type: \"category_deleted\",\n      workspaceId,\n      resourceType: \"category\",\n      resourceId: id,\n      actorId: sessionData.user.id,\n      payload: toCategoryPayload(category),\n    }).catch(logDashboardEventError);\n\n    // Invalidate cache for categories and posts (categories affect posts)\n    invalidateCache(workspaceId, \"categories\");\n    invalidateCache(workspaceId, \"posts\");\n\n    return new NextResponse(null, { status: 204 });\n  } catch (_e) {\n    return NextResponse.json(\n      { error: \"Failed to delete category\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/categories/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toCategoryPayload } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { categorySchema } from \"@/lib/validations/workspace\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const categories = await db.category.findMany({\n    where: { workspaceId },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      description: true,\n      _count: {\n        select: {\n          posts: true,\n        },\n      },\n    },\n  });\n\n  const transformedCategories = categories.map((category) => {\n    const { _count, ...rest } = category;\n    return {\n      ...rest,\n      postsCount: _count.posts,\n    };\n  });\n\n  return NextResponse.json(transformedCategories, { status: 200 });\n}\n\nexport async function POST(req: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const json = await req.json();\n  const body = categorySchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existingCategory = await db.category.findFirst({\n    where: {\n      slug: body.data.slug,\n      workspaceId,\n    },\n  });\n\n  if (existingCategory) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  const categoryCreated = await db.category.create({\n    data: {\n      name: body.data.name,\n      slug: body.data.slug,\n      description: body.data.description,\n      workspaceId,\n    },\n  });\n\n  await emitDashboardEvent({\n    type: \"category_created\",\n    workspaceId,\n    resourceType: \"category\",\n    resourceId: categoryCreated.id,\n    actorId: sessionData.user.id,\n    payload: toCategoryPayload(categoryCreated),\n  }).catch(logDashboardEventError);\n\n  // Invalidate cache for categories and posts (categories affect posts)\n  invalidateCache(workspaceId, \"categories\");\n  invalidateCache(workspaceId, \"posts\");\n\n  return NextResponse.json(categoryCreated, { status: 201 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/fields/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { Prisma } from \"@marble/db/browser\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { customFieldUpdateSchema } from \"@/lib/validations/fields\";\n\nfunction buildFieldOptionWrites(\n  options: Array<{ value: string; label: string }>\n) {\n  return options.map((option, index) => ({\n    value: option.value,\n    label: option.label,\n    position: index,\n  }));\n}\n\nfunction areFieldOptionsEqual(\n  nextOptions: Array<{ value: string; label: string }>,\n  currentOptions: Array<{ value: string; label: string }>\n) {\n  if (nextOptions.length !== currentOptions.length) {\n    return false;\n  }\n\n  return nextOptions.every((option, index) => {\n    const currentOption = currentOptions[index];\n    return (\n      currentOption !== undefined &&\n      option.value === currentOption.value &&\n      option.label === currentOption.label\n    );\n  });\n}\n\nfunction isUniqueConstraintError(error: unknown) {\n  if (!(error instanceof Error)) {\n    return false;\n  }\n\n  const candidate = error as Error & {\n    code?: string;\n    meta?: { target?: unknown };\n  };\n\n  if (candidate.code !== \"P2002\") {\n    return false;\n  }\n\n  const target = candidate.meta?.target;\n\n  return (\n    Array.isArray(target) &&\n    target.includes(\"workspaceId\") &&\n    target.includes(\"key\")\n  );\n}\n\nfunction isTransactionConflict(error: unknown) {\n  return (\n    error instanceof Error &&\n    \"code\" in error &&\n    (error as Error & { code?: string }).code === \"P2034\"\n  );\n}\n\nexport async function PATCH(\n  req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const json = await req.json();\n  const body = customFieldUpdateSchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  // Verify field exists and belongs to workspace\n  const existingField = await db.field.findFirst({\n    where: {\n      id,\n      workspaceId,\n    },\n    include: {\n      options: {\n        orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n      },\n    },\n  });\n\n  if (!existingField) {\n    return NextResponse.json({ error: \"Field not found\" }, { status: 404 });\n  }\n\n  // If key is being changed, check uniqueness\n  if (body.data.key && body.data.key !== existingField.key) {\n    const keyConflict = await db.field.findFirst({\n      where: {\n        workspaceId,\n        key: body.data.key,\n        id: { not: id },\n      },\n    });\n\n    if (keyConflict) {\n      return NextResponse.json(\n        { error: \"A field with this key already exists in your workspace\" },\n        { status: 409 }\n      );\n    }\n  }\n\n  const updateData: Record<string, unknown> = {};\n  if (body.data.name !== undefined) {\n    updateData.name = body.data.name;\n  }\n  if (body.data.description !== undefined) {\n    updateData.description = body.data.description.trim() || null;\n  }\n  if (body.data.key !== undefined) {\n    updateData.key = body.data.key;\n  }\n  if (body.data.type !== undefined) {\n    updateData.type = body.data.type;\n  }\n  if (body.data.required !== undefined) {\n    updateData.required = body.data.required;\n  }\n\n  const effectiveType = body.data.type ?? existingField.type;\n  const effectiveOptions = body.data.options ?? existingField.options;\n  const requiresOptions =\n    effectiveType === \"select\" || effectiveType === \"multiselect\";\n  const existingOptions = existingField.options.map((option) => ({\n    value: option.value,\n    label: option.label,\n  }));\n  const typeChanged =\n    body.data.type !== undefined && body.data.type !== existingField.type;\n  const optionsChanged =\n    body.data.options !== undefined &&\n    !areFieldOptionsEqual(body.data.options, existingOptions);\n\n  if (requiresOptions && effectiveOptions.length === 0) {\n    return NextResponse.json(\n      { error: \"Select fields must define at least one option\" },\n      { status: 400 }\n    );\n  }\n\n  if (!requiresOptions && effectiveOptions.length > 0) {\n    return NextResponse.json(\n      { error: \"Only select and multiselect fields can define options\" },\n      { status: 400 }\n    );\n  }\n\n  try {\n    const field = await db.$transaction(\n      async (tx) => {\n        if (typeChanged || optionsChanged) {\n          const fieldValueCount = await tx.fieldValue.count({\n            where: {\n              fieldId: id,\n              workspaceId,\n            },\n          });\n\n          if (fieldValueCount > 0) {\n            return null;\n          }\n        }\n\n        return tx.field.update({\n          where: {\n            id_workspaceId: {\n              id,\n              workspaceId,\n            },\n          },\n          data: {\n            ...updateData,\n            options:\n              body.data.options !== undefined || !requiresOptions\n                ? {\n                    deleteMany: {},\n                    create: requiresOptions\n                      ? buildFieldOptionWrites(body.data.options ?? [])\n                      : [],\n                  }\n                : undefined,\n          },\n          include: {\n            options: {\n              orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n            },\n          },\n        });\n      },\n      { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }\n    );\n\n    if (!field) {\n      return NextResponse.json(\n        {\n          error:\n            \"This field already has saved values. You can't change its type or options.\",\n        },\n        { status: 400 }\n      );\n    }\n\n    return NextResponse.json(field, { status: 200 });\n  } catch (error) {\n    if (isUniqueConstraintError(error)) {\n      return NextResponse.json(\n        { error: \"A field with this key already exists in your workspace\" },\n        { status: 409 }\n      );\n    }\n\n    if (isTransactionConflict(error)) {\n      return NextResponse.json(\n        {\n          error:\n            \"This field was updated concurrently. Please retry your changes.\",\n        },\n        { status: 409 }\n      );\n    }\n\n    throw error;\n  }\n}\n\nexport async function DELETE(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const existingField = await db.field.findFirst({\n    where: {\n      id,\n      workspaceId,\n    },\n  });\n\n  if (!existingField) {\n    return NextResponse.json({ error: \"Field not found\" }, { status: 404 });\n  }\n\n  await db.field.delete({\n    where: {\n      id_workspaceId: {\n        id,\n        workspaceId,\n      },\n    },\n  });\n\n  return NextResponse.json({ id }, { status: 200 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/fields/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport type { FieldType as PrismaFieldType } from \"@marble/db/browser\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { customFieldSchema } from \"@/lib/validations/fields\";\n\nfunction buildFieldOptionWrites(\n  options: Array<{ value: string; label: string }>\n) {\n  return options.map((option, index) => ({\n    value: option.value,\n    label: option.label,\n    position: index,\n  }));\n}\n\nfunction isUniqueFieldKeyConflict(error: unknown) {\n  if (!(error instanceof Error)) {\n    return false;\n  }\n\n  const candidate = error as Error & {\n    code?: string;\n    meta?: { target?: unknown };\n  };\n\n  if (candidate.code !== \"P2002\") {\n    return false;\n  }\n\n  const target = candidate.meta?.target;\n\n  return (\n    Array.isArray(target) &&\n    target.includes(\"workspaceId\") &&\n    target.includes(\"key\")\n  );\n}\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const fields = await db.field.findMany({\n    where: {\n      workspaceId,\n    },\n    include: {\n      options: {\n        orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n      },\n      _count: {\n        select: {\n          values: true,\n        },\n      },\n    },\n    orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n  });\n\n  const fieldsWithUsage = fields.map(({ _count, ...field }) => ({\n    ...field,\n    hasValues: _count.values > 0,\n  }));\n\n  return NextResponse.json(fieldsWithUsage, { status: 200 });\n}\n\nexport async function POST(req: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const json = await req.json();\n  const body = customFieldSchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  // Check key uniqueness within workspace\n  const existing = await db.field.findFirst({\n    where: {\n      workspaceId,\n      key: body.data.key,\n    },\n  });\n\n  if (existing) {\n    return NextResponse.json(\n      { error: \"A field with this key already exists in your workspace\" },\n      { status: 409 }\n    );\n  }\n\n  // Get the next position value\n  const maxPosition = await db.field.aggregate({\n    where: {\n      workspaceId,\n    },\n    _max: {\n      position: true,\n    },\n  });\n\n  try {\n    const field = await db.field.create({\n      data: {\n        name: body.data.name,\n        description: body.data.description?.trim() || null,\n        key: body.data.key,\n        type: body.data.type as PrismaFieldType,\n        required: body.data.required ?? false,\n        position: (maxPosition._max.position ?? -1) + 1,\n        workspaceId,\n        options:\n          (body.data.options ?? []).length > 0\n            ? {\n                create: buildFieldOptionWrites(body.data.options ?? []),\n              }\n            : undefined,\n      },\n      include: {\n        options: {\n          orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n        },\n      },\n    });\n\n    return NextResponse.json(field, { status: 201 });\n  } catch (error) {\n    if (isUniqueFieldKeyConflict(error)) {\n      return NextResponse.json(\n        { error: \"A field with this key already exists in your workspace\" },\n        { status: 409 }\n      );\n    }\n\n    throw error;\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/keys/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { updateApiKeySchema } from \"@/lib/validations/keys\";\nimport type { ApiScope } from \"@/utils/keys\";\n\nexport async function GET(\n  _request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n  const { id } = await params;\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const apiKey = await db.apiKey.findFirst({\n    where: { id, workspaceId },\n    select: {\n      id: true,\n      name: true,\n      prefix: true,\n      preview: true,\n      type: true,\n      scopes: true,\n      requestCount: true,\n      enabled: true,\n      lastUsed: true,\n      expiresAt: true,\n      createdAt: true,\n      updatedAt: true,\n      rateLimitTimeWindow: true,\n      rateLimitMax: true,\n      lastRequest: true,\n      // Never return 'key' field - it's hashed\n    },\n  });\n\n  if (!apiKey) {\n    return NextResponse.json({ error: \"API key not found\" }, { status: 404 });\n  }\n\n  return NextResponse.json(apiKey, { status: 200 });\n}\n\nexport async function PATCH(\n  request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n  const { id } = await params;\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const json = await request.json();\n  const body = updateApiKeySchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  // Verify the key exists and belongs to the workspace\n  const existingKey = await db.apiKey.findFirst({\n    where: { id, workspaceId },\n  });\n\n  if (!existingKey) {\n    return NextResponse.json({ error: \"API key not found\" }, { status: 404 });\n  }\n\n  const updateData: {\n    name?: string;\n    scopes?: ApiScope[];\n    expiresAt?: Date | null;\n    enabled?: boolean;\n  } = {};\n\n  if (body.data.name !== undefined) {\n    updateData.name = body.data.name;\n  }\n  if (body.data.scopes !== undefined) {\n    updateData.scopes = body.data.scopes;\n  }\n  if (body.data.expiresAt !== undefined) {\n    updateData.expiresAt = body.data.expiresAt;\n  }\n  if (body.data.enabled !== undefined) {\n    updateData.enabled = body.data.enabled;\n  }\n\n  const updatedKey = await db.apiKey.update({\n    where: {\n      id,\n      workspaceId,\n    },\n    data: updateData,\n    select: {\n      id: true,\n      name: true,\n      prefix: true,\n      preview: true,\n      type: true,\n      scopes: true,\n      requestCount: true,\n      enabled: true,\n      lastUsed: true,\n      expiresAt: true,\n      createdAt: true,\n      updatedAt: true,\n      rateLimitTimeWindow: true,\n      rateLimitMax: true,\n      lastRequest: true,\n      // Never return 'key' field - it's hashed\n    },\n  });\n\n  return NextResponse.json(updatedKey, { status: 200 });\n}\n\nexport async function DELETE(\n  _request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n  const { id } = await params;\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const apiKey = await db.apiKey.findFirst({\n    where: { id, workspaceId },\n  });\n\n  if (!apiKey) {\n    return NextResponse.json({ error: \"API key not found\" }, { status: 404 });\n  }\n\n  try {\n    await db.apiKey.delete({\n      where: {\n        id,\n        workspaceId,\n      },\n    });\n\n    return new NextResponse(null, { status: 204 });\n  } catch (_e) {\n    return NextResponse.json(\n      { error: \"Failed to delete API key\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/keys/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { generateApiKey } from \"@marble/utils\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { createApiKeySchema } from \"@/lib/validations/keys\";\nimport { DEFAULT_PRIVATE_SCOPES, DEFAULT_PUBLIC_SCOPES } from \"@/utils/keys\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const keys = await db.apiKey.findMany({\n    where: { workspaceId },\n    select: {\n      id: true,\n      name: true,\n      preview: true,\n      type: true,\n      scopes: true,\n      enabled: true,\n      lastUsed: true,\n      expiresAt: true,\n      createdAt: true,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  return NextResponse.json(keys, { status: 200 });\n}\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const json = await request.json();\n  const body = createApiKeySchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const { key, hash, prefix, preview } = generateApiKey(body.data.type);\n\n  // Set default scopes based on type if not provided\n  const scopesToSet =\n    body.data.scopes ??\n    (body.data.type === \"public\"\n      ? [...DEFAULT_PUBLIC_SCOPES]\n      : [...DEFAULT_PRIVATE_SCOPES]);\n\n  const apiKey = await db.apiKey.create({\n    data: {\n      name: body.data.name,\n      workspaceId,\n      key: hash,\n      prefix,\n      preview,\n      type: body.data.type,\n      scopes: scopesToSet,\n      expiresAt: body.data.expiresAt ?? null,\n      // Automatically set default rate limits: 1000 requests per 24 hours\n      rateLimitTimeWindow: 86_400_000, // 24 hours in ms\n      rateLimitMax: 1000,\n    },\n    select: {\n      id: true,\n      name: true,\n      prefix: true,\n      preview: true,\n      type: true,\n      scopes: true,\n      requestCount: true,\n      enabled: true,\n      lastUsed: true,\n      expiresAt: true,\n      createdAt: true,\n    },\n  });\n\n  // Return with plaintext key (only time it's visible)\n  return NextResponse.json({ ...apiKey, key }, { status: 201 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/media/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\n\nconst updateMediaSchema = z.object({\n  name: z.string().trim().min(1).max(255),\n  alt: z.string().trim().max(1000).nullable(),\n});\n\nexport async function GET(\n  _request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const { id } = await params;\n  if (!id) {\n    return NextResponse.json(\n      { error: \"Media ID is required\" },\n      { status: 400 }\n    );\n  }\n\n  try {\n    const media = await db.media.findFirst({\n      where: {\n        id,\n        workspaceId,\n      },\n      select: {\n        id: true,\n        name: true,\n        url: true,\n        alt: true,\n        createdAt: true,\n        type: true,\n        size: true,\n        mimeType: true,\n        width: true,\n        height: true,\n        duration: true,\n        blurHash: true,\n      },\n    });\n\n    if (!media) {\n      return NextResponse.json({ error: \"Media not found\" }, { status: 404 });\n    }\n\n    return NextResponse.json(media, { status: 200 });\n  } catch (error) {\n    console.error(\"[Media] Failed to fetch media:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch media\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function PATCH(\n  request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const { id } = await params;\n  if (!id) {\n    return NextResponse.json(\n      { error: \"Media ID is required\" },\n      { status: 400 }\n    );\n  }\n\n  try {\n    const body = await request.json();\n    const parsedBody = updateMediaSchema.safeParse(body);\n\n    if (!parsedBody.success) {\n      return NextResponse.json(\n        { error: \"Invalid request body\", details: parsedBody.error.issues },\n        { status: 400 }\n      );\n    }\n\n    const existingMedia = await db.media.findFirst({\n      where: {\n        id,\n        workspaceId,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!existingMedia) {\n      return NextResponse.json({ error: \"Media not found\" }, { status: 404 });\n    }\n\n    const updatedMedia = await db.media.update({\n      where: {\n        id,\n      },\n      data: parsedBody.data,\n      select: {\n        id: true,\n        name: true,\n        url: true,\n        alt: true,\n        createdAt: true,\n        type: true,\n        size: true,\n        mimeType: true,\n        width: true,\n        height: true,\n        duration: true,\n        blurHash: true,\n      },\n    });\n\n    return NextResponse.json(updatedMedia, { status: 200 });\n  } catch (error) {\n    console.error(\"[Media] Failed to update media:\", error);\n    return NextResponse.json(\n      { error: \"Failed to update media\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/media/editor/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { loadMediaEditorApiFilters } from \"@/lib/search-params\";\nimport { splitMediaSort } from \"@/utils/media\";\n\nexport async function GET(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const filters = loadMediaEditorApiFilters(request, { strict: true });\n  if (!z.number().int().min(1).max(100).safeParse(filters.limit).success) {\n    return NextResponse.json({ error: \"Invalid limit\" }, { status: 400 });\n  }\n\n  const { field, direction } = splitMediaSort(filters.sort);\n  const { cursor, limit } = filters;\n\n  try {\n    const hasAnyMedia =\n      (await db.media.count({\n        where: { workspaceId },\n      })) > 0;\n\n    let cursorId: string | null = null;\n    let parsedCursorValue: string | Date | null = null;\n    if (cursor) {\n      const separatorIndex = cursor.indexOf(\"_\");\n      if (separatorIndex === -1) {\n        return NextResponse.json({ error: \"Invalid cursor\" }, { status: 400 });\n      }\n\n      const idPart = cursor.slice(0, separatorIndex);\n      const encodedValue = cursor.slice(separatorIndex + 1);\n      let valuePart: string;\n      try {\n        valuePart = decodeURIComponent(encodedValue);\n      } catch {\n        return NextResponse.json({ error: \"Invalid cursor\" }, { status: 400 });\n      }\n\n      cursorId = idPart || null;\n      if (valuePart) {\n        if (field === \"createdAt\") {\n          const date = new Date(valuePart);\n          if (Number.isNaN(date.getTime())) {\n            return NextResponse.json(\n              { error: \"Invalid cursor\" },\n              { status: 400 }\n            );\n          }\n          parsedCursorValue = date;\n        } else {\n          parsedCursorValue = valuePart;\n        }\n      }\n    }\n\n    const media = await db.media.findMany({\n      where: {\n        workspaceId,\n        ...(cursorId &&\n          parsedCursorValue !== null && {\n            OR: [\n              {\n                [field]: {\n                  [direction === \"asc\" ? \"gt\" : \"lt\"]: parsedCursorValue,\n                },\n              },\n              {\n                [field]: parsedCursorValue,\n                id: { [direction === \"asc\" ? \"gt\" : \"lt\"]: cursorId },\n              },\n            ],\n          }),\n      },\n      take: limit + 1,\n      orderBy: [{ [field]: direction }, { id: direction }],\n      select: {\n        id: true,\n        name: true,\n        url: true,\n        alt: true,\n        createdAt: true,\n        type: true,\n        size: true,\n        mimeType: true,\n        width: true,\n        height: true,\n        duration: true,\n        blurHash: true,\n      },\n    });\n\n    let nextCursor: string | undefined;\n\n    if (media.length > limit) {\n      media.pop();\n      const lastItem = media.at(-1);\n\n      if (lastItem) {\n        const value =\n          field === \"createdAt\"\n            ? lastItem.createdAt.toISOString()\n            : lastItem.name;\n        nextCursor = `${lastItem.id}_${encodeURIComponent(value)}`;\n      }\n    }\n\n    return NextResponse.json(\n      { media, nextCursor, hasAnyMedia },\n      { status: 200 }\n    );\n  } catch (error) {\n    console.error(\"[MediaEditor] Failed to fetch media:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch media\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/media/route.ts",
    "content": "import { DeleteObjectCommand } from \"@aws-sdk/client-s3\";\nimport { db } from \"@marble/db\";\nimport { toMediaPayload } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { R2_BUCKET_NAME, r2 } from \"@/lib/r2\";\nimport { loadMediaApiFilters } from \"@/lib/search-params\";\nimport { DeleteSchema } from \"@/lib/validations/upload\";\nimport { splitMediaSort } from \"@/utils/media\";\n\nexport async function GET(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const filters = loadMediaApiFilters(request, { strict: true });\n  if (!z.number().int().min(1).safeParse(filters.page).success) {\n    return NextResponse.json({ error: \"Invalid page\" }, { status: 400 });\n  }\n  if (!z.number().int().min(1).max(100).safeParse(filters.perPage).success) {\n    return NextResponse.json({ error: \"Invalid perPage\" }, { status: 400 });\n  }\n  const { field, direction } = splitMediaSort(filters.sort);\n  const { page, perPage, search, type } = filters;\n\n  try {\n    const where = {\n      workspaceId,\n      ...(type && { type }),\n      ...(search?.trim() && {\n        name: {\n          contains: search.trim(),\n          mode: \"insensitive\" as const,\n        },\n      }),\n    };\n\n    const orderBy = [{ [field]: direction }, { id: direction }];\n    const hasFilters = Boolean(type || search?.trim());\n\n    const [media, totalCount, workspaceMediaCount] = await Promise.all([\n      db.media.findMany({\n        where,\n        skip: (page - 1) * perPage,\n        take: perPage,\n        orderBy,\n        select: {\n          id: true,\n          name: true,\n          url: true,\n          alt: true,\n          createdAt: true,\n          type: true,\n          size: true,\n          mimeType: true,\n          width: true,\n          height: true,\n          duration: true,\n          blurHash: true,\n        },\n      }),\n      db.media.count({ where }),\n      hasFilters ? db.media.count({ where: { workspaceId } }) : null,\n    ]);\n    const hasAnyMedia =\n      workspaceMediaCount === null ? totalCount > 0 : workspaceMediaCount > 0;\n\n    return NextResponse.json(\n      {\n        media,\n        pageCount: Math.max(1, Math.ceil(totalCount / perPage)),\n        totalCount,\n        hasAnyMedia,\n      },\n      { status: 200 }\n    );\n  } catch (error) {\n    console.error(\"[Media] Failed to fetch media:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch media\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function DELETE(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const parsedBody = await request.json();\n\n  const parsed = DeleteSchema.safeParse(parsedBody);\n  if (!parsed.success) {\n    return NextResponse.json({ error: parsed.error.message }, { status: 400 });\n  }\n\n  const { mediaIds } = parsed.data;\n\n  try {\n    const deletedIds: string[] = [];\n    const failedIds: string[] = [];\n\n    const existingMedia = await db.media.findMany({\n      where: {\n        id: { in: mediaIds },\n        workspaceId,\n      },\n    });\n\n    const existingIds = existingMedia.map((media) => media.id);\n    for (const id of mediaIds) {\n      if (!existingIds.includes(id)) {\n        failedIds.push(id);\n      }\n    }\n\n    const mediaDeletedFromR2: Array<{\n      id: string;\n      media: (typeof existingMedia)[0];\n    }> = [];\n\n    for (const media of existingMedia) {\n      if (media.url) {\n        try {\n          const rawPath = media.url.startsWith(\"http\")\n            ? new URL(media.url).pathname\n            : media.url;\n          let key = decodeURIComponent(rawPath).replace(/^\\/+/, \"\");\n          if (key.startsWith(`${R2_BUCKET_NAME}/`)) {\n            key = key.slice(R2_BUCKET_NAME.length + 1);\n          }\n          key = key.replace(/\\/{2,}/g, \"/\");\n          if (\n            !key ||\n            key.split(\"/\").some((seg) => [\"\", \".\", \"..\"].includes(seg))\n          ) {\n            throw new Error(\n              \"Invalid storage key: contains empty or traversal path segments.\"\n            );\n          }\n          await r2.send(\n            new DeleteObjectCommand({\n              Bucket: R2_BUCKET_NAME,\n              Key: key,\n            })\n          );\n          mediaDeletedFromR2.push({ id: media.id, media });\n        } catch (error) {\n          console.error(\n            `Failed to delete media object from R2 for media ID ${media.id}. URL: ${media.url}`,\n            error\n          );\n          failedIds.push(media.id);\n        }\n      } else {\n        console.error(\n          `Media with ID ${media.id} has no URL. Deleting database record only.`\n        );\n        mediaDeletedFromR2.push({ id: media.id, media });\n      }\n    }\n\n    if (mediaDeletedFromR2.length > 0) {\n      await db.media.deleteMany({\n        where: {\n          id: { in: mediaDeletedFromR2.map((item) => item.id) },\n        },\n      });\n\n      deletedIds.push(...mediaDeletedFromR2.map((item) => item.id));\n\n      for (const { media } of mediaDeletedFromR2) {\n        await emitDashboardEvent({\n          type: \"media_deleted\",\n          workspaceId,\n          resourceType: \"media\",\n          resourceId: media.id,\n          actorId: sessionData.user.id,\n          payload: toMediaPayload(media),\n        }).catch(logDashboardEventError);\n      }\n    }\n\n    if (deletedIds.length === 0) {\n      return NextResponse.json(\n        { error: \"No media items were deleted successfully\" },\n        { status: 500 }\n      );\n    }\n\n    return NextResponse.json(\n      {\n        deletedIds,\n        failedIds: failedIds.length > 0 ? failedIds : undefined,\n        message:\n          failedIds.length > 0\n            ? `Deleted ${deletedIds.length} items, ${failedIds.length} failed`\n            : `Deleted ${deletedIds.length} items successfully`,\n      },\n      { status: 200 }\n    );\n  } catch (error) {\n    console.error(\"[Media] Failed to delete media:\", error);\n    return NextResponse.json(\n      { error: \"Failed to delete media\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/metrics/publishing/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { eachDayOfInterval, endOfYear, format, startOfYear } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const now = new Date();\n  const startOfCurrentYear = startOfYear(now);\n  const endOfCurrentYear = endOfYear(now);\n\n  const posts = await db.post.findMany({\n    where: {\n      workspaceId,\n      status: \"published\",\n      publishedAt: {\n        gte: startOfCurrentYear,\n        lte: endOfCurrentYear,\n      },\n    },\n    select: {\n      publishedAt: true,\n    },\n    orderBy: {\n      publishedAt: \"asc\",\n    },\n  });\n\n  const dateCountMap = new Map<string, number>();\n\n  for (const post of posts) {\n    if (post.publishedAt) {\n      const dateKey = format(post.publishedAt, \"yyyy-MM-dd\");\n      dateCountMap.set(dateKey, (dateCountMap.get(dateKey) || 0) + 1);\n    }\n  }\n\n  const allDaysInYear = eachDayOfInterval({\n    start: startOfCurrentYear,\n    end: endOfCurrentYear,\n  });\n\n  const maxCount = Math.max(...Array.from(dateCountMap.values()), 1);\n\n  const activityData = allDaysInYear.map((date) => {\n    const dateKey = format(date, \"yyyy-MM-dd\");\n    const count = dateCountMap.get(dateKey) || 0;\n\n    let level: number;\n    const percentage = count === 0 ? 0 : (count / maxCount) * 100;\n\n    if (count === 0) {\n      level = 0;\n    } else if (percentage <= 25) {\n      level = 1;\n    } else if (percentage <= 50) {\n      level = 2;\n    } else if (percentage <= 75) {\n      level = 3;\n    } else {\n      level = 4;\n    }\n\n    return {\n      date: dateKey,\n      count,\n      level,\n    };\n  });\n\n  return NextResponse.json({\n    graph: {\n      activity: activityData,\n    },\n  });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/metrics/usage/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { UsageEventType } from \"@marble/db/browser\";\nimport { addDays, format, startOfDay, subDays, subHours } from \"date-fns\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\n\nconst CHART_DAYS = 30;\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n  const now = new Date();\n  const today = startOfDay(now);\n  const chartStart = subDays(today, CHART_DAYS - 1);\n  const previousPeriodStart = subDays(chartStart, CHART_DAYS);\n\n  const [apiEvents, apiPrevPeriodCount, apiTotalCount] = await Promise.all([\n    db.usageEvent.findMany({\n      where: {\n        workspaceId,\n        type: UsageEventType.api_request,\n        createdAt: {\n          gte: chartStart,\n        },\n      },\n      select: {\n        createdAt: true,\n      },\n    }),\n    db.usageEvent.count({\n      where: {\n        workspaceId,\n        type: UsageEventType.api_request,\n        createdAt: {\n          gte: previousPeriodStart,\n          lt: chartStart,\n        },\n      },\n    }),\n    db.usageEvent.count({\n      where: {\n        workspaceId,\n        type: UsageEventType.api_request,\n      },\n    }),\n  ]);\n\n  const chartBuckets = new Map<string, number>();\n  for (let i = 0; i < CHART_DAYS; i += 1) {\n    const date = addDays(chartStart, i);\n    const key = format(date, \"yyyy-MM-dd\");\n    chartBuckets.set(key, 0);\n  }\n  for (const event of apiEvents) {\n    const key = format(startOfDay(event.createdAt), \"yyyy-MM-dd\");\n    chartBuckets.set(key, (chartBuckets.get(key) ?? 0) + 1);\n  }\n\n  const apiChart = Array.from(chartBuckets.entries()).map(\n    ([dateKey, count]) => ({\n      date: dateKey,\n      label: format(new Date(dateKey), \"MMM d\"),\n      value: count,\n    })\n  );\n\n  const apiLastPeriodCount = apiChart.reduce(\n    (acc, curr) => acc + curr.value,\n    0\n  );\n  const apiPrev = apiPrevPeriodCount;\n  const apiChange =\n    apiPrev === 0\n      ? apiLastPeriodCount > 0\n        ? 100\n        : 0\n      : ((apiLastPeriodCount - apiPrev) / apiPrev) * 100;\n\n  const WEBHOOK_CHART_DAYS = 30;\n  const webhookChartStart = subDays(today, WEBHOOK_CHART_DAYS - 1);\n\n  const [\n    webhookTotal,\n    webhookWeek,\n    webhookDay,\n    webhookTopEndpoint,\n    webhookEvents,\n    mediaTotals,\n    mediaLast30,\n    mediaLastUpload,\n    recentMediaUploads,\n  ] = await Promise.all([\n    db.usageEvent.count({\n      where: { workspaceId, type: UsageEventType.webhook_delivery },\n    }),\n    db.usageEvent.count({\n      where: {\n        workspaceId,\n        type: UsageEventType.webhook_delivery,\n        createdAt: { gte: subDays(now, 6) },\n      },\n    }),\n    db.usageEvent.count({\n      where: {\n        workspaceId,\n        type: UsageEventType.webhook_delivery,\n        createdAt: { gte: subHours(now, 24) },\n      },\n    }),\n    db.usageEvent.groupBy({\n      by: [\"endpoint\"],\n      where: {\n        workspaceId,\n        type: UsageEventType.webhook_delivery,\n        endpoint: { not: null },\n      },\n      _count: { endpoint: true },\n      orderBy: { _count: { endpoint: \"desc\" } },\n      take: 1,\n    }),\n    db.usageEvent.findMany({\n      where: {\n        workspaceId,\n        type: UsageEventType.webhook_delivery,\n        createdAt: {\n          gte: webhookChartStart,\n        },\n      },\n      select: {\n        createdAt: true,\n      },\n    }),\n    db.usageEvent.count({\n      where: { workspaceId, type: UsageEventType.media_upload },\n    }),\n    db.usageEvent.count({\n      where: {\n        workspaceId,\n        type: UsageEventType.media_upload,\n        createdAt: { gte: subDays(now, 29) },\n      },\n    }),\n    db.usageEvent.findFirst({\n      where: { workspaceId, type: UsageEventType.media_upload },\n      orderBy: { createdAt: \"desc\" },\n      select: { createdAt: true },\n    }),\n    db.media.findMany({\n      where: { workspaceId },\n      orderBy: { createdAt: \"desc\" },\n      take: 10,\n      select: {\n        id: true,\n        name: true,\n        size: true,\n        alt: true,\n        createdAt: true,\n        type: true,\n        url: true,\n        mimeType: true,\n        width: true,\n        height: true,\n        duration: true,\n        blurHash: true,\n      },\n    }),\n  ]);\n\n  const webhookChartBuckets = new Map<string, number>();\n  for (let i = 0; i < WEBHOOK_CHART_DAYS; i += 1) {\n    const date = addDays(webhookChartStart, i);\n    const key = format(date, \"yyyy-MM-dd\");\n    webhookChartBuckets.set(key, 0);\n  }\n  for (const event of webhookEvents) {\n    const key = format(startOfDay(event.createdAt), \"yyyy-MM-dd\");\n    webhookChartBuckets.set(key, (webhookChartBuckets.get(key) ?? 0) + 1);\n  }\n\n  const webhookChart = Array.from(webhookChartBuckets.entries()).map(\n    ([dateKey, count]) => ({\n      date: dateKey,\n      label: format(new Date(dateKey), \"MMM d\"),\n      value: count,\n    })\n  );\n\n  const response = {\n    api: {\n      totals: {\n        total: apiTotalCount,\n        lastPeriod: apiLastPeriodCount,\n        changePercentage: Math.round(apiChange * 100) / 100,\n      },\n      chart: apiChart,\n    },\n    webhooks: {\n      total: webhookTotal,\n      last7Days: webhookWeek,\n      last24Hours: webhookDay,\n      topEndpoint: webhookTopEndpoint[0]?.endpoint ?? null,\n      topEndpointCount: webhookTopEndpoint[0]?._count.endpoint ?? 0,\n      chart: webhookChart,\n    },\n    media: {\n      total: mediaTotals,\n      last30Days: mediaLast30,\n      totalSize: recentMediaUploads.reduce((sum, media) => sum + media.size, 0),\n      lastUploadAt: mediaLastUpload?.createdAt.toISOString() ?? null,\n      recentUploads: recentMediaUploads.map((media) => ({\n        id: media.id,\n        name: media.name,\n        size: media.size,\n        alt: media.alt,\n        createdAt: media.createdAt.toISOString(),\n        type: media.type,\n        url: media.url,\n        mimeType: media.mimeType,\n        width: media.width,\n        height: media.height,\n        duration: media.duration,\n        blurHash: media.blurHash,\n      })),\n    },\n  };\n\n  return NextResponse.json(response);\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/polar/success/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { cookies } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\nimport { getServerSession } from \"@/lib/auth/session\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport { getLastVisitedWorkspace } from \"@/utils/workspace/client\";\n\nexport async function GET(request: Request) {\n  const { searchParams } = new URL(request.url);\n  const checkoutId = searchParams.get(\"checkout_id\");\n\n  const authInfo = await getServerSession();\n\n  if (!authInfo) {\n    return NextResponse.redirect(\n      new URL(\"/login\", process.env.NEXT_PUBLIC_APP_URL)\n    );\n  }\n\n  const cookieStore = await cookies();\n\n  console.log(\"Checkout ID\", checkoutId);\n\n  let workspaceSlug: string | undefined;\n\n  workspaceSlug = getLastVisitedWorkspace(cookieStore);\n\n  if (!workspaceSlug && authInfo.session.activeOrganizationId) {\n    const workspace = await db.organization.findUnique({\n      where: { id: authInfo.session.activeOrganizationId as string },\n      select: { slug: true },\n    });\n    workspaceSlug = workspace?.slug;\n  }\n\n  if (authInfo.session.activeOrganizationId) {\n    invalidateCache(authInfo.session.activeOrganizationId as string, \"usage\");\n  }\n\n  if (workspaceSlug) {\n    return NextResponse.redirect(\n      new URL(\n        `/${workspaceSlug}/settings/billing?success=true`,\n        process.env.NEXT_PUBLIC_APP_URL\n      )\n    );\n  }\n\n  return NextResponse.redirect(new URL(\"/\", process.env.NEXT_PUBLIC_APP_URL));\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/posts/[id]/fields/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport {\n  customFieldsPayloadSchema,\n  resolveCustomFieldValues,\n} from \"@/lib/custom-fields\";\nimport { sanitizeRichTextHtml } from \"@/utils/editor\";\n\nexport async function GET(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n  const { id: postId } = await params;\n\n  const post = await db.post.findFirst({\n    where: {\n      id: postId,\n      workspaceId,\n    },\n    select: { id: true },\n  });\n\n  if (!post) {\n    return NextResponse.json({ error: \"Post not found\" }, { status: 404 });\n  }\n\n  // Fetch workspace custom field definitions and this post's values\n  const [fields, values] = await Promise.all([\n    db.field.findMany({\n      where: { workspaceId },\n      include: {\n        options: {\n          orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n        },\n      },\n      orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n    }),\n    db.fieldValue.findMany({\n      where: {\n        postId,\n        workspaceId,\n      },\n    }),\n  ]);\n\n  // Build a map of fieldId -> value for easy lookup\n  const valueMap: Record<string, string> = {};\n  for (const v of values) {\n    valueMap[v.fieldId] = v.value;\n  }\n\n  return NextResponse.json({ fields, values: valueMap }, { status: 200 });\n}\n\nexport async function PUT(\n  req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n  const { id: postId } = await params;\n\n  const post = await db.post.findFirst({\n    where: {\n      id: postId,\n      workspaceId,\n    },\n    select: { id: true },\n  });\n\n  if (!post) {\n    return NextResponse.json({ error: \"Post not found\" }, { status: 404 });\n  }\n\n  const requestJson = await req.json();\n  const payload = customFieldsPayloadSchema.safeParse(requestJson);\n\n  if (!payload.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: payload.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const json = payload.data;\n\n  const fields = await db.field.findMany({\n    where: {\n      workspaceId,\n    },\n    select: {\n      id: true,\n      key: true,\n      name: true,\n      type: true,\n      required: true,\n      options: {\n        select: {\n          value: true,\n          label: true,\n        },\n        orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n      },\n    },\n  });\n\n  const resolvedValues = resolveCustomFieldValues(fields, json);\n\n  if (!resolvedValues.success) {\n    return NextResponse.json(resolvedValues.error, { status: 400 });\n  }\n\n  const operations = resolvedValues.values.map(\n    ({ fieldId, fieldType, value }) => {\n      if (value === null) {\n        return db.fieldValue.deleteMany({\n          where: {\n            postId,\n            fieldId,\n            workspaceId,\n          },\n        });\n      }\n\n      return db.fieldValue.upsert({\n        where: {\n          postId_fieldId: { postId, fieldId },\n        },\n        update: {\n          value: fieldType === \"richtext\" ? sanitizeRichTextHtml(value) : value,\n          workspaceId,\n        },\n        create: {\n          postId,\n          fieldId,\n          workspaceId,\n          value: fieldType === \"richtext\" ? sanitizeRichTextHtml(value) : value,\n        },\n      });\n    }\n  );\n\n  if (operations.length > 0) {\n    await db.$transaction(operations);\n  }\n\n  return NextResponse.json({ success: true }, { status: 200 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/posts/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toPostPayload, withChanges } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport { resolveCustomFieldValues } from \"@/lib/custom-fields\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { postUpsertSchema } from \"@/lib/validations/post\";\nimport { validateWorkspaceTags } from \"@/lib/validations/tags\";\nimport { sanitizeHtml, sanitizeRichTextHtml } from \"@/utils/editor\";\n\nasync function buildCustomFieldWrites(\n  workspaceId: string,\n  input: Record<string, string | null | undefined>\n): Promise<ReturnType<typeof resolveCustomFieldValues>> {\n  const fields = await db.field.findMany({\n    where: {\n      workspaceId,\n    },\n    select: {\n      id: true,\n      key: true,\n      name: true,\n      type: true,\n      required: true,\n      options: {\n        select: {\n          value: true,\n          label: true,\n        },\n        orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n      },\n    },\n  });\n\n  return resolveCustomFieldValues(fields, input);\n}\n\nexport async function GET(\n  _request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n  const { id } = await params;\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const post = await db.post.findFirst({\n    where: { id, workspaceId },\n    select: {\n      id: true,\n      slug: true,\n      title: true,\n      status: true,\n      featured: true,\n      content: true,\n      coverImage: true,\n      description: true,\n      publishedAt: true,\n      contentJson: true,\n      categoryId: true,\n      tags: {\n        select: { id: true },\n      },\n      authors: {\n        select: { id: true },\n      },\n    },\n  });\n\n  if (!post) {\n    return NextResponse.json({ error: \"Post not found\" }, { status: 404 });\n  }\n\n  const structuredData = {\n    slug: post.slug,\n    title: post.title,\n    status: post.status,\n    featured: post.featured,\n    content: post.content,\n    coverImage: post.coverImage,\n    description: post.description,\n    publishedAt: post.publishedAt,\n    contentJson: JSON.stringify(post.contentJson),\n    tags: post.tags.map((tag) => tag.id),\n    category: post.categoryId,\n    authors: post.authors.map((author) => author.id),\n  };\n\n  return NextResponse.json(structuredData, { status: 200 });\n}\n\nexport async function PATCH(\n  request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n  const { id } = await params;\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const body = await request.json();\n\n  const values = postUpsertSchema.safeParse(body);\n\n  if (!values.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: values.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existingPostWithSlug = await db.post.findFirst({\n    where: {\n      slug: values.data.slug,\n      workspaceId,\n      id: { not: id },\n    },\n  });\n\n  if (existingPostWithSlug) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  const contentJson = JSON.parse(values.data.contentJson);\n  const cleanContent = sanitizeHtml(values.data.content);\n\n  const tagValidation = await validateWorkspaceTags(\n    values.data.tags,\n    workspaceId\n  );\n\n  if (!tagValidation.success) {\n    return tagValidation.response;\n  }\n\n  const { uniqueTagIds } = tagValidation;\n\n  if (values.data.category) {\n    const category = await db.category.findFirst({\n      where: {\n        id: values.data.category,\n        workspaceId,\n      },\n    });\n\n    if (!category) {\n      return NextResponse.json(\n        { error: \"Invalid category provided\" },\n        { status: 400 }\n      );\n    }\n  }\n\n  // Find all authors for the provided author IDs\n  const validAuthors = await db.author.findMany({\n    where: {\n      id: { in: values.data.authors },\n      workspaceId,\n    },\n  });\n\n  if (validAuthors.length === 0) {\n    return NextResponse.json(\n      { error: \"No valid authors found\" },\n      { status: 400 }\n    );\n  }\n\n  // Use the first valid author as primary\n  const primaryAuthor = validAuthors[0];\n\n  if (!primaryAuthor) {\n    // This should never happen since validAuthors.length > 0\n    return NextResponse.json(\n      { error: \"Unable to determine primary author\" },\n      { status: 500 }\n    );\n  }\n\n  const post = await db.post.findFirst({\n    where: { id, workspaceId },\n    select: { status: true },\n  });\n\n  if (!post) {\n    return NextResponse.json({ error: \"Post not found\" }, { status: 404 });\n  }\n\n  try {\n    const customFieldWrites = await buildCustomFieldWrites(\n      workspaceId,\n      values.data.customFields\n    );\n\n    if (!customFieldWrites.success) {\n      return NextResponse.json(customFieldWrites.error, { status: 400 });\n    }\n\n    const postUpdated = await db.$transaction(async (tx) => {\n      const updatedPost = await tx.post.update({\n        where: { id },\n        data: {\n          contentJson,\n          slug: values.data.slug,\n          title: values.data.title,\n          status: values.data.status,\n          featured: values.data.featured,\n          content: cleanContent,\n          categoryId: values.data.category,\n          coverImage: values.data.coverImage,\n          description: values.data.description,\n          publishedAt: values.data.publishedAt,\n          workspaceId,\n          tags: values.data.tags\n            ? { set: uniqueTagIds.map((tagId) => ({ id: tagId })) }\n            : undefined,\n          authors: {\n            set: validAuthors.map((author) => ({ id: author.id })),\n          },\n        },\n      });\n\n      if (customFieldWrites.values.length > 0) {\n        await Promise.all(\n          customFieldWrites.values.map(({ fieldId, fieldType, value }) => {\n            if (value === null) {\n              return tx.fieldValue.deleteMany({\n                where: {\n                  postId: id,\n                  fieldId,\n                  workspaceId,\n                },\n              });\n            }\n\n            return tx.fieldValue.upsert({\n              where: {\n                postId_fieldId: { postId: id, fieldId },\n              },\n              update: {\n                workspaceId,\n                value:\n                  fieldType === \"richtext\"\n                    ? sanitizeRichTextHtml(value)\n                    : value,\n              },\n              create: {\n                postId: id,\n                fieldId,\n                workspaceId,\n                value:\n                  fieldType === \"richtext\"\n                    ? sanitizeRichTextHtml(value)\n                    : value,\n              },\n            });\n          })\n        );\n      }\n\n      return updatedPost;\n    });\n\n    const eventType =\n      post.status !== \"published\" && postUpdated.status === \"published\"\n        ? \"post_published\"\n        : post.status === \"published\" && postUpdated.status !== \"published\"\n          ? \"post_unpublished\"\n          : \"post_updated\";\n    const payload =\n      eventType === \"post_updated\"\n        ? withChanges(toPostPayload(postUpdated), Object.keys(values.data))\n        : toPostPayload(postUpdated);\n\n    await emitDashboardEvent({\n      type: eventType,\n      workspaceId,\n      resourceType: \"post\",\n      resourceId: postUpdated.id,\n      actorId: sessionData.user.id,\n      payload,\n    }).catch(logDashboardEventError);\n\n    // Invalidate cache for posts\n    invalidateCache(workspaceId, \"posts\");\n\n    return NextResponse.json({ id: postUpdated.id }, { status: 200 });\n  } catch (error) {\n    console.error(`[PostUpdate] Error updating post ${id}:`, error);\n    return NextResponse.json(\n      { error: \"Failed to update post\" },\n      { status: 500 }\n    );\n  }\n}\n\nexport async function DELETE(\n  _request: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n  const { id } = await params;\n\n  try {\n    const deletedPost = await db.post.delete({\n      where: { id, workspaceId },\n    });\n\n    if (!deletedPost) {\n      return NextResponse.json({ error: \"Post not found\" }, { status: 404 });\n    }\n\n    await emitDashboardEvent({\n      type: \"post_deleted\",\n      workspaceId,\n      resourceType: \"post\",\n      resourceId: id,\n      actorId: sessionData.user.id,\n      payload: toPostPayload(deletedPost),\n    }).catch(logDashboardEventError);\n\n    // Invalidate cache for posts\n    invalidateCache(workspaceId, \"posts\");\n\n    return NextResponse.json({ id: deletedPost.id }, { status: 200 });\n  } catch (_e) {\n    return NextResponse.json(\n      { error: \"Failed to delete post\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/posts/import/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toPostPayload } from \"@marble/events\";\nimport { markdownToHtml, markdownToTiptap } from \"@marble/parser/tiptap\";\nimport { nanoid } from \"nanoid\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { postImportSchema } from \"@/lib/validations/post\";\nimport { validateWorkspaceTags } from \"@/lib/validations/tags\";\nimport { sanitizeHtml } from \"@/utils/editor\";\nimport { generateSlug } from \"@/utils/string\";\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const body = await request.json();\n  const values = postImportSchema.safeParse(body);\n  if (!values.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: values.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existingPost = await db.post.findFirst({\n    where: {\n      slug: values.data.slug,\n      workspaceId,\n    },\n  });\n\n  if (existingPost) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  const baseSlug = generateSlug(sessionData.user.name);\n  const uniqueSlug = `${baseSlug}-${nanoid(6)}`;\n\n  const primaryAuthor = await db.author.upsert({\n    where: {\n      workspaceId_userId: {\n        workspaceId,\n        userId: sessionData.user.id,\n      },\n    },\n    update: {},\n    create: {\n      name: sessionData.user.name,\n      email: sessionData.user.email,\n      slug: uniqueSlug,\n      image: sessionData.user.image,\n      workspaceId,\n      userId: sessionData.user.id,\n      role: \"Writer\",\n    },\n  });\n\n  const contentJson = markdownToTiptap(values.data.content);\n  const htmlContent = await markdownToHtml(values.data.content || \"\");\n  const cleanContent = sanitizeHtml(htmlContent);\n\n  const tagValidation = await validateWorkspaceTags(\n    values.data.tags,\n    workspaceId\n  );\n\n  if (!tagValidation.success) {\n    return tagValidation.response;\n  }\n\n  const { uniqueTagIds } = tagValidation;\n\n  if (values.data.category) {\n    const category = await db.category.findFirst({\n      where: {\n        id: values.data.category,\n        workspaceId,\n      },\n    });\n\n    if (!category) {\n      return NextResponse.json(\n        { error: \"Invalid category provided\" },\n        { status: 400 }\n      );\n    }\n  }\n\n  const authorIds = values.data.authors?.length\n    ? values.data.authors\n    : [primaryAuthor.id];\n  const validAuthors = await db.author.findMany({\n    where: {\n      id: { in: authorIds },\n      workspaceId,\n    },\n  });\n\n  if (validAuthors.length === 0) {\n    return NextResponse.json(\n      { error: \"No valid authors found\" },\n      { status: 400 }\n    );\n  }\n\n  const postCreated = await db.post.create({\n    data: {\n      primaryAuthorId: primaryAuthor.id,\n      contentJson,\n      slug: values.data.slug,\n      title: values.data.title,\n      status: values.data.status,\n      featured: values.data.featured,\n      content: cleanContent,\n      categoryId: values.data.category,\n      coverImage: values.data.coverImage,\n      publishedAt: values.data.publishedAt,\n      description: values.data.description,\n      workspaceId,\n      tags:\n        uniqueTagIds.length > 0\n          ? {\n              connect: uniqueTagIds.map((id) => ({ id })),\n            }\n          : undefined,\n      authors: {\n        connect: validAuthors.map((author) => ({ id: author.id })),\n      },\n    },\n  });\n\n  await emitDashboardEvent({\n    type:\n      postCreated.status === \"published\" ? \"post_published\" : \"post_created\",\n    workspaceId,\n    resourceType: \"post\",\n    resourceId: postCreated.id,\n    actorId: sessionData.user.id,\n    payload: toPostPayload(postCreated),\n  }).catch(logDashboardEventError);\n\n  return NextResponse.json({ id: postCreated.id });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/posts/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toPostPayload } from \"@marble/events\";\nimport { nanoid } from \"nanoid\";\nimport { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport { resolveCustomFieldValues } from \"@/lib/custom-fields\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { loadPostApiFilters } from \"@/lib/search-params\";\nimport { postUpsertSchema } from \"@/lib/validations/post\";\nimport { validateWorkspaceTags } from \"@/lib/validations/tags\";\nimport { sanitizeHtml, sanitizeRichTextHtml } from \"@/utils/editor\";\nimport { generateSlug } from \"@/utils/string\";\n\nasync function buildCustomFieldWrites(\n  workspaceId: string,\n  input: Record<string, string | null | undefined>\n): Promise<ReturnType<typeof resolveCustomFieldValues>> {\n  const fields = await db.field.findMany({\n    where: {\n      workspaceId,\n    },\n    select: {\n      id: true,\n      key: true,\n      name: true,\n      type: true,\n      required: true,\n      options: {\n        select: {\n          value: true,\n          label: true,\n        },\n        orderBy: [{ position: \"asc\" }, { createdAt: \"asc\" }],\n      },\n    },\n  });\n\n  const normalizedInput: Record<string, string | null | undefined> = {\n    ...input,\n  };\n  for (const field of fields) {\n    if (!(field.id in normalizedInput)) {\n      normalizedInput[field.id] = undefined;\n    }\n  }\n\n  return resolveCustomFieldValues(fields, normalizedInput);\n}\n\nconst POST_SORT_FIELDS = new Set([\n  \"createdAt\",\n  \"publishedAt\",\n  \"updatedAt\",\n  \"title\",\n]);\n\nfunction splitPostSort(sort: string) {\n  const [field = \"createdAt\", direction = \"desc\"] = sort.split(\"_\");\n  return {\n    field: POST_SORT_FIELDS.has(field) ? field : \"createdAt\",\n    direction: direction === \"asc\" ? \"asc\" : \"desc\",\n  } as const;\n}\n\nexport async function GET(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const filters = loadPostApiFilters(request, { strict: true });\n  if (!z.number().int().min(1).safeParse(filters.page).success) {\n    return NextResponse.json({ error: \"Invalid page\" }, { status: 400 });\n  }\n  if (!z.number().int().min(1).max(100).safeParse(filters.perPage).success) {\n    return NextResponse.json({ error: \"Invalid perPage\" }, { status: 400 });\n  }\n\n  const { category, page, perPage, search, sort, status } = filters;\n  const { direction, field } = splitPostSort(sort);\n  const trimmedSearch = search.trim();\n  const where = {\n    workspaceId,\n    ...(category !== \"all\" && { categoryId: category }),\n    ...(status !== \"all\" && { status }),\n    ...(trimmedSearch && {\n      title: {\n        contains: trimmedSearch,\n        mode: \"insensitive\" as const,\n      },\n    }),\n  };\n\n  const hasFilters = Boolean(\n    category !== \"all\" || status !== \"all\" || trimmedSearch\n  );\n  const [posts, totalCount, workspacePostCount] = await Promise.all([\n    db.post.findMany({\n      where,\n      skip: (page - 1) * perPage,\n      take: perPage,\n      select: {\n        id: true,\n        title: true,\n        coverImage: true,\n        status: true,\n        featured: true,\n        publishedAt: true,\n        updatedAt: true,\n        category: {\n          select: {\n            id: true,\n            name: true,\n          },\n        },\n        authors: {\n          select: {\n            id: true,\n            name: true,\n            image: true,\n          },\n        },\n      },\n      orderBy: {\n        [field]: direction,\n      },\n    }),\n    db.post.count({ where }),\n    hasFilters ? db.post.count({ where: { workspaceId } }) : null,\n  ]);\n\n  return NextResponse.json({\n    hasAnyPosts:\n      workspacePostCount === null ? totalCount > 0 : workspacePostCount > 0,\n    pageCount: Math.max(1, Math.ceil(totalCount / perPage)),\n    posts,\n    totalCount,\n  });\n}\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  let body: unknown;\n\n  try {\n    body = await request.json();\n  } catch (error) {\n    return NextResponse.json(\n      {\n        error: \"Invalid JSON\",\n        details: error instanceof Error ? error.message : \"\",\n      },\n      { status: 400 }\n    );\n  }\n\n  const values = postUpsertSchema.safeParse(body);\n  if (!values.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: values.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existingPost = await db.post.findFirst({\n    where: {\n      slug: values.data.slug,\n      workspaceId,\n    },\n  });\n\n  if (existingPost) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  // Try to find an existing author profile for this user\n  let primaryAuthor = await db.author.findUnique({\n    where: {\n      workspaceId_userId: {\n        workspaceId,\n        userId: sessionData.user.id,\n      },\n    },\n  });\n\n  // If no author profile exists for this user fallback to the first available author in the workspace.\n  if (!primaryAuthor) {\n    primaryAuthor = await db.author.findFirst({\n      where: {\n        workspaceId,\n      },\n      orderBy: {\n        createdAt: \"asc\",\n      },\n    });\n  }\n\n  // If STILL no author exists then we create one for the current user to proceed.\n  if (!primaryAuthor) {\n    try {\n      const baseSlug = generateSlug(sessionData.user.name || \"user\");\n      const uniqueSlug = `${baseSlug}-${nanoid(6)}`;\n\n      primaryAuthor = await db.author.create({\n        data: {\n          name: sessionData.user.name || \"Member\",\n          email: sessionData.user.email,\n          slug: uniqueSlug,\n          image: sessionData.user.image,\n          workspaceId,\n          userId: sessionData.user.id,\n          role: \"Writer\",\n        },\n      });\n    } catch (error) {\n      console.error(\"[PostCreate] Failed to generate fallback author:\", error);\n      return NextResponse.json(\n        { error: \"Failed to create author profile for post\" },\n        { status: 500 }\n      );\n    }\n  }\n\n  const contentJson = JSON.parse(values.data.contentJson);\n  const cleanContent = sanitizeHtml(values.data.content);\n\n  const tagValidation = await validateWorkspaceTags(\n    values.data.tags,\n    workspaceId\n  );\n\n  if (!tagValidation.success) {\n    return tagValidation.response;\n  }\n\n  const { uniqueTagIds } = tagValidation;\n\n  if (values.data.category) {\n    const category = await db.category.findFirst({\n      where: {\n        id: values.data.category,\n        workspaceId,\n      },\n    });\n\n    if (!category) {\n      return NextResponse.json(\n        { error: \"Invalid category provided\" },\n        { status: 400 }\n      );\n    }\n  }\n\n  // Find all authors for the provided author IDs, this may or may not include the primary author\n  // if the list of authors selected by the user doesnt include their own author profile\n  // it will not be added to the list as this is what is returned to users via the public api\n  // however for internal tracking they will be saved as the primary author\n  const authorIds = values.data.authors || [primaryAuthor.id];\n  const validAuthors = await db.author.findMany({\n    where: {\n      id: { in: authorIds },\n      workspaceId,\n    },\n  });\n\n  if (validAuthors.length === 0) {\n    return NextResponse.json(\n      { error: \"No valid authors found\" },\n      { status: 400 }\n    );\n  }\n\n  try {\n    const postCreated = await db.$transaction(async (tx) => {\n      const createdPost = await tx.post.create({\n        data: {\n          primaryAuthorId: primaryAuthor.id,\n          contentJson,\n          slug: values.data.slug,\n          title: values.data.title,\n          status: values.data.status,\n          featured: values.data.featured,\n          content: cleanContent,\n          categoryId: values.data.category,\n          coverImage: values.data.coverImage,\n          publishedAt: values.data.publishedAt,\n          description: values.data.description,\n          workspaceId,\n          tags:\n            uniqueTagIds.length > 0\n              ? {\n                  connect: uniqueTagIds.map((id) => ({ id })),\n                }\n              : undefined,\n          authors: {\n            connect: validAuthors.map((author) => ({ id: author.id })),\n          },\n        },\n      });\n\n      const customFieldWrites = await buildCustomFieldWrites(\n        workspaceId,\n        values.data.customFields\n      );\n\n      if (!customFieldWrites.success) {\n        throw new Error(JSON.stringify(customFieldWrites.error));\n      }\n\n      if (customFieldWrites.values.length > 0) {\n        await Promise.all(\n          customFieldWrites.values.map(({ fieldId, fieldType, value }) => {\n            if (value === null) {\n              return tx.fieldValue.deleteMany({\n                where: {\n                  postId: createdPost.id,\n                  fieldId,\n                  workspaceId,\n                },\n              });\n            }\n\n            return tx.fieldValue.upsert({\n              where: {\n                postId_fieldId: { postId: createdPost.id, fieldId },\n              },\n              update: {\n                workspaceId,\n                value:\n                  fieldType === \"richtext\"\n                    ? sanitizeRichTextHtml(value)\n                    : value,\n              },\n              create: {\n                postId: createdPost.id,\n                fieldId,\n                workspaceId,\n                value:\n                  fieldType === \"richtext\"\n                    ? sanitizeRichTextHtml(value)\n                    : value,\n              },\n            });\n          })\n        );\n      }\n\n      return createdPost;\n    });\n\n    await emitDashboardEvent({\n      type:\n        postCreated.status === \"published\" ? \"post_published\" : \"post_created\",\n      workspaceId,\n      resourceType: \"post\",\n      resourceId: postCreated.id,\n      actorId: sessionData.user.id,\n      payload: toPostPayload(postCreated),\n    }).catch(logDashboardEventError);\n\n    invalidateCache(workspaceId, \"posts\");\n\n    return NextResponse.json({ id: postCreated.id });\n  } catch (error) {\n    if (error instanceof Error) {\n      try {\n        const customFieldError = JSON.parse(error.message);\n        if (customFieldError?.error) {\n          return NextResponse.json(customFieldError, { status: 400 });\n        }\n      } catch {\n        // Ignore\n      }\n    }\n    console.error(\"[PostCreate] Error creating post:\", error);\n    return NextResponse.json(\n      { error: \"Failed to create post\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/share/[token]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\n\nconst NO_STORE_HEADERS = {\n  \"Cache-Control\": \"no-store\",\n} as const;\n\nexport async function GET(\n  _request: Request,\n  { params }: { params: Promise<{ token: string }> }\n) {\n  const { token } = await params;\n\n  const shareLink = await db.shareLink.findFirst({\n    where: {\n      token,\n      isActive: true,\n    },\n    include: {\n      post: {\n        select: {\n          id: true,\n          title: true,\n          content: true,\n          contentJson: true,\n          description: true,\n          coverImage: true,\n          status: true,\n          createdAt: true,\n          updatedAt: true,\n          publishedAt: true,\n          authors: {\n            select: {\n              id: true,\n              name: true,\n              image: true,\n              bio: true,\n            },\n          },\n          category: {\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n            },\n          },\n          tags: {\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n            },\n          },\n          workspace: {\n            select: {\n              id: true,\n              name: true,\n              logo: true,\n              slug: true,\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!shareLink) {\n    return NextResponse.json(\n      { error: \"Share link not found\" },\n      { headers: NO_STORE_HEADERS, status: 404 }\n    );\n  }\n\n  if (shareLink.expiresAt < new Date()) {\n    return NextResponse.json(\n      { error: \"Share link has expired\" },\n      { headers: NO_STORE_HEADERS, status: 410 }\n    );\n  }\n\n  return NextResponse.json(\n    {\n      post: shareLink.post,\n      expiresAt: shareLink.expiresAt,\n    },\n    { headers: NO_STORE_HEADERS }\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/share/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { nanoid } from \"nanoid\";\nimport { NextResponse } from \"next/server\";\nimport { checkWorkspaceSubscriptionAction } from \"@/lib/actions/checks\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { shareLinkSchema } from \"@/lib/validations/post\";\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const hasValidSubscription =\n    await checkWorkspaceSubscriptionAction(workspaceId);\n\n  if (!hasValidSubscription) {\n    return NextResponse.json(\n      { error: \"Upgrade to Pro to share drafts\" },\n      { status: 403 }\n    );\n  }\n\n  const values = shareLinkSchema.safeParse(await request.json());\n  if (!values.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: values.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const { postId } = values.data;\n\n  const post = await db.post.findFirst({\n    where: {\n      id: postId,\n      workspaceId,\n    },\n    select: {\n      id: true,\n      title: true,\n      status: true,\n    },\n  });\n\n  if (!post) {\n    return NextResponse.json({ error: \"Post not found\" }, { status: 404 });\n  }\n\n  const existingShareLink = await db.shareLink.findFirst({\n    where: {\n      postId,\n      isActive: true,\n      expiresAt: {\n        gt: new Date(),\n      },\n    },\n  });\n\n  if (existingShareLink) {\n    return NextResponse.json({\n      shareLink: `${process.env.NEXT_PUBLIC_APP_URL}/share/${existingShareLink.token}`,\n      expiresAt: existingShareLink.expiresAt,\n    });\n  }\n\n  const token = nanoid(32);\n\n  const expiresAt = new Date();\n  expiresAt.setHours(expiresAt.getHours() + 24);\n\n  const shareLink = await db.shareLink.create({\n    data: {\n      token,\n      postId,\n      workspaceId,\n      expiresAt,\n    },\n  });\n\n  return NextResponse.json({\n    shareLink: `${process.env.NEXT_PUBLIC_APP_URL}/share/${shareLink.token}`,\n    expiresAt: shareLink.expiresAt,\n  });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/tags/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toTagPayload, withChanges } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { tagSchema } from \"@/lib/validations/workspace\";\n\nasync function parseTagRequest(req: Request) {\n  try {\n    return tagSchema.safeParse(await req.json());\n  } catch {\n    return tagSchema.safeParse(null);\n  }\n}\n\nexport async function PATCH(\n  req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const body = await parseTagRequest(req);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existing = await db.tag.findFirst({\n    where: { id, workspaceId },\n    select: { id: true },\n  });\n\n  if (!existing) {\n    return NextResponse.json({ error: \"Tag not found\" }, { status: 404 });\n  }\n\n  const existingTagWithSlug = await db.tag.findFirst({\n    where: {\n      slug: body.data.slug,\n      workspaceId,\n      id: { not: id },\n    },\n  });\n\n  if (existingTagWithSlug) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  const updatedTag = await db.tag.update({\n    where: { id },\n    data: {\n      name: body.data.name,\n      slug: body.data.slug,\n      description: body.data.description,\n    },\n  });\n\n  await emitDashboardEvent({\n    type: \"tag_updated\",\n    workspaceId,\n    resourceType: \"tag\",\n    resourceId: updatedTag.id,\n    actorId: sessionData.user.id,\n    payload: withChanges(toTagPayload(updatedTag), Object.keys(body.data)),\n  }).catch(logDashboardEventError);\n\n  // Invalidate cache for tags and posts (tags affect posts)\n  invalidateCache(workspaceId, \"tags\");\n  invalidateCache(workspaceId, \"posts\");\n\n  return NextResponse.json(updatedTag, { status: 200 });\n}\n\nexport async function DELETE(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const tag = await db.tag.findFirst({\n    where: { id, workspaceId },\n    select: { id: true, name: true, slug: true, description: true },\n  });\n\n  if (!tag) {\n    return NextResponse.json({ error: \"Tag not found\" }, { status: 404 });\n  }\n\n  try {\n    await db.tag.delete({\n      where: {\n        id,\n        workspaceId,\n      },\n    });\n\n    await emitDashboardEvent({\n      type: \"tag_deleted\",\n      workspaceId,\n      resourceType: \"tag\",\n      resourceId: id,\n      actorId: sessionData.user.id,\n      payload: toTagPayload(tag),\n    }).catch(logDashboardEventError);\n\n    // Invalidate cache for tags and posts (tags affect posts)\n    invalidateCache(workspaceId, \"tags\");\n    invalidateCache(workspaceId, \"posts\");\n\n    return new NextResponse(null, { status: 204 });\n  } catch (_e) {\n    return NextResponse.json(\n      { error: \"Failed to delete post\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/tags/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toTagPayload } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { invalidateCache } from \"@/lib/cache/invalidate\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { tagSchema } from \"@/lib/validations/workspace\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const tags = await db.tag.findMany({\n    where: { workspaceId },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      description: true,\n      _count: {\n        select: {\n          posts: true,\n        },\n      },\n    },\n  });\n\n  const transformedTags = tags.map((tag) => {\n    const { _count, ...rest } = tag;\n    return {\n      ...rest,\n      postsCount: _count.posts,\n    };\n  });\n\n  return NextResponse.json(transformedTags, { status: 200 });\n}\n\nexport async function POST(req: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const json = await req.json();\n  const body = tagSchema.parse(json);\n\n  const existingTag = await db.tag.findFirst({\n    where: {\n      slug: body.slug,\n      workspaceId,\n    },\n  });\n\n  if (existingTag) {\n    return NextResponse.json({ error: \"Slug already in use\" }, { status: 409 });\n  }\n\n  const tagCreated = await db.tag.create({\n    data: {\n      name: body.name,\n      slug: body.slug,\n      description: body.description,\n      workspaceId,\n    },\n  });\n\n  await emitDashboardEvent({\n    type: \"tag_created\",\n    workspaceId,\n    resourceType: \"tag\",\n    resourceId: tagCreated.id,\n    actorId: sessionData.user.id,\n    payload: toTagPayload(tagCreated),\n  }).catch(logDashboardEventError);\n\n  // Invalidate cache for tags and posts (tags affect posts)\n  invalidateCache(workspaceId, \"tags\");\n  invalidateCache(workspaceId, \"posts\");\n\n  return NextResponse.json(tagCreated, { status: 201 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/upload/complete/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { toMediaPayload } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport {\n  emitDashboardEvent,\n  logDashboardEventError,\n} from \"@/lib/events/dispatch\";\nimport { R2_PUBLIC_URL } from \"@/lib/r2\";\nimport { completeSchema } from \"@/lib/validations/upload\";\nimport { getMediaType } from \"@/utils/media\";\nimport { trackMediaUpload } from \"@/utils/usage/media\";\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n  const body = await request.json();\n  const parsedBody = completeSchema.safeParse(body);\n\n  if (!parsedBody.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\" },\n      { status: 400 }\n    );\n  }\n\n  const { type, key, fileType, fileSize } = parsedBody.data;\n  const url = `${R2_PUBLIC_URL}/${key}`;\n\n  try {\n    switch (type) {\n      case \"avatar\": {\n        return NextResponse.json({ url });\n      }\n      case \"logo\": {\n        await db.organization.update({\n          where: { id: workspaceId },\n          data: { logo: url },\n        });\n        return NextResponse.json({ url });\n      }\n      case \"media\": {\n        const mediaName = parsedBody.data.name;\n        const mediaType = getMediaType(fileType);\n        const media = await db.media.create({\n          data: {\n            name: mediaName,\n            url,\n            size: fileSize,\n            mimeType: parsedBody.data.mimeType ?? fileType,\n            width: parsedBody.data.width,\n            height: parsedBody.data.height,\n            duration: parsedBody.data.duration,\n            blurHash: parsedBody.data.blurHash,\n            type: mediaType,\n            workspaceId,\n          },\n        });\n\n        trackMediaUpload(workspaceId, fileSize, mediaType).catch((err) => {\n          console.error(\"[Media Upload] Failed to track upload:\", err);\n        });\n\n        await emitDashboardEvent({\n          type: \"media_uploaded\",\n          workspaceId,\n          resourceType: \"media\",\n          resourceId: media.id,\n          actorId: sessionData.user.id,\n          payload: toMediaPayload(media),\n        }).catch(logDashboardEventError);\n\n        const mediaResponse = {\n          id: media.id,\n          name: media.name,\n          url: media.url,\n          alt: media.alt,\n          size: media.size,\n          mimeType: media.mimeType,\n          width: media.width,\n          height: media.height,\n          duration: media.duration,\n          blurHash: media.blurHash,\n          type: media.type,\n          createdAt: media.createdAt.toISOString(),\n        };\n        return NextResponse.json(mediaResponse);\n      }\n      default:\n        return NextResponse.json(\n          { error: \"Invalid upload type\" },\n          { status: 400 }\n        );\n    }\n  } catch (error) {\n    console.error(\"Error completing upload:\", error);\n    const errorMessage =\n      error instanceof Error ? error.message : \"Failed to complete upload\";\n    return NextResponse.json({ error: errorMessage }, { status: 500 });\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/upload/route.ts",
    "content": "import { PutObjectCommand } from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport { nanoid } from \"nanoid\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { R2_BUCKET_NAME, r2 } from \"@/lib/r2\";\nimport { rateLimitHeaders, userAvatarUploadRateLimiter } from \"@/lib/ratelimit\";\nimport { uploadSchema, validateUpload } from \"@/lib/validations/upload\";\n\nexport async function POST(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const body = await request.json();\n  const parsedBody = uploadSchema.safeParse(body);\n\n  if (!parsedBody.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\" },\n      { status: 400 }\n    );\n  }\n\n  const { type, fileType, fileSize } = parsedBody.data;\n\n  if (type === \"avatar\") {\n    const { success, limit, remaining, reset } =\n      await userAvatarUploadRateLimiter.limit(sessionData.user.id);\n\n    if (!success) {\n      return NextResponse.json(\n        { error: \"Too Many Requests\", remaining },\n        { status: 429, headers: rateLimitHeaders(limit, remaining, reset) }\n      );\n    }\n  }\n\n  try {\n    validateUpload({ type, fileType, fileSize });\n  } catch (error) {\n    const message =\n      error instanceof Error ? error.message : \"Invalid file type\";\n    return NextResponse.json({ error: message }, { status: 400 });\n  }\n\n  const userId = sessionData.user.id;\n  const id = nanoid();\n  const extension = fileType.split(\"/\")[1];\n  let key: string;\n\n  switch (type) {\n    case \"avatar\":\n      key = `avatars/${userId}/${id}.${extension}`;\n      break;\n    case \"logo\":\n      key = `logos/${workspaceId}/${id}.${extension}`;\n      break;\n    case \"media\":\n      key = `media/${workspaceId}/${id}.${extension}`;\n      break;\n    default:\n      return NextResponse.json(\n        { error: \"Invalid upload type\" },\n        { status: 400 }\n      );\n  }\n\n  const presignedUrl = await getSignedUrl(\n    r2,\n    new PutObjectCommand({\n      Bucket: R2_BUCKET_NAME,\n      Key: key,\n      ContentType: fileType,\n      ContentLength: fileSize,\n    }),\n    { expiresIn: 3600 }\n  );\n\n  return NextResponse.json({ url: presignedUrl, key });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/user/notifications/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { DEFAULT_NOTIFICATION_PREFERENCES } from \"@/lib/notifications\";\n\nasync function getNotificationPreferences(\n  userId: string,\n  organizationId?: string | null\n) {\n  const preferences = await db.userNotificationPreferences.findUnique({\n    where: { userId },\n    select: {\n      marketing: true,\n      product: true,\n    },\n  });\n\n  let workspacePreferences: {\n    usageAlerts: boolean;\n    subscriptions: boolean;\n  } | null = null;\n\n  if (organizationId) {\n    const member = await db.member.findFirst({\n      where: { userId, organizationId },\n      select: {\n        notificationPreferences: {\n          select: {\n            usageAlerts: true,\n            subscriptions: true,\n          },\n        },\n      },\n    });\n    workspacePreferences = member?.notificationPreferences ?? null;\n  }\n\n  return {\n    user: preferences ?? DEFAULT_NOTIFICATION_PREFERENCES.user,\n    workspace:\n      workspacePreferences ?? DEFAULT_NOTIFICATION_PREFERENCES.workspace,\n  };\n}\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  return NextResponse.json(\n    await getNotificationPreferences(sessionData.user.id, workspaceId)\n  );\n}\n\nexport async function PATCH(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { member, sessionData, workspaceId } = accessData;\n\n  try {\n    const body = await request.json();\n    const { scope, key, value } = body as {\n      scope: \"user\" | \"workspace\";\n      key: string;\n      value: boolean;\n    };\n\n    if (typeof value !== \"boolean\") {\n      return NextResponse.json(\n        { error: \"Value must be a boolean\" },\n        { status: 400 }\n      );\n    }\n\n    if (scope === \"user\") {\n      const allowedKeys = [\"marketing\", \"product\"] as const;\n      type UserKey = (typeof allowedKeys)[number];\n\n      if (!allowedKeys.includes(key as UserKey)) {\n        return NextResponse.json({ error: \"Invalid key\" }, { status: 400 });\n      }\n\n      const data: Record<string, unknown> = { [key]: value };\n\n      if (key === \"marketing\") {\n        if (value) {\n          data.marketingConsentedAt = new Date();\n          data.marketingConsentSource = \"settings\";\n          data.marketingUnsubscribedAt = null;\n        } else {\n          data.marketingUnsubscribedAt = new Date();\n        }\n      }\n\n      await db.userNotificationPreferences.upsert({\n        where: { userId: sessionData.user.id },\n        create: {\n          userId: sessionData.user.id,\n          ...data,\n        },\n        update: data,\n      });\n\n      return NextResponse.json(\n        await getNotificationPreferences(sessionData.user.id, workspaceId)\n      );\n    }\n\n    if (scope === \"workspace\") {\n      const allowedKeys = [\"usageAlerts\", \"subscriptions\"] as const;\n      type WorkspaceKey = (typeof allowedKeys)[number];\n\n      if (!allowedKeys.includes(key as WorkspaceKey)) {\n        return NextResponse.json({ error: \"Invalid key\" }, { status: 400 });\n      }\n\n      await db.workspaceNotificationPreferences.upsert({\n        where: { memberId: member.id },\n        create: {\n          memberId: member.id,\n          [key]: value,\n        },\n        update: { [key]: value },\n      });\n\n      return NextResponse.json(\n        await getNotificationPreferences(sessionData.user.id, workspaceId)\n      );\n    }\n\n    return NextResponse.json({ error: \"Invalid scope\" }, { status: 400 });\n  } catch (error) {\n    console.error(\"Error updating notification preferences:\", error);\n    return NextResponse.json(\n      { error: \"Failed to update preferences\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/user/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const user = await db.user.findUnique({\n    where: { id: sessionData.user.id },\n    select: {\n      id: true,\n      name: true,\n      email: true,\n      image: true,\n      emailVerified: true,\n      createdAt: true,\n      updatedAt: true,\n      members: {\n        where: {\n          organizationId: workspaceId,\n        },\n        select: {\n          role: true,\n          organization: {\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n            },\n          },\n        },\n        take: 1,\n      },\n    },\n  });\n\n  if (!user) {\n    return NextResponse.json(null, { status: 401 });\n  }\n\n  const userWithRole = {\n    ...user,\n    workspaceRole: user.members[0]?.role || null,\n    activeWorkspace: user.members[0]?.organization || null,\n    members: undefined,\n  };\n\n  return NextResponse.json(userWithRole, { status: 200 });\n}\n\nexport async function PATCH(request: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  try {\n    const body = await request.json();\n    const { name, image } = body;\n\n    const updateData: { name?: string; image?: string } = {};\n\n    if (\n      name !== undefined &&\n      typeof name === \"string\" &&\n      name.trim().length > 0\n    ) {\n      updateData.name = name.trim();\n    }\n\n    if (image !== undefined && typeof image === \"string\") {\n      updateData.image = image;\n    }\n\n    const updatedUser = await db.user.update({\n      where: { id: sessionData.user.id },\n      data: updateData,\n      select: {\n        id: true,\n        name: true,\n        email: true,\n        image: true,\n        emailVerified: true,\n        createdAt: true,\n        updatedAt: true,\n        members: {\n          where: {\n            organizationId: workspaceId,\n          },\n          select: {\n            role: true,\n            organization: {\n              select: {\n                id: true,\n                name: true,\n                slug: true,\n              },\n            },\n          },\n          take: 1,\n        },\n      },\n    });\n\n    const userWithRole = {\n      ...updatedUser,\n      workspaceRole: updatedUser.members[0]?.role || null,\n      activeWorkspace: updatedUser.members[0]?.organization || null,\n      members: undefined,\n    };\n\n    return NextResponse.json(userWithRole, { status: 200 });\n  } catch (error) {\n    console.error(\"Error updating user:\", error);\n    return NextResponse.json(\n      { error: \"Failed to update user\" },\n      { status: 500 }\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/webhooks/[id]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport {\n  type PayloadFormat,\n  type WebhookEvent,\n  webhookSchema,\n  webhookUpdateSchema,\n} from \"@/lib/validations/webhook\";\n\nexport async function PATCH(\n  req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const json = await req.json();\n  const body = webhookUpdateSchema.safeParse(json);\n\n  if (!body.success) {\n    return NextResponse.json(\n      { error: \"Invalid request body\", details: body.error.issues },\n      { status: 400 }\n    );\n  }\n\n  const existingWebhook = await db.webhookEndpoint.findFirst({\n    where: {\n      id,\n      workspaceId,\n    },\n  });\n\n  if (!existingWebhook) {\n    return NextResponse.json({ error: \"Webhook not found\" }, { status: 404 });\n  }\n\n  const effectiveWebhook = webhookSchema.safeParse({\n    name: body.data.name ?? existingWebhook.name,\n    endpoint: body.data.endpoint ?? existingWebhook.url,\n    events: body.data.events ?? existingWebhook.events,\n    format: body.data.format ?? existingWebhook.format,\n  });\n\n  if (!effectiveWebhook.success) {\n    return NextResponse.json(\n      {\n        error: \"Invalid request body\",\n        details: effectiveWebhook.error.issues,\n      },\n      { status: 400 }\n    );\n  }\n\n  const updateData: {\n    name?: string;\n    url?: string;\n    events?: WebhookEvent[];\n    format?: PayloadFormat;\n    enabled?: boolean;\n  } = {};\n\n  if (body.data.name !== undefined) {\n    updateData.name = body.data.name;\n  }\n  if (body.data.endpoint !== undefined) {\n    updateData.url = body.data.endpoint;\n  }\n  if (body.data.events !== undefined) {\n    updateData.events = body.data.events;\n  }\n  if (body.data.format !== undefined) {\n    updateData.format = body.data.format;\n  }\n  if (body.data.enabled !== undefined) {\n    updateData.enabled = body.data.enabled;\n  }\n\n  const webhook = await db.webhookEndpoint.update({\n    where: {\n      id,\n      workspaceId,\n    },\n    data: updateData,\n  });\n\n  return NextResponse.json(webhook, { status: 200 });\n}\n\nexport async function DELETE(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const { id } = await params;\n\n  const existingWebhook = await db.webhookEndpoint.findFirst({\n    where: {\n      id,\n      workspaceId,\n    },\n  });\n\n  if (!existingWebhook) {\n    return NextResponse.json({ error: \"Webhook not found\" }, { status: 404 });\n  }\n\n  await db.webhookEndpoint.delete({\n    where: { id },\n  });\n\n  return new NextResponse(null, { status: 204 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/webhooks/[id]/test/route.ts",
    "content": "import { getDemoPostPublishedPayload } from \"@marble/events\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\n\nexport async function POST(\n  _req: Request,\n  { params }: { params: Promise<{ id: string }> }\n) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { sessionData, workspaceId } = accessData;\n\n  const apiUrl = process.env.MARBLE_API_URL;\n  const systemSecret = process.env.SYSTEM_SECRET;\n\n  if (!apiUrl || !systemSecret) {\n    return NextResponse.json(\n      { error: \"Webhook test delivery is not configured\" },\n      { status: 500 }\n    );\n  }\n\n  const { id } = await params;\n  let response: Response;\n\n  try {\n    response = await fetch(`${apiUrl}/internal/events`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        \"X-System-Secret\": systemSecret,\n      },\n      body: JSON.stringify({\n        type: \"post_published\",\n        workspaceId,\n        source: \"dashboard\",\n        resourceType: \"post\",\n        resourceId: \"test\",\n        actorType: \"user\",\n        actorId: sessionData.user.id,\n        payload: getDemoPostPublishedPayload(),\n        isTest: true,\n        targetWebhookEndpointId: id,\n      }),\n    });\n  } catch (error) {\n    return NextResponse.json(\n      {\n        error: \"Failed to send test webhook\",\n        message: error instanceof Error ? error.message : \"Unknown error\",\n      },\n      { status: 502 }\n    );\n  }\n\n  const result = await response.json().catch(() => null);\n\n  if (!response.ok) {\n    return NextResponse.json(\n      result ?? { error: \"Failed to send test webhook\" },\n      { status: response.status }\n    );\n  }\n\n  return NextResponse.json(result, { status: 202 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/webhooks/route.ts",
    "content": "import { randomBytes } from \"node:crypto\";\nimport { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { requireActiveWorkspaceAccess } from \"@/lib/auth/access\";\nimport { webhookSchema } from \"@/lib/validations/webhook\";\n\nexport async function GET() {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const webhooks = await db.webhookEndpoint.findMany({\n    where: {\n      workspaceId,\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  return NextResponse.json(webhooks, { status: 200 });\n}\n\nexport async function POST(req: Request) {\n  const accessData = await requireActiveWorkspaceAccess();\n\n  if (!accessData.ok) {\n    return accessData.response;\n  }\n\n  const { workspaceId } = accessData;\n\n  const json = await req.json();\n  const body = webhookSchema.parse(json);\n\n  const secret = randomBytes(32).toString(\"hex\");\n\n  const webhook = await db.webhookEndpoint.create({\n    data: {\n      name: body.name,\n      url: body.endpoint,\n      events: body.events,\n      secret,\n      format: body.format,\n      workspaceId,\n    },\n  });\n\n  return NextResponse.json(webhook, { status: 201 });\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/workspaces/[slug]/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { getServerSession } from \"@/lib/auth/session\";\nimport { getWorkspacePlan } from \"@/lib/plans\";\n\nexport async function GET(\n  _request: Request,\n  { params }: { params: Promise<{ slug: string }> }\n) {\n  const slug = (await params).slug;\n\n  const sessionData = await getServerSession();\n\n  if (!sessionData) {\n    return NextResponse.json({ error: \"Not authenticated\" }, { status: 401 });\n  }\n\n  const workspace = await db.organization.findUnique({\n    where: {\n      slug,\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      logo: true,\n      createdAt: true,\n      timezone: true,\n      members: {\n        select: {\n          id: true,\n          role: true,\n          organizationId: true,\n          createdAt: true,\n          userId: true,\n          user: { select: { id: true, name: true, email: true, image: true } },\n        },\n      },\n      invitations: {\n        select: {\n          id: true,\n          email: true,\n          role: true,\n          status: true,\n          organizationId: true,\n          inviterId: true,\n          expiresAt: true,\n        },\n      },\n      subscriptions: {\n        where: {\n          OR: [\n            { status: \"active\" },\n            { status: \"trialing\" },\n            {\n              status: \"canceled\",\n              cancelAtPeriodEnd: true,\n              currentPeriodEnd: { gt: new Date() },\n            },\n          ],\n        },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          id: true,\n          status: true,\n          plan: true,\n          currentPeriodStart: true,\n          currentPeriodEnd: true,\n          cancelAtPeriodEnd: true,\n          canceledAt: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    return NextResponse.json({ error: \"Workspace not found\" }, { status: 404 });\n  }\n\n  // check is user is member of the workspace\n  const isUserMember = workspace.members.some(\n    (member) => member.userId === sessionData.user.id\n  );\n  if (!isUserMember) {\n    return NextResponse.json(\n      { error: \"User is not a member of the workspace\" },\n      { status: 403 }\n    );\n  }\n\n  // Find current user's role in this workspace\n  const currentUserMember = workspace.members.find(\n    (member) => member.userId === sessionData.user.id\n  );\n\n  const currentUserRole = currentUserMember?.role || null;\n  const activeSubscription = workspace.subscriptions[0] || null;\n  const activePlan = getWorkspacePlan(activeSubscription);\n\n  const workspaceWithUserRole = {\n    ...workspace,\n    currentUserRole,\n    subscription: activeSubscription\n      ? {\n          ...activeSubscription,\n          activePlan,\n        }\n      : null,\n  };\n\n  return NextResponse.json(workspaceWithUserRole);\n}\n"
  },
  {
    "path": "apps/cms/src/app/api/workspaces/route.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { getServerSession } from \"@/lib/auth/session\";\nimport { getWorkspacePlan } from \"@/lib/plans\";\n\nexport async function GET() {\n  const sessionData = await getServerSession();\n\n  if (!sessionData) {\n    return NextResponse.json({ error: \"Not authenticated\" }, { status: 401 });\n  }\n\n  const workspaces = await db.organization.findMany({\n    where: {\n      members: {\n        some: {\n          userId: sessionData.user.id,\n        },\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      slug: true,\n      logo: true,\n      timezone: true,\n      createdAt: true,\n      members: {\n        select: {\n          id: true,\n          role: true,\n          organizationId: true,\n          createdAt: true,\n          userId: true,\n          user: { select: { id: true, name: true, email: true, image: true } },\n        },\n      },\n      invitations: {\n        select: {\n          id: true,\n          email: true,\n          role: true,\n          status: true,\n          organizationId: true,\n          inviterId: true,\n          expiresAt: true,\n        },\n      },\n      subscriptions: {\n        where: {\n          OR: [\n            { status: \"active\" },\n            { status: \"trialing\" },\n            {\n              status: \"canceled\",\n              cancelAtPeriodEnd: true,\n              currentPeriodEnd: { gt: new Date() },\n            },\n          ],\n        },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          id: true,\n          status: true,\n          plan: true,\n          currentPeriodStart: true,\n          currentPeriodEnd: true,\n          cancelAtPeriodEnd: true,\n          canceledAt: true,\n        },\n      },\n    },\n    orderBy: {\n      createdAt: \"desc\",\n    },\n  });\n\n  const workspacesWithRole = workspaces.map((workspace) => {\n    const currentUserMember = workspace.members.find(\n      (member) => member.userId === sessionData.user.id\n    );\n    const activeSubscription = workspace.subscriptions[0] || null;\n    const activePlan = getWorkspacePlan(activeSubscription);\n    return {\n      ...workspace,\n      currentUserRole: currentUserMember?.role || null,\n      subscription: activeSubscription\n        ? {\n            ...activeSubscription,\n            activePlan,\n          }\n        : null,\n    };\n  });\n\n  return NextResponse.json(workspacesWithRole);\n}\n"
  },
  {
    "path": "apps/cms/src/app/layout.tsx",
    "content": "import type { Metadata } from \"next\";\nimport \"@/styles/globals.css\";\nimport { Databuddy } from \"@databuddy/sdk/react\";\nimport { Geist } from \"next/font/google\";\nimport Script from \"next/script\";\nimport { SITE_CONFIG } from \"@/utils/site\";\nimport Providers from \"./providers\";\n\nexport const metadata: Metadata = {\n  title: SITE_CONFIG.title,\n  metadataBase: new URL(SITE_CONFIG.url),\n  description: SITE_CONFIG.description,\n  openGraph: {\n    type: \"website\",\n    locale: \"en_US\",\n    url: SITE_CONFIG.url,\n    title: SITE_CONFIG.title,\n    description: SITE_CONFIG.description,\n    images: [\n      {\n        url: `${SITE_CONFIG.url}/og.webp`,\n        width: 1200,\n        height: 630,\n        alt: SITE_CONFIG.title,\n      },\n    ],\n  },\n  twitter: {\n    images: [\n      {\n        url: `${SITE_CONFIG.url}/og.webp`,\n        width: \"1200\",\n        height: \"630\",\n      },\n    ],\n  },\n  icons: {\n    icon: \"/favicon.ico\",\n    shortcut: \"/icon.svg\",\n    apple: \"/apple-icon.png\",\n  },\n};\n\nconst fontSans = Geist({\n  subsets: [\"latin\"],\n  variable: \"--font-sans\",\n});\n\nfunction DatabuddyAnalytics() {\n  return (\n    <>\n      {process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID && (\n        <Databuddy\n          clientId={process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID}\n          enableBatching={true}\n        />\n      )}\n    </>\n  );\n}\n\nexport default async function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <html lang=\"en\" suppressHydrationWarning>\n      <head>\n        {process.env.NODE_ENV === \"development\" && (\n          <Script\n            crossOrigin=\"anonymous\"\n            src=\"https://unpkg.com/react-grab@0.1.34/dist/index.global.js\"\n            strategy=\"beforeInteractive\"\n          />\n        )}\n      </head>\n      <DatabuddyAnalytics />\n      <body className={`${fontSans.className} font-sans antialiased`}>\n        <Providers>{children}</Providers>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/not-found.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { ArrowArcLeftIcon } from \"@phosphor-icons/react/dist/ssr\";\nimport Link from \"next/link\";\n\nexport default function NotFound() {\n  return (\n    <main className=\"flex h-screen flex-col items-center justify-center\">\n      <h1 className=\"font-bold text-5xl\">404</h1>\n      <p className=\"mt-2 text-2xl\">Page not found</p>\n\n      <Button className=\"mt-4\" nativeButton={false} render={<Link href=\"/\" />}>\n        <ArrowArcLeftIcon className=\"size-4\" /> Go to homepage\n      </Button>\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/providers.tsx",
    "content": "\"use client\";\n\nimport { Toaster } from \"@marble/ui/components/sonner\";\nimport { TooltipProvider } from \"@marble/ui/components/tooltip\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { ReactQueryDevtools } from \"@tanstack/react-query-devtools\";\nimport { ThemeProvider } from \"next-themes\";\nimport { NuqsAdapter } from \"nuqs/adapters/next/app\";\n\nconst queryClient = new QueryClient({\n  defaultOptions: {\n    queries: {\n      staleTime: 60 * 1000,\n      gcTime: 1000 * 60 * 60, // 1 hour\n    },\n  },\n});\n\nexport default function Providers({ children }: { children: React.ReactNode }) {\n  return (\n    <QueryClientProvider client={queryClient}>\n      <ThemeProvider attribute=\"class\" disableTransitionOnChange enableSystem>\n        <TooltipProvider>\n          <NuqsAdapter>{children}</NuqsAdapter>\n          <Toaster position=\"top-center\" />\n        </TooltipProvider>\n      </ThemeProvider>\n      <ReactQueryDevtools buttonPosition=\"top-right\" initialIsOpen={false} />\n    </QueryClientProvider>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/app/robots.ts",
    "content": "import type { MetadataRoute } from \"next\";\n\nexport default function robots(): MetadataRoute.Robots {\n  return {\n    rules: {\n      userAgent: \"*\",\n      disallow: \"*\",\n      allow: [\"/login\", \"/register\"],\n    },\n    host: process.env.NEXT_PUBLIC_APP_URL,\n  };\n}\n"
  },
  {
    "path": "apps/cms/src/components/auth/login-form.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { EyeIcon, EyeSlashIcon } from \"@phosphor-icons/react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useLocalStorage } from \"@/hooks/use-localstorage\";\nimport { authClient } from \"@/lib/auth/client\";\nimport { safeRedirectPath } from \"@/lib/auth/redirect\";\nimport { type CredentialData, credentialSchema } from \"@/lib/validations/auth\";\nimport type { AuthMethod } from \"@/types/misc\";\nimport { Github, Google } from \"../icons/social\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport { LastUsedBadge } from \"../ui/last-used-badge\";\n\nexport function LoginForm() {\n  const {\n    register,\n    handleSubmit,\n    formState: { errors },\n  } = useForm<CredentialData>({\n    resolver: zodResolver(credentialSchema),\n  });\n  const [isGoogleLoading, setIsGoogleLoading] = useState(false);\n  const [isGithubLoading, setIsGithubLoading] = useState(false);\n  const [isCredentialsLoading, setIsCredentialsLoading] = useState(false);\n  const [isPasswordVisible, setIsPasswordVisible] = useState(false);\n  const [lastUsedAuthMethod, setLastUsedAuthMethod] =\n    useLocalStorage<AuthMethod | null>(\"lastUsedAuthMethod\", null);\n  const searchParams = useSearchParams();\n  const router = useRouter();\n  const callbackURL = safeRedirectPath(searchParams?.get(\"from\"));\n\n  async function onSubmit(data: CredentialData) {\n    setIsCredentialsLoading(true);\n\n    try {\n      await authClient.signIn.email(\n        {\n          email: data.email.toLowerCase(),\n          password: data.password,\n        },\n        {\n          onSuccess: (_ctx) => {\n            setLastUsedAuthMethod(\"email\");\n            toast.success(\"Welcome!\");\n            router.push(callbackURL);\n          },\n          onError: (ctx) => {\n            if (ctx.error.status === 403) {\n              toast.error(\"Please verify your email address\");\n            } else if (ctx.error.status === 401) {\n              toast.error(\"Invalid email or password\");\n            } else {\n              toast.error(\n                ctx.error.message || \"Login failed. Please try again.\"\n              );\n            }\n          },\n        }\n      );\n    } catch (_error) {\n      toast(\"Login failed. Please try again.\");\n    }\n    setIsCredentialsLoading(false);\n  }\n\n  const handleSocialSignIn = async (provider: \"google\" | \"github\") => {\n    if (provider === \"google\") {\n      setIsGoogleLoading(true);\n    } else {\n      setIsGithubLoading(true);\n    }\n\n    try {\n      await authClient.signIn.social({\n        provider,\n        callbackURL,\n      });\n    } catch (_error) {\n      toast(\"Sign in failed. Please try again.\");\n    }\n    setLastUsedAuthMethod(provider);\n    if (provider === \"google\") {\n      setIsGoogleLoading(false);\n    } else {\n      setIsGithubLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"grid gap-6\">\n      <div className=\"grid grid-cols-1 gap-4\">\n        <AsyncButton\n          className={cn(\"relative shadow-none\")}\n          disabled={isCredentialsLoading || isGoogleLoading || isGithubLoading}\n          isLoading={isGoogleLoading}\n          onClick={async () => handleSocialSignIn(\"google\")}\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <LastUsedBadge\n            show={lastUsedAuthMethod === \"google\"}\n            variant=\"info\"\n          />\n          <Google className=\"size-4\" />\n          Google\n        </AsyncButton>\n        <AsyncButton\n          className={cn(\"relative shadow-none\")}\n          disabled={isCredentialsLoading || isGoogleLoading || isGithubLoading}\n          isLoading={isGithubLoading}\n          onClick={async () => handleSocialSignIn(\"github\")}\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <LastUsedBadge\n            show={lastUsedAuthMethod === \"github\"}\n            variant=\"info\"\n          />\n          <Github className=\"size-4\" />\n          GitHub\n        </AsyncButton>\n      </div>\n      <div className=\"relative flex items-center\">\n        <span className=\"inline-block h-px w-full border-t bg-border\" />\n        <span className=\"shrink-0 px-2 text-[10px] text-muted-foreground uppercase\">\n          Or\n        </span>\n        <span className=\"inline-block h-px w-full border-t bg-border\" />\n      </div>\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"grid gap-3\">\n          <div className=\"grid gap-1\">\n            <Label className=\"sr-only\" htmlFor=\"email\">\n              Email\n            </Label>\n\n            <Input\n              autoCapitalize=\"none\"\n              autoComplete=\"email\"\n              autoCorrect=\"off\"\n              disabled={\n                isCredentialsLoading || isGoogleLoading || isGithubLoading\n              }\n              id=\"email\"\n              placeholder=\"Email\"\n              type=\"email\"\n              {...register(\"email\")}\n            />\n            {errors?.email && (\n              <ErrorMessage>{errors.email.message}</ErrorMessage>\n            )}\n          </div>\n          <div className=\"grid gap-1\">\n            <Label className=\"sr-only\" htmlFor=\"password\">\n              Password\n            </Label>\n            <div className=\"relative\">\n              <Input\n                autoCapitalize=\"none\"\n                autoCorrect=\"off\"\n                className=\"pr-9\"\n                disabled={\n                  isCredentialsLoading || isGoogleLoading || isGithubLoading\n                }\n                id=\"password\"\n                placeholder=\"Password\"\n                type={isPasswordVisible ? \"text\" : \"password\"}\n                {...register(\"password\")}\n              />\n              <button\n                className=\"-translate-y-1/2 absolute top-1/2 right-4 text-muted-foreground\"\n                onClick={() => setIsPasswordVisible((prev) => !prev)}\n                type=\"button\"\n              >\n                {isPasswordVisible ? (\n                  <EyeIcon className=\"size-4\" />\n                ) : (\n                  <EyeSlashIcon className=\"size-4\" />\n                )}\n              </button>\n            </div>\n            {errors?.password && (\n              <ErrorMessage>{errors.password.message}</ErrorMessage>\n            )}\n          </div>\n          <AsyncButton\n            className={cn(\"mt-4\", \"relative\")}\n            disabled={\n              isCredentialsLoading || isGoogleLoading || isGithubLoading\n            }\n            isLoading={isCredentialsLoading}\n            type=\"submit\"\n          >\n            <LastUsedBadge\n              className=\"border-input\"\n              show={lastUsedAuthMethod === \"email\"}\n              variant=\"secondary\"\n            />\n            Continue\n          </AsyncButton>\n        </div>\n      </form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/auth/register-form.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { EyeIcon, EyeSlashIcon } from \"@phosphor-icons/react\";\nimport { useRouter, useSearchParams } from \"next/navigation\";\nimport { useState, useTransition } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useLocalStorage } from \"@/hooks/use-localstorage\";\nimport { authClient } from \"@/lib/auth/client\";\nimport { safeRedirectPath } from \"@/lib/auth/redirect\";\nimport { type CredentialData, credentialSchema } from \"@/lib/validations/auth\";\nimport type { AuthMethod } from \"@/types/misc\";\nimport { Github, Google } from \"../icons/social\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport { LastUsedBadge } from \"../ui/last-used-badge\";\n\nexport function RegisterForm() {\n  const {\n    register,\n    handleSubmit,\n    formState: { errors },\n  } = useForm<CredentialData>({\n    resolver: zodResolver(credentialSchema),\n  });\n  const [isGoogleLoading, setIsGoogleLoading] = useState(false);\n  const [isGithubLoading, setIsGithubLoading] = useState(false);\n  const [isCredentialsLoading, setIsCredentialsLoading] = useState(false);\n  const [isPasswordVisible, setIsPasswordVisible] = useState(false);\n  const [lastUsedAuthMethod, setLastUsedAuthMethod] =\n    useLocalStorage<AuthMethod | null>(\"lastUsedAuthMethod\", null);\n  const searchParams = useSearchParams();\n  const callbackURL = safeRedirectPath(searchParams.get(\"from\"));\n  const router = useRouter();\n  const [isRedirecting, startTransition] = useTransition();\n\n  const initiateEmailVerification = async (email: string) => {\n    // at this point user is already registered\n    // so we can redirect to verify even if sending fails\n    // they can initiate another verification email from the verify page\n    await authClient.emailOtp\n      .sendVerificationOtp({\n        email,\n        type: \"email-verification\",\n      })\n      .then((_res) => {\n        startTransition(() => {\n          router.push(\n            `/verify?email=${encodeURIComponent(email)}&from=${callbackURL}`\n          );\n        });\n      });\n  };\n\n  async function onSubmit(formData: CredentialData) {\n    setIsCredentialsLoading(true);\n\n    try {\n      await authClient.signUp.email(\n        {\n          email: formData.email.toLowerCase(),\n          password: formData.password,\n          name: formData.email.toLowerCase().split(\"@\")[0] || \"User\",\n        },\n        {\n          onSuccess: () => {\n            setLastUsedAuthMethod(\"email\");\n            initiateEmailVerification(formData.email);\n          },\n          onError: (ctx) => {\n            toast.error(ctx.error.message);\n          },\n        }\n      );\n    } catch (_error) {\n      toast.error(\"Sign in failed. Please try again.\");\n    }\n    setIsCredentialsLoading(false);\n  }\n\n  const handleSocialSignIn = async (provider: \"google\" | \"github\") => {\n    if (provider === \"google\") {\n      setIsGoogleLoading(true);\n    } else {\n      setIsGithubLoading(true);\n    }\n\n    try {\n      await authClient.signIn.social({\n        provider,\n        callbackURL,\n      });\n    } catch (_error) {\n      toast(\"Your sign in request failed. Please try again.\");\n    }\n    setLastUsedAuthMethod(provider);\n    if (provider === \"google\") {\n      setIsGoogleLoading(false);\n    } else {\n      setIsGithubLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"grid gap-6\">\n      <div className=\"grid grid-cols-1 gap-4\">\n        <AsyncButton\n          className={cn(\"relative\")}\n          disabled={isCredentialsLoading || isGoogleLoading || isGithubLoading}\n          isLoading={isGoogleLoading}\n          onClick={async () => handleSocialSignIn(\"google\")}\n          size=\"lg\"\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <LastUsedBadge\n            show={lastUsedAuthMethod === \"google\"}\n            variant=\"info\"\n          />\n          <Google className=\"size-4\" />\n          Google\n        </AsyncButton>\n        <AsyncButton\n          className={cn(\"relative\")}\n          disabled={isCredentialsLoading || isGoogleLoading || isGithubLoading}\n          isLoading={isGithubLoading}\n          onClick={async () => handleSocialSignIn(\"github\")}\n          size=\"lg\"\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <LastUsedBadge\n            show={lastUsedAuthMethod === \"github\"}\n            variant=\"info\"\n          />\n          <Github className=\"size-4\" />\n          GitHub\n        </AsyncButton>\n      </div>\n      <div className=\"relative flex items-center\">\n        <span className=\"inline-block h-px w-full border-t bg-border\" />\n        <span className=\"shrink-0 px-2 text-[10px] text-muted-foreground uppercase\">\n          Or\n        </span>\n        <span className=\"inline-block h-px w-full border-t bg-border\" />\n      </div>\n      <form onSubmit={handleSubmit(onSubmit)}>\n        <div className=\"grid gap-3\">\n          <div className=\"grid gap-1\">\n            <Label className=\"sr-only\" htmlFor=\"email\">\n              Email\n            </Label>\n\n            <Input\n              autoCapitalize=\"none\"\n              autoComplete=\"email\"\n              autoCorrect=\"off\"\n              disabled={\n                isCredentialsLoading || isGoogleLoading || isGithubLoading\n              }\n              id=\"email\"\n              placeholder=\"name@example.com\"\n              type=\"email\"\n              {...register(\"email\")}\n            />\n            {errors?.email && (\n              <ErrorMessage>{errors.email.message}</ErrorMessage>\n            )}\n          </div>\n          <div className=\"grid gap-1\">\n            <Label className=\"sr-only\" htmlFor=\"password\">\n              Password\n            </Label>\n            <div className=\"relative\">\n              <Input\n                autoCapitalize=\"none\"\n                autoCorrect=\"off\"\n                className=\"pr-9\"\n                disabled={\n                  isCredentialsLoading || isGoogleLoading || isGithubLoading\n                }\n                id=\"password\"\n                placeholder=\"Your password\"\n                type={isPasswordVisible ? \"text\" : \"password\"}\n                {...register(\"password\")}\n              />\n              <button\n                className=\"-translate-y-1/2 absolute top-1/2 right-4 text-muted-foreground\"\n                onClick={() => setIsPasswordVisible((prev) => !prev)}\n                type=\"button\"\n              >\n                {isPasswordVisible ? (\n                  <EyeIcon className=\"size-4\" />\n                ) : (\n                  <EyeSlashIcon className=\"size-4\" />\n                )}\n              </button>\n            </div>\n            {errors?.password && (\n              <ErrorMessage>{errors.password.message}</ErrorMessage>\n            )}\n          </div>\n          <AsyncButton\n            className={cn(\"mt-4\", \"relative\")}\n            disabled={isGoogleLoading || isGithubLoading || isRedirecting}\n            isLoading={isCredentialsLoading || isRedirecting}\n            type=\"submit\"\n          >\n            Continue\n          </AsyncButton>\n        </div>\n      </form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/auth/reset/reset-form.tsx",
    "content": "\"use client\";\n\nimport { Input } from \"@marble/ui/components/input\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { authClient } from \"@/lib/auth/client\";\nimport { safeRedirectPath } from \"@/lib/auth/redirect\";\nimport Container from \"../../shared/container\";\nimport { AsyncButton } from \"../../ui/async-button\";\n\ninterface ResetFormProps {\n  callbackUrl: string;\n  token: string;\n}\n\nexport function ResetForm({ callbackUrl, token }: ResetFormProps) {\n  const safeCallbackUrl = safeRedirectPath(callbackUrl, \"/login\");\n  const [password, setPassword] = useState(\"\");\n  const [confirmPassword, setConfirmPassword] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const router = useRouter();\n\n  const handleResetPassword = async () => {\n    if (password.length < 8) {\n      toast.error(\"Password must be at least 8 characters\");\n      return;\n    }\n    if (password !== confirmPassword) {\n      toast.error(\"Passwords do not match\");\n      return;\n    }\n\n    setIsLoading(true);\n    try {\n      const { error } = await authClient.resetPassword({\n        token,\n        newPassword: password,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      toast.success(\"Password has been reset\");\n      router.push(safeCallbackUrl);\n    } catch (error) {\n      console.error(\"Password reset failed:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Password reset failed\"\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <Container className=\"flex flex-col items-center justify-between py-24\">\n      <section className=\"flex w-full flex-col items-center gap-8\">\n        <div className=\"flex flex-col items-center gap-4 text-center\">\n          <h1 className=\"font-semibold text-lg leading-7\">\n            Reset your password\n          </h1>\n        </div>\n\n        <div className=\"flex w-full max-w-sm flex-col gap-4\">\n          <Input\n            onChange={(e) => setPassword(e.target.value)}\n            placeholder=\"New password\"\n            type=\"password\"\n            value={password}\n          />\n          <Input\n            onChange={(e) => setConfirmPassword(e.target.value)}\n            placeholder=\"Confirm new password\"\n            type=\"password\"\n            value={confirmPassword}\n          />\n        </div>\n\n        <AsyncButton\n          className=\"flex min-w-48 items-center justify-center\"\n          disabled={!password || !confirmPassword}\n          isLoading={isLoading}\n          onClick={handleResetPassword}\n        >\n          Reset password\n        </AsyncButton>\n      </section>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/auth/reset/reset-request-form.tsx",
    "content": "\"use client\";\n\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { useEffect, useState } from \"react\";\nimport { authClient } from \"@/lib/auth/client\";\nimport { AsyncButton } from \"../../ui/async-button\";\n\nexport default function ResetRequestForm() {\n  const [email, setEmail] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [isRequestSuccess, setIsRequestSuccess] = useState(false);\n  const [waitingSeconds, setWaitingSeconds] = useState(0);\n\n  useEffect(() => {\n    if (waitingSeconds > 0) {\n      const timeout = setTimeout(() => {\n        setWaitingSeconds((prev) => prev - 1);\n      }, 1000);\n      return () => clearTimeout(timeout);\n    }\n    if (waitingSeconds === 0 && isRequestSuccess) {\n      // Reset success state when countdown ends\n      setIsRequestSuccess(false);\n    }\n  }, [waitingSeconds, isRequestSuccess]);\n\n  const handleRequest = async () => {\n    if (!email) {\n      toast.error(\"Please enter your email\");\n      return;\n    }\n\n    setIsLoading(true);\n    try {\n      const { error } = await authClient.requestPasswordReset({\n        email,\n        redirectTo: \"/reset\",\n      });\n\n      if (error) {\n        throw new Error(error.message || \"Failed to request reset\");\n      }\n\n      toast.success(\"Password reset link sent to your inbox\");\n      setWaitingSeconds(60);\n      setIsRequestSuccess(true);\n    } catch (err) {\n      console.error(err);\n      toast.error(\n        err instanceof Error ? err.message : \"Failed to request reset\"\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <section className=\"flex w-full max-w-sm flex-col items-center gap-6\">\n      <div className=\"flex flex-col items-center gap-2 text-center\">\n        <h1 className=\"font-semibold text-lg\">Forgot your password?</h1>\n        <p className=\"text-muted-foreground text-sm\">\n          Enter your email address and we&apos;ll send you a reset link.\n        </p>\n      </div>\n      <div className=\"flex w-full flex-col gap-4\">\n        <Label className=\"sr-only\" htmlFor=\"email\">\n          Email\n        </Label>\n        <Input\n          onChange={(e) => setEmail(e.target.value)}\n          placeholder=\"you@example.com\"\n          type=\"email\"\n          value={email}\n        />\n\n        <AsyncButton\n          className={cn(\n            \"flex items-center justify-center\",\n            isLoading || (waitingSeconds > 0 && \"cursor-not-allowed\")\n          )}\n          disabled={!email || isLoading || waitingSeconds > 0}\n          isLoading={isLoading}\n          onClick={handleRequest}\n        >\n          <div>\n            Send link {waitingSeconds > 0 && <span>({waitingSeconds}s)</span>}\n          </div>\n        </AsyncButton>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/auth/verify-form.tsx",
    "content": "\"use client\";\n\nimport {\n  InputOTP,\n  InputOTPGroup,\n  InputOTPSlot,\n} from \"@marble/ui/components/input-otp\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { REGEXP_ONLY_DIGITS } from \"input-otp\";\n\nimport { useRouter } from \"next/navigation\";\nimport { useEffect, useState } from \"react\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { authClient } from \"@/lib/auth/client\";\nimport { safeRedirectPath } from \"@/lib/auth/redirect\";\nimport Container from \"../shared/container\";\n\ninterface VerifyFormProps {\n  email: string;\n  callbackUrl: string;\n}\n\nexport function VerifyForm({ email, callbackUrl }: VerifyFormProps) {\n  const safeCallbackUrl = safeRedirectPath(callbackUrl);\n  const [otp, setOtp] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [isResendLoading, setIsResendLoading] = useState(false);\n  const [isResendSuccess, setIsResendSuccess] = useState(false);\n  const [waitingSeconds, setWaitingSeconds] = useState(30);\n  const router = useRouter();\n\n  useEffect(() => {\n    if (waitingSeconds > 0) {\n      const timeout = setTimeout(() => {\n        setWaitingSeconds((prev) => prev - 1);\n      }, 1000);\n      return () => clearTimeout(timeout);\n    }\n    if (waitingSeconds === 0 && isResendSuccess) {\n      // Reset success state when countdown ends\n      setIsResendSuccess(false);\n    }\n  }, [waitingSeconds, isResendSuccess]);\n\n  const handleResendCode = async () => {\n    setIsResendLoading(true);\n    try {\n      await authClient.emailOtp.sendVerificationOtp({\n        email,\n        type: \"email-verification\",\n      });\n      toast.success(\"Verification code sent!\");\n    } catch {\n      toast.error(\"Failed to resend code. Please try again.\");\n    }\n    setWaitingSeconds(30);\n    setIsResendLoading(false);\n    setIsResendSuccess(true);\n  };\n\n  const handleVerifyOtp = async () => {\n    setIsLoading(true);\n    try {\n      const result = await authClient.emailOtp.verifyEmail({\n        email,\n        otp,\n      });\n\n      if (result.data) {\n        toast.success(\"Email verified successfully!\");\n        router.push(safeCallbackUrl);\n      } else {\n        toast.error(\"Invalid verification code\");\n      }\n    } catch {\n      toast.error(\"Invalid verification code. Please try again.\");\n    }\n    setIsLoading(false);\n  };\n\n  return (\n    <Container className=\"flex min-h-screen items-center justify-center py-12\">\n      <div className=\"flex w-full max-w-sm flex-col items-center gap-8\">\n        <div className=\"flex flex-col items-center gap-4 text-center\">\n          <h1 className=\"font-semibold text-lg leading-7\">Verify your email</h1>\n          <p className=\"text-muted-foreground leading-6\">\n            We sent a verification code to\n            <span className=\"block font-medium text-foreground\">{email}</span>\n          </p>\n        </div>\n\n        <InputOTP\n          maxLength={6}\n          onChange={(value: string) => setOtp(value)}\n          pattern={REGEXP_ONLY_DIGITS}\n          value={otp}\n        >\n          <InputOTPGroup className=\"flex items-center gap-3\">\n            {Array.from({ length: 6 }).map((_, index) => (\n              <InputOTPSlot index={index} key={crypto.randomUUID()} />\n            ))}\n          </InputOTPGroup>\n        </InputOTP>\n\n        <div className=\"flex w-full flex-col items-center gap-4\">\n          <AsyncButton\n            className={cn(\n              \"flex w-full items-center justify-center\",\n              otp.length !== 6 && \"cursor-not-allowed\"\n            )}\n            disabled={otp.length !== 6}\n            isLoading={isLoading}\n            onClick={handleVerifyOtp}\n          >\n            Verify email\n          </AsyncButton>\n\n          <div className=\"flex flex-col items-center gap-3\">\n            <p className=\"text-muted-foreground text-sm\">\n              Didn&apos;t receive the code?\n            </p>\n            <AsyncButton\n              className={cn(\n                \"text-muted-foreground\",\n                isResendLoading || (waitingSeconds > 0 && \"cursor-not-allowed\")\n              )}\n              disabled={waitingSeconds > 0}\n              isLoading={isResendLoading}\n              onClick={handleResendCode}\n              variant=\"outline\"\n            >\n              {isResendLoading\n                ? \"Sending...\"\n                : waitingSeconds > 0\n                  ? `Resend code (${waitingSeconds}s)`\n                  : \"Resend code\"}\n            </AsyncButton>\n          </div>\n        </div>\n      </div>\n    </Container>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/authors/author-modals.tsx",
    "content": "/** biome-ignore-all lint/correctness/useUniqueElementIds: IDs are unique within their respective modals */\n\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { AsyncButton } from \"../ui/async-button\";\n\nexport const DeleteAuthorModal = ({\n  open,\n  setOpen,\n  id,\n  name,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  id: string;\n  name: string;\n}) => {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: deleteAuthor, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await fetch(`/api/authors/${id}`, {\n        method: \"DELETE\",\n      });\n\n      if (!res.ok) {\n        const errorText = await res.json().catch(() => \"Unknown error\");\n        throw new Error(errorText.error || \"Failed to delete author\");\n      }\n\n      return true;\n    },\n    onSuccess: () => {\n      toast.success(\"Author deleted successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.AUTHORS(workspaceId),\n        });\n      }\n      setOpen(false);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete \"{name}\"?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            This will permanently delete this author from your workspace. Any\n            posts associated with this author will need to be reassigned.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isPending} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteAuthor();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/authors/author-sheet.tsx",
    "content": "/** biome-ignore-all lint/correctness/useUniqueElementIds: IDs are unique within their respective modals */\n\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@marble/ui/components/sheet\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CircleNotchIcon,\n  ImageIcon,\n  PlusIcon,\n  UploadSimpleIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useState } from \"react\";\nimport { useFieldArray, useForm, useWatch } from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  authorSchema,\n  type CreateAuthorValues,\n} from \"@/lib/validations/authors\";\nimport type { Author } from \"@/types/author\";\nimport { detectPlatform, getPlatformIcon } from \"@/utils/author\";\nimport { generateSlug } from \"@/utils/string\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport { CopyButton } from \"../ui/copy-button\";\n\ninterface AuthorSheetProps {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  mode?: \"create\" | \"update\";\n  authorData?: Partial<Author>;\n  onAuthorCreated?: (author: Author) => void;\n}\n\nexport const AuthorSheet = ({\n  open,\n  setOpen,\n  mode = \"create\",\n  authorData = {\n    name: \"\",\n    slug: \"\",\n    role: \"\",\n    bio: \"\",\n    email: \"\",\n    image: null,\n    userId: null,\n    socials: [],\n  },\n  onAuthorCreated,\n}: AuthorSheetProps) => {\n  const queryClient = useQueryClient();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    reset,\n    clearErrors,\n    formState: { errors, isSubmitting, isDirty },\n  } = useForm<CreateAuthorValues>({\n    resolver: zodResolver(authorSchema),\n    defaultValues: {\n      name: authorData.name || \"\",\n      slug: authorData.slug || \"\",\n      role: authorData.role || \"\",\n      bio: authorData.bio || \"\",\n      email: authorData.email || \"\",\n      image: authorData.image || null,\n      userId: authorData.userId || null,\n      socials:\n        authorData.socials && authorData.socials.length > 0\n          ? authorData.socials.map((social) => ({\n              id: social.id,\n              url: social.url,\n              platform: social.platform,\n            }))\n          : [],\n    },\n  });\n\n  const { fields, append, remove } = useFieldArray({\n    name: \"socials\",\n    control,\n  });\n\n  const watchedSocials = useWatch({ control, name: \"socials\" });\n  const workspaceId = useWorkspaceId();\n\n  const [pendingAvatarUrl, setPendingAvatarUrl] = useState<string | null>(null);\n  const avatarUrl = pendingAvatarUrl ?? authorData.image ?? null;\n\n  const resetAuthorForm = () => {\n    setPendingAvatarUrl(null);\n    reset();\n  };\n\n  const { mutate: uploadAvatar, isPending: isUploading } = useMutation({\n    mutationFn: (file: File) => uploadFile({ file, type: \"avatar\" }),\n    onSuccess: (data) => {\n      setPendingAvatarUrl(data.url);\n      setValue(\"image\", data.url, { shouldDirty: true });\n      toast.success(\"Avatar uploaded successfully\");\n    },\n    onError: (error) => {\n      console.error(\"Upload error:\", error);\n      toast.error(error.message || \"Failed to upload avatar\");\n    },\n  });\n\n  const { mutate: createAuthor, isPending: isCreating } = useMutation({\n    mutationFn: async (data: CreateAuthorValues) => {\n      const res = await fetch(\"/api/authors\", {\n        method: \"POST\",\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to create author\");\n      }\n\n      return res.json();\n    },\n    onSuccess: (data) => {\n      setOpen(false);\n      toast.success(\"Author created successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.AUTHORS(workspaceId),\n        });\n      }\n      if (onAuthorCreated) {\n        onAuthorCreated(data);\n      }\n      resetAuthorForm();\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const { mutate: updateAuthor, isPending: isUpdating } = useMutation({\n    mutationFn: async (data: CreateAuthorValues) => {\n      const res = await fetch(`/api/authors/${authorData.id}`, {\n        method: \"PATCH\",\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to update author\");\n      }\n\n      return res.json();\n    },\n    onSuccess: () => {\n      setOpen(false);\n      toast.success(\"Author updated successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.AUTHORS(workspaceId),\n        });\n      }\n      resetAuthorForm();\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const addSocialLink = () => {\n    append({ url: \"\", platform: \"website\" });\n  };\n\n  const onSubmit = async (data: CreateAuthorValues) => {\n    if (!workspaceId) {\n      toast.error(\"No active workspace\");\n      return;\n    }\n\n    if (mode === \"update\" && !authorData.id) {\n      toast.error(\"Author ID is missing - cannot update author\");\n      return;\n    }\n\n    const submissionData = {\n      ...data,\n      image: avatarUrl,\n      socials: data.socials?.filter((social) => social.url.trim() !== \"\") || [],\n    };\n\n    if (mode === \"create\") {\n      createAuthor(submissionData);\n    } else {\n      updateAuthor(submissionData);\n    }\n  };\n\n  return (\n    <Sheet\n      onOpenChange={(nextOpen) => {\n        setOpen(nextOpen);\n        if (!nextOpen) {\n          resetAuthorForm();\n        }\n      }}\n      open={open}\n    >\n      <SheetContent className=\"overflow-y-auto\">\n        <SheetHeader className=\"p-6\">\n          <SheetTitle className=\"font-medium text-xl\">\n            {mode === \"create\" ? \"Create Author\" : \"Update Author\"}\n          </SheetTitle>\n          <SheetDescription className=\"sr-only\">\n            {mode === \"create\"\n              ? \"Create a new author\"\n              : \"Update the author's information\"}\n          </SheetDescription>\n        </SheetHeader>\n        <form\n          className=\"flex h-full flex-col justify-between\"\n          onSubmit={handleSubmit(onSubmit)}\n        >\n          <div className=\"mb-5 grid flex-1 auto-rows-min gap-6 px-6\">\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"avatar\">Avatar</Label>\n              <div className=\"flex items-center gap-4\">\n                <div className=\"flex items-center gap-4\">\n                  <Label\n                    className={cn(\n                      \"group relative size-16 cursor-pointer overflow-hidden rounded-full\",\n                      isUploading && \"pointer-events-none\"\n                    )}\n                    htmlFor=\"avatar\"\n                  >\n                    <Avatar className=\"size-16\">\n                      <AvatarImage src={avatarUrl || undefined} />\n                      <AvatarFallback>\n                        <ImageIcon className=\"size-4 text-muted-foreground\" />\n                      </AvatarFallback>\n                    </Avatar>\n                    <input\n                      accept=\"image/*\"\n                      className=\"sr-only\"\n                      id=\"avatar\"\n                      onChange={(e) => {\n                        const selectedFile = e.target.files?.[0];\n                        if (selectedFile && !isUploading) {\n                          uploadAvatar(selectedFile);\n                        }\n                      }}\n                      title=\"Upload avatar\"\n                      type=\"file\"\n                    />\n                    <div\n                      className={cn(\n                        \"absolute inset-0 flex size-full items-center justify-center bg-black/50 backdrop-blur-xs transition-opacity duration-300\",\n                        isUploading\n                          ? \"opacity-100\"\n                          : \"opacity-0 group-hover:opacity-100\"\n                      )}\n                    >\n                      {isUploading ? (\n                        <CircleNotchIcon className=\"size-4 animate-spin text-white\" />\n                      ) : (\n                        <UploadSimpleIcon className=\"size-4 text-white\" />\n                      )}\n                    </div>\n                  </Label>\n                </div>\n                <div className=\"flex w-full items-center gap-2\">\n                  <Input\n                    placeholder=\"Square images work best for avatars\"\n                    readOnly\n                    value={avatarUrl || \"\"}\n                  />\n                  <CopyButton\n                    disabled={!avatarUrl}\n                    textToCopy={avatarUrl || \"\"}\n                    toastMessage=\"Avatar URL copied to clipboard.\"\n                    type=\"button\"\n                  />\n                </div>\n              </div>\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"name\">Name</Label>\n              <Input\n                id=\"name\"\n                {...register(\"name\", {\n                  onChange: (e) => {\n                    if (mode === \"create\") {\n                      setValue(\"slug\", generateSlug(e.target.value));\n                    }\n                  },\n                })}\n                placeholder=\"Author's full name\"\n              />\n              {errors.name && (\n                <ErrorMessage>{errors.name.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"slug\">Slug</Label>\n              <Input\n                id=\"slug\"\n                {...register(\"slug\")}\n                placeholder=\"unique-identifier\"\n              />\n              {errors.slug && (\n                <ErrorMessage>{errors.slug.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"email\">Email (optional)</Label>\n              <Input\n                id=\"email\"\n                type=\"email\"\n                {...register(\"email\")}\n                placeholder=\"author@example.com\"\n              />\n              {errors.email && (\n                <ErrorMessage>{errors.email.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"role\">Role (optional)</Label>\n              <Input\n                id=\"role\"\n                {...register(\"role\")}\n                placeholder=\"e.g., Marketing Lead, Content Writer\"\n              />\n              {errors.role && (\n                <ErrorMessage>{errors.role.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"bio\">Bio (optional)</Label>\n              <Textarea\n                id=\"bio\"\n                {...register(\"bio\")}\n                placeholder=\"Authors bio\"\n              />\n              {errors.bio && <ErrorMessage>{errors.bio.message}</ErrorMessage>}\n            </div>\n\n            <div className=\"grid flex-1 gap-3\">\n              <Label htmlFor=\"socials\">Socials (optional)</Label>\n\n              {/* Social links - now all consistent */}\n              <ul className=\"flex flex-col gap-2\">\n                {fields.map((field, index) => (\n                  <li className=\"flex flex-col gap-1\" key={field.id}>\n                    <div className=\"flex items-center gap-2\">\n                      <div>\n                        {getPlatformIcon(\n                          watchedSocials?.[index]?.url\n                            ? detectPlatform(watchedSocials[index].url)\n                            : field.platform\n                        )}\n                      </div>\n                      <Input\n                        {...register(`socials.${index}.url`)}\n                        className={cn(\n                          errors.socials?.[index]?.url && \"border-destructive\"\n                        )}\n                        onChange={(e) => {\n                          const newPlatform = detectPlatform(e.target.value);\n                          setValue(`socials.${index}.platform`, newPlatform);\n                          setValue(`socials.${index}.url`, e.target.value, {\n                            shouldDirty: true,\n                          });\n                          clearErrors(`socials.${index}.url`);\n                        }}\n                        placeholder=\"Enter social media URL\"\n                      />\n                      <Button\n                        aria-label=\"Remove social link\"\n                        className=\"size-9 shadow-none\"\n                        onClick={() => remove(index)}\n                        type=\"button\"\n                        variant=\"ghost\"\n                      >\n                        <XIcon className=\"size-4\" />\n                      </Button>\n                    </div>\n                    {errors.socials?.[index]?.url && (\n                      <ErrorMessage className=\"ml-8\">\n                        {errors.socials[index].url.message}\n                      </ErrorMessage>\n                    )}\n                  </li>\n                ))}\n              </ul>\n\n              {fields.length < 5 && (\n                <Button\n                  className=\"w-fit shadow-none\"\n                  onClick={addSocialLink}\n                  size=\"sm\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <PlusIcon className=\"size-4\" />\n                  Add Link\n                </Button>\n              )}\n\n              {errors.socials?.message && (\n                <ErrorMessage>{errors.socials.message}</ErrorMessage>\n              )}\n            </div>\n          </div>\n\n          <SheetFooter className=\"p-6\">\n            <AsyncButton\n              className=\"flex w-full gap-2\"\n              disabled={(mode === \"update\" && !isDirty) || isUploading}\n              isLoading={isSubmitting || isCreating || isUpdating}\n              type=\"submit\"\n            >\n              {mode === \"create\" ? \"Create Author\" : \"Update Author\"}\n            </AsyncButton>\n          </SheetFooter>\n        </form>\n      </SheetContent>\n    </Sheet>\n  );\n};\n\nexport default AuthorSheet;\n"
  },
  {
    "path": "apps/cms/src/components/authors/columns.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Badge } from \"@marble/ui/components/badge\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport type { Author } from \"@/types/author\";\nimport { AuthorTableActions } from \"./table-actions\";\n\nexport const columns: ColumnDef<Author>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"Name\",\n    cell: ({ row }) => {\n      const author = row.original;\n      const avatarFallback = author.name?.charAt(0).toUpperCase() || \"?\";\n\n      return (\n        <div className=\"flex items-center gap-3\">\n          <Avatar className=\"size-8\">\n            <AvatarImage src={author.image || undefined} />\n            <AvatarFallback>{avatarFallback}</AvatarFallback>\n          </Avatar>\n          <div className=\"flex flex-col\">\n            <span className=\"font-medium text-sm\">{author.name}</span>\n            {/* {author.email && (\n              <span className=\"text-xs text-muted-foreground\">\n                {author.email}\n              </span>\n            )} */}\n          </div>\n        </div>\n      );\n    },\n  },\n  {\n    accessorKey: \"isActive\",\n    header: \"Status\",\n    cell: ({ row }) => {\n      const author = row.original;\n\n      return (\n        <Badge variant={author.isActive ? \"positive\" : \"negative\"}>\n          {author.isActive ? \"Active\" : \"Inactive\"}\n        </Badge>\n      );\n    },\n  },\n  {\n    id: \"actions\",\n    header: () => <div className=\"flex justify-end pr-10\">Actions</div>,\n    cell: ({ row }) => (\n      <div className=\"flex justify-end pr-10\">\n        <AuthorTableActions author={row.original} />\n      </div>\n    ),\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/authors/data-table.tsx",
    "content": "\"use client\";\n\nimport { Users } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport { MagnifyingGlassIcon, PlusIcon, XIcon } from \"@phosphor-icons/react\";\nimport {\n  type ColumnDef,\n  type ColumnFiltersState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport dynamic from \"next/dynamic\";\nimport { useState } from \"react\";\nimport { usePlan } from \"@/hooks/use-plan\";\nimport type { Author } from \"@/types/author\";\nimport AuthorSheet from \"./author-sheet\";\n\nconst UpgradeModal = dynamic(() =>\n  import(\"@/components/billing/upgrade-modal\").then((mod) => mod.UpgradeModal)\n);\n\ninterface AuthorDataTableProps {\n  columns: ColumnDef<Author>[];\n  data: Author[];\n}\n\nexport function AuthorDataTable({ columns, data }: AuthorDataTableProps) {\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n  const [showCreateModal, setShowCreateModal] = useState(false);\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n  const { isHobbyPlan } = usePlan();\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    onColumnFiltersChange: setColumnFilters,\n    getFilteredRowModel: getFilteredRowModel(),\n    state: {\n      columnFilters,\n    },\n  });\n\n  const handleAddAuthor = () => {\n    // Free plan is limited to 1 author\n    if (isHobbyPlan && data.length >= 1) {\n      setShowUpgradeModal(true);\n      return;\n    }\n    setShowCreateModal(true);\n  };\n\n  return (\n    <div>\n      <div className=\"flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"relative\">\n          <MagnifyingGlassIcon\n            className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n            size={16}\n          />\n          <Input\n            className=\"w-full px-8 sm:w-72\"\n            onChange={(event) =>\n              table.getColumn(\"name\")?.setFilterValue(event.target.value)\n            }\n            placeholder=\"Search authors...\"\n            value={(table.getColumn(\"name\")?.getFilterValue() as string) ?? \"\"}\n          />\n          {(table.getColumn(\"name\")?.getFilterValue() as string) && (\n            <button\n              className=\"absolute top-3 right-3\"\n              onClick={() => table.getColumn(\"name\")?.setFilterValue(\"\")}\n              type=\"button\"\n            >\n              <XIcon className=\"size-4\" />\n              <span className=\"sr-only\">Clear search</span>\n            </button>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-4\">\n          <Button onClick={handleAddAuthor}>\n            <PlusIcon className=\"size-4\" />\n            <span>Add Author</span>\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  data-state={row.getIsSelected() && \"selected\"}\n                  key={row.id}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  className=\"h-24 text-center\"\n                  colSpan={columns.length}\n                >\n                  <div className=\"flex flex-col items-center justify-center gap-2\">\n                    <HugeiconsIcon\n                      className=\"size-12 text-muted-foreground\"\n                      icon={Users}\n                    />\n                    <p className=\"text-muted-foreground text-sm\">\n                      No authors found.\n                    </p>\n                  </div>\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      <AuthorSheet\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n\n      <UpgradeModal\n        feature=\"authors\"\n        isOpen={showUpgradeModal}\n        onClose={() => setShowUpgradeModal(false)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/authors/table-actions.tsx",
    "content": "\"use client\";\n\nimport {\n  Delete02Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { useState } from \"react\";\nimport type { Author } from \"@/types/author\";\nimport { DeleteAuthorModal } from \"./author-modals\";\nimport { AuthorSheet } from \"./author-sheet\";\n\ninterface AuthorTableActionsProps {\n  author: Author;\n}\n\nexport function AuthorTableActions({ author }: AuthorTableActionsProps) {\n  const [showEditModal, setShowEditModal] = useState(false);\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n\n  const handleEdit = () => {\n    setShowEditModal(true);\n  };\n\n  const handleDelete = () => {\n    setShowDeleteModal(true);\n  };\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button className=\"size-8 p-0\" variant=\"ghost\">\n              <span className=\"sr-only\">Open menu</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent\n          align=\"end\"\n          className=\"text-muted-foreground shadow-sm\"\n        >\n          <DropdownMenuItem onClick={() => handleEdit()}>\n            <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n            <span>Edit Author</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => handleDelete()}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon icon={Delete02Icon} size={16} />\n            <span>Delete Author</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <AuthorSheet\n        authorData={author}\n        mode=\"update\"\n        open={showEditModal}\n        setOpen={setShowEditModal}\n      />\n\n      <DeleteAuthorModal\n        id={author.id}\n        name={author.name}\n        open={showDeleteModal}\n        setOpen={setShowDeleteModal}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/billing/success-modal.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@marble/ui/components/dialog\";\nimport { CheckCircle } from \"@phosphor-icons/react\";\nimport confetti from \"canvas-confetti\";\nimport { useEffect, useRef } from \"react\";\n\ninterface PaymentSuccessModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  planName?: string;\n}\n\nexport function PaymentSuccessModal({\n  isOpen,\n  onClose,\n  planName = \"Pro\",\n}: PaymentSuccessModalProps) {\n  const hasTriggeredConfettiRef = useRef(false);\n\n  useEffect(() => {\n    if (isOpen && !hasTriggeredConfettiRef.current) {\n      const duration = 3000;\n      const animationEnd = Date.now() + duration;\n      const defaults = {\n        startVelocity: 30,\n        spread: 360,\n        ticks: 60,\n        zIndex: 1000,\n      };\n\n      function randomInRange(min: number, max: number) {\n        return Math.random() * (max - min) + min;\n      }\n\n      const interval = setInterval(() => {\n        const timeLeft = animationEnd - Date.now();\n\n        if (timeLeft <= 0) {\n          clearInterval(interval);\n          return;\n        }\n\n        const particleCount = 50 * (timeLeft / duration);\n\n        confetti({\n          ...defaults,\n          particleCount,\n          origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },\n        });\n        confetti({\n          ...defaults,\n          particleCount,\n          origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },\n        });\n      }, 250);\n\n      hasTriggeredConfettiRef.current = true;\n\n      return () => clearInterval(interval);\n    }\n  }, [isOpen]);\n\n  const handleClose = () => {\n    hasTriggeredConfettiRef.current = false;\n    onClose();\n  };\n\n  return (\n    <Dialog onOpenChange={handleClose} open={isOpen}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader className=\"text-center\">\n          <div className=\"mx-auto mb-4 flex h-16 w-16 animate-pulse items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20\">\n            <CheckCircle\n              className=\"h-8 w-8 text-green-600 dark:text-green-400\"\n              weight=\"fill\"\n            />\n          </div>\n          <DialogTitle className=\"font-semibold text-xl\">\n            🎉 Payment Successful!\n          </DialogTitle>\n          <DialogDescription className=\"text-base\">\n            Welcome to the {planName} plan! Your subscription is now active and\n            you have access to all {planName.toLowerCase()} features.\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"mt-6 space-y-4\">\n          <div className=\"rounded-lg border bg-muted/50 p-4\">\n            <h4 className=\"mb-2 font-medium\">What's next?</h4>\n            <ul className=\"space-y-2 text-muted-foreground text-sm\">\n              <li className=\"flex items-center gap-2\">\n                <CheckCircle className=\"h-4 w-4 text-green-500\" weight=\"fill\" />\n                Explore your new features in the dashboard\n              </li>\n              <li className=\"flex items-center gap-2\">\n                <CheckCircle className=\"h-4 w-4 text-green-500\" weight=\"fill\" />\n                Invite team members to collaborate\n              </li>\n              <li className=\"flex items-center gap-2\">\n                <CheckCircle className=\"h-4 w-4 text-green-500\" weight=\"fill\" />\n                Check out the billing section for invoice history\n              </li>\n            </ul>\n          </div>\n\n          <Button className=\"w-full\" onClick={handleClose}>\n            Get Started\n          </Button>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/billing/upgrade-modal.tsx",
    "content": "\"use client\";\n\nimport { ShoppingCart02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { checkout } from \"@/lib/auth/client\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\ntype FeatureType = \"authors\" | \"share-drafts\" | \"team-members\" | \"storage\";\n\nconst FEATURE_CONTENT: Record<\n  FeatureType,\n  { title: string; description: string }\n> = {\n  authors: {\n    title: \"Add more authors\",\n    description: \"Upgrade to add unlimited authors and grow your content team.\",\n  },\n  \"share-drafts\": {\n    title: \"Share draft links\",\n    description:\n      \"Upgrade to share draft links with others for feedback before publishing.\",\n  },\n  \"team-members\": {\n    title: \"Invite team members\",\n    description:\n      \"Upgrade to invite up to 5 team members to collaborate on your workspace.\",\n  },\n  storage: {\n    title: \"Get more storage\",\n    description:\n      \"Upgrade to get 10GB of media storage for your images and files.\",\n  },\n};\n\ninterface UpgradeModalProps {\n  feature: FeatureType;\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport function UpgradeModal({ feature, isOpen, onClose }: UpgradeModalProps) {\n  const [loadingPlan, setLoadingPlan] = useState<\"monthly\" | \"yearly\" | null>(\n    null\n  );\n  const { activeWorkspace } = useWorkspace();\n\n  const content = FEATURE_CONTENT[feature];\n\n  const handleUpgrade = async (plan: \"monthly\" | \"yearly\") => {\n    if (!activeWorkspace?.id) {\n      return;\n    }\n\n    setLoadingPlan(plan);\n\n    try {\n      await checkout({\n        slug: plan === \"monthly\" ? \"pro\" : \"pro-yearly\",\n        referenceId: activeWorkspace.id,\n      });\n    } catch (error) {\n      console.error(error);\n    }\n    setLoadingPlan(null);\n    onClose();\n  };\n\n  return (\n    <Dialog\n      onOpenChange={(nextOpen) => {\n        if (!nextOpen) {\n          onClose();\n        }\n      }}\n      open={isOpen}\n    >\n      <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={ShoppingCart02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Upgrade to Pro\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogBody>\n          <DialogDescription className=\"text-balance\">\n            {content.description}\n          </DialogDescription>\n          <DialogFooter className=\"flex-col gap-2 sm:flex-col\">\n            <AsyncButton\n              className=\"w-full\"\n              disabled={loadingPlan !== null}\n              isLoading={loadingPlan === \"yearly\"}\n              onClick={() => handleUpgrade(\"yearly\")}\n              size=\"sm\"\n            >\n              $200/year\n            </AsyncButton>\n            <Button\n              className=\"w-full\"\n              disabled={loadingPlan !== null}\n              onClick={() => handleUpgrade(\"monthly\")}\n              size=\"sm\"\n              variant=\"outline\"\n            >\n              {loadingPlan === \"monthly\" ? \"Loading...\" : \"$20/month\"}\n            </Button>\n          </DialogFooter>\n          <Link\n            className=\"text-center text-muted-foreground text-xs underline hover:text-foreground\"\n            href={`/${activeWorkspace?.slug}/settings/billing`}\n            onClick={onClose}\n          >\n            View pricing\n          </Link>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nexport type { FeatureType };\n"
  },
  {
    "path": "apps/cms/src/components/categories/category-modals.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Alert02Icon, Package01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useForm } from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type CreateCategoryValues,\n  categorySchema,\n} from \"@/lib/validations/workspace\";\nimport { generateSlug } from \"@/utils/string\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport type { Category } from \"./columns\";\n\nexport const CategoryModal = ({\n  open,\n  setOpen,\n  mode = \"create\",\n  categoryData = { name: \"\", slug: \"\", description: \"\" },\n  onCategoryCreated,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  mode?: \"create\" | \"update\";\n  categoryData?: Partial<Category>;\n  onCategoryCreated?: (category: {\n    name: string;\n    id: string;\n    slug: string;\n  }) => void;\n}) => {\n  const queryClient = useQueryClient();\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    reset,\n    formState: { errors, isSubmitting },\n  } = useForm<CreateCategoryValues>({\n    resolver: zodResolver(categorySchema),\n    defaultValues: {\n      name: categoryData.name || \"\",\n      slug: categoryData.slug || \"\",\n      description: categoryData.description || \"\",\n    },\n  });\n\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: createCategory, isPending: isCreating } = useMutation({\n    mutationFn: async (data: CreateCategoryValues) => {\n      const res = await fetch(\"/api/categories\", {\n        method: \"POST\",\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to create category\");\n      }\n\n      return res.json();\n    },\n    onSuccess: (data) => {\n      setOpen(false);\n      toast.success(\"Category created successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.CATEGORIES(workspaceId),\n        });\n      }\n      if (onCategoryCreated) {\n        onCategoryCreated(data);\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const { mutate: updateCategory, isPending: isUpdating } = useMutation({\n    mutationFn: async (data: CreateCategoryValues) => {\n      const res = await fetch(`/api/categories/${categoryData.id}`, {\n        method: \"PATCH\",\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to update category\");\n      }\n\n      return res.json();\n    },\n    onSuccess: () => {\n      setOpen(false);\n      toast.success(\"Category updated successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.CATEGORIES(workspaceId),\n        });\n      }\n      reset();\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const onSubmit = async (data: CreateCategoryValues) => {\n    if (!workspaceId) {\n      toast.error(\"No active workspace\");\n      return;\n    }\n\n    // Guard against missing category ID in update mode\n    if (mode === \"update\" && !categoryData.id) {\n      toast.error(\"Category ID is missing - cannot update category\");\n      return;\n    }\n\n    if (mode === \"create\") {\n      createCategory(data);\n    } else {\n      updateCategory(data);\n    }\n  };\n\n  return (\n    <Dialog onOpenChange={setOpen} open={open}>\n      <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={Package01Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              {mode === \"create\" ? \"Create Category\" : \"Update Category\"}\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogBody>\n          <form\n            className=\"flex flex-col gap-3\"\n            onSubmit={handleSubmit(onSubmit)}\n          >\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"category-name\">Name</Label>\n              <Input\n                id=\"category-name\"\n                {...register(\"name\", {\n                  onChange: (e) => {\n                    if (mode === \"create\") {\n                      setValue(\"slug\", generateSlug(e.target.value));\n                    }\n                  },\n                })}\n                placeholder=\"The name of the category\"\n              />\n              {errors.name && (\n                <ErrorMessage>{errors.name.message}</ErrorMessage>\n              )}\n            </div>\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"category-slug\">Slug</Label>\n              <Input\n                id=\"category-slug\"\n                {...register(\"slug\", {\n                  onChange: (e) => {\n                    setValue(\n                      \"slug\",\n                      generateSlug(e.target.value, { trimEdges: false }),\n                      { shouldValidate: true, shouldDirty: true }\n                    );\n                  },\n                })}\n                placeholder=\"unique-identifier\"\n              />\n              {errors.slug && (\n                <ErrorMessage>{errors.slug.message}</ErrorMessage>\n              )}\n            </div>\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"category-description\">Description</Label>\n              <Textarea\n                id=\"category-description\"\n                {...register(\"description\")}\n                placeholder=\"An optional description of the category\"\n              />\n            </div>\n            <DialogFooter>\n              <DialogClose\n                render={\n                  <Button size=\"sm\" variant=\"outline\">\n                    Cancel\n                  </Button>\n                }\n              />\n              <AsyncButton\n                className=\"gap-2\"\n                isLoading={isSubmitting || isCreating || isUpdating}\n                size=\"sm\"\n                type=\"submit\"\n              >\n                {mode === \"create\" ? \"Create\" : \"Update\"}\n              </AsyncButton>\n            </DialogFooter>\n          </form>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nexport const DeleteCategoryModal = ({\n  open,\n  setOpen,\n  id,\n  name,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  id: string;\n  name: string;\n}) => {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: deleteCategory, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await fetch(`/api/categories/${id}`, {\n        method: \"DELETE\",\n      });\n\n      if (!res.ok) {\n        const errorText = await res.json().catch(() => \"Unknown error\");\n        throw new Error(errorText.error || \"Failed to delete category\");\n      }\n\n      return true;\n    },\n    onSuccess: () => {\n      toast.success(\"Category deleted successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.CATEGORIES(workspaceId),\n        });\n      }\n      setOpen(false);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete \"{name}\"?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription>\n            This will permanently delete this category from your list and you\n            can no longer use this in articles.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isPending} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteCategory();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/categories/columns.tsx",
    "content": "\"use client\";\n\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport TableActions from \"./table-actions\";\n\nexport interface Category {\n  id: string;\n  name: string;\n  slug: string;\n  description?: string | null;\n  postsCount: number;\n}\n\nexport const columns: ColumnDef<Category>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"Name\",\n  },\n  {\n    accessorKey: \"slug\",\n    header: \"Slug\",\n  },\n  {\n    accessorKey: \"postsCount\",\n    header: () => <div className=\"text-center\">Posts</div>,\n    cell: ({ row }) => (\n      <p className=\"text-center\">{row.getValue(\"postsCount\")}</p>\n    ),\n  },\n  {\n    id: \"actions\",\n    header: () => <div className=\"flex justify-end pr-10\">Actions</div>,\n    cell: ({ row }) => {\n      const category = row.original;\n\n      return (\n        <div className=\"flex justify-end pr-10\">\n          <TableActions {...category} />\n        </div>\n      );\n    },\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/categories/data-table.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport { MagnifyingGlassIcon, PlusIcon, XIcon } from \"@phosphor-icons/react\";\nimport {\n  type ColumnDef,\n  type ColumnFiltersState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  type SortingState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useState } from \"react\";\nimport { CategoryModal } from \"./category-modals\";\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n}\n\nexport function DataTable<TData, TValue>({\n  columns,\n  data,\n}: DataTableProps<TData, TValue>) {\n  const [sorting, _setSorting] = useState<SortingState>([]);\n\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n\n  const [showCreateModal, setShowCreateModal] = useState(false);\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    onColumnFiltersChange: setColumnFilters,\n    getFilteredRowModel: getFilteredRowModel(),\n    state: {\n      sorting,\n      columnFilters,\n    },\n  });\n\n  return (\n    <div>\n      <div className=\"flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"relative\">\n          <MagnifyingGlassIcon\n            className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n            size={16}\n          />\n          <Input\n            className=\"w-full px-8 sm:w-72\"\n            onChange={(event) =>\n              table.getColumn(\"name\")?.setFilterValue(event.target.value)\n            }\n            placeholder=\"Search categories...\"\n            value={(table.getColumn(\"name\")?.getFilterValue() as string) ?? \"\"}\n          />\n          {(table.getColumn(\"name\")?.getFilterValue() as string) && (\n            <button\n              className=\"absolute top-3 right-3\"\n              onClick={() => table.getColumn(\"name\")?.setFilterValue(\"\")}\n              type=\"button\"\n            >\n              <XIcon className=\"size-4\" />\n              <span className=\"sr-only\">Clear search</span>\n            </button>\n          )}\n        </div>\n        <div>\n          <Button onClick={() => setShowCreateModal(true)}>\n            <PlusIcon size={16} />\n            <span>New Category</span>\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  data-state={row.getIsSelected() && \"selected\"}\n                  key={row.id}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  className=\"h-96 text-center\"\n                  colSpan={columns.length}\n                >\n                  No categories to show.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      <CategoryModal\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/categories/table-actions.tsx",
    "content": "import {\n  Delete02Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { useState } from \"react\";\nimport { CategoryModal, DeleteCategoryModal } from \"./category-modals\";\nimport type { Category } from \"./columns\";\n\nexport default function TableActions(props: Category) {\n  const [showUpdateModal, setShowUpdateModal] = useState(false);\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button className=\"size-8 p-0\" variant=\"ghost\">\n              <span className=\"sr-only\">Open menu</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent\n          align=\"end\"\n          className=\"text-muted-foreground shadow-sm\"\n        >\n          <DropdownMenuItem onClick={() => setShowUpdateModal(true)}>\n            <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n            <span>Edit</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setShowDeleteModal(true)}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon icon={Delete02Icon} size={16} />\n            <span>Delete</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {showUpdateModal && (\n        <CategoryModal\n          categoryData={{ ...props }}\n          mode=\"update\"\n          open={showUpdateModal}\n          setOpen={setShowUpdateModal}\n        />\n      )}\n\n      <DeleteCategoryModal\n        id={props.id}\n        name={props.name}\n        open={showDeleteModal}\n        setOpen={setShowDeleteModal}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/ai/readability-suggestions.tsx",
    "content": "\"use client\";\n\nimport type { Editor } from \"@marble/editor\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CursorClickIcon } from \"@phosphor-icons/react\";\nimport React from \"react\";\n\nexport interface ReadabilitySuggestion {\n  text: string;\n  explanation?: string;\n  textReference?: string;\n}\n\ninterface ReadabilitySuggestionsProps {\n  editor?: Editor | null;\n  suggestions: ReadabilitySuggestion[];\n  isLoading?: boolean;\n  onRefresh?: () => void;\n}\n\nfunction highlightTextInEditor(editor: Editor, textReference: string) {\n  const trimmed = textReference.trim();\n  if (!trimmed) {\n    return;\n  }\n\n  let foundFrom: number | null = null;\n  let foundTo: number | null = null;\n\n  editor.state.doc.descendants((node, pos) => {\n    if (foundFrom !== null) {\n      return false;\n    }\n    if (node.isText && typeof node.text === \"string\") {\n      const index = node.text.indexOf(trimmed);\n      if (index >= 0) {\n        foundFrom = pos + index;\n        foundTo = foundFrom + trimmed.length;\n        return false;\n      }\n    }\n    return true;\n  });\n\n  if (foundFrom !== null && foundTo !== null) {\n    editor\n      .chain()\n      .focus()\n      .setTextSelection({ from: foundFrom, to: foundTo })\n      .run();\n  } else {\n    editor.chain().focus().run();\n  }\n}\n\nfunction ReadabilitySuggestionsBase({\n  editor,\n  suggestions,\n  isLoading,\n}: ReadabilitySuggestionsProps) {\n  return (\n    <div className=\"space-y-2\">\n      {isLoading ? (\n        <p className=\"text-muted-foreground text-sm\">Generating suggestions…</p>\n      ) : suggestions.length > 0 ? (\n        <div className=\"space-y-3\">\n          {suggestions.map((suggestion) => (\n            <button\n              className={cn(\n                \"text-left text-muted-foreground text-sm leading-relaxed transition-colors\",\n                suggestion.textReference\n                  ? \"cursor-pointer hover:text-foreground\"\n                  : \"\"\n              )}\n              key={`${suggestion.text}-${suggestion.textReference ?? \"\"}`}\n              onClick={() => {\n                if (suggestion.textReference && editor) {\n                  highlightTextInEditor(editor, suggestion.textReference);\n                }\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" || e.key === \" \") {\n                  e.preventDefault();\n                  if (suggestion.textReference && editor) {\n                    highlightTextInEditor(editor, suggestion.textReference);\n                  }\n                }\n              }}\n              type=\"button\"\n            >\n              <div className=\"flex items-start gap-2\">\n                <span className=\"text-primary\">•</span>\n                <div className=\"flex-1\">\n                  <span>{suggestion.text}</span>\n                  {suggestion.textReference && (\n                    <CursorClickIcon className=\"ml-1 inline h-3 w-3\" />\n                  )}\n                  {suggestion.explanation && (\n                    <div className=\"mt-1 text-muted-foreground/80 text-xs italic\">\n                      {suggestion.explanation}\n                    </div>\n                  )}\n                </div>\n              </div>\n            </button>\n          ))}\n        </div>\n      ) : (\n        <p className=\"text-muted-foreground text-sm\">No suggestions yet.</p>\n      )}\n    </div>\n  );\n}\n\nexport const ReadabilitySuggestions = React.memo(\n  ReadabilitySuggestionsBase,\n  (prev, next) => {\n    if (prev.isLoading !== next.isLoading) {\n      return false;\n    }\n    if (prev.editor !== next.editor) {\n      return false;\n    }\n    if (prev.suggestions.length !== next.suggestions.length) {\n      return false;\n    }\n    for (let i = 0; i < prev.suggestions.length; i += 1) {\n      const a = prev.suggestions[i];\n      const b = next.suggestions[i];\n      if (!a || !b) {\n        return false;\n      }\n      if (\n        a.text !== b.text ||\n        a.explanation !== b.explanation ||\n        a.textReference !== b.textReference\n      ) {\n        return false;\n      }\n    }\n    return true;\n  }\n);\n"
  },
  {
    "path": "apps/cms/src/components/editor/editor-data-provider.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { notFound, useParams, useRouter } from \"next/navigation\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { FormProvider, type Resolver, useForm } from \"react-hook-form\";\nimport { emptyPost } from \"@/lib/data/post\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type PostEditorValues,\n  type PostValues,\n  postEditorSchema,\n} from \"@/lib/validations/post\";\nimport type { CustomField } from \"@/types/fields\";\nimport PageLoader from \"../shared/page-loader\";\n\ntype EditorMode = \"create\" | \"update\";\n\ninterface EditorBootstrap {\n  fields: CustomField[];\n  values: PostEditorValues;\n}\n\ninterface EditorDataContextValue {\n  fieldDefinitions: CustomField[];\n  form: ReturnType<typeof useForm<PostEditorValues>>;\n  hasUnsavedChanges: boolean;\n  isReady: boolean;\n  isSubmitting: boolean;\n  mode: EditorMode;\n  postId?: string;\n  submit: () => void;\n}\n\nconst EditorDataContext = createContext<EditorDataContextValue | undefined>(\n  undefined\n);\n\nconst CORE_FIELD_LABELS: Record<string, string> = {\n  title: \"Title\",\n  description: \"Description\",\n  slug: \"Slug\",\n  category: \"Category\",\n  content: \"Content\",\n  contentJson: \"Content\",\n  publishedAt: \"Publish date\",\n  coverImage: \"Cover image\",\n};\n\nfunction buildEditorValues(\n  fields: CustomField[],\n  post?: PostValues,\n  customFieldValues?: Record<string, string>\n): PostEditorValues {\n  const values: PostValues = post\n    ? {\n        ...post,\n        publishedAt: new Date(post.publishedAt),\n      }\n    : {\n        ...emptyPost,\n        authors: [],\n      };\n\n  return {\n    ...values,\n    customFields: Object.fromEntries(\n      fields.map((field) => [field.id, customFieldValues?.[field.id] ?? \"\"])\n    ),\n  };\n}\n\nfunction buildCustomFieldPayload(\n  fields: CustomField[],\n  values: Record<string, string>\n) {\n  return Object.fromEntries(\n    fields.map((field) => {\n      const value = values[field.id] ?? \"\";\n      return [field.id, value.trim() === \"\" ? null : value];\n    })\n  );\n}\n\nasync function fetchEditorBootstrap(\n  postId?: string\n): Promise<EditorBootstrap | null> {\n  if (!postId) {\n    const response = await fetch(\"/api/fields\");\n\n    if (!response.ok) {\n      throw new Error(\"Failed to fetch custom fields\");\n    }\n\n    const fields: CustomField[] = await response.json();\n\n    return {\n      fields,\n      values: buildEditorValues(fields),\n    };\n  }\n\n  const [postResponse, customFieldsResponse] = await Promise.all([\n    fetch(`/api/posts/${postId}`),\n    fetch(`/api/posts/${postId}/fields`),\n  ]);\n\n  if (postResponse.status === 404 || customFieldsResponse.status === 404) {\n    return null;\n  }\n\n  if (!postResponse.ok) {\n    throw new Error(\"Failed to fetch post\");\n  }\n\n  if (!customFieldsResponse.ok) {\n    throw new Error(\"Failed to fetch post custom fields\");\n  }\n\n  const post = (await postResponse.json()) as PostValues;\n  const customFieldData = (await customFieldsResponse.json()) as {\n    fields: CustomField[];\n    values: Record<string, string>;\n  };\n\n  return {\n    fields: customFieldData.fields,\n    values: buildEditorValues(\n      customFieldData.fields,\n      post,\n      customFieldData.values\n    ),\n  };\n}\n\nexport function EditorDataProvider({\n  children,\n  postId,\n}: {\n  children: React.ReactNode;\n  postId?: string;\n}) {\n  const router = useRouter();\n  const params = useParams<{ workspace: string }>();\n  const queryClient = useQueryClient();\n  const mode: EditorMode = postId ? \"update\" : \"create\";\n  const [hasHydrated, setHasHydrated] = useState(false);\n  const didInitialize = useRef(false);\n\n  const form = useForm<PostEditorValues>({\n    resolver: zodResolver(postEditorSchema) as Resolver<PostEditorValues>,\n    defaultValues: buildEditorValues([]),\n  });\n\n  const bootstrapQuery = useQuery({\n    queryKey: [\"editor-bootstrap\", params.workspace, postId ?? \"new\"],\n    staleTime: 1000 * 60 * 5,\n    queryFn: () => fetchEditorBootstrap(postId),\n  });\n\n  useEffect(() => {\n    if (bootstrapQuery.data === undefined) {\n      return;\n    }\n\n    if (bootstrapQuery.data === null) {\n      setHasHydrated(true);\n      return;\n    }\n\n    if (!didInitialize.current) {\n      form.reset(bootstrapQuery.data.values);\n      didInitialize.current = true;\n    }\n\n    setHasHydrated(true);\n  }, [bootstrapQuery.data, form]);\n\n  useEffect(() => {\n    if (!form.formState.isDirty) {\n      return;\n    }\n\n    const beforeUnload = (event: BeforeUnloadEvent) => {\n      event.preventDefault();\n      event.returnValue = \"\";\n    };\n\n    window.addEventListener(\"beforeunload\", beforeUnload);\n    return () => window.removeEventListener(\"beforeunload\", beforeUnload);\n  }, [form.formState.isDirty]);\n\n  const createMutation = useMutation({\n    mutationFn: async (values: PostEditorValues) => {\n      const response = await fetch(\"/api/posts\", {\n        method: \"POST\",\n        body: JSON.stringify({\n          ...values,\n          customFields: buildCustomFieldPayload(\n            bootstrapQuery.data?.fields ?? [],\n            values.customFields\n          ),\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json().catch(() => ({}));\n        throw new Error(error.error || \"Failed to create post\");\n      }\n\n      return (await response.json()) as { id: string };\n    },\n    onSuccess: async (data) => {\n      toast.success(\"Post created\");\n      await queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.POSTS(params.workspace),\n      });\n      router.push(`/${params.workspace}/editor/p/${data.id}`);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const updateMutation = useMutation({\n    mutationFn: async (values: PostEditorValues) => {\n      if (!postId) {\n        throw new Error(\"Missing post ID\");\n      }\n\n      const response = await fetch(`/api/posts/${postId}`, {\n        method: \"PATCH\",\n        body: JSON.stringify({\n          ...values,\n          customFields: buildCustomFieldPayload(\n            bootstrapQuery.data?.fields ?? [],\n            values.customFields\n          ),\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json().catch(() => ({}));\n        throw new Error(error.error || \"Failed to update post\");\n      }\n\n      return values;\n    },\n    onSuccess: async (values) => {\n      toast.success(\"Post updated\");\n      await Promise.all([\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.POSTS(params.workspace),\n        }),\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.POST(params.workspace, postId ?? \"\"),\n        }),\n        queryClient.invalidateQueries({\n          queryKey: [\"editor-bootstrap\", params.workspace, postId ?? \"new\"],\n        }),\n      ]);\n      form.reset(values);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const handleInvalidSubmit = useCallback(() => {\n    const formErrors = form.formState.errors;\n    const invalidFields = new Set<string>();\n\n    for (const [fieldName, label] of Object.entries(CORE_FIELD_LABELS)) {\n      if (fieldName in formErrors) {\n        invalidFields.add(label);\n      }\n    }\n\n    const customFieldErrors = formErrors.customFields;\n    if (customFieldErrors && bootstrapQuery.data) {\n      for (const field of bootstrapQuery.data.fields) {\n        if (customFieldErrors[field.id]) {\n          invalidFields.add(field.name);\n        }\n      }\n    }\n\n    toast.error(\n      invalidFields.size > 0\n        ? `Missing or invalid fields: ${Array.from(invalidFields).join(\", \")}`\n        : \"Please fix the highlighted fields\"\n    );\n  }, [bootstrapQuery.data, form.formState.errors]);\n\n  const handleValidSubmit = useCallback(\n    async (values: PostEditorValues) => {\n      if (mode === \"update\") {\n        await updateMutation.mutateAsync(values);\n        return;\n      }\n\n      await createMutation.mutateAsync(values);\n    },\n    [createMutation, mode, updateMutation]\n  );\n\n  const submit = useCallback(() => {\n    form.handleSubmit(handleValidSubmit, handleInvalidSubmit)();\n  }, [form, handleInvalidSubmit, handleValidSubmit]);\n\n  const contextValue = useMemo<EditorDataContextValue>(() => {\n    const fieldDefinitions = bootstrapQuery.data?.fields ?? [];\n    return {\n      fieldDefinitions,\n      form,\n      hasUnsavedChanges: form.formState.isDirty,\n      isReady: bootstrapQuery.isSuccess && hasHydrated,\n      isSubmitting: createMutation.isPending || updateMutation.isPending,\n      mode,\n      postId,\n      submit,\n    };\n  }, [\n    bootstrapQuery.data?.fields,\n    bootstrapQuery.isSuccess,\n    createMutation.isPending,\n    form,\n    form.formState.isDirty,\n    hasHydrated,\n    mode,\n    postId,\n    submit,\n    updateMutation.isPending,\n  ]);\n\n  if (bootstrapQuery.isLoading || (bootstrapQuery.isSuccess && !hasHydrated)) {\n    return <PageLoader />;\n  }\n\n  if (bootstrapQuery.data === null) {\n    return notFound();\n  }\n\n  if (bootstrapQuery.isError || !bootstrapQuery.data) {\n    throw bootstrapQuery.error;\n  }\n\n  return (\n    <EditorDataContext.Provider value={contextValue}>\n      <FormProvider {...form}>{children}</FormProvider>\n    </EditorDataContext.Provider>\n  );\n}\n\nexport function useEditorData() {\n  const context = useContext(EditorDataContext);\n\n  if (!context) {\n    throw new Error(\"useEditorData must be used within EditorDataProvider\");\n  }\n\n  return context;\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/editor-header.tsx",
    "content": "\"use client\";\n\nimport {\n  Cancel01Icon,\n  SidebarRight01Icon,\n  SidebarRightIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport {\n  getSidebarKeyboardShortcutLabel,\n  SidebarTrigger,\n} from \"@marble/ui/components/sidebar\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { SidebarSimpleIcon, XIcon } from \"@phosphor-icons/react\";\nimport Link from \"next/link\";\nimport { ShareModal } from \"./share-modal\";\n\ninterface EditorHeaderProps {\n  postId?: string;\n  workspace: string;\n}\n\nexport function EditorHeader({ postId, workspace }: EditorHeaderProps) {\n  return (\n    <header className=\"sticky top-0 z-50 flex justify-between p-3\">\n      <div className=\"flex items-center gap-4\">\n        <Tooltip>\n          <TooltipTrigger\n            delay={400}\n            render={\n              <Link\n                className={cn(\n                  \"group\",\n                  buttonVariants({ variant: \"ghost\", size: \"icon-sm\" })\n                )}\n                href={`/${workspace}/posts`}\n              >\n                <HugeiconsIcon icon={Cancel01Icon} />\n              </Link>\n            }\n          />\n          <TooltipContent>\n            <p>Close Editor</p>\n          </TooltipContent>\n        </Tooltip>\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        {postId && <ShareModal postId={postId} />}\n        <Tooltip>\n          <TooltipTrigger\n            delay={400}\n            render={\n              <SidebarTrigger\n                className=\"size-8\"\n                render={\n                  <Button size=\"icon-sm\" type=\"button\" variant=\"ghost\">\n                    <HugeiconsIcon icon={SidebarRightIcon} />\n                  </Button>\n                }\n              />\n            }\n          />\n          <TooltipContent>\n            <p>Toggle Sidebar ({getSidebarKeyboardShortcutLabel()})</p>\n          </TooltipContent>\n        </Tooltip>\n      </div>\n    </header>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/editor-page.tsx",
    "content": "\"use client\";\n\nimport { MarbleEditorMenus } from \"@/components/editor/editor\";\nimport { useEditorData } from \"@/components/editor/editor-data-provider\";\nimport { EditorHeader } from \"@/components/editor/editor-header\";\nimport { EditorSidebar } from \"@/components/editor/editor-sidebar\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { HiddenScrollbar } from \"@/components/ui/hidden-scrollbar\";\nimport { MAX_MEDIA_FILE_SIZE } from \"@/lib/constants\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport type { PostEditorValues } from \"@/lib/validations/post\";\nimport \"@/styles/editor.css\";\nimport {\n  EditorContext,\n  ImageUpload,\n  type JSONContent,\n  type MediaPage,\n  useMarbleEditor,\n  VideoUpload,\n} from \"@marble/editor\";\nimport { SidebarInset, useSidebar } from \"@marble/ui/components/sidebar\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { useParams } from \"next/navigation\";\nimport { useCallback, useMemo } from \"react\";\nimport { useFormContext } from \"react-hook-form\";\nimport { getMediaEditorApiUrl } from \"@/lib/search-params\";\nimport type { MediaCursorListResponse } from \"@/types/media\";\nimport { generateSlug } from \"@/utils/string\";\nimport { TextareaAutosize } from \"./textarea-autosize\";\n\nfunction EditorPageContent() {\n  \"use no memo\";\n  const params = useParams<{ workspace: string }>();\n  const { open, isMobile } = useSidebar();\n  const { mode, postId } = useEditorData();\n  const {\n    clearErrors,\n    formState: { errors },\n    register,\n    setValue,\n    watch,\n  } = useFormContext<PostEditorValues>();\n\n  const content = watch(\"content\");\n\n  const handleImageUpload = useCallback(async (file: File): Promise<string> => {\n    const result = await uploadFile({ file, type: \"media\" });\n    if (!result?.url) {\n      throw new Error(\"Upload failed: Invalid response from server.\");\n    }\n    return result.url;\n  }, []);\n\n  const handleVideoUpload = useCallback(async (file: File): Promise<string> => {\n    const result = await uploadFile({ file, type: \"media\" });\n    if (!result?.url) {\n      throw new Error(\"Upload failed: Invalid response from server.\");\n    }\n    return result.url;\n  }, []);\n\n  const fetchMediaPage = useCallback(\n    async (cursor?: string): Promise<MediaPage> => {\n      try {\n        const url = getMediaEditorApiUrl(\"/api/media/editor\", {\n          cursor: cursor || null,\n        });\n        const response = await fetch(url);\n        if (!response.ok) {\n          return { media: [] };\n        }\n        const data: MediaCursorListResponse = await response.json();\n        return {\n          media: data.media.map((item) => ({\n            id: item.id,\n            url: item.url,\n            name: item.name,\n            type: item.type as \"image\" | \"video\" | \"file\",\n          })),\n          nextCursor: data.nextCursor,\n        };\n      } catch {\n        return { media: [] };\n      }\n    },\n    []\n  );\n\n  const handleUploadError = useCallback((error: Error) => {\n    toast.error(`Upload failed: ${error.message}`);\n  }, []);\n\n  const handleEditorUpdate = useCallback(\n    ({\n      editor,\n    }: {\n      editor: { getHTML: () => string; getJSON: () => JSONContent };\n    }) => {\n      const html = editor.getHTML();\n      const json = editor.getJSON();\n      if (html.length > 0) {\n        clearErrors(\"content\");\n      }\n      setValue(\"content\", html, { shouldDirty: true, shouldValidate: true });\n      setValue(\"contentJson\", JSON.stringify(json), {\n        shouldDirty: true,\n        shouldValidate: true,\n      });\n    },\n    [clearErrors, setValue]\n  );\n\n  const editor = useMarbleEditor({\n    content: content || \"\",\n    placeholder: \"Start typing or press '/' for commands\",\n    editorProps: {\n      attributes: {\n        class:\n          \"prose dark:prose-invert min-h-96 h-full sm:px-4 focus:outline-hidden max-w-full prose-blockquote:border-border\",\n      },\n      transformPastedHTML(html) {\n        const cleaned = html\n          .replace(/<img[^>]*\\ssrc=[\"']data:image\\/[^\"']*[\"'][^>]*\\/?>/gi, \"\")\n          .replace(\n            /<video[^>]*\\ssrc=[\"']data:video\\/[^\"']*[\"'][^>]*>.*?<\\/video>/gi,\n            \"\"\n          );\n        const doc = new DOMParser().parseFromString(cleaned, \"text/html\");\n        for (const el of Array.from(doc.querySelectorAll(\"img, video\"))) {\n          if (!el.closest(\"figure\")) {\n            const figure = doc.createElement(\"figure\");\n            const figcaption = doc.createElement(\"figcaption\");\n            el.parentNode?.insertBefore(figure, el);\n            figure.appendChild(el);\n            figure.appendChild(figcaption);\n          }\n        }\n        return doc.body.innerHTML;\n      },\n    },\n    extensions: [\n      ImageUpload.configure({\n        accept: \"image/*\",\n        maxSize: MAX_MEDIA_FILE_SIZE,\n        limit: 3,\n        upload: handleImageUpload,\n        onError: handleUploadError,\n        fetchMediaPage,\n      }),\n      VideoUpload.configure({\n        accept: \"video/*\",\n        maxSize: MAX_MEDIA_FILE_SIZE,\n        upload: handleVideoUpload,\n        onError: handleUploadError,\n        fetchMediaPage,\n      }),\n    ],\n    onUpdate: handleEditorUpdate,\n  });\n  const editorContextValue = useMemo(() => ({ editor }), [editor]);\n\n  return (\n    <EditorContext.Provider value={editorContextValue}>\n      <SidebarInset className=\"h-[calc(100vh-1rem)] min-h-[calc(100vh-1rem)] rounded-xl border bg-editor-content-background shadow-xs\">\n        <EditorHeader postId={postId} workspace={params.workspace} />\n        <section className=\"mx-auto w-full max-w-3xl flex-1\">\n          <HiddenScrollbar className=\"h-[calc(100vh-7rem)]\">\n            <form\n              className=\"space-y-5 rounded-md p-4\"\n              onSubmit={(event) => {\n                event.preventDefault();\n              }}\n            >\n              <div className=\"flex flex-col\">\n                <label className=\"sr-only\" htmlFor=\"title\">\n                  Enter post your title\n                </label>\n\n                <TextareaAutosize\n                  id=\"title\"\n                  placeholder=\"Title\"\n                  {...register(\"title\", {\n                    onChange: (e) => {\n                      if (mode === \"create\") {\n                        setValue(\"slug\", generateSlug(e.target.value), {\n                          shouldDirty: true,\n                        });\n                        clearErrors(\"slug\");\n                      }\n                    },\n                  })}\n                  className=\"scrollbar-hide mb-2 w-full resize-none bg-transparent font-semibold prose-headings:font-semibold text-4xl focus:outline-hidden focus:ring-0 sm:px-4\"\n                  onEnterPress={() => {\n                    editor\n                      ?.chain()\n                      .focus()\n                      .insertContentAt(0, { type: \"paragraph\" })\n                      .focus(\"start\")\n                      .run();\n                  }}\n                />\n                {errors.title && (\n                  <ErrorMessage className=\"text-sm\">\n                    {errors.title.message}\n                  </ErrorMessage>\n                )}\n              </div>\n              <div className=\"flex flex-col\">\n                <MarbleEditorMenus />\n\n                {errors.content && (\n                  <ErrorMessage className=\"text-sm\">\n                    {errors.content.message}\n                  </ErrorMessage>\n                )}\n              </div>\n            </form>\n          </HiddenScrollbar>\n        </section>\n      </SidebarInset>\n      {!isMobile && (\n        <div\n          className={cn(\n            \"h-svh transition-[width] ease-linear\",\n            open ? \"w-2\" : \"w-0\"\n          )}\n        />\n      )}\n      <EditorSidebar />\n    </EditorContext.Provider>\n  );\n}\n\nfunction EditorPage() {\n  return <EditorPageContent />;\n}\n\nexport default EditorPage;\n"
  },
  {
    "path": "apps/cms/src/components/editor/editor-sidebar.tsx",
    "content": "\"use client\";\n\nimport { useCurrentEditor } from \"@marble/editor\";\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarHeader,\n  useSidebar,\n} from \"@marble/ui/components/sidebar\";\nimport {\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from \"@marble/ui/components/tabs\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { SpinnerIcon } from \"@phosphor-icons/react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { lazy, Suspense, useEffect, useMemo, useRef, useState } from \"react\";\nimport { useEditorData } from \"@/components/editor/editor-data-provider\";\nimport { useDebounce } from \"@/hooks/use-debounce\";\nimport { fetchAiReadabilitySuggestionsObject } from \"@/lib/ai/readability\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport { calculateReadabilityScore } from \"@/utils/readability\";\nimport { MetadataFooter } from \"./footer/metadata-footer\";\nimport { MetadataTab } from \"./tabs/metadata-tab\";\n\nconst AnalysisTab = lazy(() =>\n  import(\"./tabs/analysis-tab\").then((m) => ({ default: m.AnalysisTab }))\n);\n\nconst tabs = {\n  metadata: \"Metadata\",\n  analysis: \"Analysis\",\n};\n\nconst TabLoadingSpinner = () => (\n  <div className=\"flex h-full items-center justify-center px-6\">\n    <SpinnerIcon className=\"size-5 animate-spin\" />\n  </div>\n);\n\ntype EditorSidebarProps = React.ComponentProps<typeof Sidebar>;\n\nexport function EditorSidebar({ ...props }: EditorSidebarProps) {\n  const { open } = useSidebar();\n  const { activeWorkspace } = useWorkspace();\n  const { editor } = useCurrentEditor();\n  const {\n    form: { watch },\n    isSubmitting,\n    mode,\n    postId,\n  } = useEditorData();\n  const { tags, authors: initialAuthors } = watch();\n\n  const [editorText, setEditorText] = useState(\"\");\n  const [editorHTML, setEditorHTML] = useState(\"\");\n\n  useEffect(() => {\n    if (!editor) {\n      return;\n    }\n    setEditorText(editor.getText());\n    setEditorHTML(editor.getHTML());\n    const handler = () => {\n      const nextText = editor.getText();\n      const nextHTML = editor.getHTML();\n      setEditorText((prev) => (prev === nextText ? prev : nextText));\n      setEditorHTML((prev) => (prev === nextHTML ? prev : nextHTML));\n    };\n    editor.on(\"update\", handler);\n    editor.on(\"create\", handler);\n    return () => {\n      editor.off(\"update\", handler);\n      editor.off(\"create\", handler);\n    };\n  }, [editor]);\n\n  const debouncedText = useDebounce(editorText, 1500);\n\n  const metrics = useMemo(() => {\n    if (!editor) {\n      return {\n        wordCount: 0,\n        sentenceCount: 0,\n        wordsPerSentence: 0,\n        readabilityScore: 0,\n        readingTime: 0,\n      };\n    }\n\n    // Use CharacterCount extension for word count\n    const wordCount = editor.storage.characterCount?.words\n      ? editor.storage.characterCount.words()\n      : 0;\n\n    // Calculate sentence count from text (CharacterCount doesn't provide this)\n    const text = debouncedText;\n    const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);\n    const sentenceCount = sentences.length;\n\n    const wordsPerSentence =\n      sentenceCount > 0 ? Math.round(wordCount / sentenceCount) : 0;\n\n    const readabilityScore = calculateReadabilityScore(editor);\n    const readingTime = wordCount / 238;\n    return {\n      wordCount,\n      sentenceCount,\n      wordsPerSentence,\n      readabilityScore,\n      readingTime,\n    };\n  }, [editor, debouncedText]);\n\n  const [hasFetchedAiOnce, setHasFetchedAiOnce] = useState(false);\n\n  // biome-ignore lint/style/noNonNullAssertion: <>\n  const workspaceId = activeWorkspace!.id;\n  const bypassCacheRef = useRef(false);\n\n  const {\n    data: aiData,\n    isFetching: aiLoading,\n    refetch: refetchAi,\n  } = useQuery({\n    queryKey: QUERY_KEYS.AI_READABILITY_SUGGESTIONS(\n      workspaceId,\n      postId ?? \"draft\"\n    ),\n    enabled: editorHTML.trim().length > 0,\n    staleTime: 5 * 60 * 1000,\n    refetchOnWindowFocus: false,\n    refetchOnReconnect: false,\n    retry: 0,\n    queryFn: async () => {\n      const result = await fetchAiReadabilitySuggestionsObject({\n        content: editorHTML,\n        metrics: {\n          wordCount: metrics.wordCount,\n          sentenceCount: metrics.sentenceCount,\n          wordsPerSentence: metrics.wordsPerSentence,\n          readabilityScore: metrics.readabilityScore,\n          readingTime: metrics.readingTime,\n        },\n        postId,\n        bypassCache: bypassCacheRef.current,\n      });\n      bypassCacheRef.current = false;\n      return result;\n    },\n  });\n\n  const [activeTab, setActiveTab] = useState<keyof typeof tabs>(\"metadata\");\n\n  useEffect(() => {\n    if (\n      activeTab === \"analysis\" &&\n      !!workspaceId &&\n      !hasFetchedAiOnce &&\n      editorHTML.trim().length > 0\n    ) {\n      refetchAi();\n      setHasFetchedAiOnce(true);\n    }\n  }, [activeTab, workspaceId, hasFetchedAiOnce, editorHTML, refetchAi]);\n\n  const handleRefreshAi = () => {\n    bypassCacheRef.current = true;\n    refetchAi();\n  };\n\n  return (\n    <div>\n      <Sidebar\n        className={cn(\n          \"m-2 h-[calc(100vh-1rem)] min-h-[calc(100vh-1rem)] overflow-hidden rounded-xl border bg-editor-sidebar-background\",\n          open ? \"\" : \"mr-0\"\n        )}\n        side=\"right\"\n        {...props}\n      >\n        <SidebarHeader className=\"sticky top-0 z-10 shrink-0 bg-transparent px-6 py-4\">\n          <Tabs\n            className=\"w-full\"\n            onValueChange={(value) => setActiveTab(value)}\n            value={activeTab}\n          >\n            <TabsList\n              className=\"grid\"\n              style={{\n                gridTemplateColumns: `repeat(${Object.keys(tabs).length}, 1fr)`,\n              }}\n            >\n              {Object.entries(tabs).map(([value, label]) => (\n                <TabsTrigger className=\"px-2\" key={value} value={value}>\n                  {label}\n                </TabsTrigger>\n              ))}\n            </TabsList>\n          </Tabs>\n        </SidebarHeader>\n\n        <SidebarContent className=\"min-h-0 flex-1 overflow-hidden bg-transparent\">\n          <Tabs\n            className=\"flex h-full flex-col\"\n            onValueChange={(value) => setActiveTab(value)}\n            value={activeTab}\n          >\n            <TabsContent\n              className=\"min-h-0 flex-1 data-[state=inactive]:hidden\"\n              value=\"metadata\"\n            >\n              <Suspense fallback={<TabLoadingSpinner />}>\n                <MetadataTab initialAuthors={initialAuthors} tags={tags} />\n              </Suspense>\n            </TabsContent>\n\n            <TabsContent\n              className=\"min-h-0 flex-1 data-[state=inactive]:hidden\"\n              value=\"analysis\"\n            >\n              <Suspense fallback={<TabLoadingSpinner />}>\n                <AnalysisTab\n                  aiLoading={aiLoading}\n                  aiSuggestions={aiData?.suggestions ?? []}\n                  onRefreshAi={handleRefreshAi}\n                />\n              </Suspense>\n            </TabsContent>\n          </Tabs>\n        </SidebarContent>\n\n        <SidebarFooter className=\"shrink-0 bg-transparent px-6 py-6\">\n          {activeTab === \"metadata\" && (\n            <MetadataFooter isSubmitting={isSubmitting} />\n          )}\n        </SidebarFooter>\n      </Sidebar>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/editor.tsx",
    "content": "\"use client\";\n\nimport {\n  EditorAlignSelector,\n  EditorBlockHandleMenu,\n  EditorBubbleMenu,\n  EditorClearFormatting,\n  EditorContent,\n  EditorLinkSelector,\n  EditorMarkBold,\n  EditorMarkCode,\n  EditorMarkHighlight,\n  EditorMarkItalic,\n  EditorMarkStrike,\n  EditorMarkSubscript,\n  EditorMarkSuperscript,\n  EditorMarkTextColor,\n  EditorMarkUnderline,\n  EditorNodeBulletList,\n  EditorNodeHeading1,\n  EditorNodeHeading2,\n  EditorNodeHeading3,\n  EditorNodeOrderedList,\n  EditorNodeQuote,\n  EditorNodeTaskList,\n  EditorNodeText,\n  EditorSelector,\n  EditorTableMenus,\n} from \"@marble/editor\";\n\n/**\n * Marble Editor Menus and Content\n *\n * This component provides the editor menus (bubble menu, table menus) and content.\n * It should be used inside an EditorProvider.\n */\nexport function MarbleEditorMenus() {\n  return (\n    <>\n      <EditorBubbleMenu>\n        <EditorSelector title=\"Text\">\n          <EditorNodeText />\n          <EditorNodeHeading1 />\n          <EditorNodeHeading2 />\n          <EditorNodeHeading3 />\n          <EditorNodeBulletList />\n          <EditorNodeOrderedList />\n          <EditorNodeTaskList />\n          <EditorNodeQuote />\n        </EditorSelector>\n\n        <EditorSelector title=\"Format\">\n          <EditorMarkBold />\n          <EditorMarkItalic />\n          <EditorMarkUnderline />\n          <EditorMarkStrike />\n          <EditorMarkCode />\n          <EditorMarkSuperscript />\n          <EditorMarkSubscript />\n        </EditorSelector>\n\n        <EditorMarkTextColor />\n        <EditorMarkHighlight />\n\n        <EditorLinkSelector />\n\n        <EditorAlignSelector />\n\n        <EditorClearFormatting />\n      </EditorBubbleMenu>\n\n      <EditorContent />\n\n      <EditorBlockHandleMenu />\n\n      <EditorTableMenus />\n    </>\n  );\n}\n\nexport default MarbleEditorMenus;\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/author-selector.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@marble/ui/components/command\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CaretUpDownIcon, CheckIcon } from \"@phosphor-icons/react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useEffect, useMemo } from \"react\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  type PathValue,\n  useController,\n} from \"react-hook-form\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useUser } from \"@/providers/user\";\nimport { ErrorMessage } from \"../../ui/error-message\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface AuthorOptions {\n  id: string;\n  name: string;\n  image: string | null;\n  userId: string | null;\n}\n\ninterface AuthorSelectorProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n  placeholder?: string;\n  isOpen?: boolean;\n  setIsOpen?: (open: boolean) => void;\n  defaultAuthors?: string[];\n}\n\nconst EMPTY_AUTHORS: string[] = [];\n\nexport function AuthorSelector<TFieldValues extends FieldValues>({\n  control,\n  placeholder,\n  isOpen,\n  setIsOpen,\n  defaultAuthors = EMPTY_AUTHORS,\n}: AuthorSelectorProps<TFieldValues>) {\n  const {\n    field: { onChange, value },\n    fieldState: { error },\n  } = useController({\n    name: \"authors\" as Path<TFieldValues>,\n    control,\n    defaultValue: defaultAuthors as PathValue<TFieldValues, Path<TFieldValues>>,\n  });\n  const selectedAuthorIds = (value as string[] | undefined) ?? [];\n\n  const { user } = useUser();\n  const workspaceId = useWorkspaceId();\n  const isNewPost = defaultAuthors.length === 0;\n\n  const { data: authors = [], isLoading } = useQuery<AuthorOptions[]>({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.AUTHORS(workspaceId!),\n    queryFn: async () => {\n      const response = await fetch(\"/api/authors\");\n      if (!response.ok) {\n        throw new Error(\"Failed to fetch authors\");\n      }\n      return response.json();\n    },\n    enabled: !!workspaceId,\n  });\n\n  // Memoize the primary author to avoid recalculation\n  const derivedPrimaryAuthor = useMemo(() => {\n    if (!user || authors.length === 0) {\n      return;\n    }\n    return authors.find((author) => author.userId === user.id) || authors[0];\n  }, [user, authors]);\n\n  const selected = useMemo(() => {\n    if (isLoading || authors.length === 0) {\n      return [];\n    }\n    if (selectedAuthorIds.length > 0) {\n      return authors.filter((opt) => selectedAuthorIds.includes(opt.id));\n    }\n    return [];\n  }, [authors, isLoading, selectedAuthorIds]);\n\n  // Auto-select current user's author profile on initial load for better UX\n  // This makes it obvious who is creating the content and saves them from\n  // having to manually select themselves for original content.\n  // The user can always remove themselves from the list if they want to.\n  // In a case where they are publishing on behalf of another author, they can select them from the list.\n  // This auto select is only for new posts, not when editing.\n  // Check the post creation route to see how this is handled.\n  useEffect(() => {\n    if (\n      authors.length > 0 &&\n      derivedPrimaryAuthor &&\n      selectedAuthorIds.length === 0 &&\n      !isLoading &&\n      isNewPost\n    ) {\n      onChange([derivedPrimaryAuthor.id] as PathValue<\n        TFieldValues,\n        Path<TFieldValues>\n      >);\n      // console.log(\"auto selected primary author\", derivedPrimaryAuthor);\n    }\n  }, [\n    authors,\n    derivedPrimaryAuthor,\n    isLoading,\n    isNewPost,\n    onChange,\n    selectedAuthorIds.length,\n  ]);\n\n  const addOrRemoveAuthor = (authorToAdd: string) => {\n    const currentValues = selectedAuthorIds;\n    let newValue = currentValues.includes(authorToAdd)\n      ? currentValues.filter((id: string) => id !== authorToAdd)\n      : [...currentValues, authorToAdd];\n\n    if (\n      derivedPrimaryAuthor &&\n      newValue.length === 0 &&\n      currentValues.includes(derivedPrimaryAuthor.id) &&\n      authorToAdd === derivedPrimaryAuthor.id\n    ) {\n      newValue = [derivedPrimaryAuthor.id];\n    }\n\n    if (newValue.length === 0 && derivedPrimaryAuthor) {\n      newValue = [derivedPrimaryAuthor.id];\n    }\n\n    onChange(newValue as PathValue<TFieldValues, Path<TFieldValues>>);\n  };\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"authors\">Authors</Label>\n        <FieldInfo text=\"List of authors who contributed to the article.\" />\n      </div>\n      <Popover onOpenChange={setIsOpen} open={isOpen}>\n        <PopoverTrigger\n          nativeButton={false}\n          render={\n            <div className=\"relative flex h-auto min-h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border bg-editor-field px-3 py-1.5 text-sm\">\n              <ul className=\"-space-x-2 flex flex-wrap\">\n                {selected.length === 0 && (\n                  <li className=\"text-muted-foreground\">\n                    {placeholder || \"Select authors\"}\n                  </li>\n                )}\n                {selected.length === 1 && (\n                  <li className=\"flex items-center gap-2\">\n                    <Avatar className=\"size-6\">\n                      <AvatarImage src={selected[0]?.image || undefined} />\n                      <AvatarFallback>\n                        {selected[0]?.name.charAt(0)}\n                      </AvatarFallback>\n                    </Avatar>\n                    <p className=\"max-w-64 text-sm\">{selected[0]?.name}</p>\n                  </li>\n                )}\n                {selected.length > 1 &&\n                  selected.map((author) => (\n                    <li className=\"flex items-center\" key={author.id}>\n                      <Tooltip>\n                        <TooltipTrigger\n                          render={\n                            <Avatar className=\"size-6\">\n                              <AvatarImage src={author.image || undefined} />\n                              <AvatarFallback>\n                                {author.name.charAt(0)}\n                              </AvatarFallback>\n                            </Avatar>\n                          }\n                        />\n                        <TooltipContent>\n                          <p className=\"max-w-64 text-xs\">{author.name}</p>\n                        </TooltipContent>\n                      </Tooltip>\n                    </li>\n                  ))}\n              </ul>\n              <CaretUpDownIcon className=\"size-4 shrink-0 opacity-50\" />\n            </div>\n          }\n        />\n        {error && <ErrorMessage>{error.message}</ErrorMessage>}\n        <PopoverContent align=\"start\" className=\"min-w-[350.67px] p-0\">\n          <Command className=\"w-full\">\n            <CommandInput placeholder=\"Search authors...\" />\n            <CommandList>\n              <CommandEmpty>\n                {isLoading ? \"Loading authors...\" : \"No authors found.\"}\n              </CommandEmpty>\n              {authors && authors.length > 0 && (\n                <CommandGroup>\n                  {authors.map((option) => (\n                    <CommandItem\n                      id={option.id}\n                      key={option.id}\n                      onSelect={() => {\n                        addOrRemoveAuthor(option.id);\n                      }}\n                    >\n                      <div className=\"flex items-center gap-2\">\n                        <Avatar className=\"size-6\">\n                          <AvatarImage src={option.image || undefined} />\n                          <AvatarFallback>\n                            {option.name.charAt(0)}\n                          </AvatarFallback>\n                        </Avatar>\n                        <p className=\"max-w-64\">{option.name}</p>\n                      </div>\n                      <CheckIcon\n                        className={cn(\n                          \"ml-auto h-4 w-4\",\n                          selected.some((item) => item.id === option.id)\n                            ? \"opacity-100\"\n                            : \"opacity-0\"\n                        )}\n                      />\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              )}\n              {authors && authors.length > 0 && <CommandSeparator />}\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/category-selector.tsx",
    "content": "import { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport { PlusIcon } from \"@phosphor-icons/react\";\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { useState } from \"react\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { CategoryModal } from \"@/components/categories/category-modals\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface CategoryResponse {\n  id: string;\n  name: string;\n  slug: string;\n}\n\ninterface CategorySelectorProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nexport function CategorySelector<TFieldValues extends FieldValues>({\n  control,\n}: CategorySelectorProps<TFieldValues>) {\n  const {\n    field: { onChange, value },\n    fieldState: { error },\n  } = useController({\n    name: \"category\" as Path<TFieldValues>,\n    control,\n  });\n\n  const [showCategoyModal, setShowCategoryModal] = useState(false);\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const { data: categories = [], isLoading: isLoadingCategories } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.CATEGORIES(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      const res = await fetch(\"/api/categories\");\n      if (!res.ok) {\n        throw new Error(\"Failed to fetch categories\");\n      }\n      const data: CategoryResponse[] = await res.json();\n      return data;\n    },\n    enabled: !!workspaceId,\n  });\n\n  const handleCategoryCreated = (newCategory: CategoryResponse) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    queryClient.setQueryData(\n      QUERY_KEYS.CATEGORIES(workspaceId),\n      (oldData: CategoryResponse[] | undefined) =>\n        oldData ? [...oldData, newCategory] : [newCategory]\n    );\n\n    queryClient.invalidateQueries({\n      queryKey: QUERY_KEYS.CATEGORIES(workspaceId),\n    });\n\n    onChange(newCategory.id);\n  };\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-3\">\n        <div className=\"flex items-center gap-1\">\n          <Label htmlFor=\"category\">Category</Label>\n          <FieldInfo text=\"Good for grouping posts together. You can have one category per post.\" />\n        </div>\n        <Select\n          items={[\n            { label: \"Choose a category\", value: null },\n            ...categories.map((cat) => ({ label: cat.name, value: cat.id })),\n          ]}\n          onValueChange={onChange}\n          value={value || null}\n        >\n          <SelectTrigger className=\"w-full bg-editor-field shadow-none\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectGroup>\n              <SelectLabel className=\"flex items-center justify-between gap-1 p-1 font-normal text-xs\">\n                <span className=\"text-muted-foreground text-xs\">\n                  {isLoadingCategories\n                    ? \"Loading categories...\"\n                    : categories.length === 0\n                      ? \"No categories\"\n                      : \"Categories\"}\n                </span>\n                <button\n                  className=\"flex items-center gap-1 p-1 hover:bg-accent\"\n                  onClick={() => setShowCategoryModal(true)}\n                  type=\"button\"\n                >\n                  <PlusIcon className=\"size-4 text-muted-foreground\" />\n                  <span className=\"sr-only\">Add New Category</span>\n                </button>\n              </SelectLabel>\n              {categories.map((cat) => (\n                <SelectItem key={cat.id} value={cat.id}>\n                  {cat.name}\n                </SelectItem>\n              ))}\n            </SelectGroup>\n          </SelectContent>\n        </Select>\n        {error && (\n          <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n        )}\n      </div>\n      <CategoryModal\n        mode=\"create\"\n        onCategoryCreated={handleCategoryCreated}\n        open={showCategoyModal}\n        setOpen={setShowCategoryModal}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/cover-image-selector.tsx",
    "content": "\"use client\";\n\nimport { Album02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport {\n  Tabs,\n  TabsContent,\n  TabsList,\n  TabsTrigger,\n} from \"@marble/ui/components/tabs\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CheckIcon,\n  CircleNotchIcon,\n  SpinnerIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport {\n  useInfiniteQuery,\n  useMutation,\n  useQueryClient,\n} from \"@tanstack/react-query\";\nimport NextImage from \"next/image\";\nimport { useState } from \"react\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { z } from \"zod\";\nimport { ImageDropzone } from \"@/components/shared/dropzone\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { getMediaEditorApiUrl } from \"@/lib/search-params\";\nimport type { Media, MediaCursorListResponse } from \"@/types/media\";\nimport { FieldInfo } from \"./field-info\";\n\nconst urlSchema = z.string().url({\n  message: \"Please enter a valid URL\",\n});\n\ninterface CoverImageSelectorProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nexport function CoverImageSelector<TFieldValues extends FieldValues>({\n  control,\n}: CoverImageSelectorProps<TFieldValues>) {\n  const {\n    field: { onChange, value: coverImage },\n  } = useController({\n    name: \"coverImage\" as Path<TFieldValues>,\n    control,\n  });\n\n  const [file, setFile] = useState<File | undefined>();\n  const [embedUrl, setEmbedUrl] = useState<string>(\"\");\n  const [isValidatingUrl, setIsValidatingUrl] = useState(false);\n  const [urlError, setUrlError] = useState<string | null>(null);\n  const [isGalleryOpen, setIsGalleryOpen] = useState(false);\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const { mutate: uploadCover, isPending: isUploading } = useMutation({\n    mutationFn: (file: File) => uploadFile({ file, type: \"media\" }),\n    onSuccess: (data: Media) => {\n      if (data?.url) {\n        onChange(data.url);\n        toast.success(\"Uploaded successfully!\");\n        setFile(undefined);\n        if (workspaceId) {\n          queryClient.invalidateQueries({\n            queryKey: QUERY_KEYS.MEDIA(workspaceId),\n          });\n        }\n      } else {\n        toast.error(\"Upload failed: Invalid response from server.\");\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const {\n    data,\n    isLoading: isLoadingMedia,\n    hasNextPage,\n    fetchNextPage,\n    isFetchingNextPage,\n  } = useInfiniteQuery({\n    queryKey: [\n      // biome-ignore lint/style/noNonNullAssertion: workspace is verified before enabled\n      ...QUERY_KEYS.MEDIA(workspaceId!),\n      { context: \"cover-selector\" },\n    ],\n    staleTime: 1000 * 60 * 5,\n    queryFn: async ({ pageParam }: { pageParam?: string }) => {\n      const url = getMediaEditorApiUrl(\"/api/media/editor\", {\n        cursor: pageParam || null,\n      });\n      const res = await fetch(url);\n      if (!res.ok) {\n        throw new Error(\"Failed to load media\");\n      }\n      const data: MediaCursorListResponse = await res.json();\n      return data;\n    },\n    getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,\n    initialPageParam: undefined,\n    enabled: !!workspaceId,\n  });\n\n  const media = data?.pages.flatMap((page) => page.media) ?? [];\n\n  const handleEmbed = async (url: string) => {\n    if (!url) {\n      return;\n    }\n\n    setIsValidatingUrl(true);\n    setUrlError(null);\n\n    try {\n      await urlSchema.parseAsync(url);\n      const img = new Image();\n      img.onload = () => {\n        onChange(url);\n        setEmbedUrl(\"\");\n        setIsValidatingUrl(false);\n      };\n      img.onerror = () => {\n        setUrlError(\"Invalid image URL\");\n        setIsValidatingUrl(false);\n      };\n      img.src = url;\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        setUrlError(error.issues?.[0]?.message || \"Invalid URL\");\n      } else {\n        setUrlError(\"Invalid URL\");\n      }\n      setIsValidatingUrl(false);\n    }\n  };\n\n  const handleImageSelect = (url: string) => {\n    onChange(url);\n    setIsGalleryOpen(false);\n  };\n\n  const renderContent = () => {\n    if (coverImage) {\n      return (\n        <div className=\"group/cover relative isolate h-48 w-full\">\n          <NextImage\n            alt=\"cover\"\n            className=\"rounded-md object-cover\"\n            fill\n            src={coverImage}\n            unoptimized\n          />\n          <div className=\"absolute inset-0 rounded-md bg-black/50 opacity-0 transition-opacity duration-300 group-hover/cover:opacity-100\" />\n          <button\n            className=\"absolute top-2 right-2 rounded-full bg-white p-2 text-black opacity-0 transition hover:text-destructive group-hover/cover:opacity-100\"\n            onClick={() => onChange(null)}\n            type=\"button\"\n          >\n            <TrashIcon className=\"size-5\" />\n            <span className=\"sr-only\">remove image</span>\n          </button>\n        </div>\n      );\n    }\n\n    return (\n      <Tabs className=\"w-full\" defaultValue=\"upload\">\n        <TabsList className=\"mb-4 grid grid-cols-3\" variant=\"line\">\n          <TabsTrigger value=\"upload\">Upload</TabsTrigger>\n          <TabsTrigger value=\"embed\">Embed</TabsTrigger>\n          <TabsTrigger value=\"media\">Media</TabsTrigger>\n        </TabsList>\n        <TabsContent className=\"h-48\" value=\"upload\">\n          {file ? (\n            <div className=\"flex flex-col gap-4\">\n              <div className=\"relative h-48 w-full\">\n                {/* biome-ignore lint/performance/noImgElement: <> */}\n                {/** biome-ignore lint/correctness/useImageSize: <> */}\n                <img\n                  alt=\"cover preview\"\n                  className=\"h-full w-full rounded-md object-cover\"\n                  src={URL.createObjectURL(file)}\n                />\n                <div className=\"absolute inset-0 grid size-full place-content-center rounded-md bg-black/50 p-2 backdrop-blur-xs\">\n                  {isUploading ? (\n                    <div className=\"flex flex-col items-center gap-2\">\n                      <SpinnerIcon className=\"size-5 animate-spin text-white\" />\n                      <p className=\"text-sm text-white\">Uploading...</p>\n                    </div>\n                  ) : (\n                    <div className=\"flex items-center gap-2\">\n                      <Button\n                        className=\"rounded-full bg-white text-black hover:bg-white hover:text-destructive\"\n                        onClick={() => setFile(undefined)}\n                        size=\"icon\"\n                      >\n                        <TrashIcon className=\"size-4\" />\n                      </Button>\n                    </div>\n                  )}\n                </div>\n              </div>\n            </div>\n          ) : (\n            <ImageDropzone\n              className=\"flex h-48 w-full cursor-pointer items-center justify-center rounded-md border border-dashed bg-editor-field\"\n              multiple={false}\n              onFilesAccepted={(files: File[]) => {\n                if (files[0]) {\n                  setFile(files[0]);\n                  uploadCover(files[0]);\n                }\n              }}\n            />\n          )}\n        </TabsContent>\n        <TabsContent className=\"h-48\" value=\"embed\">\n          <div className=\"flex h-48 w-full items-center justify-start rounded-md border border-dashed bg-editor-field\">\n            <div className=\"flex w-full max-w-sm flex-col gap-2 px-4\">\n              <div className=\"flex items-center gap-2\">\n                <Input\n                  className={cn(\n                    \"bg-editor-sidebar-background\",\n                    urlError && \"border-destructive\"\n                  )}\n                  onChange={({ target }) => {\n                    setEmbedUrl(target.value);\n                    setUrlError(null);\n                  }}\n                  placeholder=\"Paste your cover image link\"\n                  value={embedUrl}\n                />\n                <AsyncButton\n                  className=\"shrink-0\"\n                  disabled={!embedUrl}\n                  isLoading={isValidatingUrl}\n                  onClick={() => handleEmbed(embedUrl)}\n                  size=\"icon\"\n                >\n                  <CheckIcon className=\"size-4\" />\n                </AsyncButton>\n              </div>\n              {urlError && (\n                <ErrorMessage className=\"text-sm\">{urlError}</ErrorMessage>\n              )}\n            </div>\n          </div>\n        </TabsContent>\n        <TabsContent className=\"h-48\" value=\"media\">\n          <button\n            className=\"flex h-48 w-full cursor-pointer items-center justify-center rounded-md border border-dashed bg-editor-field transition-colors\"\n            onClick={() => setIsGalleryOpen(true)}\n            type=\"button\"\n          >\n            <div className=\"flex flex-col items-center gap-2 text-muted-foreground\">\n              <HugeiconsIcon icon={Album02Icon} />\n              <p className=\"font-medium text-sm\">Click to view your gallery</p>\n            </div>\n          </button>\n        </TabsContent>\n      </Tabs>\n    );\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <div className=\"flex items-center gap-1\">\n        <p className=\"font-medium text-sm leading-none\">Cover Image</p>\n        <FieldInfo text=\"A featured image usually used for the post thumbnail and social media previews (optional)\" />\n      </div>\n      {renderContent()}\n\n      {/* Media Gallery Dialog */}\n      <Dialog onOpenChange={setIsGalleryOpen} open={isGalleryOpen}>\n        <DialogContent\n          className=\"flex max-h-[800px] flex-col overflow-hidden text-clip sm:max-w-4xl\"\n          variant=\"card\"\n        >\n          <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n            <div className=\"flex flex-1 items-center gap-2\">\n              <HugeiconsIcon\n                className=\"text-muted-foreground\"\n                icon={Album02Icon}\n                size={20}\n              />\n              <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n                Media Gallery\n              </DialogTitle>\n            </div>\n            <DialogX />\n          </DialogHeader>\n          <DialogBody className=\"min-h-[400px] p-4\">\n            {isLoadingMedia ? (\n              <div className=\"flex min-h-[360px] items-center justify-center\">\n                <div className=\"flex flex-col items-center justify-center gap-2 text-muted-foreground\">\n                  <CircleNotchIcon className=\"size-6 animate-spin\" />\n                  <p className=\"font-medium text-sm\">Loading media...</p>\n                </div>\n              </div>\n            ) : media && media.length > 0 ? (\n              <div className=\"flex max-h-[500px] flex-col gap-4 overflow-y-auto\">\n                <ul className=\"m-0 grid w-full list-none grid-cols-[repeat(auto-fill,minmax(8.125rem,1fr))] gap-2.5 p-0\">\n                  {media\n                    ?.filter((item) => item.type === \"image\")\n                    .map((item) => (\n                      <li\n                        className=\"group relative size-[8.125rem]\"\n                        key={item.id}\n                      >\n                        <button\n                          className=\"flex h-full w-full items-center justify-center rounded-lg border border-border bg-background p-1 transition-opacity hover:opacity-80\"\n                          onClick={() => handleImageSelect(item.url)}\n                          type=\"button\"\n                        >\n                          <div className=\"flex h-full w-full items-center justify-center overflow-hidden rounded-md border border-border\">\n                            {/* biome-ignore lint: Preview images from media library */}\n                            <img\n                              alt={item.name}\n                              className=\"h-full w-full object-contain\"\n                              src={item.url}\n                            />\n                          </div>\n                        </button>\n                      </li>\n                    ))}\n                </ul>\n                {hasNextPage && (\n                  <div className=\"flex justify-center py-2\">\n                    <Button\n                      disabled={isFetchingNextPage}\n                      onClick={() => fetchNextPage()}\n                      type=\"button\"\n                      variant=\"outline\"\n                    >\n                      {isFetchingNextPage ? (\n                        <>\n                          <CircleNotchIcon className=\"mr-2 size-4 animate-spin\" />\n                          Loading...\n                        </>\n                      ) : (\n                        \"Load More\"\n                      )}\n                    </Button>\n                  </div>\n                )}\n              </div>\n            ) : (\n              <div className=\"flex min-h-[360px] items-center justify-center\">\n                <div className=\"flex flex-col items-center justify-center gap-2 text-muted-foreground\">\n                  <HugeiconsIcon icon={Album02Icon} />\n                  <p className=\"font-medium text-sm\">\n                    Your gallery is empty. Upload some media to get started.\n                  </p>\n                </div>\n              </div>\n            )}\n          </DialogBody>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/custom-fields-section.tsx",
    "content": "\"use client\";\n\nimport { FieldRichTextEditor } from \"@marble/editor\";\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Calendar } from \"@marble/ui/components/calendar\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from \"@marble/ui/components/command\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CalendarDotsIcon,\n  CaretUpDownIcon,\n  CheckIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { format, parseISO } from \"date-fns\";\nimport { useMemo, useState } from \"react\";\nimport { useController, useFormContext } from \"react-hook-form\";\nimport { useEditorData } from \"@/components/editor/editor-data-provider\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport {\n  SUPPORTED_CUSTOM_FIELD_TYPES,\n  validateCustomFieldValue,\n} from \"@/lib/custom-fields\";\nimport type { PostEditorValues } from \"@/lib/validations/post\";\nimport type { CustomField } from \"@/types/fields\";\nimport { FieldInfo } from \"./field-info\";\n\nexport function CustomFieldsSection() {\n  const { fieldDefinitions } = useEditorData();\n\n  const supportedFields = useMemo(\n    () =>\n      fieldDefinitions.filter((field) =>\n        SUPPORTED_CUSTOM_FIELD_TYPES.has(field.type)\n      ),\n    [fieldDefinitions]\n  );\n\n  if (supportedFields.length === 0) {\n    return null;\n  }\n\n  return (\n    <div className=\"grid gap-6\">\n      {supportedFields.map((field) => (\n        <FieldInput field={field} key={field.id} />\n      ))}\n    </div>\n  );\n}\n\nfunction parseMultiselectValue(value: string | null | undefined) {\n  if (!value) {\n    return [];\n  }\n\n  try {\n    const parsed = JSON.parse(value);\n    return Array.isArray(parsed)\n      ? parsed.filter((item): item is string => typeof item === \"string\")\n      : [];\n  } catch {\n    return [];\n  }\n}\n\nfunction getCustomFieldControlId(fieldId: string) {\n  return `cf-${fieldId}`;\n}\n\nfunction getCustomFieldLabelId(fieldId: string) {\n  return `cf-label-${fieldId}`;\n}\n\nfunction FieldLabel({\n  field,\n  targetId,\n}: {\n  field: CustomField;\n  targetId?: string;\n}) {\n  return (\n    <div className=\"flex items-center gap-1\">\n      <Label\n        htmlFor={targetId ?? getCustomFieldControlId(field.id)}\n        id={getCustomFieldLabelId(field.id)}\n      >\n        {field.name}\n      </Label>\n      {field.description ? <FieldInfo text={field.description} /> : null}\n    </div>\n  );\n}\n\nfunction FieldInput({ field }: { field: CustomField }) {\n  const { control } = useFormContext<PostEditorValues>();\n  const {\n    field: formField,\n    fieldState: { error },\n  } = useController({\n    name: `customFields.${field.id}`,\n    control,\n    defaultValue: \"\",\n    rules: {\n      validate: (value) => {\n        const validation = validateCustomFieldValue(field, value);\n        return validation.success ? true : validation.message;\n      },\n    },\n  });\n\n  switch (field.type) {\n    case \"text\":\n      return (\n        <div className=\"grid gap-2\">\n          <FieldLabel field={field} />\n          <Textarea\n            className=\"min-h-[60px] resize-y bg-editor-field\"\n            id={getCustomFieldControlId(field.id)}\n            onBlur={formField.onBlur}\n            onChange={formField.onChange}\n            placeholder={`Enter ${field.name.toLowerCase()}`}\n            ref={formField.ref}\n            value={formField.value ?? \"\"}\n          />\n          {error ? (\n            <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n          ) : null}\n        </div>\n      );\n\n    case \"number\":\n      return (\n        <div className=\"grid gap-2\">\n          <FieldLabel field={field} />\n          <Input\n            className=\"bg-editor-field\"\n            id={getCustomFieldControlId(field.id)}\n            onBlur={formField.onBlur}\n            onChange={formField.onChange}\n            placeholder=\"0\"\n            ref={formField.ref}\n            type=\"number\"\n            value={formField.value ?? \"\"}\n          />\n          {error ? (\n            <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n          ) : null}\n        </div>\n      );\n\n    case \"boolean\":\n      return (\n        <div className=\"grid gap-2\">\n          <div className=\"flex items-center justify-between\">\n            <FieldLabel field={field} />\n            <Switch\n              checked={formField.value === \"true\"}\n              id={getCustomFieldControlId(field.id)}\n              onCheckedChange={(checked) =>\n                formField.onChange(checked ? \"true\" : \"false\")\n              }\n            />\n          </div>\n          {error ? (\n            <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n          ) : null}\n        </div>\n      );\n\n    case \"date\": {\n      const value = formField.value ?? \"\";\n      const dateValue = value ? parseISO(value) : undefined;\n      const isValidDate = dateValue && !Number.isNaN(dateValue.getTime());\n\n      return (\n        <div className=\"grid gap-2\">\n          <FieldLabel field={field} />\n          <Popover>\n            <PopoverTrigger\n              render={\n                <Button\n                  aria-labelledby={getCustomFieldLabelId(field.id)}\n                  className={cn(\n                    \"justify-between bg-editor-field text-left font-normal shadow-none active:scale-100\",\n                    !isValidDate && \"text-muted-foreground\"\n                  )}\n                  id={getCustomFieldControlId(field.id)}\n                  variant=\"outline\"\n                >\n                  {isValidDate ? (\n                    format(dateValue, \"PPP\")\n                  ) : (\n                    <span>Pick a date</span>\n                  )}\n                  <CalendarDotsIcon className=\"text-muted-foreground\" />\n                </Button>\n              }\n            />\n            <PopoverContent className=\"w-auto overflow-hidden p-0\">\n              <Calendar\n                autoFocus\n                captionLayout=\"dropdown\"\n                mode=\"single\"\n                onSelect={(date: Date | undefined) => {\n                  if (date) {\n                    formField.onChange(format(date, \"yyyy-MM-dd\"));\n                    return;\n                  }\n\n                  formField.onChange(\"\");\n                }}\n                selected={isValidDate ? dateValue : undefined}\n              />\n            </PopoverContent>\n          </Popover>\n          {error ? (\n            <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n          ) : null}\n        </div>\n      );\n    }\n\n    case \"richtext\":\n      return (\n        <div className=\"grid gap-2\">\n          <FieldLabel field={field} />\n          <FieldRichTextEditor\n            id={getCustomFieldControlId(field.id)}\n            labelId={getCustomFieldLabelId(field.id)}\n            onBlur={formField.onBlur}\n            onChange={formField.onChange}\n            placeholder={`Write ${field.name.toLowerCase()}`}\n            value={formField.value ?? \"\"}\n          />\n          {error ? (\n            <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n          ) : null}\n        </div>\n      );\n\n    case \"select\":\n      return (\n        <div className=\"grid gap-2\">\n          <FieldLabel field={field} />\n          <Select\n            items={[\n              { label: `Select ${field.name.toLowerCase()}`, value: null },\n              ...field.options.map((option) => ({\n                label: option.label,\n                value: option.value,\n              })),\n            ]}\n            onValueChange={(value) => formField.onChange(value ?? \"\")}\n            value={formField.value || null}\n          >\n            <SelectTrigger\n              aria-labelledby={getCustomFieldLabelId(field.id)}\n              className=\"w-full bg-editor-field shadow-none\"\n              id={getCustomFieldControlId(field.id)}\n            >\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {/* <SelectItem value={null}>\n                {`Select ${field.name.toLowerCase()}`}\n              </SelectItem> */}\n              {field.options.map((option) => (\n                <SelectItem key={option.id} value={option.value}>\n                  {option.label}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n          {error ? (\n            <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n          ) : null}\n        </div>\n      );\n\n    case \"multiselect\":\n      return (\n        <MultiselectField\n          error={error?.message}\n          field={field}\n          onBlur={formField.onBlur}\n          onChange={formField.onChange}\n          value={formField.value ?? \"\"}\n        />\n      );\n\n    default:\n      return null;\n  }\n}\n\nfunction MultiselectField({\n  error,\n  field,\n  onBlur,\n  onChange,\n  value,\n}: {\n  error?: string;\n  field: CustomField;\n  onBlur: () => void;\n  onChange: (value: string) => void;\n  value: string;\n}) {\n  const [open, setOpen] = useState(false);\n  const selectedValues = useMemo(() => parseMultiselectValue(value), [value]);\n  const selectedOptions = useMemo(\n    () =>\n      field.options.filter((option) => selectedValues.includes(option.value)),\n    [field.options, selectedValues]\n  );\n\n  const toggleValue = (optionValue: string) => {\n    const nextValues = selectedValues.includes(optionValue)\n      ? selectedValues.filter((value) => value !== optionValue)\n      : [...selectedValues, optionValue];\n\n    onChange(JSON.stringify(nextValues));\n  };\n\n  const removeValue = (optionValue: string) => {\n    onChange(\n      JSON.stringify(selectedValues.filter((value) => value !== optionValue))\n    );\n  };\n\n  return (\n    <div className=\"grid gap-2\">\n      <FieldLabel field={field} />\n      <Popover\n        onOpenChange={(nextOpen) => {\n          setOpen(nextOpen);\n          if (!nextOpen) {\n            onBlur();\n          }\n        }}\n        open={open}\n      >\n        <PopoverTrigger\n          nativeButton={false}\n          render={\n            // biome-ignore lint/a11y/useAriaPropsSupportedByRole: <>\n            <div\n              aria-haspopup=\"dialog\"\n              aria-labelledby={getCustomFieldLabelId(field.id)}\n              className=\"relative h-auto min-h-9 w-full cursor-pointer rounded-md border bg-editor-field px-3 py-2 text-sm\"\n              id={getCustomFieldControlId(field.id)}\n            >\n              <div className=\"flex items-center justify-between gap-2\">\n                <ul className=\"flex flex-wrap gap-1\">\n                  {selectedOptions.length === 0 ? (\n                    <li className=\"text-muted-foreground\">\n                      {`Select ${field.name.toLowerCase()}`}\n                    </li>\n                  ) : (\n                    selectedOptions.map((option) => (\n                      <li key={option.id}>\n                        <Badge\n                          className=\"bg-background font-normal\"\n                          variant=\"outline\"\n                        >\n                          {option.label}\n                          <button\n                            className=\"ml-1 h-auto p-0 hover:bg-transparent\"\n                            onClick={(event) => {\n                              event.stopPropagation();\n                              removeValue(option.value);\n                            }}\n                            type=\"button\"\n                          >\n                            <XIcon className=\"size-2.5 p-0\" />\n                          </button>\n                        </Badge>\n                      </li>\n                    ))\n                  )}\n                </ul>\n                <CaretUpDownIcon className=\"size-4 shrink-0 opacity-50\" />\n              </div>\n            </div>\n          }\n          tabIndex={0}\n        />\n        <PopoverContent align=\"start\" className=\"min-w-[350px] p-0\">\n          <Command className=\"w-full\">\n            <CommandList>\n              <CommandEmpty>No options found.</CommandEmpty>\n              <CommandGroup>\n                {field.options.map((option) => {\n                  const isSelected = selectedValues.includes(option.value);\n\n                  return (\n                    <CommandItem\n                      id={option.id}\n                      key={option.id}\n                      onSelect={() => toggleValue(option.value)}\n                    >\n                      {option.label}\n                      <CheckIcon\n                        className={cn(\n                          \"ml-auto size-4\",\n                          isSelected ? \"opacity-100\" : \"opacity-0\"\n                        )}\n                      />\n                    </CommandItem>\n                  );\n                })}\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n      {error ? <ErrorMessage className=\"text-sm\">{error}</ErrorMessage> : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/description-field.tsx",
    "content": "\"use client\";\n\nimport { Label } from \"@marble/ui/components/label\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface DescriptionFieldProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nexport function DescriptionField<TFieldValues extends FieldValues>({\n  control,\n}: DescriptionFieldProps<TFieldValues>) {\n  const {\n    field,\n    fieldState: { error },\n  } = useController({\n    name: \"description\" as Path<TFieldValues>,\n    control,\n  });\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"description\">Description</Label>\n        <FieldInfo text=\"A short description of your post recommended to be 155 characters or less\" />\n      </div>\n\n      <Textarea\n        id=\"description\"\n        {...field}\n        className=\"col-span-3 bg-editor-field\"\n        placeholder=\"A short description of your post\"\n      />\n      {error && (\n        <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/featured-field.tsx",
    "content": "\"use client\";\n\nimport { Label } from \"@marble/ui/components/label\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface FeaturedFieldProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nexport function FeaturedField<TFieldValues extends FieldValues>({\n  control,\n}: FeaturedFieldProps<TFieldValues>) {\n  const {\n    field: { onChange, value },\n  } = useController({\n    name: \"featured\" as Path<TFieldValues>,\n    control,\n  });\n\n  return (\n    <div className=\"flex items-center justify-between gap-2\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"featured\">Featured</Label>\n        <FieldInfo text=\"Whether your post is featured or not.\" />\n      </div>\n\n      <Switch\n        checked={value === true}\n        id=\"featured\"\n        onCheckedChange={() => onChange(!value)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/field-info.tsx",
    "content": "import {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { InfoIcon } from \"@phosphor-icons/react\";\n\ninterface FieldInfoProps {\n  /**\n   * The information text to display in the tooltip\n   */\n  text: string;\n  /**\n   * Additional className for the icon\n   */\n  className?: string;\n}\n\nexport function FieldInfo({\n  text,\n  className = \"size-4 text-gray-400\",\n}: FieldInfoProps) {\n  return (\n    <Tooltip>\n      <TooltipTrigger render={<InfoIcon className={className} />} />\n      <TooltipContent>\n        <p className=\"max-w-64 text-balance text-xs\">{text}</p>\n      </TooltipContent>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/publish-date-field.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { Calendar } from \"@marble/ui/components/calendar\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CalendarDotsIcon } from \"@phosphor-icons/react\";\nimport { format } from \"date-fns\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface PublishDateFieldProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nfunction toUTCMidnight(date: Date) {\n  return new Date(\n    Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())\n  );\n}\n\nfunction utcToLocalDate(date: Date) {\n  return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());\n}\n\nexport function PublishDateField<TFieldValues extends FieldValues>({\n  control,\n}: PublishDateFieldProps<TFieldValues>) {\n  const {\n    field: { onChange, value },\n    fieldState: { error },\n  } = useController({\n    name: \"publishedAt\" as Path<TFieldValues>,\n    control,\n  });\n\n  const displayDate = value ? utcToLocalDate(value) : undefined;\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"publishedAt\">Published On</Label>\n        <FieldInfo text=\"The date your post was published. This is set by default but you can change it to any date.\" />\n      </div>\n      <Popover>\n        <PopoverTrigger\n          render={\n            <Button\n              className={cn(\n                \"justify-between bg-editor-field text-left font-normal shadow-none active:scale-100\",\n                !value && \"text-muted-foreground\"\n              )}\n              variant=\"outline\"\n            >\n              {displayDate ? (\n                format(displayDate, \"PPP\")\n              ) : (\n                <span>Pick a date</span>\n              )}\n              <CalendarDotsIcon className=\"text-muted-foreground\" />\n            </Button>\n          }\n        />\n        <PopoverContent className=\"w-auto overflow-hidden p-0\">\n          <Calendar\n            autoFocus\n            captionLayout=\"dropdown\"\n            mode=\"single\"\n            onSelect={(date: Date | undefined) => {\n              if (date) {\n                onChange(toUTCMidnight(date));\n              }\n            }}\n            selected={displayDate}\n          />\n        </PopoverContent>\n      </Popover>\n      {error && (\n        <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/slug-field.tsx",
    "content": "\"use client\";\n\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport type { ChangeEvent } from \"react\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { generateSlug } from \"@/utils/string\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface SlugFieldProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nexport function SlugField<TFieldValues extends FieldValues>({\n  control,\n}: SlugFieldProps<TFieldValues>) {\n  const {\n    field,\n    fieldState: { error },\n  } = useController({\n    name: \"slug\" as Path<TFieldValues>,\n    control,\n  });\n\n  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {\n    field.onChange(generateSlug(event.target.value, { trimEdges: false }));\n  };\n\n  return (\n    <div className=\"flex flex-col gap-3\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"slug\">Slug</Label>\n        <FieldInfo text=\"A url friendly string that can be used to access your post.\" />\n      </div>\n      <Input\n        {...field}\n        className=\"col-span-3 bg-editor-field\"\n        id=\"slug\"\n        onChange={handleChange}\n        placeholder=\"my-awesome-post\"\n      />\n      {error && (\n        <ErrorMessage className=\"text-sm\">{error.message}</ErrorMessage>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/status-field.tsx",
    "content": "\"use client\";\n\nimport { Label } from \"@marble/ui/components/label\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  useController,\n} from \"react-hook-form\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface StatusFieldProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n}\n\nexport function StatusField<TFieldValues extends FieldValues>({\n  control,\n}: StatusFieldProps<TFieldValues>) {\n  const {\n    field: { onChange, value },\n  } = useController({\n    name: \"status\" as Path<TFieldValues>,\n    control,\n  });\n\n  return (\n    <div className=\"flex items-center justify-between gap-2\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"status\">Published</Label>\n        <FieldInfo text=\"Whether your post is published or saved as a draft.\" />\n      </div>\n\n      <Switch\n        checked={value === \"published\"}\n        id=\"status\"\n        onCheckedChange={() =>\n          onChange(value === \"published\" ? \"draft\" : \"published\")\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/fields/tag-selector.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@marble/ui/components/command\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CaretUpDownIcon,\n  CheckIcon,\n  PlusIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { useMemo, useState } from \"react\";\nimport {\n  type Control,\n  type FieldValues,\n  type Path,\n  type PathValue,\n  useController,\n} from \"react-hook-form\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { TagModal } from \"../../tags/tag-modals\";\nimport { ErrorMessage } from \"../../ui/error-message\";\nimport { FieldInfo } from \"./field-info\";\n\ninterface Option {\n  id: string;\n  name: string;\n  slug: string;\n}\n\ninterface TagResponse {\n  id: string;\n  name: string;\n  slug: string;\n}\n\ninterface MultiSelectPopoverProps<TFieldValues extends FieldValues> {\n  control: Control<TFieldValues>;\n  placeholder?: string;\n  isOpen?: boolean;\n  setIsOpen?: (open: boolean) => void;\n  defaultTags?: string[];\n}\n\nconst EMPTY_TAGS: string[] = [];\n\nexport const TagSelector = <TFieldValues extends FieldValues>({\n  control,\n  placeholder,\n  isOpen,\n  setIsOpen,\n  defaultTags = EMPTY_TAGS,\n}: MultiSelectPopoverProps<TFieldValues>) => {\n  const {\n    field: { onChange, value },\n    fieldState: { error },\n  } = useController({\n    name: \"tags\" as Path<TFieldValues>,\n    control,\n    defaultValue: defaultTags as PathValue<TFieldValues, Path<TFieldValues>>,\n  });\n  const selectedTagIds = (value as string[] | undefined) ?? [];\n  const [openTagModal, setOpenTagModal] = useState(false);\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const { data: tags = [], isLoading: isLoadingTags } = useQuery({\n    // biome-ignore lint/style/noNonNullAssertion: <>\n    queryKey: QUERY_KEYS.TAGS(workspaceId!),\n    staleTime: 1000 * 60 * 60,\n    queryFn: async () => {\n      const res = await fetch(\"/api/tags\");\n      if (!res.ok) {\n        throw new Error(\"Failed to fetch tags\");\n      }\n      const data: TagResponse[] = await res.json();\n      return data;\n    },\n    enabled: !!workspaceId,\n  });\n\n  // Compute selected tags directly without useEffect\n  const selected = useMemo(() => {\n    if (tags.length > 0 && selectedTagIds.length > 0) {\n      return tags.filter((opt) => selectedTagIds.includes(opt.id));\n    }\n    return [];\n  }, [selectedTagIds, tags]);\n\n  const addTag = (tagToAdd: string) => {\n    if (selectedTagIds.includes(tagToAdd)) {\n      return;\n    }\n    const newValue = [...selectedTagIds, tagToAdd];\n    onChange(newValue as PathValue<TFieldValues, Path<TFieldValues>>);\n  };\n\n  const handleRemoveTag = (tagToDelete: string) => {\n    const newValue = selectedTagIds.filter((tag) => tag !== tagToDelete);\n    onChange(newValue as PathValue<TFieldValues, Path<TFieldValues>>);\n  };\n\n  const handleTagCreated = async (newTag: Option) => {\n    if (!workspaceId) {\n      return;\n    }\n\n    // Optimistically update React Query cache\n    queryClient.setQueryData(\n      QUERY_KEYS.TAGS(workspaceId),\n      (oldData: TagResponse[] | undefined) =>\n        oldData ? [...oldData, newTag] : [newTag]\n    );\n\n    // Also invalidate to refetch from server\n    queryClient.invalidateQueries({ queryKey: QUERY_KEYS.TAGS(workspaceId) });\n\n    const newValue = [...(value || []), newTag.id];\n    onChange(newValue);\n  };\n\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <div className=\"flex items-center gap-1\">\n        <Label htmlFor=\"tags\">Tags</Label>\n        <FieldInfo text=\"Your articles can have multiple tags, we will use this to determine related articles.\" />\n      </div>\n      <Popover onOpenChange={setIsOpen} open={isOpen}>\n        <PopoverTrigger\n          nativeButton={false}\n          render={\n            <div className=\"relative h-auto min-h-9 w-full cursor-pointer rounded-md border bg-editor-field px-3 py-2 text-sm\">\n              <div className=\"flex items-center justify-between gap-2\">\n                <ul className=\"flex flex-wrap gap-1\">\n                  {selected.length === 0 && (\n                    <li className=\"text-muted-foreground\">\n                      {placeholder || \"Select some tags\"}\n                    </li>\n                  )}\n                  {selected.map((item) => (\n                    <li key={item.id}>\n                      <Badge\n                        className=\"bg-background font-normal\"\n                        variant=\"outline\"\n                      >\n                        {item.name}\n                        <button\n                          className=\"ml-1 h-auto p-0 hover:bg-transparent\"\n                          onClick={(e) => {\n                            e.stopPropagation();\n                            handleRemoveTag(item.id);\n                          }}\n                          type=\"button\"\n                        >\n                          <XIcon className=\"size-2.5 p-0\" />\n                        </button>\n                      </Badge>\n                    </li>\n                  ))}\n                </ul>\n                <CaretUpDownIcon className=\"size-4 shrink-0 opacity-50\" />\n              </div>\n            </div>\n          }\n        />\n        {error && <ErrorMessage>{error.message}</ErrorMessage>}\n        <PopoverContent align=\"start\" className=\"min-w-[350.67px] p-0\">\n          <Command className=\"w-full\">\n            <CommandInput placeholder=\"Search tags...\" />\n            <CommandList>\n              <CommandEmpty>No results found.</CommandEmpty>\n              <div className=\"flex items-center justify-between gap-1 bg-background px-2 pt-2 pb-1 font-normal text-xs\">\n                <span className=\"text-muted-foreground text-xs\">\n                  {isLoadingTags\n                    ? \"Loading tags...\"\n                    : tags.length === 0\n                      ? \"No tags\"\n                      : \"Tags\"}\n                </span>\n                <button\n                  className=\"flex items-center gap-1 p-1 hover:bg-accent\"\n                  onClick={() => setOpenTagModal(true)}\n                  type=\"button\"\n                >\n                  <PlusIcon className=\"size-4 text-muted-foreground\" />\n                  <span className=\"sr-only\">Add a new tag</span>\n                </button>\n              </div>\n              {tags.length > 0 && (\n                <CommandGroup>\n                  {tags.map((option) => (\n                    <CommandItem\n                      id={option.id}\n                      key={option.id}\n                      onSelect={() => addTag(option.id)}\n                    >\n                      {option.name}\n                      <CheckIcon\n                        className={cn(\n                          \"ml-auto h-4 w-4\",\n                          selected.some((item) => item.id === option.id)\n                            ? \"opacity-100\"\n                            : \"opacity-0\"\n                        )}\n                      />\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              )}\n              {tags.length > 0 && <CommandSeparator />}\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n\n      <TagModal\n        mode=\"create\"\n        onTagCreated={handleTagCreated}\n        open={openTagModal}\n        setOpen={setOpenTagModal}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/editor/footer/metadata-footer.tsx",
    "content": "import { useEditorData } from \"@/components/editor/editor-data-provider\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\n\ninterface MetadataFooterProps {\n  isSubmitting: boolean;\n}\n\nexport function MetadataFooter({ isSubmitting }: MetadataFooterProps) {\n  const { hasUnsavedChanges, mode, submit } = useEditorData();\n\n  return (\n    <AsyncButton\n      className=\"w-full\"\n      disabled={!hasUnsavedChanges}\n      isLoading={isSubmitting}\n      onClick={submit}\n      type=\"button\"\n    >\n      {mode === \"create\" ? \"Save\" : \"Update\"}\n    </AsyncButton>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/link-selector.tsx",
    "content": "import { useCurrentEditor } from \"@marble/editor\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { Separator } from \"@marble/ui/components/separator\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CheckIcon, LinkSimpleIcon, TrashIcon } from \"@phosphor-icons/react\";\nimport { useRef, useState } from \"react\";\n\nexport function isValidUrl(url: string) {\n  try {\n    new URL(url);\n    return true;\n  } catch (_e) {\n    return false;\n  }\n}\n\nexport function getUrlFromString(str: string) {\n  if (isValidUrl(str)) {\n    return str;\n  }\n  try {\n    if (str.includes(\".\") && !str.includes(\" \")) {\n      return new URL(`https://${str}`).toString();\n    }\n  } catch (_e) {\n    return null;\n  }\n}\n\ninterface LinkSelectorProps {\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const { editor } = useCurrentEditor();\n  const [openInNewTab, setOpenInNewTab] = useState(true);\n  const [inputValue, setInputValue] = useState(\"\");\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <Popover modal={true} onOpenChange={onOpenChange} open={open}>\n      <PopoverTrigger\n        render={\n          <Button\n            className={cn(\"gap-2 border-none\", {\n              \"text-emerald-500\": editor.isActive(\"link\"),\n            })}\n            size=\"icon\"\n            variant=\"ghost\"\n          >\n            <LinkSimpleIcon className=\"size-4\" />\n          </Button>\n        }\n      />\n      <PopoverContent align=\"start\" className=\"w-60 p-0\" sideOffset={10}>\n        <form\n          className=\"flex flex-col p-1\"\n          onSubmit={(e) => {\n            e.preventDefault();\n            const url = getUrlFromString(inputValue);\n            if (url) {\n              editor\n                .chain()\n                .focus()\n                .setLink({\n                  href: url,\n                  target: openInNewTab ? \"_blank\" : \"_self\",\n                })\n                .run();\n            }\n          }}\n        >\n          <div className=\"mb-3 flex\">\n            <input\n              className=\"flex-1 bg-background p-1 text-sm outline-hidden\"\n              defaultValue={editor.getAttributes(\"link\").href || \"\"}\n              onChange={({ target }) => setInputValue(target.value)}\n              placeholder=\"Paste or type link\"\n              ref={inputRef}\n              type=\"text\"\n            />\n            {editor.getAttributes(\"link\").href ? (\n              <Button\n                className=\"flex items-center rounded-sm text-destructive transition-all hover:bg-destructive hover:text-white\"\n                onClick={() => {\n                  editor.chain().focus().unsetLink().run();\n                }}\n                size=\"icon\"\n                type=\"button\"\n                variant=\"outline\"\n              >\n                <TrashIcon className=\"size-4\" />\n              </Button>\n            ) : (\n              <Button\n                className=\"size-8 shrink-0\"\n                onClick={() => {\n                  const url = getUrlFromString(inputRef.current?.value || \"\");\n                  if (url) {\n                    editor\n                      .chain()\n                      .focus()\n                      .setLink({\n                        href: url,\n                        target: openInNewTab ? \"_blank\" : \"_self\",\n                      })\n                      .run();\n                  }\n                }}\n                size=\"icon\"\n                type=\"button\"\n                variant=\"outline\"\n              >\n                <CheckIcon className=\"size-4\" />\n              </Button>\n            )}\n          </div>\n          <Separator className=\"mb-3\" />\n          <div className=\"flex items-center space-x-2 p-2\">\n            <Switch\n              aria-labelledby=\"new-tab\"\n              checked={openInNewTab}\n              id=\"new-tab\"\n              onCheckedChange={setOpenInNewTab}\n            />\n            <Label className=\"text-muted-foreground text-xs\" htmlFor=\"new-tab\">\n              Open in new tab\n            </Label>\n          </div>\n        </form>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/editor/share-modal.tsx",
    "content": "import { Link02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { TimerIcon } from \"@phosphor-icons/react\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport { differenceInHours, differenceInMinutes, isBefore } from \"date-fns\";\nimport { useState } from \"react\";\nimport { UpgradeModal } from \"@/components/billing/upgrade-modal\";\nimport { usePlan } from \"@/hooks/use-plan\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport { CopyButton } from \"../ui/copy-button\";\n\ninterface ShareModalProps {\n  postId: string;\n}\n\nexport function ShareModal({ postId }: ShareModalProps) {\n  const [shareLink, setShareLink] = useState<string | null>(null);\n  const [expiresAt, setExpiresAt] = useState<Date | null>(null);\n  const [date, setDate] = useState<Date | undefined>(new Date());\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n  const [showShareDialog, setShowShareDialog] = useState(false);\n\n  const { isHobbyPlan } = usePlan();\n\n  const { mutate: generateShareLink, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await fetch(\"/api/share\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ postId, expiresAt: date }),\n      });\n\n      if (!res.ok) {\n        const error = await res.json();\n        throw new Error(error.error || \"Failed to generate share link\");\n      }\n\n      const data = await res.json();\n      setShareLink(data.shareLink);\n      setExpiresAt(new Date(data.expiresAt));\n      return data;\n    },\n    onSuccess: () => {\n      toast.success(\"Link generated successfully\");\n    },\n    onError: (error) => {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to generate share link\"\n      );\n    },\n  });\n\n  const formatExpiration = (date: Date) => {\n    const now = new Date();\n    if (isBefore(date, now)) {\n      return \"Expired\";\n    }\n    const hours = differenceInHours(date, now);\n    if (hours >= 1) {\n      return `Expires in ${hours} hour${hours === 1 ? \"\" : \"s\"}`;\n    }\n    const minutes = differenceInMinutes(date, now);\n    if (minutes >= 1) {\n      return `Expires in ${minutes} minute${minutes === 1 ? \"\" : \"s\"}`;\n    }\n    return \"Expires in less than a minute\";\n  };\n\n  const handleShareClick = () => {\n    if (isHobbyPlan) {\n      setShowUpgradeModal(true);\n    } else {\n      setShowShareDialog(true);\n    }\n  };\n\n  return (\n    <>\n      <Button\n        aria-label=\"Share draft link\"\n        onClick={handleShareClick}\n        size=\"icon-sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <HugeiconsIcon icon={Link02Icon} />\n      </Button>\n\n      {/* Share Dialog - only for Pro users */}\n      <Dialog onOpenChange={setShowShareDialog} open={showShareDialog}>\n        <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n          <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n            <div className=\"flex flex-1 items-center gap-2\">\n              <HugeiconsIcon\n                className=\"text-muted-foreground\"\n                icon={Link02Icon}\n                size={18}\n                strokeWidth={2}\n              />\n              <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n                Share Draft link\n              </DialogTitle>\n            </div>\n            <DialogX />\n          </DialogHeader>\n          <DialogDescription className=\"sr-only\">\n            Anyone with this link will be able to view your draft.\n          </DialogDescription>\n          <DialogBody>\n            <div className=\"flex flex-col gap-3\">\n              <div className=\"flex w-full items-center gap-2\">\n                <Label className=\"sr-only\" htmlFor=\"link\">\n                  Link\n                </Label>\n                <Input\n                  id=\"link\"\n                  placeholder=\"your share link will appear here\"\n                  readOnly\n                  value={shareLink || \"\"}\n                />\n                <CopyButton\n                  className=\"shadow-none\"\n                  disabled={!shareLink}\n                  textToCopy={shareLink || \"\"}\n                  toastMessage=\"Link copied to clipboard.\"\n                />\n              </div>\n              {expiresAt && (\n                <Badge variant=\"pending\">\n                  <TimerIcon className=\"size-2.5\" />{\" \"}\n                  {formatExpiration(expiresAt)}\n                </Badge>\n              )}\n              <DialogFooter>\n                <DialogClose size=\"sm\">Close</DialogClose>\n                <AsyncButton\n                  disabled={isPending}\n                  isLoading={isPending}\n                  onClick={() => generateShareLink()}\n                  size=\"sm\"\n                  type=\"button\"\n                >\n                  Generate\n                </AsyncButton>\n              </DialogFooter>\n            </div>\n          </DialogBody>\n        </DialogContent>\n      </Dialog>\n\n      {/* Upgrade Modal - for free users */}\n      <UpgradeModal\n        feature=\"share-drafts\"\n        isOpen={showUpgradeModal}\n        onClose={() => setShowUpgradeModal(false)}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/tabs/analysis-tab.tsx",
    "content": "\"use client\";\n\nimport { useCurrentEditor } from \"@marble/editor\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Separator } from \"@marble/ui/components/separator\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { ArrowClockwiseIcon, InfoIcon } from \"@phosphor-icons/react\";\nimport { useCallback, useSyncExternalStore } from \"react\";\nimport { useReadability } from \"@/hooks/use-readability\";\nimport { Gauge } from \"../../ui/gauge\";\nimport { HiddenScrollbar } from \"../../ui/hidden-scrollbar\";\nimport type { ReadabilitySuggestion } from \"../ai/readability-suggestions\";\nimport { ReadabilitySuggestions } from \"../ai/readability-suggestions\";\n\ninterface AnalysisTabProps {\n  aiSuggestions?: ReadabilitySuggestion[];\n  aiLoading?: boolean;\n  onRefreshAi?: () => void;\n}\n\nexport function AnalysisTab({\n  aiSuggestions,\n  aiLoading,\n  onRefreshAi,\n}: AnalysisTabProps) {\n  const { editor } = useCurrentEditor();\n\n  const subscribe = useCallback(\n    (callback: () => void) => {\n      if (!editor) {\n        return () => {\n          return;\n        };\n      }\n      editor.on(\"update\", callback);\n      editor.on(\"create\", callback);\n      return () => {\n        editor.off(\"update\", callback);\n        editor.off(\"create\", callback);\n      };\n    },\n    [editor]\n  );\n\n  const editorText = useSyncExternalStore(\n    subscribe,\n    () => editor?.getText() ?? \"\",\n    () => \"\"\n  );\n\n  const textMetrics = useReadability({ editor, text: editorText });\n\n  return (\n    <HiddenScrollbar className=\"h-full px-6\">\n      <section className=\"grid gap-6 pt-4 pb-5\">\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"space-y-2\">\n            <h4 className=\"font-medium text-sm\">Readability</h4>\n            <div className=\"flex items-center justify-center\">\n              <Gauge\n                label=\"Score\"\n                size={200}\n                value={textMetrics.readabilityScore}\n              />\n            </div>\n            {textMetrics.wordCount > 0 && (\n              <div className=\"space-y-1\">\n                <h5 className=\"font-medium text-sm\">Feedback</h5>\n                <p className=\"text-muted-foreground text-xs\">\n                  <span className=\"font-medium\">\n                    {textMetrics.readabilityLevel.level}:\n                  </span>{\" \"}\n                  {textMetrics.readabilityLevel.description}\n                </p>\n              </div>\n            )}\n          </div>\n\n          <Separator />\n\n          <div className=\"space-y-3\">\n            <h4 className=\"font-medium text-sm\">Text Statistics</h4>\n            <div className=\"grid grid-cols-2 gap-3 text-sm\">\n              <div className=\"space-y-1\">\n                <p className=\"text-muted-foreground\">Words</p>\n                <p className=\"font-medium\">{textMetrics.wordCount}</p>\n              </div>\n              <div className=\"space-y-1\">\n                <p className=\"text-muted-foreground\">Sentences</p>\n                <p className=\"font-medium\">{textMetrics.sentenceCount}</p>\n              </div>\n              <div className=\"space-y-1\">\n                <p className=\"text-muted-foreground\">Words per Sentence</p>\n                <p className=\"font-medium\">{textMetrics.wordsPerSentence}</p>\n              </div>\n              <div className=\"space-y-1\">\n                <p className=\"text-muted-foreground\">Reading Time</p>\n                <p className=\"font-medium\">\n                  {textMetrics.readingTime.toFixed(0)} minutes\n                </p>\n              </div>\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"group space-y-3\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex items-center gap-1\">\n                <h4 className=\"font-medium text-sm\">\n                  {textMetrics.wordCount === 0\n                    ? \"Getting Started\"\n                    : \"Suggestions\"}\n                </h4>\n                {textMetrics.wordCount > 0 ? (\n                  <Tooltip>\n                    <TooltipTrigger\n                      render={\n                        <InfoIcon\n                          aria-label=\"AI generated\"\n                          className=\"h-3.5 w-3.5 cursor-help text-muted-foreground\"\n                        />\n                      }\n                    />\n                    <TooltipContent>\n                      <p className=\"text-xs\">\n                        These suggestions are AI-generated\n                      </p>\n                    </TooltipContent>\n                  </Tooltip>\n                ) : null}\n              </div>\n              <Button\n                aria-label=\"Refresh suggestions\"\n                className=\"h-7 w-7 cursor-pointer\"\n                disabled={Boolean(aiLoading)}\n                onClick={onRefreshAi}\n                size=\"icon\"\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <ArrowClockwiseIcon\n                  className={aiLoading ? \"h-4 w-4 animate-spin\" : \"h-4 w-4\"}\n                />\n              </Button>\n            </div>\n            <ReadabilitySuggestions\n              editor={editor ?? null}\n              isLoading={aiLoading}\n              suggestions={aiSuggestions ?? []}\n            />\n          </div>\n        </div>\n      </section>\n    </HiddenScrollbar>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/tabs/metadata-tab.tsx",
    "content": "\"use client\";\n\nimport { Separator } from \"@marble/ui/components/separator\";\nimport { useFormContext } from \"react-hook-form\";\nimport type { PostEditorValues } from \"@/lib/validations/post\";\nimport { HiddenScrollbar } from \"../../ui/hidden-scrollbar\";\nimport { AuthorSelector } from \"../fields/author-selector\";\nimport { CategorySelector } from \"../fields/category-selector\";\nimport { CoverImageSelector } from \"../fields/cover-image-selector\";\nimport { CustomFieldsSection } from \"../fields/custom-fields-section\";\nimport { DescriptionField } from \"../fields/description-field\";\nimport { FeaturedField } from \"../fields/featured-field\";\nimport { PublishDateField } from \"../fields/publish-date-field\";\nimport { SlugField } from \"../fields/slug-field\";\nimport { StatusField } from \"../fields/status-field\";\nimport { TagSelector } from \"../fields/tag-selector\";\n\ninterface MetadataTabProps {\n  initialAuthors?: string[];\n  tags?: string[];\n}\n\nexport function MetadataTab({ initialAuthors, tags }: MetadataTabProps) {\n  \"use no memo\";\n  const { control } = useFormContext<PostEditorValues>();\n  return (\n    <HiddenScrollbar className=\"h-full px-6\">\n      <section className=\"grid gap-6 pt-4 pb-5\">\n        <StatusField control={control} />\n\n        <FeaturedField control={control} />\n\n        <Separator className=\"flex\" orientation=\"horizontal\" />\n\n        <CoverImageSelector control={control} />\n\n        <DescriptionField control={control} />\n\n        <SlugField control={control} />\n\n        <AuthorSelector\n          control={control}\n          defaultAuthors={initialAuthors || []}\n        />\n\n        <TagSelector control={control} defaultTags={tags || []} />\n\n        <CategorySelector control={control} />\n\n        <PublishDateField control={control} />\n\n        <CustomFieldsSection />\n      </section>\n    </HiddenScrollbar>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/editor/textarea-autosize.tsx",
    "content": "\"use client\";\n\nimport type { KeyboardEvent } from \"react\";\nimport { useState } from \"react\";\n\nimport type { TextareaAutosizeProps } from \"react-textarea-autosize\";\nimport ReactTextareaAutosize from \"react-textarea-autosize\";\n\nimport { useIsomorphicLayoutEffect } from \"@/hooks/use-isomorphic-layout-effect\";\n\ntype ExtendedTextareaAutosizeProps = TextareaAutosizeProps & {\n  onEnterPress?: () => void;\n};\n\nexport function TextareaAutosize({\n  onEnterPress,\n  onKeyDown,\n  ...props\n}: ExtendedTextareaAutosizeProps) {\n  const [isRerendered, setIsRerendered] = useState(false);\n\n  useIsomorphicLayoutEffect(() => setIsRerendered(true), []);\n\n  function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n    if (event.key === \"Enter\" && !event.shiftKey) {\n      event.preventDefault();\n      onEnterPress?.();\n    }\n    onKeyDown?.(event);\n  }\n\n  return isRerendered ? (\n    <ReactTextareaAutosize onKeyDown={handleKeyDown} {...props} />\n  ) : null;\n}\n\nTextareaAutosize.displayName = \"TextareaAutosize\";\n"
  },
  {
    "path": "apps/cms/src/components/fields/create-custom-field.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n} from \"@marble/ui/components/sheet\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { PlusIcon } from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { useFieldArray, useForm, useWatch } from \"react-hook-form\";\nimport { FieldOptionsInput } from \"@/components/fields/field-options-input\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type CustomFieldFormValues,\n  customFieldSchema,\n} from \"@/lib/validations/fields\";\n\nconst typeOptions = [\n  { label: \"Text\", value: \"text\" },\n  { label: \"Number\", value: \"number\" },\n  { label: \"Boolean\", value: \"boolean\" },\n  { label: \"Date\", value: \"date\" },\n  { label: \"Rich Text\", value: \"richtext\" },\n  { label: \"Select\", value: \"select\" },\n  { label: \"Multi Select\", value: \"multiselect\" },\n];\n\nfunction toSnakeCase(str: string): string {\n  return str\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"_\")\n    .replace(/^_|_$/g, \"\");\n}\n\ninterface CreateCustomFieldSheetProps {\n  children?: React.ReactNode;\n}\n\nfunction CreateCustomFieldSheet({ children }: CreateCustomFieldSheetProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const params = useParams<{ workspace: string }>();\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n  const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    reset,\n    formState: { errors },\n  } = useForm<CustomFieldFormValues>({\n    resolver: zodResolver(customFieldSchema),\n    defaultValues: {\n      name: \"\",\n      description: \"\",\n      key: \"\",\n      type: \"text\",\n      options: [],\n    },\n  });\n\n  const watchedType = useWatch({ control, name: \"type\" });\n  const showsOptions =\n    watchedType === \"select\" || watchedType === \"multiselect\";\n  const { fields, append, remove } = useFieldArray({\n    control,\n    name: \"options\",\n  });\n\n  const router = useRouter();\n\n  const { mutate: createField, isPending: isCreating } = useMutation({\n    mutationFn: (data: CustomFieldFormValues) =>\n      fetch(\"/api/fields\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(data),\n      }).then(async (res) => {\n        if (!res.ok) {\n          const err = await res.json();\n          throw new Error(err.error || \"Failed to create field\");\n        }\n        return res.json();\n      }),\n    onSuccess: () => {\n      toast.success(\"Custom field created\");\n      reset();\n      setKeyManuallyEdited(false);\n      setIsOpen(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.CUSTOM_FIELDS(workspaceId),\n        });\n      }\n      if (params.workspace) {\n        queryClient.invalidateQueries({\n          queryKey: [\"editor-bootstrap\", params.workspace],\n        });\n      }\n      router.refresh();\n    },\n    onError: (error) => {\n      toast.error(error.message || \"Failed to create field\");\n    },\n  });\n\n  const onSubmit = (data: CustomFieldFormValues) => {\n    createField(data);\n  };\n\n  return (\n    <Sheet\n      onOpenChange={(open) => {\n        setIsOpen(open);\n        if (!open) {\n          reset();\n          setKeyManuallyEdited(false);\n        }\n      }}\n      open={isOpen}\n    >\n      <SheetTrigger\n        render={\n          (children as React.ReactElement) || (\n            <Button>\n              <PlusIcon className=\"mr-2 size-4\" />\n              New Field\n            </Button>\n          )\n        }\n      />\n      <SheetContent className=\"overflow-y-auto\">\n        <SheetHeader className=\"p-6\">\n          <SheetTitle className=\"font-medium text-xl\">\n            New Custom Field\n          </SheetTitle>\n          <SheetDescription className=\"text-muted-foreground text-sm\">\n            Custom fields extend the default post schema. They will be available\n            on every post in your workspace.\n          </SheetDescription>\n        </SheetHeader>\n        <form\n          className=\"flex h-full flex-col justify-between\"\n          onSubmit={handleSubmit(onSubmit)}\n        >\n          <div className=\"mb-5 grid flex-1 auto-rows-min gap-6 px-6\">\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"cf-name\">Name</Label>\n              <Input\n                id=\"cf-name\"\n                placeholder=\"Release Date\"\n                {...register(\"name\", {\n                  onChange: (e) => {\n                    if (!keyManuallyEdited) {\n                      setValue(\"key\", toSnakeCase(e.target.value));\n                    }\n                  },\n                })}\n              />\n              {errors.name && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.name.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"cf-key\">Key</Label>\n              <Input\n                id=\"cf-key\"\n                placeholder=\"release_date\"\n                {...register(\"key\", {\n                  onChange: () => {\n                    setKeyManuallyEdited(true);\n                  },\n                })}\n              />\n              <p className=\"text-muted-foreground text-xs\">\n                Used as the identifier when storing the value. Must be unique\n                per workspace. Only lowercase letters, numbers, and underscores.\n              </p>\n              {errors.key && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.key.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"cf-description\">Description</Label>\n              <Textarea\n                id=\"cf-description\"\n                placeholder=\"Date of release of the post\"\n                {...register(\"description\")}\n              />\n              <p className=\"text-muted-foreground text-xs\">\n                Optional guidance for editors. Useful for tooltips or helper\n                text in the sidebar.\n              </p>\n              {errors.description && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.description.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"cf-type\">Type</Label>\n              <Select\n                items={typeOptions}\n                onValueChange={(value) => {\n                  if (value) {\n                    setValue(\"type\", value);\n                    const isOptionType =\n                      value === \"select\" || value === \"multiselect\";\n\n                    if (isOptionType && fields.length === 0) {\n                      append({ value: \"\", label: \"\" });\n                      return;\n                    }\n\n                    if (!isOptionType) {\n                      setValue(\"options\", [], { shouldDirty: true });\n                    }\n                  }\n                }}\n                value={watchedType}\n              >\n                <SelectTrigger className=\"w-full shadow-none\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {typeOptions.map((option) => (\n                    <SelectItem key={option.value} value={option.value}>\n                      {option.label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              {errors.type && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.type.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            {showsOptions ? (\n              <FieldOptionsInput\n                append={append}\n                errors={errors}\n                fields={fields}\n                register={register}\n                remove={remove}\n              />\n            ) : null}\n          </div>\n\n          <SheetFooter className=\"p-6\">\n            <AsyncButton\n              className=\"w-full\"\n              isLoading={isCreating}\n              type=\"submit\"\n            >\n              Create field\n            </AsyncButton>\n          </SheetFooter>\n        </form>\n      </SheetContent>\n    </Sheet>\n  );\n}\n\nexport default CreateCustomFieldSheet;\n"
  },
  {
    "path": "apps/cms/src/components/fields/custom-field-row.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport {\n  DotsThreeVerticalIcon,\n  PencilSimpleLineIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport { useState } from \"react\";\nimport type { CustomField } from \"@/types/fields\";\nimport { DeleteCustomFieldModal } from \"./delete-custom-field\";\nimport { EditCustomFieldSheet } from \"./edit-custom-field\";\n\ninterface CustomFieldRowProps {\n  field: CustomField;\n  fieldTypeLabels: Record<string, string>;\n  onDelete: () => void;\n}\n\nexport function CustomFieldRow({\n  field,\n  fieldTypeLabels,\n  onDelete,\n}: CustomFieldRowProps) {\n  const [isEditOpen, setIsEditOpen] = useState(false);\n  const [isDeleteOpen, setIsDeleteOpen] = useState(false);\n\n  return (\n    <>\n      <tr className=\"border-b transition-colors last:border-b-0 hover:bg-muted/50\">\n        <td className=\"px-4 py-3 font-medium text-sm\">{field.name}</td>\n        <td className=\"px-4 py-3 text-sm\">\n          <code className=\"rounded bg-muted px-1.5 py-0.5 text-xs\">\n            {field.key}\n          </code>\n        </td>\n        <td className=\"px-4 py-3 text-muted-foreground text-sm\">\n          {fieldTypeLabels[field.type] || field.type}\n        </td>\n        <td className=\"px-4 py-3 text-right\">\n          <DropdownMenu>\n            <DropdownMenuTrigger\n              render={\n                <Button className=\"h-8 w-8 p-0\" variant=\"ghost\">\n                  <span className=\"sr-only\">Open menu</span>\n                  <DotsThreeVerticalIcon />\n                </Button>\n              }\n            />\n            <DropdownMenuContent align=\"end\" className=\"text-muted-foreground\">\n              <DropdownMenuItem onClick={() => setIsEditOpen(true)}>\n                <PencilSimpleLineIcon className=\"mr-1.5 size-4\" />\n                <span>Edit</span>\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onClick={() => setIsDeleteOpen(true)}\n                variant=\"destructive\"\n              >\n                <TrashIcon className=\"mr-1.5 size-4\" /> <span>Delete</span>\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </td>\n      </tr>\n\n      {isEditOpen && (\n        <EditCustomFieldSheet\n          field={field}\n          onOpenChange={setIsEditOpen}\n          open={isEditOpen}\n        />\n      )}\n\n      <DeleteCustomFieldModal\n        fieldId={field.id}\n        fieldName={field.name}\n        isOpen={isDeleteOpen}\n        onDelete={onDelete}\n        onOpenChange={setIsDeleteOpen}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/fields/delete-custom-field.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useParams } from \"next/navigation\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\n\ninterface DeleteCustomFieldModalProps {\n  fieldId: string;\n  fieldName: string;\n  onDelete: () => void;\n  isOpen: boolean;\n  onOpenChange: (isOpen: boolean) => void;\n}\n\nexport function DeleteCustomFieldModal({\n  fieldId,\n  fieldName,\n  onDelete,\n  isOpen,\n  onOpenChange,\n}: DeleteCustomFieldModalProps) {\n  const params = useParams<{ workspace: string }>();\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const { mutate: deleteField, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await fetch(`/api/fields/${fieldId}`, {\n        method: \"DELETE\",\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to delete field\");\n      }\n\n      return true;\n    },\n    onSuccess: () => {\n      toast.success(\"Custom field deleted\");\n      onDelete();\n      onOpenChange(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.CUSTOM_FIELDS(workspaceId),\n        });\n      }\n      if (params.workspace) {\n        queryClient.invalidateQueries({\n          queryKey: [\"editor-bootstrap\", params.workspace],\n        });\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={onOpenChange} open={isOpen}>\n      <AlertDialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete \"{fieldName}\"?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription>\n            This will permanently delete this field and all values stored for it\n            across every post. This action cannot be undone.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isPending} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteField();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/fields/edit-custom-field.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@marble/ui/components/sheet\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useParams } from \"next/navigation\";\nimport { useEffect } from \"react\";\nimport { useFieldArray, useForm, useWatch } from \"react-hook-form\";\nimport { FieldOptionsInput } from \"@/components/fields/field-options-input\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type CustomFieldFormValues,\n  customFieldSchema,\n} from \"@/lib/validations/fields\";\nimport type { CustomField } from \"@/types/fields\";\n\nconst typeOptions = [\n  { label: \"Text\", value: \"text\" },\n  { label: \"Number\", value: \"number\" },\n  { label: \"Boolean\", value: \"boolean\" },\n  { label: \"Date\", value: \"date\" },\n  { label: \"Rich Text\", value: \"richtext\" },\n  { label: \"Select\", value: \"select\" },\n  { label: \"Multi Select\", value: \"multiselect\" },\n];\n\ninterface EditCustomFieldSheetProps {\n  field: CustomField;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport function EditCustomFieldSheet({\n  field,\n  open,\n  onOpenChange,\n}: EditCustomFieldSheetProps) {\n  const params = useParams<{ workspace: string }>();\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const {\n    register,\n    handleSubmit,\n    reset,\n    setValue,\n    control,\n    formState: { errors },\n  } = useForm<CustomFieldFormValues>({\n    resolver: zodResolver(customFieldSchema),\n    defaultValues: {\n      name: field.name,\n      description: field.description ?? \"\",\n      key: field.key,\n      type: field.type,\n      options: field.options.map((option) => ({\n        value: option.value,\n        label: option.label,\n      })),\n    },\n  });\n\n  useEffect(() => {\n    if (!open) {\n      return;\n    }\n\n    reset({\n      name: field.name,\n      description: field.description ?? \"\",\n      key: field.key,\n      type: field.type,\n      options: field.options.map((option) => ({\n        value: option.value,\n        label: option.label,\n      })),\n    });\n  }, [field, open, reset]);\n\n  const watchedType = useWatch({ control, name: \"type\" });\n  const showsOptions =\n    watchedType === \"select\" || watchedType === \"multiselect\";\n  const hasSavedValues = field.hasValues === true;\n  const { fields, append, remove } = useFieldArray({\n    control,\n    name: \"options\",\n  });\n\n  const { mutate: updateField, isPending } = useMutation({\n    mutationFn: async (data: CustomFieldFormValues) => {\n      const res = await fetch(`/api/fields/${field.id}`, {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json();\n        throw new Error(err.error || \"Failed to update field\");\n      }\n\n      return res.json();\n    },\n    onSuccess: () => {\n      toast.success(\"Custom field updated\");\n      onOpenChange(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.CUSTOM_FIELDS(workspaceId),\n        });\n      }\n      if (params.workspace) {\n        queryClient.invalidateQueries({\n          queryKey: [\"editor-bootstrap\", params.workspace],\n        });\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message || \"Failed to update field\");\n    },\n  });\n\n  const onSubmit = (data: CustomFieldFormValues) => {\n    updateField(data);\n  };\n\n  return (\n    <Sheet onOpenChange={onOpenChange} open={open}>\n      <SheetContent className=\"overflow-y-auto\">\n        <SheetHeader className=\"p-6\">\n          <SheetTitle className=\"font-medium text-xl\">\n            Edit Custom Field\n          </SheetTitle>\n          <SheetDescription className=\"sr-only\">\n            Update the custom field properties.\n          </SheetDescription>\n          <p className=\"text-muted-foreground text-sm\">\n            {hasSavedValues\n              ? \"Type and options are locked because this field already has saved values.\"\n              : \"Type and options can still be changed until this field has saved values.\"}\n          </p>\n        </SheetHeader>\n        <form\n          className=\"flex h-full flex-col justify-between\"\n          onSubmit={handleSubmit(onSubmit)}\n        >\n          <div className=\"mb-5 grid flex-1 auto-rows-min gap-6 px-6\">\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"edit-cf-name\">Name</Label>\n              <Input\n                id=\"edit-cf-name\"\n                placeholder=\"Release Date\"\n                {...register(\"name\")}\n              />\n              {errors.name && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.name.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"edit-cf-key\">Key</Label>\n              <Input\n                id=\"edit-cf-key\"\n                placeholder=\"release_date\"\n                {...register(\"key\")}\n              />\n              <p className=\"text-muted-foreground text-xs\">\n                Used as the identifier when storing the value. Must be unique\n                per workspace. Only lowercase letters, numbers, and underscores.\n              </p>\n              {errors.key && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.key.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"edit-cf-description\">Description</Label>\n              <Textarea\n                id=\"edit-cf-description\"\n                placeholder=\"Optional helper text shown to editors\"\n                {...register(\"description\")}\n              />\n              <p className=\"text-muted-foreground text-xs\">\n                Optional guidance for editors. Useful for tooltips or helper\n                text in the sidebar.\n              </p>\n              {errors.description && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.description.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"edit-cf-type\">Type</Label>\n              <Select\n                disabled\n                items={typeOptions}\n                onValueChange={(value) => {\n                  if (value) {\n                    setValue(\"type\", value);\n                    const isOptionType =\n                      value === \"select\" || value === \"multiselect\";\n\n                    if (isOptionType && fields.length === 0) {\n                      append({ value: \"\", label: \"\" });\n                      return;\n                    }\n\n                    if (!isOptionType) {\n                      setValue(\"options\", [], { shouldDirty: true });\n                    }\n                  }\n                }}\n                value={watchedType}\n              >\n                <SelectTrigger className=\"w-full shadow-none\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {typeOptions.map((option) => (\n                    <SelectItem key={option.value} value={option.value}>\n                      {option.label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              {errors.type && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.type.message}\n                </ErrorMessage>\n              )}\n              <p className=\"text-muted-foreground text-xs\">\n                {hasSavedValues\n                  ? \"Field type is locked once this field has saved values.\"\n                  : \"Field type can still change until this field has saved values.\"}\n              </p>\n            </div>\n\n            {showsOptions ? (\n              <FieldOptionsInput\n                append={append}\n                disabled={hasSavedValues}\n                errors={errors}\n                fields={fields}\n                register={register}\n                remove={remove}\n              />\n            ) : null}\n          </div>\n\n          <SheetFooter className=\"p-6\">\n            <AsyncButton className=\"w-full\" isLoading={isPending} type=\"submit\">\n              Save changes\n            </AsyncButton>\n          </SheetFooter>\n        </form>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/fields/field-options-input.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { PlusIcon, XIcon } from \"@phosphor-icons/react\";\nimport type {\n  FieldArrayWithId,\n  FieldErrors,\n  UseFieldArrayAppend,\n  UseFieldArrayRemove,\n  UseFormRegister,\n} from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport type { CustomFieldFormValues } from \"@/lib/validations/fields\";\n\ninterface FieldOptionsInputProps {\n  append: UseFieldArrayAppend<CustomFieldFormValues, \"options\">;\n  disabled?: boolean;\n  errors: FieldErrors<CustomFieldFormValues>;\n  fields: FieldArrayWithId<CustomFieldFormValues, \"options\", \"id\">[];\n  register: UseFormRegister<CustomFieldFormValues>;\n  remove: UseFieldArrayRemove;\n}\n\nexport function FieldOptionsInput({\n  append,\n  disabled = false,\n  errors,\n  fields,\n  register,\n  remove,\n}: FieldOptionsInputProps) {\n  return (\n    <fieldset className=\"grid gap-3\">\n      <legend className=\"mb-3 font-medium text-sm\">Options</legend>\n      <div className=\"grid gap-2\">\n        {fields.map((field, index) => (\n          <div className=\"grid gap-1\" key={field.id}>\n            <div className=\"sr-only\">{`Option ${index + 1}`}</div>\n            <div className=\"flex items-start gap-2\">\n              <label\n                className=\"sr-only\"\n                htmlFor={`field-option-value-${field.id}`}\n              >\n                {`Option ${index + 1} value`}\n              </label>\n              <Input\n                disabled={disabled}\n                id={`field-option-value-${field.id}`}\n                placeholder=\"value\"\n                {...register(`options.${index}.value`)}\n              />\n              <label\n                className=\"sr-only\"\n                htmlFor={`field-option-label-${field.id}`}\n              >\n                {`Option ${index + 1} label`}\n              </label>\n              <Input\n                disabled={disabled}\n                id={`field-option-label-${field.id}`}\n                placeholder=\"Label\"\n                {...register(`options.${index}.label`)}\n              />\n              <Button\n                aria-label=\"Remove option\"\n                className=\"size-9 shrink-0 shadow-none\"\n                disabled={disabled}\n                onClick={() => remove(index)}\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <XIcon className=\"size-4\" />\n              </Button>\n            </div>\n            {errors.options?.[index]?.value ? (\n              <ErrorMessage className=\"text-sm\">\n                {errors.options[index]?.value?.message}\n              </ErrorMessage>\n            ) : null}\n            {errors.options?.[index]?.label ? (\n              <ErrorMessage className=\"text-sm\">\n                {errors.options[index]?.label?.message}\n              </ErrorMessage>\n            ) : null}\n          </div>\n        ))}\n      </div>\n      <Button\n        className=\"w-fit shadow-none\"\n        disabled={disabled}\n        onClick={() => append({ value: \"\", label: \"\" })}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"outline\"\n      >\n        <PlusIcon className=\"size-4\" />\n        Add option\n      </Button>\n      {disabled ? (\n        <p className=\"text-muted-foreground text-xs\">\n          Options can't be changed after this field has saved values.\n        </p>\n      ) : null}\n      {errors.options?.message ? (\n        <ErrorMessage className=\"text-sm\">\n          {errors.options.message}\n        </ErrorMessage>\n      ) : null}\n    </fieldset>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/home/api-usage-card.tsx",
    "content": "\"use client\";\n\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport {\n  type ChartConfig,\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n} from \"@marble/ui/components/chart\";\nimport { Bar, BarChart, CartesianGrid, XAxis } from \"recharts\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport type { UsageDashboardData } from \"@/types/dashboard\";\n\ninterface ApiUsageCardProps {\n  data?: UsageDashboardData[\"api\"];\n  isLoading?: boolean;\n}\n\nconst numberFormatter = new Intl.NumberFormat(\"en-US\");\n\nexport function ApiUsageCard({ data, isLoading }: ApiUsageCardProps) {\n  const rawChartData = data?.chart ?? [];\n\n  const chartData =\n    rawChartData.map((item) => ({\n      label: item.label,\n      value: item.value,\n      date: item.date,\n    })) ?? [];\n\n  const chartConfig = {\n    requests: {\n      label: \"Requests\",\n      color: \"var(--primary)\",\n    },\n  } satisfies ChartConfig;\n\n  const formatXAxisLabel = (value: string) => value;\n\n  const formatTooltipLabel = (value?: string) => {\n    if (!value) {\n      return \"\";\n    }\n    const date = new Date(value);\n    return date.toLocaleDateString(undefined, {\n      month: \"short\",\n      day: \"numeric\",\n      year: \"numeric\",\n    });\n  };\n\n  const startDate = chartData[0]?.date\n    ? new Date(chartData[0].date).toLocaleDateString(\"en-US\", {\n        month: \"short\",\n        day: \"numeric\",\n      })\n    : null;\n  const lastItem = chartData.at(-1);\n  const endDate = lastItem?.date\n    ? new Date(lastItem.date).toLocaleDateString(\"en-US\", {\n        month: \"short\",\n        day: \"numeric\",\n      })\n    : null;\n\n  return (\n    <Card className=\"col-span-full gap-4 rounded-[20px] border-none bg-surface p-2\">\n      <CardHeader className=\"gap-0 px-4 pt-4\">\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex items-center justify-between gap-4\">\n            <CardTitle className=\"text-xl\">API Requests</CardTitle>\n            <p className=\"rounded-full px-3 py-1 text-muted-foreground text-xs\">\n              {startDate && endDate\n                ? `${startDate} - ${endDate}`\n                : \"Last 30 Days\"}\n            </p>\n          </div>\n          <p className=\"font-medium text-muted-foreground text-xl leading-none tracking-tight\">\n            {numberFormatter.format(data?.totals.lastPeriod ?? 0)}\n          </p>\n        </div>\n      </CardHeader>\n      <CardContent className=\"h-84 rounded-[12px] bg-background p-4 pt-8 shadow-xs\">\n        {isLoading ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        ) : chartData.length === 0 ? (\n          <div className=\"flex h-full items-center justify-center text-muted-foreground text-sm\">\n            No data available.\n          </div>\n        ) : (\n          <ChartContainer\n            className=\"aspect-auto size-full\"\n            config={chartConfig}\n          >\n            <BarChart data={chartData} margin={{ left: 12, right: 12 }}>\n              <CartesianGrid strokeDasharray=\"3 3\" vertical={false} />\n              <XAxis\n                axisLine={false}\n                dataKey=\"label\"\n                minTickGap={24}\n                tickFormatter={formatXAxisLabel}\n                tickLine={false}\n                tickMargin={8}\n              />\n              <ChartTooltip\n                content={\n                  <ChartTooltipContent\n                    labelFormatter={(value) =>\n                      formatTooltipLabel(\n                        chartData.find((item) => item.label === value)?.date\n                      )\n                    }\n                    nameKey=\"requests\"\n                  />\n                }\n                cursor={{ fill: \"hsl(var(--muted)/0.4)\" }}\n              />\n              <Bar\n                dataKey=\"value\"\n                fill=\"var(--color-requests)\"\n                name=\"requests\"\n                radius={[6, 6, 0, 0]}\n              />\n            </BarChart>\n          </ChartContainer>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/home/media-usage-card.tsx",
    "content": "\"use client\";\n\nimport {\n  ArrowsOutSimpleIcon,\n  FileAudioIcon,\n  FileIcon,\n  FileImageIcon,\n  FileVideoIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { type RefObject, useEffect, useId, useRef, useState } from \"react\";\nimport { useOnClickOutside } from \"usehooks-ts\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport type { UsageDashboardData } from \"@/types/dashboard\";\nimport type { Media } from \"@/types/media\";\nimport { formatBytes } from \"@/utils/string\";\nimport { VideoPlayer } from \"../media/video-player\";\nimport { HiddenScrollbar } from \"../ui/hidden-scrollbar\";\n\ninterface MediaUsageCardProps {\n  data?: UsageDashboardData[\"media\"];\n  isLoading?: boolean;\n}\n\nfunction getMediaTypeIcon(type: string) {\n  switch (type) {\n    case \"image\":\n      return FileImageIcon;\n    case \"video\":\n      return FileVideoIcon;\n    case \"audio\":\n      return FileAudioIcon;\n    default:\n      return FileIcon;\n  }\n}\n\nexport function MediaUsageCard({ data, isLoading }: MediaUsageCardProps) {\n  \"use no memo\"; // React Compiler affects layout measurement timing for Motion layoutId open animation\n  const recentUploads = data?.recentUploads ?? [];\n  const [selectedFile, setSelectedFile] = useState<Media | null>(null);\n  const dialogRef = useRef<HTMLDivElement>(null);\n  useOnClickOutside(dialogRef as RefObject<HTMLDivElement>, () =>\n    setSelectedFile(null)\n  );\n  const mountKey = useId();\n\n  useEffect(() => {\n    function handleClose(event: KeyboardEvent) {\n      if (event.key === \"Escape\") {\n        setSelectedFile(null);\n      }\n    }\n    document.addEventListener(\"keydown\", handleClose);\n    return () => {\n      document.removeEventListener(\"keydown\", handleClose);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (selectedFile) {\n      document.body.style.overflow = \"hidden\";\n    } else {\n      document.body.style.overflow = \"auto\";\n    }\n  }, [selectedFile]);\n\n  return (\n    <div className=\"flex flex-col gap-4 rounded-[20px] border border-none bg-surface p-2 text-card-foreground\">\n      <AnimatePresence key={mountKey} mode=\"wait\">\n        {selectedFile ? (\n          <>\n            <motion.div\n              animate={{ opacity: 1 }}\n              className=\"fixed inset-0 z-55 bg-black/30 backdrop-blur-xs\"\n              exit={{ opacity: 0 }}\n            />\n            <div className=\"pointer-events-none fixed inset-0 z-56 grid place-items-center\">\n              <motion.div\n                aria-labelledby={`file-name-${selectedFile.id}`}\n                aria-modal=\"true\"\n                className=\"pointer-events-auto z-50 h-fit max-h-[90vh] w-full max-w-[600px] overflow-y-auto rounded-[20px] bg-background p-3 shadow-sm sm:w-[600px] sm:max-w-[calc(100vw-var(--sidebar-width,16rem))]\"\n                key={selectedFile.id}\n                layoutId={`file-${selectedFile.id}`}\n                ref={dialogRef}\n                role=\"dialog\"\n              >\n                <div className=\"flex flex-col gap-4\">\n                  <div className=\"flex justify-between\">\n                    <div>\n                      <motion.p\n                        className=\"line-clamp-1 max-w-[400px] font-medium text-sm\"\n                        id={`file-name-${selectedFile.id}`}\n                        layoutId={`name-${selectedFile.id}`}\n                      >\n                        {selectedFile.name}\n                      </motion.p>\n                      <motion.p\n                        className=\"text-xs\"\n                        layoutId={`size-${selectedFile.id}`}\n                      >\n                        {formatBytes(selectedFile.size)}\n                      </motion.p>\n                      <motion.p\n                        className=\"mt-auto text-muted-foreground text-xs\"\n                        layoutId={`date-${selectedFile.id}`}\n                      >\n                        {new Date(selectedFile.createdAt).toLocaleDateString(\n                          \"en-US\",\n                          {\n                            year: \"numeric\",\n                            month: \"long\",\n                            day: \"numeric\",\n                          }\n                        )}\n                      </motion.p>\n                    </div>\n                    <motion.button\n                      aria-label=\"Close file\"\n                      autoFocus\n                      className=\"flex size-8 cursor-pointer items-center justify-center rounded-full bg-accent outline-none transition-colors hover:bg-accent/80 hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                      layoutId={`button-${selectedFile.id}`}\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        setSelectedFile(null);\n                      }}\n                      type=\"button\"\n                    >\n                      <XIcon className=\"size-4\" />\n                    </motion.button>\n                  </div>\n\n                  <motion.div\n                    className=\"w-full overflow-hidden rounded-[12px] bg-background\"\n                    layoutId={`image-${selectedFile.id}`}\n                  >\n                    {selectedFile.type === \"image\" ? (\n                      <motion.div\n                        animate={{ opacity: 1 }}\n                        className=\"flex justify-center\"\n                        exit={{ opacity: 0 }}\n                        initial={{ opacity: 0 }}\n                        transition={{ duration: 0.2 }}\n                      >\n                        {/** biome-ignore lint/performance/noImgElement: <> */}\n                        <img\n                          alt={selectedFile.name}\n                          className=\"h-auto max-h-[60vh] w-full object-contain\"\n                          height={350}\n                          src={selectedFile.url}\n                          width={600}\n                        />\n                      </motion.div>\n                    ) : selectedFile.type === \"video\" ? (\n                      <div className=\"relative flex aspect-video max-h-[60vh] w-full\">\n                        <VideoPlayer\n                          className=\"h-auto max-h-[60vh] w-full object-contain\"\n                          src={selectedFile.url}\n                        />\n                      </div>\n                    ) : (\n                      <div className=\"flex min-h-[200px] w-full items-center justify-center bg-muted\">\n                        {(() => {\n                          const Icon = getMediaTypeIcon(selectedFile.type);\n                          return (\n                            <Icon\n                              className=\"size-16 text-muted-foreground\"\n                              weight=\"duotone\"\n                            />\n                          );\n                        })()}\n                      </div>\n                    )}\n                  </motion.div>\n                </div>\n              </motion.div>\n            </div>\n          </>\n        ) : null}\n      </AnimatePresence>\n\n      <div className=\"gap-0 px-4 pt-4\">\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex items-center justify-between gap-4\">\n            <p className=\"font-semibold text-xl\">Media Storage</p>\n            <p className=\"rounded-full px-3 py-1 text-muted-foreground text-xs\">\n              Recent Uploads\n            </p>\n          </div>\n          <p className=\"font-medium text-muted-foreground text-xl leading-none tracking-tight\">\n            {formatBytes(data?.totalSize ?? 0)}\n          </p>\n        </div>\n      </div>\n\n      <HiddenScrollbar className=\"h-60 rounded-[12px]\">\n        {isLoading ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        ) : recentUploads.length === 0 ? (\n          <div className=\"flex h-full items-center justify-center bg-background text-muted-foreground text-sm shadow-xs\">\n            No uploads yet.\n          </div>\n        ) : (\n          <ul className=\"flex flex-col gap-2\">\n            {recentUploads.map((file) => {\n              const Icon = getMediaTypeIcon(file.type);\n              return (\n                <motion.li key={file.id} layoutId={`file-${file.id}`}>\n                  <button\n                    className=\"flex w-full cursor-pointer rounded-[18px] border-transparent bg-background p-2.5 shadow-none shadow-s outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                    onClick={() => {\n                      setSelectedFile(file);\n                    }}\n                    type=\"button\"\n                  >\n                    <div className=\"flex w-full justify-between gap-4\">\n                      <div className=\"flex items-center gap-4\">\n                        <motion.div\n                          className=\"grid size-20 shrink-0 place-items-center rounded-[8px] border border-dashed bg-[length:8px_8px] bg-[linear-gradient(45deg,transparent_25%,rgba(0,0,0,0.05)_25%,rgba(0,0,0,0.05)_50%,transparent_50%,transparent_75%,rgba(0,0,0,0.05)_75%,rgba(0,0,0,0.05))] dark:bg-[linear-gradient(45deg,transparent_25%,rgba(255,255,255,0.05)_25%,rgba(255,255,255,0.05)_50%,transparent_50%,transparent_75%,rgba(255,255,255,0.05)_75%,rgba(255,255,255,0.05))]\"\n                          layoutId={`image-${file.id}`}\n                        >\n                          <Icon className=\"size-6 text-primary\" />\n                        </motion.div>\n                        <div className=\"flex flex-col items-start justify-between gap-2 text-left\">\n                          <div>\n                            <motion.p\n                              className=\"line-clamp-1 max-w-[200px] font-medium text-sm\"\n                              layoutId={`name-${file.id}`}\n                            >\n                              {file.name}\n                            </motion.p>\n                            <motion.p\n                              className=\"text-muted-foreground text-xs\"\n                              layoutId={`size-${file.id}`}\n                            >\n                              {formatBytes(file.size)}\n                            </motion.p>\n                          </div>\n                          <motion.p\n                            className=\"mt-auto text-muted-foreground text-xs\"\n                            layoutId={`date-${file.id}`}\n                          >\n                            {formatDistanceToNow(new Date(file.createdAt), {\n                              addSuffix: true,\n                            })}\n                          </motion.p>\n                        </div>\n                      </div>\n                      <motion.div\n                        className=\"flex size-8 items-center justify-center rounded-full bg-surface hover:bg-primary/10 hover:text-primary dark:bg-accent/50 dark:hover:bg-accent dark:hover:text-accent-foreground\"\n                        layoutId={`button-${file.id}`}\n                      >\n                        <ArrowsOutSimpleIcon size={16} />\n                      </motion.div>\n                    </div>\n                  </button>\n                </motion.li>\n              );\n            })}\n          </ul>\n        )}\n      </HiddenScrollbar>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/home/publishing-activity-card.tsx",
    "content": "\"use client\";\n\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport {\n  ContributionGraph,\n  ContributionGraphBlock,\n  ContributionGraphCalendar,\n  ContributionGraphFooter,\n  ContributionGraphLegend,\n  ContributionGraphTotalCount,\n} from \"@marble/ui/components/kibo-ui/contribution-graph\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { format, parseISO } from \"date-fns\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { PublishingMetricsData } from \"@/types/dashboard\";\n\nconst numberFormatter = new Intl.NumberFormat(\"en-US\");\n\nexport const PublishingActivityCard = () => {\n  const workspaceId = useWorkspaceId();\n  const { isFetchingWorkspace } = useWorkspace();\n\n  const { data: metrics, isPending } = useQuery({\n    queryKey: workspaceId\n      ? QUERY_KEYS.PUBLISHING_METRICS(workspaceId)\n      : [\"publishing-metrics\", \"disabled\"],\n    queryFn: async (): Promise<PublishingMetricsData> => {\n      const response = await fetch(\"/api/metrics/publishing\");\n      if (!response.ok) {\n        throw new Error(\"Failed to fetch publishing metrics\");\n      }\n      return response.json();\n    },\n    enabled: Boolean(workspaceId) && !isFetchingWorkspace,\n  });\n\n  if (isFetchingWorkspace || !workspaceId || isPending) {\n    return (\n      <Card className=\"rounded-[20px] border-none bg-surface p-2.5\">\n        <CardHeader className=\"gap-0 px-4 pt-4\">\n          <div className=\"flex flex-col gap-1\">\n            <div className=\"flex items-center justify-between gap-4\">\n              <CardTitle className=\"text-xl\">Publishing Activity</CardTitle>\n              <p className=\"rounded-full px-3 py-1 text-muted-foreground text-xs\">\n                {new Date().getFullYear()}\n              </p>\n            </div>\n            <p className=\"font-medium text-muted-foreground text-xl leading-none tracking-tight\">\n              0 posts published\n            </p>\n          </div>\n        </CardHeader>\n        <CardContent className=\"h-[191px] rounded-[12px] bg-background p-4 shadow-xs\">\n          <div className=\"flex h-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  return (\n    <Card className=\"rounded-[20px] border-none bg-surface p-2\">\n      <CardHeader className=\"gap-0 px-4 pt-4\">\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex items-center justify-between gap-4\">\n            <CardTitle className=\"text-xl\">Publishing Activity</CardTitle>\n            <p className=\"rounded-full px-3 py-1 text-muted-foreground text-xs\">\n              {new Date().getFullYear()}\n            </p>\n          </div>\n          <p className=\"font-medium text-muted-foreground text-xl leading-none tracking-tight\">\n            {numberFormatter.format(\n              metrics?.graph?.activity?.reduce(\n                (acc, curr) => acc + curr.count,\n                0\n              ) ?? 0\n            )}{\" \"}\n            posts published\n          </p>\n        </div>\n      </CardHeader>\n      <CardContent className=\"rounded-[12px] bg-background p-4 shadow-xs\">\n        {metrics?.graph?.activity ? (\n          <ContributionGraph\n            blockMargin={3}\n            blockSize={13}\n            data={metrics.graph.activity}\n            fontSize={12}\n          >\n            <ContributionGraphCalendar>\n              {({ activity, dayIndex, weekIndex }) => (\n                <Tooltip>\n                  <TooltipTrigger\n                    render={\n                      <g>\n                        <ContributionGraphBlock\n                          activity={activity}\n                          className={cn(\n                            'data-[level=\"0\"]:fill-muted dark:data-[level=\"0\"]:fill-white/5',\n                            'data-[level=\"1\"]:fill-primary/20 dark:data-[level=\"1\"]:fill-primary/30',\n                            'data-[level=\"2\"]:fill-primary/40 dark:data-[level=\"2\"]:fill-primary/50',\n                            'data-[level=\"3\"]:fill-primary/60 dark:data-[level=\"3\"]:fill-primary/70',\n                            'data-[level=\"4\"]:fill-primary/80 dark:data-[level=\"4\"]:fill-primary/90'\n                          )}\n                          dayIndex={dayIndex}\n                          weekIndex={weekIndex}\n                        />\n                      </g>\n                    }\n                  />\n                  <TooltipContent>\n                    <p className=\"font-semibold\">\n                      {format(parseISO(activity.date), \"MMMM d, yyyy\")}\n                    </p>\n                    <p>\n                      {activity.count} {activity.count === 1 ? \"post\" : \"posts\"}{\" \"}\n                      published\n                    </p>\n                  </TooltipContent>\n                </Tooltip>\n              )}\n            </ContributionGraphCalendar>\n            {/* @ts-ignore - ContributionGraphFooter types seem incomplete but it renders children */}\n            <ContributionGraphFooter>\n              <ContributionGraphTotalCount>\n                {/* @ts-ignore - same issue with props type */}\n                {({ totalCount }) => (\n                  <div className=\"flex items-center gap-2\">\n                    <span className=\"text-muted-foreground text-sm\">\n                      Total:\n                    </span>\n                    <span className=\"font-medium text-sm\">\n                      {totalCount.toLocaleString()} posts\n                    </span>\n                  </div>\n                )}\n              </ContributionGraphTotalCount>\n              <ContributionGraphLegend>\n                {/* @ts-ignore - same issue with props type */}\n                {({ level }) => (\n                  <div\n                    className=\"group relative flex h-3 w-3 items-center justify-center\"\n                    data-level={level}\n                  >\n                    <div\n                      className={cn(\n                        \"h-full w-full rounded-sm border border-border\",\n                        level === 0 && \"bg-muted dark:bg-white/5\",\n                        level === 1 && \"bg-primary/20 dark:bg-primary/30\",\n                        level === 2 && \"bg-primary/40 dark:bg-primary/50\",\n                        level === 3 && \"bg-primary/60 dark:bg-primary/70\",\n                        level === 4 && \"bg-primary/80 dark:bg-primary/90\"\n                      )}\n                    />\n                    <span className=\"-top-8 absolute hidden rounded bg-popover px-2 py-1 text-popover-foreground text-xs shadow-md group-hover:block\">\n                      Level {level}\n                    </span>\n                  </div>\n                )}\n              </ContributionGraphLegend>\n            </ContributionGraphFooter>\n          </ContributionGraph>\n        ) : (\n          <div className=\"flex h-32 items-center justify-center text-muted-foreground text-sm\">\n            No publishing activity data available\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/home/webhook-usage-card.tsx",
    "content": "\"use client\";\n\nimport {\n  Card,\n  CardContent,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport {\n  type ChartConfig,\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n} from \"@marble/ui/components/chart\";\nimport { Bar, BarChart, CartesianGrid, XAxis } from \"recharts\";\nimport { LoadingSpinner } from \"@/components/ui/loading-spinner\";\nimport type { UsageDashboardData } from \"@/types/dashboard\";\n\ninterface WebhookUsageCardProps {\n  data?: UsageDashboardData[\"webhooks\"];\n  isLoading?: boolean;\n}\n\nconst numberFormatter = new Intl.NumberFormat(\"en-US\");\n\nexport function WebhookUsageCard({ data, isLoading }: WebhookUsageCardProps) {\n  const rawChartData = data?.chart ?? [];\n\n  const chartData =\n    rawChartData.map((item) => ({\n      label: item.label,\n      value: item.value,\n      date: item.date,\n    })) ?? [];\n\n  const chartConfig = {\n    deliveries: {\n      label: \"Deliveries\",\n      color: \"var(--primary)\",\n    },\n  } satisfies ChartConfig;\n\n  const formatXAxisLabel = (value: string) => value;\n\n  const formatTooltipLabel = (value?: string) => {\n    if (!value) {\n      return \"\";\n    }\n    const date = new Date(value);\n    return date.toLocaleDateString(undefined, {\n      month: \"short\",\n      day: \"numeric\",\n      year: \"numeric\",\n    });\n  };\n\n  const startDate = chartData[0]?.date\n    ? new Date(chartData[0].date).toLocaleDateString(\"en-US\", {\n        month: \"short\",\n        day: \"numeric\",\n      })\n    : null;\n  const lastItem = chartData.at(-1);\n  const endDate = lastItem?.date\n    ? new Date(lastItem.date).toLocaleDateString(\"en-US\", {\n        month: \"short\",\n        day: \"numeric\",\n      })\n    : null;\n\n  return (\n    <Card className=\"gap-4 rounded-[20px] border-none bg-surface p-2\">\n      <CardHeader className=\"gap-0 px-4 pt-4\">\n        <div className=\"flex flex-col gap-1\">\n          <div className=\"flex items-center justify-between gap-4\">\n            <CardTitle className=\"text-xl\">Webhook Deliveries</CardTitle>\n            <p className=\"rounded-full px-3 py-1 text-muted-foreground text-xs\">\n              {startDate && endDate\n                ? `${startDate} - ${endDate}`\n                : \"Last 30 Days\"}\n            </p>\n          </div>\n          <p className=\"font-medium text-muted-foreground text-xl leading-none tracking-tight\">\n            {numberFormatter.format(data?.total ?? 0)}\n          </p>\n        </div>\n      </CardHeader>\n      <CardContent className=\"h-60 rounded-[12px] bg-background p-4 pt-8 shadow-xs\">\n        {isLoading ? (\n          <div className=\"flex h-full items-center justify-center\">\n            <LoadingSpinner />\n          </div>\n        ) : chartData.length === 0 ? (\n          <div className=\"flex h-full items-center justify-center text-muted-foreground text-sm\">\n            No data available.\n          </div>\n        ) : (\n          <ChartContainer\n            className=\"aspect-auto size-full\"\n            config={chartConfig}\n          >\n            <BarChart data={chartData} margin={{ left: 12, right: 12 }}>\n              <CartesianGrid strokeDasharray=\"3 3\" vertical={false} />\n              <XAxis\n                axisLine={false}\n                dataKey=\"label\"\n                minTickGap={24}\n                tickFormatter={formatXAxisLabel}\n                tickLine={false}\n                tickMargin={8}\n              />\n              <ChartTooltip\n                content={\n                  <ChartTooltipContent\n                    labelFormatter={(value) =>\n                      formatTooltipLabel(\n                        chartData.find((item) => item.label === value)?.date\n                      )\n                    }\n                    nameKey=\"deliveries\"\n                  />\n                }\n                cursor={{ fill: \"hsl(var(--muted)/0.4)\" }}\n              />\n              <Bar\n                dataKey=\"value\"\n                fill=\"var(--primary)\"\n                name=\"deliveries\"\n                radius={[2, 2, 0, 0]}\n              />\n            </BarChart>\n          </ChartContainer>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/icons/marble.tsx",
    "content": "export default function MarbleIcon() {\n  return (\n    <svg\n      className=\"size-8\"\n      fill=\"none\"\n      height=\"32\"\n      viewBox=\"0 0 32 32\"\n      width=\"32\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <title>Marble</title>\n      <rect\n        className=\"fill-[#1E1E1E] dark:fill-white\"\n        height=\"32\"\n        rx=\"6\"\n        width=\"32\"\n      />\n      <path\n        className=\"fill-white dark:fill-[#1E1E1E]\"\n        d=\"M14.116 11.5C14.2333 11.5213 14.4947 11.932 14.9 12.732C15.3053 13.5213 15.8013 14.5453 16.388 15.804L16.9 16.876C17.124 16.6307 17.332 16.348 17.524 16.028C17.716 15.6973 17.956 15.2547 18.244 14.7C18.5747 14.0707 18.804 13.6653 18.932 13.484C19.444 12.7053 19.9987 11.8787 20.596 11.004C20.6707 10.8867 20.804 10.8067 20.996 10.764C21.028 10.7533 21.0813 10.748 21.156 10.748C21.412 10.748 21.6947 10.8547 22.004 11.068C22.324 11.2813 22.532 11.5 22.628 11.724C22.6493 11.7667 22.66 11.8467 22.66 11.964C22.66 12.0707 22.6547 12.1507 22.644 12.204C22.6227 12.3 22.596 12.4227 22.564 12.572C22.5427 12.7107 22.5213 12.876 22.5 13.068C22.1587 15.6707 21.9133 17.6227 21.764 18.924C21.7213 19.2227 21.7 19.5587 21.7 19.932L21.668 20.444C21.6573 20.5507 21.652 20.6733 21.652 20.812C21.652 20.9507 21.636 21.0467 21.604 21.1C21.572 21.1533 21.5133 21.18 21.428 21.18C21.364 21.18 21.268 21.164 21.14 21.132C20.7987 21.0573 20.5373 20.924 20.356 20.732C20.1747 20.5293 20.0733 20.252 20.052 19.9C20.0413 19.7187 20.036 19.436 20.036 19.052C20.036 18.6573 20.0467 18.0547 20.068 17.244L20.084 16.364L20.116 15.1C20.02 15.1853 19.7747 15.5213 19.38 16.108C18.996 16.684 18.6173 17.2867 18.244 17.916C17.8707 18.5453 17.6253 19.0307 17.508 19.372C17.4013 19.6387 17.2947 19.836 17.188 19.964C17.092 20.092 16.9747 20.156 16.836 20.156C16.708 20.156 16.5427 20.0973 16.34 19.98C15.764 19.628 15.412 19.1427 15.284 18.524C15.0813 17.5747 14.8307 16.588 14.532 15.564C14.4787 15.372 14.372 15.1107 14.212 14.78C14.0627 14.4493 13.9667 14.2413 13.924 14.156L13.716 13.74C12.1587 15.8093 11.1027 17.708 10.548 19.436C10.4413 19.7987 10.34 20.06 10.244 20.22C10.148 20.3693 10.036 20.444 9.908 20.444C9.70533 20.444 9.41733 20.2627 9.044 19.9C8.756 19.644 8.612 19.356 8.612 19.036C8.612 18.8653 8.644 18.6947 8.708 18.524C9.156 17.2013 9.82267 15.948 10.708 14.764L11.204 14.108C11.876 13.2227 12.3827 12.5293 12.724 12.028C12.98 11.6547 13.3213 11.468 13.748 11.468C13.8227 11.468 13.9453 11.4787 14.116 11.5Z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/icons/social/index.tsx",
    "content": "import type { SVGProps } from \"react\";\n\ninterface SocialIconProps extends SVGProps<SVGSVGElement> {}\n\nexport const Google = (props: SocialIconProps) => (\n  <svg\n    height=\"1em\"\n    preserveAspectRatio=\"xMidYMid\"\n    viewBox=\"0 0 256 262\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Google</title>\n    <path\n      d=\"M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027\"\n      fill=\"#4285F4\"\n    />\n    <path\n      d=\"M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1\"\n      fill=\"#34A853\"\n    />\n    <path\n      d=\"M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782\"\n      fill=\"#FBBC05\"\n    />\n    <path\n      d=\"M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251\"\n      fill=\"#EB4335\"\n    />\n  </svg>\n);\n\nexport const Github = (props: SocialIconProps) => (\n  <svg\n    fill=\"currentColor\"\n    height=\"1em\"\n    preserveAspectRatio=\"xMidYMid\"\n    viewBox=\"0 0 256 250\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Github</title>\n    <path d=\"M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0Zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931Zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66Zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08Zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27Zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622Zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868Zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403Z\" />\n  </svg>\n);\n\nexport const Discord = (props: SocialIconProps) => (\n  <svg\n    height=\"1em\"\n    preserveAspectRatio=\"xMidYMid\"\n    viewBox=\"0 0 256 199\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Discord</title>\n    <path\n      d=\"M216.856 16.597A208.502 208.502 0 0 0 164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 0 0-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0 0 79.735 175.3a136.413 136.413 0 0 1-21.846-10.632 108.636 108.636 0 0 0 5.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 0 0 5.355 4.237 136.07 136.07 0 0 1-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36ZM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18Zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18Z\"\n      fill=\"#5865F2\"\n    />\n  </svg>\n);\nexport const XFormerlyTwitter = (props: SocialIconProps) => (\n  <svg\n    fill=\"none\"\n    height=\"1em\"\n    viewBox=\"0 0 1200 1227\"\n    width=\"1em\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>X</title>\n    <path\n      d=\"M714.163 519.284 1160.89 0h-105.86L667.137 450.887 357.328 0H0l468.492 681.821L0 1226.37h105.866l409.625-476.152 327.181 476.152H1200L714.137 519.284h.026ZM569.165 687.828l-47.468-67.894-377.686-540.24h162.604l304.797 435.991 47.468 67.894 396.2 566.721H892.476L569.165 687.854v-.026Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const Bluesky = (props: SocialIconProps) => (\n  <svg\n    fill=\"none\"\n    height=\"28\"\n    viewBox=\"0 0 31 28\"\n    width=\"31\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Bluesky</title>\n    <path\n      d=\"M26.05 2c.757-.011 1.56.215 2.15.897.566.654.8 1.573.8 2.652 0 .434-.115 2.086-.255 3.692-.071.816-.15 1.642-.23 2.324-.074.646-.156 1.259-.246 1.58-.537 1.917-1.806 3.14-3.344 3.787q-.133.055-.268.103c.526.408.941.88 1.22 1.423.89 1.725.206 3.609-1.327 5.182-1.401 1.437-2.74 2.207-4.034 2.34-1.335.136-2.41-.424-3.211-1.177-.784-.738-1.357-1.702-1.762-2.544l-.043-.093-.043.093c-.405.842-.978 1.806-1.762 2.544-.8.753-1.876 1.313-3.21 1.177-1.295-.133-2.634-.903-4.035-2.34-1.533-1.573-2.217-3.457-1.328-5.182.28-.542.694-1.015 1.22-1.423a7 7 0 0 1-.267-.103c-1.538-.646-2.807-1.87-3.344-3.786-.09-.322-.172-.934-.247-1.58a89 89 0 0 1-.23-2.325C2.115 7.635 2 5.983 2 5.55c0-1.08.234-1.998.8-2.652.59-.682 1.393-.908 2.15-.897 1.417.021 2.984.866 4.07 1.68 1.536 1.153 3.086 2.86 4.382 4.546A35 35 0 0 1 15.5 11.27c.578-.95 1.3-2.005 2.098-3.043 1.296-1.685 2.846-3.393 4.383-4.545 1.085-.815 2.652-1.66 4.07-1.681Z\"\n      stroke=\"currentColor\"\n      strokeLinejoin=\"round\"\n      strokeWidth=\"2\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "apps/cms/src/components/invoice/columns.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport TableActions from \"./table-actions\";\n\nexport interface Invoice {\n  id: string;\n  plan: string;\n  amount: number;\n  status: \"Success\" | \"Failed\";\n  date: string;\n}\n\nexport const invoiceTableColumns: ColumnDef<Invoice>[] = [\n  {\n    accessorKey: \"date\",\n    header: \"Date\",\n    cell: ({ row }) => {\n      const date = row.getValue(\"date\") as string;\n      return new Date(date).toLocaleDateString();\n    },\n  },\n  {\n    accessorKey: \"amount\",\n    header: \"Amount\",\n    cell: ({ row }) => {\n      const amount = row.getValue(\"amount\") as number;\n      return new Intl.NumberFormat(\"en-US\", {\n        style: \"currency\",\n        currency: \"USD\",\n      }).format(amount);\n    },\n  },\n  {\n    accessorKey: \"status\",\n    header: \"Status\",\n    cell: ({ row }) => {\n      const status = row.original.status;\n      return (\n        <Badge\n          className={cn(\"rounded-[6px] text-xs\", {\n            \"border-emerald-300 bg-emerald-50 text-emerald-500\":\n              status === \"Success\",\n            \"border-red-300 bg-red-50 text-red-500\": status === \"Failed\",\n          })}\n          variant=\"outline\"\n        >\n          {status === \"Success\" ? \"Success\" : \"Failed\"}\n        </Badge>\n      );\n    },\n  },\n  {\n    id: \"actions\",\n    header: () => <span className=\"sr-only\">Actions</span>,\n    cell: ({ row }) => {\n      const invoice = row.original;\n      return (\n        <div className=\"flex justify-end\">\n          <TableActions {...invoice} />\n        </div>\n      );\n    },\n    enableSorting: false,\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/invoice/data-table.tsx",
    "content": "\"use client\";\n\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  type ColumnDef,\n  type ColumnFiltersState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  type SortingState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useState } from \"react\";\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n}\n\nexport function InvoiceDataTable<TData, TValue>({\n  columns,\n  data,\n}: DataTableProps<TData, TValue>) {\n  const [sorting, _setSorting] = useState<SortingState>([]);\n\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    onColumnFiltersChange: setColumnFilters,\n    getFilteredRowModel: getFilteredRowModel(),\n    state: {\n      sorting,\n      columnFilters,\n    },\n  });\n\n  const rows = table.getRowModel().rows;\n\n  if (data.length === 0) {\n    return (\n      <div className=\"overflow-hidden rounded-[20px] bg-surface p-1\">\n        <div className=\"rounded-[16px] bg-background px-4 py-10 text-center shadow-xs\">\n          <p className=\"text-muted-foreground text-sm\">No invoices yet.</p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"overflow-hidden rounded-[20px] bg-surface p-1 [&_[data-slot=table-container]]:overflow-x-auto [&_[data-slot=table-container]]:overflow-y-hidden\">\n      <Table className=\"-mb-1 h-fit border-separate border-spacing-y-1\">\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow\n              className=\"border-0 text-[13px] hover:bg-transparent\"\n              key={headerGroup.id}\n            >\n              {headerGroup.headers.map((header) => (\n                <TableHead\n                  className={getHeaderClassName(header.column.id)}\n                  key={header.id}\n                >\n                  {header.isPlaceholder\n                    ? null\n                    : flexRender(\n                        header.column.columnDef.header,\n                        header.getContext()\n                      )}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {rows.length > 0 ? (\n            rows.map((row) => (\n              <TableRow\n                className=\"border-0 bg-background hover:bg-background/80 data-[state=selected]:bg-background\"\n                data-state={row.getIsSelected() && \"selected\"}\n                key={row.id}\n              >\n                {row.getVisibleCells().map((cell) => (\n                  <TableCell\n                    className={cn(getCellClassName(cell.column.id))}\n                    key={cell.id}\n                  >\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow className=\"border-0 bg-background\">\n              <TableCell\n                className=\"h-28 rounded-[16px] text-center text-muted-foreground text-sm\"\n                colSpan={table.getVisibleLeafColumns().length}\n              >\n                No invoices match your filters.\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n\nfunction getHeaderClassName(columnId: string) {\n  switch (columnId) {\n    case \"date\":\n      return \"px-3 text-muted-foreground\";\n    case \"amount\":\n      return \"px-3 text-muted-foreground\";\n    case \"status\":\n      return \"px-3 text-muted-foreground\";\n    case \"actions\":\n      return \"sr-only w-12 px-3 text-right text-muted-foreground\";\n    default:\n      return \"px-3 text-muted-foreground\";\n  }\n}\n\nfunction getCellClassName(columnId: string) {\n  switch (columnId) {\n    case \"date\":\n      return \"rounded-l-[16px] px-3 py-2 text-muted-foreground text-xs\";\n    case \"amount\":\n      return \"px-3 py-2 font-medium text-xs\";\n    case \"status\":\n      return \"px-3 py-2\";\n    case \"actions\":\n      return \"rounded-r-[16px] px-3 py-2\";\n    default:\n      return \"px-3 py-2\";\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/components/invoice/table-actions.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport {\n  CopyIcon,\n  DotsThreeVerticalIcon,\n  DownloadSimpleIcon,\n} from \"@phosphor-icons/react\";\nimport { toast } from \"sonner\";\nimport type { Invoice } from \"./columns\";\n\nexport default function TableActions(props: Invoice) {\n  const handleCopyInvoiceId = () => {\n    navigator.clipboard.writeText(props.id);\n    toast.success(\"ID copied to clipboard\");\n  };\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button className=\"h-8 w-8 p-0\" variant=\"ghost\">\n            <span className=\"sr-only\">Open menu</span>\n            <DotsThreeVerticalIcon className=\"h-4 w-4\" />\n          </Button>\n        }\n      />\n      <DropdownMenuContent align=\"end\" className=\"text-muted-foreground\">\n        <DropdownMenuItem>\n          <button\n            className=\"flex w-full items-center gap-2\"\n            onClick={() => console.log(\"download invoice\")}\n            type=\"button\"\n          >\n            <DownloadSimpleIcon size={16} /> <span>Download Invoice</span>\n          </button>\n        </DropdownMenuItem>\n        <DropdownMenuItem>\n          <button\n            className=\"flex w-full items-center gap-2\"\n            onClick={() => handleCopyInvoiceId()}\n            type=\"button\"\n          >\n            <CopyIcon size={16} /> <span>Copy Invoice ID</span>\n          </button>\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/keys/api-key-modal.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Key01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useState } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type CreateApiKeyValues,\n  createApiKeySchema,\n} from \"@/lib/validations/keys\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport type { APIKey } from \"./columns\";\n\ninterface ApiKeyModalProps {\n  data?: APIKey;\n  mode: \"create\" | \"update\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n}\n\nexport function ApiKeyModal({ data, mode, open, setOpen }: ApiKeyModalProps) {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n  const [createdKey, setCreatedKey] = useState<string | null>(null);\n\n  const form = useForm({\n    resolver: zodResolver(createApiKeySchema),\n    defaultValues: {\n      name: data?.name || \"\",\n      type: data?.type || \"public\",\n      expiresAt: undefined,\n    },\n  });\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    watch,\n    formState: { errors, isSubmitting },\n    reset,\n  } = form;\n\n  const type = watch(\"type\");\n\n  const { mutate: createKey, isPending: isCreating } = useMutation({\n    mutationFn: async (formData: CreateApiKeyValues) => {\n      const res = await fetch(\"/api/keys\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name: formData.name,\n          type: formData.type,\n          expiresAt: formData.expiresAt,\n        }),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to create API key\");\n      }\n\n      return res.json();\n    },\n    onSuccess: (responseData) => {\n      setCreatedKey(responseData.key);\n      toast.success(\"API key created successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.KEYS(workspaceId),\n        });\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const { mutate: updateKey, isPending: isUpdating } = useMutation({\n    mutationFn: async (formData: CreateApiKeyValues) => {\n      if (!data?.id) {\n        throw new Error(\"API key ID is required\");\n      }\n\n      const res = await fetch(`/api/keys/${data.id}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name: formData.name,\n        }),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to update API key\");\n      }\n\n      return res.json();\n    },\n    onSuccess: () => {\n      toast.success(\"API key updated successfully\");\n      setOpen(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.KEYS(workspaceId),\n        });\n      }\n      reset();\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const onSubmit = async (formData: CreateApiKeyValues) => {\n    if (mode === \"create\") {\n      createKey(formData);\n    } else {\n      updateKey(formData);\n    }\n  };\n\n  const handleClose = () => {\n    setOpen(false);\n    setCreatedKey(null);\n    reset();\n  };\n\n  return (\n    <Dialog onOpenChange={handleClose} open={open}>\n      <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={Key01Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              {createdKey\n                ? \"API key created\"\n                : mode === \"create\"\n                  ? \"Create API Key\"\n                  : \"Update API Key\"}\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogDescription className=\"sr-only\">\n          {createdKey\n            ? \"Save this now, you won't be able to see it again\"\n            : mode === \"create\"\n              ? \"Create a key to use with the API.\"\n              : \"Update your API key.\"}\n        </DialogDescription>\n        <DialogBody>\n          {createdKey ? (\n            <div className=\"flex flex-col gap-4\">\n              <p className=\"text-muted-foreground text-sm\">\n                Save this now, you won't be able to see it again.\n              </p>\n              <div className=\"grid flex-1 gap-2\">\n                <div className=\"flex items-center gap-2\">\n                  <Input readOnly value={createdKey} />\n                  <CopyButton\n                    textToCopy={createdKey}\n                    toastMessage=\"API key copied to clipboard\"\n                  />\n                </div>\n              </div>\n              <DialogFooter>\n                <Button onClick={handleClose} size=\"sm\">\n                  Done\n                </Button>\n              </DialogFooter>\n            </div>\n          ) : (\n            <form\n              className=\"flex flex-col gap-4\"\n              onSubmit={handleSubmit(onSubmit)}\n            >\n              <div className=\"grid flex-1 gap-2\">\n                <Label htmlFor=\"name\">Name</Label>\n                <Input\n                  id=\"name\"\n                  {...register(\"name\")}\n                  placeholder=\"My API Key\"\n                />\n                {errors.name && (\n                  <ErrorMessage>{errors.name.message}</ErrorMessage>\n                )}\n              </div>\n\n              {mode === \"create\" && (\n                <div className=\"grid flex-1 gap-2\">\n                  <Label htmlFor=\"type\">Type</Label>\n                  <Select\n                    onValueChange={(value) => {\n                      if (value === \"public\" || value === \"private\") {\n                        setValue(\"type\", value);\n                      }\n                    }}\n                    value={type}\n                  >\n                    <SelectTrigger className=\"w-full\">\n                      <SelectValue className=\"capitalize\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"public\">Public</SelectItem>\n                      <SelectItem value=\"private\">Private</SelectItem>\n                    </SelectContent>\n                  </Select>\n                  <p className=\"text-[11px] text-muted-foreground\">\n                    {type === \"public\"\n                      ? \"Read-only access to all resources.\"\n                      : \"Read and write access to all resources. Keep this key secret.\"}\n                  </p>\n                </div>\n              )}\n\n              <DialogFooter>\n                <DialogClose\n                  render={\n                    <Button size=\"sm\" variant=\"outline\">\n                      Cancel\n                    </Button>\n                  }\n                />\n                <AsyncButton\n                  className=\"gap-2\"\n                  isLoading={isSubmitting || isCreating || isUpdating}\n                  size=\"sm\"\n                  type=\"submit\"\n                >\n                  {mode === \"create\" ? \"Create\" : \"Update\"}\n                </AsyncButton>\n              </DialogFooter>\n            </form>\n          )}\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/keys/columns.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport type { ApiScope } from \"@/utils/keys\";\nimport TableActions from \"./table-actions\";\n\nexport interface APIKey {\n  id: string;\n  name: string;\n  preview: string;\n  type: \"public\" | \"private\";\n  scopes: ApiScope[];\n  requestCount: number;\n  enabled: boolean;\n  lastUsed: Date | null;\n  expiresAt: Date | null;\n  createdAt: Date;\n}\n\nexport const columns: ColumnDef<APIKey>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"Name\",\n  },\n  {\n    accessorKey: \"type\",\n    header: \"Type\",\n    cell: ({ row }) => {\n      const type = row.original.type;\n      return (\n        <Badge variant={type === \"public\" ? \"info\" : \"pending\"}>\n          {type === \"public\" ? \"Public\" : \"Private\"}\n        </Badge>\n      );\n    },\n  },\n  {\n    accessorKey: \"preview\",\n    header: \"Key\",\n    cell: ({ row }) => {\n      const preview = row.original.preview;\n      return <span className=\"font-mono text-sm\">{preview}</span>;\n    },\n  },\n  {\n    accessorKey: \"createdAt\",\n    header: \"Created At\",\n    cell: ({ row }) => format(row.original.createdAt, \"MMM dd, yyyy\"),\n  },\n  {\n    accessorKey: \"lastUsed\",\n    header: \"Last Used\",\n    cell: ({ row }) => {\n      const lastUsed = row.original.lastUsed;\n      return lastUsed ? format(lastUsed, \"MMM dd, yyyy\") : \"Never\";\n    },\n  },\n  {\n    id: \"actions\",\n    header: () => <div className=\"flex justify-end pr-10\">Actions</div>,\n    cell: ({ row }) => {\n      const apiKey = row.original;\n\n      return (\n        <div className=\"flex justify-end pr-10\">\n          <TableActions {...apiKey} />\n        </div>\n      );\n    },\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/keys/data-table.tsx",
    "content": "\"use client\";\n\nimport {\n  Cancel01Icon,\n  PlusSignIcon,\n  SearchIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport {\n  type ColumnDef,\n  type ColumnFiltersState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  type SortingState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useState } from \"react\";\nimport { ApiKeyModal } from \"./api-key-modal\";\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n}\n\nexport function DataTable<TData, TValue>({\n  columns,\n  data,\n}: DataTableProps<TData, TValue>) {\n  const [sorting, _setSorting] = useState<SortingState>([]);\n\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n\n  const [showCreateModal, setShowCreateModal] = useState(false);\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    onColumnFiltersChange: setColumnFilters,\n    getFilteredRowModel: getFilteredRowModel(),\n    state: {\n      sorting,\n      columnFilters,\n    },\n  });\n\n  return (\n    <div>\n      <div className=\"flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"relative\">\n          <HugeiconsIcon\n            className=\"-translate-y-1/2 absolute top-1/2 left-3 text-muted-foreground\"\n            icon={SearchIcon}\n            size={16}\n            strokeWidth={2}\n          />\n          <Input\n            className=\"w-full px-8 sm:w-72\"\n            onChange={(event) =>\n              table.getColumn(\"name\")?.setFilterValue(event.target.value)\n            }\n            placeholder=\"Search API keys...\"\n            value={(table.getColumn(\"name\")?.getFilterValue() as string) ?? \"\"}\n          />\n          {(table.getColumn(\"name\")?.getFilterValue() as string) && (\n            <button\n              className=\"-translate-y-1/2 absolute top-1/2 right-3\"\n              onClick={() => table.getColumn(\"name\")?.setFilterValue(\"\")}\n              type=\"button\"\n            >\n              <HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={2} />\n              <span className=\"sr-only\">Clear search</span>\n            </button>\n          )}\n        </div>\n        <div>\n          <Button onClick={() => setShowCreateModal(true)}>\n            <HugeiconsIcon icon={PlusSignIcon} size={16} strokeWidth={2} />\n            <span>New API Key</span>\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  data-state={row.getIsSelected() && \"selected\"}\n                  key={row.id}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  className=\"h-96 text-center\"\n                  colSpan={columns.length}\n                >\n                  No API keys to show.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      <ApiKeyModal\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/keys/delete-key.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { AsyncButton } from \"../ui/async-button\";\n\nexport const DeleteKeyModal = ({\n  open,\n  setOpen,\n  id,\n  name,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  id: string;\n  name: string;\n}) => {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: deleteKey, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await fetch(`/api/keys/${id}`, {\n        method: \"DELETE\",\n      });\n\n      if (!res.ok) {\n        const errorText = await res.json().catch(() => \"Unknown error\");\n        throw new Error(errorText.error || \"Failed to delete key\");\n      }\n\n      return true;\n    },\n    onSuccess: () => {\n      toast.success(\"Key deleted successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.KEYS(workspaceId),\n        });\n      }\n      setOpen(false);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete {name}?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            This will permanently delete this key and any requests using it will\n            fail.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isPending} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteKey();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/keys/table-actions.tsx",
    "content": "import {\n  Delete02Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { useState } from \"react\";\nimport { ApiKeyModal } from \"./api-key-modal\";\nimport type { APIKey } from \"./columns\";\nimport { DeleteKeyModal } from \"./delete-key\";\n\nexport default function TableActions(props: APIKey) {\n  const [showUpdateModal, setShowUpdateModal] = useState(false);\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button className=\"size-8 p-0\" variant=\"ghost\">\n              <span className=\"sr-only\">Open menu</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent\n          align=\"end\"\n          className=\"text-muted-foreground shadow-sm\"\n        >\n          <DropdownMenuItem onClick={() => setShowUpdateModal(true)}>\n            <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n            <span>Edit</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setShowDeleteModal(true)}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon icon={Delete02Icon} size={16} />\n            <span>Delete</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <ApiKeyModal\n        data={{ ...props }}\n        mode=\"update\"\n        open={showUpdateModal}\n        setOpen={setShowUpdateModal}\n      />\n\n      <DeleteKeyModal\n        id={props.id}\n        name={props.name}\n        open={showDeleteModal}\n        setOpen={setShowDeleteModal}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/layout/header-sidebar-trigger.tsx",
    "content": "\"use client\";\n\nimport { Separator } from \"@marble/ui/components/separator\";\nimport {\n  getSidebarKeyboardShortcutLabel,\n  SidebarTrigger,\n  useSidebar,\n} from \"@marble/ui/components/sidebar\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nconst sidebarToggleTransition = {\n  bounce: 0.18,\n  duration: 0.8,\n  type: \"spring\",\n} as const;\n\nexport function HeaderSidebarTrigger() {\n  const { isMobile, open, openMobile } = useSidebar();\n  const showSidebarTrigger = isMobile || !open;\n  const isSidebarOpen = isMobile ? openMobile : open;\n  const sidebarActionLabel = isSidebarOpen ? \"Close Sidebar\" : \"Open Sidebar\";\n  const motionProps = isMobile\n    ? {\n        animate: { opacity: 1 },\n        exit: { opacity: 1 },\n        initial: { opacity: 1 },\n        transition: { duration: 0 },\n      }\n    : {\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        initial: { opacity: 0 },\n        transition: { duration: 0.15 },\n      };\n\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      {showSidebarTrigger && (\n        <motion.div\n          className=\"z-100 flex items-center gap-2 px-4 md:px-0\"\n          key=\"header-sidebar-toggle\"\n          {...motionProps}\n        >\n          <motion.div\n            className=\"-ml-1 flex size-8 shrink-0 items-center justify-center\"\n            layoutId={isMobile ? undefined : \"main-sidebar-toggle\"}\n            transition={isMobile ? { duration: 0 } : sidebarToggleTransition}\n          >\n            <Tooltip>\n              <TooltipTrigger\n                delay={400}\n                render={\n                  <SidebarTrigger\n                    className=\"size-8 text-sidebar-foreground\"\n                    variant=\"ghost\"\n                  />\n                }\n              />\n              <TooltipContent>\n                <p>\n                  {sidebarActionLabel} ({getSidebarKeyboardShortcutLabel()})\n                </p>\n              </TooltipContent>\n            </Tooltip>\n          </motion.div>\n          <Separator className=\"my-auto mr-2 h-4\" orientation=\"vertical\" />\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/layout/page-header.tsx",
    "content": "\"use client\";\n\nimport { usePathname } from \"next/navigation\";\nimport type { ReactNode } from \"react\";\nimport { HeaderSidebarTrigger } from \"@/components/layout/header-sidebar-trigger\";\n\nexport const PageHeader = ({ title }: { title?: ReactNode }) => {\n  const pathname = usePathname();\n\n  const getHeading = () => {\n    const parts = pathname.split(\"/\").filter(Boolean);\n\n    // workspace routes: /[workspace]/\n    if (parts.length >= 2) {\n      const [, section, subsection] = parts;\n\n      // Handle settings pages\n      if (typeof section === \"string\" && section === \"settings\") {\n        if (!subsection) {\n          return \"General\";\n        }\n\n        // Map subsection to proper display names\n        const subsectionMap: Record<string, string> = {\n          general: \"General\",\n          members: \"Members\",\n          billing: \"Billing\",\n          editor: \"Editor\",\n        };\n\n        return subsectionMap[subsection] || subsection;\n      }\n\n      // For other sections like posts, just show the section name\n      if (typeof section === \"string\" && section.length > 0) {\n        return section.charAt(0).toUpperCase() + section.slice(1);\n      }\n    }\n\n    return \"Home\";\n  };\n\n  return (\n    <header className=\"sticky top-0 z-50 flex h-13 shrink-0 items-center gap-2 border-b border-dashed bg-background transition-[width,height] ease-linear md:px-4\">\n      <HeaderSidebarTrigger />\n      {/* <div>\n        <AppBreadcrumb />\n      </div> */}\n      <h1 className=\"font-medium text-lg capitalize\">\n        {title ?? getHeading()}\n      </h1>\n    </header>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/layout/wrapper.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type { ReactNode } from \"react\";\nimport { PageHeader } from \"@/components/layout/page-header\";\n\ntype DashboardBodySize = \"default\" | \"compact\" | \"wide\";\n\ninterface DashboardBodyProps {\n  children: ReactNode;\n  className?: string;\n  contentClassName?: string;\n  contextView?: ReactNode;\n  contextViewClassName?: string;\n  flush?: boolean;\n  header?: ReactNode;\n  showHeader?: boolean;\n  size?: DashboardBodySize;\n  title?: ReactNode;\n}\n\nfunction getBodySizeClassName(size: DashboardBodySize) {\n  switch (size) {\n    case \"compact\":\n      return \"workspace-container-compact\";\n    case \"wide\":\n      return \"workspace-container-wide\";\n    default:\n      return \"workspace-container\";\n  }\n}\n\nexport function DashboardBody({\n  children,\n  className,\n  contentClassName,\n  contextView,\n  contextViewClassName,\n  flush = false,\n  header,\n  showHeader = true,\n  size = \"default\",\n  title,\n}: DashboardBodyProps) {\n  if (flush) {\n    return (\n      <div className=\"flex h-svh max-h-svh w-full flex-col overflow-hidden bg-background\">\n        {showHeader ? (header ?? <PageHeader title={title} />) : null}\n        <div className=\"flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden lg:flex-row\">\n          <div\n            className={cn(\n              \"flex min-h-0 w-full min-w-0 flex-1 flex-col\",\n              className\n            )}\n          >\n            {children}\n          </div>\n          {contextView ? (\n            <aside\n              className={cn(\n                \"flex h-full min-h-0 w-full shrink-0 flex-col overflow-hidden border-t border-dashed bg-background lg:w-[360px] lg:border-t-0 lg:border-l\",\n                contextViewClassName\n              )}\n            >\n              {contextView}\n            </aside>\n          ) : null}\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex min-h-full w-full flex-col\">\n      {showHeader ? (header ?? <PageHeader title={title} />) : null}\n      <section className=\"scrollbar-stable flex min-h-[calc(100vh-56px)] w-full flex-1 flex-col gap-4 px-4 py-2 md:px-6 lg:px-8 xl:px-12\">\n        <div className=\"flex w-full flex-1 flex-col gap-4 lg:flex-row\">\n          <div\n            className={cn(\n              getBodySizeClassName(size),\n              \"mx-auto flex h-full w-full min-w-0 flex-col py-8\",\n              contextView ? \"lg:mx-0\" : \"mx-auto\"\n            )}\n          >\n            <div\n              className={cn(\n                \"flex w-full flex-col\",\n                className,\n                contentClassName\n              )}\n            >\n              {children}\n            </div>\n          </div>\n          {contextView ? (\n            <aside\n              className={cn(\n                \"w-full shrink-0 rounded-[20px] border bg-background lg:sticky lg:top-[72px] lg:max-h-[calc(100vh-88px)] lg:w-[360px] lg:overflow-y-auto\",\n                contextViewClassName\n              )}\n            >\n              {contextView}\n            </aside>\n          ) : null}\n        </div>\n      </section>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/crop-image-modal.tsx",
    "content": "\"use client\";\n\nimport { Image01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport {\n  ImageCrop,\n  ImageCropApply,\n  ImageCropContent,\n  ImageCropReset,\n} from \"@marble/ui/components/kibo-ui/image-crop\";\nimport { useCallback } from \"react\";\nimport { MAX_AVATAR_FILE_SIZE } from \"@/lib/constants\";\n\ninterface Props {\n  open: boolean;\n  reset: () => void;\n  onOpenChange: (open: boolean) => void;\n  onCropped: (file: File) => void;\n  aspect?: number;\n  title?: string;\n  maxImageSize?: number;\n  file: File | null;\n}\n\nexport function CropImageModal({\n  open,\n  reset,\n  onOpenChange,\n  onCropped,\n  aspect = 1,\n  title = \"Crop Avatar\",\n  maxImageSize = MAX_AVATAR_FILE_SIZE,\n  file,\n}: Props) {\n  const handleClose = useCallback(\n    (next: boolean) => {\n      if (!next) {\n        reset();\n      }\n      onOpenChange(next);\n    },\n    [onOpenChange, reset]\n  );\n\n  const handleCropped = useCallback(\n    (dataUrl: string) => {\n      const output = dataUrlToFile(dataUrl, file?.name || \"avatar.png\");\n      onCropped(output);\n      handleClose(false);\n    },\n    [file?.name, onCropped, handleClose]\n  );\n\n  return (\n    <Dialog onOpenChange={handleClose} open={open}>\n      <DialogContent className=\"sm:max-w-xl\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={Image01Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              {title}\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogBody>\n          <div className=\"flex flex-col items-center gap-4\">\n            {file ? (\n              <div className=\"space-y-3\">\n                <ImageCrop\n                  aspect={aspect}\n                  file={file}\n                  maxImageSize={maxImageSize}\n                  onCrop={handleCropped}\n                >\n                  <div className=\"flex flex-col items-center gap-4\">\n                    <ImageCropContent className=\"max-w-full rounded-md shadow-sm\" />\n                    <div className=\"flex items-center gap-2\">\n                      <ImageCropReset asChild>\n                        <Button size=\"sm\" type=\"button\" variant=\"secondary\">\n                          Reset\n                        </Button>\n                      </ImageCropReset>\n                      <ImageCropApply asChild>\n                        <Button size=\"sm\" type=\"button\" variant=\"default\">\n                          Apply\n                        </Button>\n                      </ImageCropApply>\n                    </div>\n                  </div>\n                </ImageCrop>\n              </div>\n            ) : null}\n          </div>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nconst DATA_URL_REGEX = /^data:(.+?);base64,(.*)$/;\n\nfunction dataUrlToFile(dataUrl: string, filename: string): File {\n  const match = dataUrl.match(DATA_URL_REGEX);\n  const mime = match?.[1] ?? \"image/png\";\n\n  const base64 = match?.[2] ?? \"\";\n  if (!base64) {\n    return new File([], filename, { type: mime });\n  }\n  const bin = atob(base64);\n  const len = bin.length;\n  const buf = new Uint8Array(len);\n\n  for (let i = 0; i < len; i += 1) {\n    buf[i] = bin.charCodeAt(i);\n  }\n\n  return new File([buf], filename, { type: mime });\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/delete-modal.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport {\n  type InfiniteData,\n  useMutation,\n  useQueryClient,\n} from \"@tanstack/react-query\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { Media, MediaListResponse } from \"@/types/media\";\nimport { AsyncButton } from \"../ui/async-button\";\n\ninterface DeleteMediaModalProps {\n  isOpen: boolean;\n  setIsOpen: (isOpen: boolean) => void;\n  mediaToDelete: Media[];\n  onDeleteComplete?: (deletedIds: string[]) => void;\n}\n\nfunction removeDeletedMediaFromPage(\n  page: MediaListResponse,\n  idsToDelete: Set<string>\n): MediaListResponse {\n  const removedCount = page.media.reduce(\n    (count, item) => count + (idsToDelete.has(item.id) ? 1 : 0),\n    0\n  );\n  const media = page.media.filter((item) => !idsToDelete.has(item.id));\n\n  if (\"totalCount\" in page) {\n    return {\n      ...page,\n      media,\n      totalCount: Math.max(0, page.totalCount - removedCount),\n    };\n  }\n\n  return {\n    ...page,\n    media,\n  };\n}\n\nexport function DeleteMediaModal({\n  isOpen,\n  setIsOpen,\n  mediaToDelete,\n  onDeleteComplete,\n}: DeleteMediaModalProps) {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const count = mediaToDelete.length;\n  const isSingleItem = count === 1;\n  const singleItem = isSingleItem ? mediaToDelete[0] : null;\n\n  const { mutate: deleteMedia, isPending } = useMutation({\n    mutationFn: async (mediaIds: string[]) => {\n      const response = await fetch(\"/api/media\", {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ mediaIds }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        throw new Error(errorData.error || \"Failed to delete media\");\n      }\n\n      return response.json();\n    },\n    onMutate: async (mediaIds) => {\n      // Cancel any outgoing refetches to avoid overwriting optimistic update\n      await queryClient.cancelQueries({\n        queryKey: workspaceId ? QUERY_KEYS.MEDIA(workspaceId) : [],\n      });\n\n      // Snapshot all media queries for rollback\n      const previousQueries = queryClient.getQueriesData<\n        InfiniteData<MediaListResponse> | MediaListResponse\n      >({\n        queryKey: workspaceId ? QUERY_KEYS.MEDIA(workspaceId) : [],\n      });\n\n      const idsToDelete = new Set(mediaIds);\n\n      // Optimistically remove items from all media queries\n      if (workspaceId) {\n        queryClient.setQueriesData<\n          InfiniteData<MediaListResponse> | MediaListResponse\n        >({ queryKey: QUERY_KEYS.MEDIA(workspaceId) }, (oldData) => {\n          if (!oldData) {\n            return oldData;\n          }\n          if (\"pages\" in oldData) {\n            return {\n              ...oldData,\n              pages: oldData.pages.map((page) =>\n                removeDeletedMediaFromPage(page, idsToDelete)\n              ),\n            };\n          }\n          return removeDeletedMediaFromPage(oldData, idsToDelete);\n        });\n      }\n\n      setIsOpen(false);\n\n      const loadingMessage =\n        mediaIds.length === 1\n          ? \"Deleting media...\"\n          : `Deleting ${mediaIds.length} items...`;\n      toast.loading(loadingMessage, { id: \"deleting-media\" });\n\n      return { previousQueries, deletedCount: mediaIds.length };\n    },\n    onSuccess: (_data, mediaIds, context) => {\n      const deletedCount = context?.deletedCount || 0;\n      const message =\n        deletedCount === 1\n          ? \"Media deleted successfully\"\n          : `${deletedCount} items deleted successfully`;\n      toast.success(message, { id: \"deleting-media\" });\n\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.MEDIA(workspaceId),\n        });\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.BILLING_USAGE(workspaceId),\n        });\n      }\n\n      onDeleteComplete?.(mediaIds);\n    },\n    onError: (error, _mediaIds, context) => {\n      // Rollback to previous state on error\n      if (context?.previousQueries) {\n        for (const [queryKey, data] of context.previousQueries) {\n          queryClient.setQueryData(queryKey, data);\n        }\n      }\n      toast.error(error.message, { id: \"deleting-media\" });\n    },\n  });\n\n  const handleDelete = () => {\n    if (mediaToDelete.length > 0) {\n      deleteMedia(mediaToDelete.map((m) => m.id));\n    }\n  };\n\n  const title = isSingleItem\n    ? `Delete this ${singleItem?.type || \"media\"}?`\n    : `Delete ${count} media items?`;\n\n  const description = isSingleItem\n    ? `Deleting this ${singleItem?.type || \"media\"} will break posts where it is being used. Please make sure to update all posts using this ${singleItem?.type || \"media\"}.`\n    : \"Deleting these media items will break posts where they are being used. Please make sure to update all posts using these items.\";\n\n  const buttonText = isSingleItem ? \"Delete\" : `Delete ${count} items`;\n\n  return (\n    <AlertDialog onOpenChange={setIsOpen} open={isOpen}>\n      <AlertDialogContent variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              {title}\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            {description}\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel size=\"sm\">Cancel</AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={handleDelete}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              {buttonText}\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/file-upload-input.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { PlusIcon, UploadIcon } from \"@phosphor-icons/react\";\nimport { useId, useRef } from \"react\";\nimport { toast } from \"sonner\";\nimport { ALLOWED_MIME_TYPES, MAX_MEDIA_FILE_SIZE } from \"@/lib/constants\";\n\ninterface FileUploadInputProps {\n  onUpload?: (files: FileList) => void;\n  isUploading?: boolean;\n  accept?: string;\n  multiple?: boolean;\n  variant?: \"default\" | \"icon\";\n  className?: string;\n  children?: React.ReactNode;\n  maxSize?: number;\n}\n\nexport function FileUploadInput({\n  onUpload,\n  isUploading: isUploadingProp = false,\n  accept = ALLOWED_MIME_TYPES.join(\",\"),\n  variant = \"default\",\n  className,\n  children,\n  maxSize = MAX_MEDIA_FILE_SIZE,\n}: FileUploadInputProps) {\n  const inputId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {\n    const files = e.target.files;\n    if (!files || files.length === 0) {\n      return;\n    }\n\n    for (const file of Array.from(files)) {\n      if (file.size > maxSize) {\n        toast.error(\n          `File \"${file.name}\" exceeds the maximum size of ${maxSize / (1024 * 1024)} MB.`\n        );\n        return;\n      }\n    }\n\n    onUpload?.(files);\n    e.target.value = \"\";\n  }\n\n  return (\n    <>\n      <input\n        accept={accept}\n        aria-label=\"Upload File(s)\"\n        className=\"hidden\"\n        disabled={isUploadingProp}\n        id={inputId}\n        onChange={handleFileUpload}\n        ref={inputRef}\n        type=\"file\"\n      />\n      <Button\n        aria-busy={isUploadingProp ? \"true\" : \"false\"}\n        aria-disabled={isUploadingProp ? \"true\" : \"false\"}\n        className={className}\n        disabled={isUploadingProp}\n        onClick={() => {\n          inputRef.current?.click();\n        }}\n        size=\"sm\"\n        type=\"button\"\n      >\n        {children || (\n          <>\n            {variant === \"icon\" ? (\n              <PlusIcon className=\"size-4\" />\n            ) : (\n              <UploadIcon size={16} />\n            )}\n            <span>{variant === \"icon\" ? \"Upload\" : \"Upload File(s)\"}</span>\n          </>\n        )}\n      </Button>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/media-actions.tsx",
    "content": "\"use client\";\n\nimport {\n  Copy01Icon,\n  Delete02Icon,\n  MoreVerticalIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { memo } from \"react\";\nimport type { Media } from \"@/types/media\";\n\ninterface MediaActionsProps {\n  media: Media;\n  onDelete: (media: Media) => void;\n}\n\nasync function copyMediaUrl(url: string) {\n  try {\n    await navigator.clipboard.writeText(url);\n    toast.success(\"Copied media URL\");\n  } catch {\n    toast.error(\"Could not copy media URL\");\n  }\n}\n\nexport const MediaActions = memo(function MediaActions({\n  media,\n  onDelete,\n}: MediaActionsProps) {\n  return (\n    <div className=\"flex justify-end\">\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button\n              className=\"size-8 p-0\"\n              onClick={(e) => {\n                e.stopPropagation();\n                e.preventDefault();\n              }}\n              variant=\"ghost\"\n            >\n              <span className=\"sr-only\">Open menu</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent align=\"end\">\n          <DropdownMenuItem\n            onClick={(event) => {\n              event.stopPropagation();\n              copyMediaUrl(media.url);\n            }}\n          >\n            <HugeiconsIcon className=\"mr-2 size-4\" icon={Copy01Icon} />\n            Copy link\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={(event) => {\n              event.stopPropagation();\n              onDelete(media);\n            }}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon className=\"mr-2 size-4\" icon={Delete02Icon} />\n            Delete\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n});\n"
  },
  {
    "path": "apps/cms/src/components/media/media-card.tsx",
    "content": "\"use client\";\n\nimport { Card, CardContent, CardFooter } from \"@marble/ui/components/card\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CheckIcon,\n  DotsThreeVerticalIcon,\n  DownloadSimpleIcon,\n  FileAudioIcon,\n  FileIcon,\n  FileImageIcon,\n  FileVideoIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport { format } from \"date-fns\";\nimport Image from \"next/image\";\nimport { type ElementType, useMemo } from \"react\";\nimport { blurhashToDataUrl } from \"@/lib/blurhash\";\nimport type { Media, MediaType } from \"@/types/media\";\nimport { formatBytes } from \"@/utils/string\";\nimport { VideoPlayer } from \"./video-player\";\n\ninterface MediaCardProps {\n  media: Media;\n  onDelete: (media: Media) => void;\n  isSelected?: boolean;\n  onSelect: () => void;\n}\n\nconst mediaTypeIcons: Record<MediaType, ElementType> = {\n  image: FileImageIcon,\n  video: FileVideoIcon,\n  audio: FileAudioIcon,\n  document: FileIcon,\n};\n\nexport function MediaCard({\n  media,\n  onDelete,\n  isSelected = false,\n  onSelect,\n}: MediaCardProps) {\n  const Icon = mediaTypeIcons[media.type];\n  const blurDataUrl = useMemo(() => {\n    if (media.type !== \"image\" || !media.blurHash) {\n      return undefined;\n    }\n\n    return blurhashToDataUrl(media.blurHash);\n  }, [media.blurHash, media.type]);\n\n  const handleDownload = () => {\n    window.open(media.url, \"_blank\", \"noopener,noreferrer\");\n  };\n\n  return (\n    <Card\n      className={cn(\n        \"gap-2.5 overflow-hidden rounded-[20px] border-none bg-surface p-2\"\n      )}\n    >\n      <button\n        aria-label={`Select ${media?.name ?? \"media\"}`}\n        className=\"cursor-pointer rounded-[12px] outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n        onClick={onSelect}\n        type=\"button\"\n      >\n        <CardContent className=\"group overflow-hidden rounded-[12px] border-0 bg-background p-0 shadow-xs\">\n          <div className=\"relative aspect-video overflow-hidden\">\n            <div\n              className={`absolute inset-0 z-10 flex items-center justify-center rounded-[12px] transition-opacity duration-300 ${\n                isSelected ? \"opacity-100\" : \"opacity-0 group-hover:opacity-100\"\n              }`}\n            >\n              <div\n                className={cn(\n                  \"pointer-events-none absolute inset-0 z-10 bg-black/50 opacity-0 transition-opacity duration-300 group-hover:opacity-100\",\n                  isSelected && \"opacity-100\"\n                )}\n              />\n              <div className=\"relative z-20 rounded-full bg-white p-2 shadow-lg\">\n                <CheckIcon className=\"size-5 text-black\" weight=\"bold\" />\n              </div>\n            </div>\n            {media.type === \"image\" && (\n              <div className=\"h-full w-full bg-background\">\n                <Image\n                  alt={media.alt || media.name}\n                  blurDataURL={blurDataUrl}\n                  className=\"size-full object-cover\"\n                  height={160}\n                  placeholder={blurDataUrl ? \"blur\" : \"empty\"}\n                  src={media.url}\n                  unoptimized\n                  width={250}\n                />\n              </div>\n            )}\n            {media.type === \"video\" && <VideoPlayer src={media.url} />}\n            {(media.type === \"audio\" || media.type === \"document\") && (\n              <div className=\"flex h-full w-full items-center justify-center bg-muted\">\n                <Icon\n                  className=\"size-16 text-muted-foreground\"\n                  weight=\"duotone\"\n                />\n              </div>\n            )}\n          </div>\n        </CardContent>\n      </button>\n      <CardFooter className=\"grid w-full grid-cols-[1fr_auto] gap-4 p-0\">\n        <div className=\"flex items-start gap-3\">\n          <Icon\n            className={\"size-6 shrink-0 text-muted-foreground\"}\n            weight=\"duotone\"\n          />\n          <div className=\"flex flex-col\">\n            <p className=\"line-clamp-1 max-w-[180px] text-wrap font-medium text-sm\">\n              {media.name}\n            </p>\n            <div className=\"flex items-center gap-1 text-muted-foreground text-xs\">\n              <p>{formatBytes(media.size)}</p>\n              <span className=\"font-bold\">·</span>\n              <p>{format(new Date(media.createdAt), \"dd MMM yyyy\")}</p>\n            </div>\n          </div>\n        </div>\n        <DropdownMenu>\n          <DropdownMenuTrigger\n            render={\n              <button\n                aria-haspopup=\"menu\"\n                aria-label=\"More actions\"\n                className=\"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-background shadow-xs outline-none transition-all hover:bg-surface-foreground/10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-zinc-800 dark:hover:bg-zinc-700\"\n                onClick={(e) => {\n                  e.stopPropagation();\n                }}\n                type=\"button\"\n              >\n                <DotsThreeVerticalIcon size={16} />\n              </button>\n            }\n          />\n          <DropdownMenuContent align=\"end\">\n            <DropdownMenuItem\n              onClick={(e) => {\n                e.stopPropagation();\n                handleDownload();\n              }}\n            >\n              <DownloadSimpleIcon className=\"mr-2\" size={16} />\n              Download\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onClick={(e) => {\n                e.stopPropagation();\n                onDelete(media);\n              }}\n              variant=\"destructive\"\n            >\n              <TrashIcon className=\"mr-2\" size={16} />\n              Delete\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/media-columns.tsx",
    "content": "\"use client\";\n\nimport { Checkbox } from \"@marble/ui/components/checkbox\";\nimport {\n  FileAudioIcon,\n  FileIcon,\n  FileImageIcon,\n  FileVideoIcon,\n} from \"@phosphor-icons/react\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport Image from \"next/image\";\nimport { type ElementType, memo, useMemo } from \"react\";\nimport { blurhashToDataUrl } from \"@/lib/blurhash\";\nimport type { Media, MediaType } from \"@/types/media\";\nimport { formatBytes } from \"@/utils/string\";\nimport { MediaActions } from \"./media-actions\";\n\ninterface MediaColumnsOptions {\n  onDelete: (media: Media) => void;\n}\n\nconst mediaTypeIcons: Record<MediaType, ElementType> = {\n  image: FileImageIcon,\n  video: FileVideoIcon,\n  audio: FileAudioIcon,\n  document: FileIcon,\n};\n\nfunction getMediaTypeLabel(media: Media) {\n  return `${media.type.charAt(0).toUpperCase()}${media.type.slice(1)}`;\n}\n\nfunction getMediaDimensions(media: Media) {\n  if (media.width && media.height) {\n    return `${media.width} x ${media.height}`;\n  }\n  if (media.duration !== null) {\n    return `${Math.round(media.duration / 1000)}s`;\n  }\n  return \"-\";\n}\n\nconst MediaThumbnail = memo(function MediaThumbnail({\n  media,\n}: {\n  media: Media;\n}) {\n  const Icon = mediaTypeIcons[media.type] || FileIcon;\n  const blurDataUrl = useMemo(() => {\n    if (media.type !== \"image\" || !media.blurHash) {\n      return undefined;\n    }\n    return blurhashToDataUrl(media.blurHash);\n  }, [media.blurHash, media.type]);\n\n  if (media.type === \"image\") {\n    return (\n      <div className=\"relative size-11 overflow-hidden rounded-md bg-muted\">\n        <Image\n          alt=\"\"\n          blurDataURL={blurDataUrl}\n          className=\"size-full object-cover\"\n          decoding=\"async\"\n          height={48}\n          placeholder={blurDataUrl ? \"blur\" : \"empty\"}\n          sizes=\"48px\"\n          src={media.url}\n          unoptimized\n          width={48}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"grid size-12 place-items-center rounded-lg border border-dashed bg-[length:8px_8px] bg-[linear-gradient(45deg,transparent_25%,rgba(0,0,0,0.05)_25%,rgba(0,0,0,0.05)_50%,transparent_50%,transparent_75%,rgba(0,0,0,0.05)_75%,rgba(0,0,0,0.05))] dark:bg-[linear-gradient(45deg,transparent_25%,rgba(255,255,255,0.05)_25%,rgba(255,255,255,0.05)_50%,transparent_50%,transparent_75%,rgba(255,255,255,0.05)_75%,rgba(255,255,255,0.05))]\">\n      <Icon className=\"size-5 text-primary\" weight=\"duotone\" />\n    </div>\n  );\n});\n\nexport function getMediaColumns({\n  onDelete,\n}: MediaColumnsOptions): ColumnDef<Media>[] {\n  return [\n    {\n      id: \"select\",\n      header: ({ table }) => (\n        <Checkbox\n          aria-checked={\n            table.getIsSomePageRowsSelected() &&\n            !table.getIsAllPageRowsSelected()\n              ? \"mixed\"\n              : undefined\n          }\n          aria-label={\n            table.getIsAllPageRowsSelected() ? \"Deselect all\" : \"Select all\"\n          }\n          checked={table.getIsAllPageRowsSelected()}\n          onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}\n        />\n      ),\n      cell: ({ row }) => (\n        <Checkbox\n          aria-label={`Select ${row.original.name}`}\n          checked={row.getIsSelected()}\n          onCheckedChange={(value) => row.toggleSelected(!!value)}\n        />\n      ),\n      enableHiding: false,\n      enableSorting: false,\n      size: 40,\n    },\n    {\n      id: \"file\",\n      accessorKey: \"name\",\n      header: \"File\",\n      cell: ({ row }) => (\n        <div className=\"flex min-w-64 items-center gap-3\">\n          <MediaThumbnail media={row.original} />\n          <div className=\"min-w-0 max-w-48\">\n            <p className=\"truncate font-medium text-xs\">{row.original.name}</p>\n            <p className=\"text-muted-foreground text-xs\">\n              {getMediaTypeLabel(row.original)}\n            </p>\n          </div>\n        </div>\n      ),\n      meta: {\n        label: \"File\",\n      },\n    },\n    {\n      id: \"alt\",\n      accessorKey: \"alt\",\n      header: \"Alt text\",\n      cell: ({ row }) => (\n        <p className=\"max-w-32 truncate text-muted-foreground text-xs\">\n          {row.original.alt || \"-\"}\n        </p>\n      ),\n      meta: {\n        label: \"Alt text\",\n      },\n    },\n    {\n      id: \"createdAt\",\n      accessorKey: \"createdAt\",\n      header: \"Uploaded\",\n      cell: ({ row }) => (\n        <span className=\"text-muted-foreground text-xs\">\n          {format(new Date(row.original.createdAt), \"MMM d, yyyy\")}\n        </span>\n      ),\n      enableSorting: true,\n      meta: {\n        label: \"Uploaded\",\n      },\n    },\n    {\n      id: \"size\",\n      accessorKey: \"size\",\n      header: \"Size\",\n      cell: ({ row }) => (\n        <span className=\"text-muted-foreground text-xs\">\n          {formatBytes(row.original.size)}\n        </span>\n      ),\n      meta: {\n        label: \"Size\",\n      },\n    },\n    {\n      id: \"actions\",\n      cell: ({ row }) => (\n        <MediaActions media={row.original} onDelete={onDelete} />\n      ),\n      enableHiding: false,\n      enableSorting: false,\n      size: 48,\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/media-controls.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport {\n  PlusIcon,\n  RowsIcon,\n  SquaresFourIcon,\n  TrashIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { motion } from \"motion/react\";\nimport { useMediaPageFilters } from \"@/lib/search-params\";\nimport { isMediaFilterType, isMediaSort } from \"@/utils/media\";\nimport { FileUploadInput } from \"./file-upload-input\";\n\nconst typeOptions = [\n  { label: \"All\", value: \"all\" },\n  { label: \"Image\", value: \"image\" },\n  { label: \"Video\", value: \"video\" },\n];\n\nconst sortOptions = [\n  { label: \"Newest first\", value: \"createdAt_desc\" },\n  { label: \"Oldest first\", value: \"createdAt_asc\" },\n  { label: \"Name A-Z\", value: \"name_asc\" },\n  { label: \"Name Z-A\", value: \"name_desc\" },\n];\n\nexport function MediaControls({\n  onUpload,\n  isUploading,\n  selectedItems,\n  onSelectAll,\n  onDeselectAll,\n  onBulkDelete,\n  mediaLength,\n  viewType,\n  onViewTypeChange,\n  disabled = false,\n}: {\n  onUpload: (files: FileList) => void;\n  isUploading: boolean;\n  selectedItems: Set<string>;\n  onSelectAll: () => void;\n  onDeselectAll: () => void;\n  onBulkDelete: () => void;\n  mediaLength: number;\n  viewType: \"table\" | \"grid\";\n  onViewTypeChange: (viewType: \"table\" | \"grid\") => void;\n  disabled?: boolean;\n}) {\n  const [{ type, sort }, setSearchParams] = useMediaPageFilters();\n\n  const isDisabled = disabled || isUploading;\n  return (\n    <section className=\"flex flex-col gap-4 md:flex-row md:items-start md:justify-between\">\n      <div className=\"flex flex-wrap items-center gap-1 sm:gap-4\">\n        <Select\n          disabled={isDisabled}\n          items={typeOptions}\n          onValueChange={(val) => {\n            if (val && isMediaFilterType(val)) {\n              setSearchParams({ type: val });\n            }\n          }}\n          value={type}\n        >\n          <SelectTrigger className=\"min-w-[100px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {typeOptions.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                {option.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <Select\n          disabled={isDisabled}\n          items={sortOptions}\n          onValueChange={(val) => {\n            if (val && isMediaSort(val)) {\n              setSearchParams({ sort: val });\n            }\n          }}\n          value={sort}\n        >\n          <SelectTrigger className=\"min-w-[150px]\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {sortOptions.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                {option.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <div className=\"flex items-center gap-2\">\n          {selectedItems.size > 0 && (\n            <Button\n              aria-label=\"Deselect all\"\n              disabled={isDisabled}\n              onClick={onDeselectAll}\n              size=\"icon\"\n              type=\"button\"\n              variant=\"outline\"\n            >\n              <XIcon aria-hidden=\"true\" size={16} />\n            </Button>\n          )}\n          {mediaLength > 0 && (\n            <Button\n              className=\"shadow-none\"\n              disabled={isDisabled}\n              onClick={onSelectAll}\n              type=\"button\"\n              variant=\"outline\"\n            >\n              {selectedItems.size === mediaLength\n                ? \"Deselect All\"\n                : \"Select All\"}\n            </Button>\n          )}\n          {selectedItems.size > 0 && (\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    aria-label={`Delete selected (${selectedItems.size})`}\n                    disabled={isDisabled}\n                    onClick={onBulkDelete}\n                    size=\"icon\"\n                    type=\"button\"\n                    variant=\"destructive\"\n                  >\n                    <TrashIcon aria-hidden=\"true\" size={16} />\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                <p>Delete selected ({selectedItems.size})</p>\n              </TooltipContent>\n            </Tooltip>\n          )}\n        </div>\n      </div>\n      <div className=\"flex items-center justify-end gap-2\">\n        <div className=\"flex gap-0.5 rounded-xl bg-surface p-0.5 dark:bg-accent/50\">\n          <Tooltip>\n            <TooltipTrigger\n              render={\n                <Button\n                  className=\"relative h-8 w-9 rounded-[10px]\"\n                  disabled\n                  onClick={() => onViewTypeChange(\"grid\")}\n                  size=\"icon-sm\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  {viewType === \"grid\" && (\n                    <motion.div\n                      className=\"absolute inset-0 rounded-[10px] bg-background shadow-sm\"\n                      layoutId=\"mediaViewToggleHighlight\"\n                      transition={{\n                        type: \"spring\",\n                        bounce: 0.2,\n                        duration: 0.4,\n                      }}\n                    />\n                  )}\n                  <SquaresFourIcon className=\"relative z-10\" size={16} />\n                  <span className=\"sr-only\">Grid view</span>\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>Grid view coming soon</p>\n            </TooltipContent>\n          </Tooltip>\n          <Tooltip>\n            <TooltipTrigger\n              render={\n                <Button\n                  className=\"relative h-8 w-9 rounded-[10px]\"\n                  onClick={() => onViewTypeChange(\"table\")}\n                  size=\"icon-sm\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  {viewType === \"table\" && (\n                    <motion.div\n                      className=\"absolute inset-0 rounded-[10px] bg-background shadow-sm\"\n                      layoutId=\"mediaViewToggleHighlight\"\n                      transition={{\n                        type: \"spring\",\n                        bounce: 0.2,\n                        duration: 0.4,\n                      }}\n                    />\n                  )}\n                  <RowsIcon className=\"relative z-10\" size={16} />\n                  <span className=\"sr-only\">Table view</span>\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>Table view</p>\n            </TooltipContent>\n          </Tooltip>\n        </div>\n        <FileUploadInput\n          className=\"w-full sm:w-auto\"\n          isUploading={isUploading}\n          onUpload={onUpload}\n          variant=\"icon\"\n        >\n          <PlusIcon className=\"size-4\" />\n          <span>Upload</span>\n        </FileUploadInput>\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/media-data-table.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { FileImageIcon } from \"@phosphor-icons/react\";\nimport {\n  flexRender,\n  getCoreRowModel,\n  type OnChangeFn,\n  type PaginationState,\n  type RowSelectionState,\n  type SortingState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useParams, useRouter } from \"next/navigation\";\nimport {\n  type MouseEvent,\n  memo,\n  useCallback,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { DataTablePagination } from \"@/components/ui/data-table-pagination\";\nimport { useMediaActions } from \"@/hooks/use-media-actions\";\nimport { useMediaPageFilters } from \"@/lib/search-params\";\nimport type { Media, MediaQueryKey, MediaSort } from \"@/types/media\";\nimport { isMediaFilterType, isMediaSort, toMediaType } from \"@/utils/media\";\nimport { DeleteMediaModal } from \"./delete-modal\";\nimport { FileUploadInput } from \"./file-upload-input\";\nimport { getMediaColumns } from \"./media-columns\";\nimport { MediaTableToolbar } from \"./media-table-toolbar\";\n\ninterface MediaDataTableProps {\n  disabled?: boolean;\n  hasAnyMedia: boolean;\n  isUploading?: boolean;\n  media: Media[];\n  mediaQueryKey: MediaQueryKey;\n  onUpload?: (files: FileList) => void;\n  pageCount: number;\n  totalCount: number;\n}\n\nfunction sortToSortingState(sort: MediaSort): SortingState {\n  const [id, direction] = sort.split(\"_\");\n  return [{ id: id ?? \"createdAt\", desc: direction === \"desc\" }];\n}\n\nfunction sortingStateToSort(sorting: SortingState): MediaSort {\n  const [firstSort] = sorting;\n  if (!firstSort) {\n    return \"createdAt_desc\";\n  }\n\n  const nextSort = `${firstSort.id}_${firstSort.desc ? \"desc\" : \"asc\"}`;\n  return isMediaSort(nextSort) ? nextSort : \"createdAt_desc\";\n}\n\nexport function MediaDataTable({\n  disabled = false,\n  hasAnyMedia,\n  isUploading = false,\n  media,\n  mediaQueryKey,\n  onUpload,\n  pageCount,\n  totalCount,\n}: MediaDataTableProps) {\n  const params = useParams<{ workspace: string }>();\n  const router = useRouter();\n  const [{ page, perPage, search, sort, type }, setSearchParams] =\n    useMediaPageFilters();\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const [mediaToDelete, setMediaToDelete] = useState<Media[]>([]);\n  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\n  const { handleDeleteComplete, handleBulkDeleteComplete } =\n    useMediaActions(mediaQueryKey);\n\n  const requestDelete = useCallback((item: Media) => {\n    setMediaToDelete([item]);\n    setShowDeleteModal(true);\n  }, []);\n\n  const columns = useMemo(\n    () =>\n      getMediaColumns({\n        onDelete: requestDelete,\n      }),\n    [requestDelete]\n  );\n\n  const pagination = useMemo<PaginationState>(\n    () => ({\n      pageIndex: Math.max(0, page - 1),\n      pageSize: perPage,\n    }),\n    [page, perPage]\n  );\n\n  const sorting = useMemo(() => sortToSortingState(sort), [sort]);\n\n  const onPaginationChange: OnChangeFn<PaginationState> = (updaterOrValue) => {\n    const nextPagination =\n      typeof updaterOrValue === \"function\"\n        ? updaterOrValue(pagination)\n        : updaterOrValue;\n\n    setSearchParams({\n      page: nextPagination.pageIndex + 1,\n      perPage: nextPagination.pageSize,\n    });\n  };\n\n  const onSortingChange: OnChangeFn<SortingState> = (updaterOrValue) => {\n    const nextSorting =\n      typeof updaterOrValue === \"function\"\n        ? updaterOrValue(sorting)\n        : updaterOrValue;\n\n    setSearchParams({\n      page: 1,\n      sort: sortingStateToSort(nextSorting),\n    });\n  };\n\n  const table = useReactTable({\n    data: media,\n    columns,\n    pageCount,\n    getCoreRowModel: getCoreRowModel(),\n    getRowId: (row) => row.id,\n    manualFiltering: true,\n    manualPagination: true,\n    manualSorting: true,\n    onPaginationChange,\n    onRowSelectionChange: setRowSelection,\n    onSortingChange,\n    state: {\n      pagination,\n      rowSelection,\n      sorting,\n    },\n  });\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: reset row selection whenever the server-backed table query changes\n  useEffect(() => {\n    setRowSelection({});\n  }, [page, perPage, search, sort, type]);\n\n  const selectedMedia = useMemo(\n    () => media.filter((item) => rowSelection[item.id]),\n    [media, rowSelection]\n  );\n\n  const onDeleteComplete = useCallback(\n    (ids: string[]) => {\n      if (ids.length === 1 && ids[0]) {\n        handleDeleteComplete(ids[0]);\n      } else {\n        handleBulkDeleteComplete(ids);\n      }\n      setRowSelection({});\n    },\n    [handleBulkDeleteComplete, handleDeleteComplete]\n  );\n\n  const handleBulkDelete = useCallback(() => {\n    setMediaToDelete(selectedMedia);\n    setShowDeleteModal(true);\n  }, [selectedMedia]);\n\n  const openMediaDetail = useCallback(\n    (item: Media) => {\n      router.push(`/${params.workspace}/media/${item.id}`);\n    },\n    [params.workspace, router]\n  );\n\n  if (!hasAnyMedia) {\n    return (\n      <>\n        <div className=\"grid min-h-[50vh] place-content-center rounded-[20px] bg-surface p-6 text-center\">\n          <div className=\"flex max-w-80 flex-col items-center gap-4\">\n            <div className=\"grid size-16 place-items-center rounded-2xl bg-background\">\n              <FileImageIcon className=\"size-8 text-muted-foreground\" />\n            </div>\n            <p className=\"text-muted-foreground text-sm\">\n              Media you upload in this workspace will appear here.\n            </p>\n            {onUpload && (\n              <FileUploadInput isUploading={isUploading} onUpload={onUpload} />\n            )}\n          </div>\n        </div>\n        <DeleteMediaModal\n          isOpen={showDeleteModal}\n          mediaToDelete={mediaToDelete}\n          onDeleteComplete={onDeleteComplete}\n          setIsOpen={setShowDeleteModal}\n        />\n      </>\n    );\n  }\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-4\">\n        <MediaTableToolbar\n          disabled={disabled}\n          isUploading={isUploading}\n          onBulkDelete={handleBulkDelete}\n          onUpload={onUpload}\n          selectedCount={selectedMedia.length}\n          table={table}\n        />\n        <div\n          className={cn(\n            \"flex flex-col gap-3 transition-opacity duration-150\",\n            disabled && \"pointer-events-none opacity-50\"\n          )}\n        >\n          <div className=\"overflow-hidden rounded-[20px] bg-surface p-1 [&_[data-slot=table-container]]:overflow-x-auto [&_[data-slot=table-container]]:overflow-y-hidden\">\n            <Table className=\"-mb-1 h-fit border-separate border-spacing-y-1\">\n              <TableHeader>\n                {table.getHeaderGroups().map((headerGroup) => (\n                  <TableRow\n                    className=\"border-0 text-[13px] hover:bg-transparent\"\n                    key={headerGroup.id}\n                  >\n                    {headerGroup.headers.map((header) => (\n                      <TableHead\n                        className={getHeaderClassName(header.column.id)}\n                        key={header.id}\n                      >\n                        {header.isPlaceholder\n                          ? null\n                          : flexRender(\n                              header.column.columnDef.header,\n                              header.getContext()\n                            )}\n                      </TableHead>\n                    ))}\n                  </TableRow>\n                ))}\n              </TableHeader>\n              <TableBody>\n                {table.getRowModel().rows.length ? (\n                  table.getRowModel().rows.map((row) => (\n                    <TableRow\n                      className=\"cursor-pointer border-0 bg-background hover:bg-background/80 data-[state=selected]:bg-background\"\n                      data-state={row.getIsSelected() && \"selected\"}\n                      key={row.id}\n                      onClick={(event) => {\n                        if (shouldIgnoreRowClick(event)) {\n                          return;\n                        }\n                        openMediaDetail(row.original);\n                      }}\n                    >\n                      {row.getVisibleCells().map((cell) => (\n                        <TableCell\n                          className={getCellClassName(cell.column.id)}\n                          data-no-row-click={\n                            cell.column.id === \"select\" ||\n                            cell.column.id === \"actions\"\n                              ? true\n                              : undefined\n                          }\n                          key={cell.id}\n                          {...(cell.column.id === \"actions\" && {\n                            \"data-actions-cell\": \"true\",\n                            onClick: (event: MouseEvent) =>\n                              event.stopPropagation(),\n                          })}\n                        >\n                          {flexRender(\n                            cell.column.columnDef.cell,\n                            cell.getContext()\n                          )}\n                        </TableCell>\n                      ))}\n                    </TableRow>\n                  ))\n                ) : (\n                  <TableRow className=\"border-0 bg-background\">\n                    <TableCell\n                      className=\"h-28 rounded-[14px] text-center text-muted-foreground text-sm\"\n                      colSpan={columns.length}\n                    >\n                      No media found. Try adjusting your filters.\n                    </TableCell>\n                  </TableRow>\n                )}\n              </TableBody>\n            </Table>\n          </div>\n          <div>\n            <DataTablePagination\n              canNextPage={pagination.pageIndex + 1 < pageCount}\n              canPreviousPage={pagination.pageIndex > 0}\n              mediaCount={media.length}\n              onPageChange={(pageIndex) => {\n                setSearchParams({ page: pageIndex + 1 });\n              }}\n              pageCount={pageCount}\n              pageIndex={pagination.pageIndex}\n              rowCount={media.length}\n              selectedCount={selectedMedia.length}\n              totalCount={totalCount}\n            />\n          </div>\n        </div>\n      </div>\n      <DeleteMediaModal\n        isOpen={showDeleteModal}\n        mediaToDelete={mediaToDelete}\n        onDeleteComplete={onDeleteComplete}\n        setIsOpen={setShowDeleteModal}\n      />\n    </>\n  );\n}\n\nfunction shouldIgnoreRowClick(event: MouseEvent) {\n  const target = event.target;\n  return (\n    target instanceof HTMLElement &&\n    Boolean(\n      target.closest(\n        \"button, a, input, textarea, select, [data-no-row-click], [role='button'], [role='checkbox'], [role='menuitem']\"\n      )\n    )\n  );\n}\n\nfunction getHeaderClassName(columnId: string) {\n  switch (columnId) {\n    case \"select\":\n      return \"w-10 px-3\";\n    case \"alt\":\n      return \"hidden px-3 text-muted-foreground lg:table-cell\";\n    case \"createdAt\":\n      return \"hidden px-3 text-muted-foreground md:table-cell\";\n    case \"details\":\n    case \"references\":\n      return \"hidden px-3 text-muted-foreground 2xl:table-cell\";\n    case \"actions\":\n      return \"sr-only w-12 px-3 text-right text-muted-foreground\";\n    default:\n      return \"px-3 text-muted-foreground\";\n  }\n}\n\nfunction getCellClassName(columnId: string) {\n  switch (columnId) {\n    case \"select\":\n      return \"rounded-l-[14px] px-3 py-2\";\n    case \"alt\":\n      return \"hidden max-w-72 px-3 py-2 lg:table-cell\";\n    case \"createdAt\":\n      return \"hidden px-3 py-2 md:table-cell\";\n    case \"details\":\n    case \"references\":\n      return \"hidden px-3 py-2 2xl:table-cell\";\n    case \"actions\":\n      return \"rounded-r-[14px] px-3 py-2\";\n    default:\n      return \"px-3 py-2\";\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/media-gallery.tsx",
    "content": "\"use client\";\n\nimport { Image02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Skeleton } from \"@marble/ui/components/skeleton\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { DeleteMediaModal } from \"@/components/media/delete-modal\";\nimport { MediaCard } from \"@/components/media/media-card\";\nimport { useMediaActions } from \"@/hooks/use-media-actions\";\nimport type { Media, MediaQueryKey, MediaType } from \"@/types/media\";\nimport { getEmptyStateMessage } from \"@/utils/media\";\nimport { FileUploadInput } from \"./file-upload-input\";\n\ninterface MediaGalleryProps {\n  media: Media[];\n  hasNextPage?: boolean;\n  onLoadMore?: () => void;\n  isFetchingNextPage?: boolean;\n  isFetching?: boolean;\n  type?: MediaType;\n  selectedItems: Set<string>;\n  onSelectItem: (items: Set<string>) => void;\n  hasAnyMedia: boolean;\n  showDeleteModal: boolean;\n  setShowDeleteModal: (show: boolean) => void;\n  mediaToDelete: Media[];\n  setMediaToDelete: (items: Media[]) => void;\n  mediaQueryKey: MediaQueryKey;\n  onUpload?: (files: FileList) => void;\n  isUploading?: boolean;\n}\n\nexport function MediaGallery({\n  media,\n  hasNextPage,\n  onLoadMore,\n  isFetchingNextPage,\n  isFetching,\n  type,\n  selectedItems,\n  onSelectItem,\n  hasAnyMedia,\n  showDeleteModal,\n  setShowDeleteModal,\n  mediaToDelete,\n  setMediaToDelete,\n  mediaQueryKey,\n  onUpload,\n  isUploading = false,\n}: MediaGalleryProps) {\n  const { handleDeleteComplete, handleBulkDeleteComplete } =\n    useMediaActions(mediaQueryKey);\n\n  const onDeleteComplete = (ids: string[]) => {\n    if (ids.length === 1 && ids[0]) {\n      handleDeleteComplete(ids[0]);\n    } else {\n      handleBulkDeleteComplete(ids);\n    }\n    // Remove deleted items from selection\n    const newSelectedItems = new Set(selectedItems);\n    for (const id of ids) {\n      newSelectedItems.delete(id);\n    }\n    onSelectItem(newSelectedItems);\n  };\n\n  const handleSelectItem = (id: string) => {\n    const newSet = new Set(selectedItems);\n    if (newSet.has(id)) {\n      newSet.delete(id);\n    } else {\n      newSet.add(id);\n    }\n    onSelectItem(newSet);\n  };\n\n  // Open delete modal for single item (from card dropdown)\n  const handleDeleteSingle = (item: Media) => {\n    setMediaToDelete([item]);\n    setShowDeleteModal(true);\n  };\n\n  // Open delete modal for selected items (from bulk action)\n  const handleDeleteSelected = () => {\n    const itemsToDelete = media.filter((item) => selectedItems.has(item.id));\n    setMediaToDelete(itemsToDelete);\n    setShowDeleteModal(true);\n  };\n\n  const isRefetching = isFetching && !isFetchingNextPage && media.length > 0;\n\n  const renderSkeletonCard = (index: number, keyPrefix: string) => (\n    <li className=\"space-y-2.5 rounded-[20px]\" key={`${keyPrefix}-${index}`}>\n      <Skeleton className=\"aspect-video w-full rounded-[12px]\" />\n      <div className=\"flex items-start gap-3\">\n        <Skeleton className=\"size-6 shrink-0 rounded\" />\n        <div className=\"flex flex-1 flex-col gap-1\">\n          <Skeleton className=\"h-4 w-3/4\" />\n          <Skeleton className=\"h-3 w-1/2\" />\n        </div>\n      </div>\n    </li>\n  );\n\n  return (\n    <>\n      <div className=\"relative h-full min-h-[50vh]\">\n        {isRefetching ? (\n          <ul className=\"grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4\">\n            {Array.from({ length: 10 }).map((_, index) =>\n              renderSkeletonCard(index, \"refetch-skeleton\")\n            )}\n          </ul>\n        ) : media.length === 0 && !hasAnyMedia ? (\n          <div className=\"grid h-full place-content-center\">\n            <div className=\"flex max-w-80 flex-col items-center gap-4\">\n              <div className=\"p-2\">\n                <HugeiconsIcon className=\"size-16\" icon={Image02Icon} />\n              </div>\n              <div className=\"flex flex-col items-center gap-4 text-center\">\n                <p className=\"text-muted-foreground text-sm\">\n                  {getEmptyStateMessage(type, hasAnyMedia)}\n                </p>\n                {onUpload && (\n                  <FileUploadInput\n                    isUploading={isUploading}\n                    onUpload={onUpload}\n                  />\n                )}\n              </div>\n            </div>\n          </div>\n        ) : media.length === 0 ? (\n          <motion.div\n            animate={{ opacity: 1 }}\n            className=\"py-8 text-center\"\n            initial={{ opacity: 0 }}\n            transition={{ duration: 0.3 }}\n          >\n            <p className=\"text-muted-foreground text-sm\">\n              {getEmptyStateMessage(type, hasAnyMedia)}\n            </p>\n          </motion.div>\n        ) : (\n          <motion.ul\n            animate=\"visible\"\n            className=\"grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4\"\n            initial=\"hidden\"\n            variants={containerVariants}\n          >\n            <AnimatePresence mode=\"popLayout\">\n              {media.map((item) => (\n                <motion.li\n                  animate=\"visible\"\n                  exit=\"exit\"\n                  initial=\"hidden\"\n                  key={item.id}\n                  layout\n                  layoutId={item.id}\n                  variants={itemVariants}\n                >\n                  <MediaCard\n                    isSelected={selectedItems.has(item.id)}\n                    media={item}\n                    onDelete={() => handleDeleteSingle(item)}\n                    onSelect={() => handleSelectItem(item.id)}\n                  />\n                </motion.li>\n              ))}\n              {isFetchingNextPage &&\n                Array.from({ length: 10 }).map((_, index) =>\n                  renderSkeletonCard(index, \"pagination-skeleton\")\n                )}\n            </AnimatePresence>\n          </motion.ul>\n        )}\n      </div>\n\n      {hasNextPage && (\n        <motion.div\n          className=\"h-1\"\n          onViewportEnter={() => {\n            if (!isFetchingNextPage) {\n              onLoadMore?.();\n            }\n          }}\n        />\n      )}\n\n      <DeleteMediaModal\n        isOpen={showDeleteModal}\n        mediaToDelete={mediaToDelete}\n        onDeleteComplete={onDeleteComplete}\n        setIsOpen={setShowDeleteModal}\n      />\n    </>\n  );\n}\n\nconst containerVariants = {\n  hidden: { opacity: 0 },\n  visible: {\n    opacity: 1,\n    transition: {\n      staggerChildren: 0.1,\n      delayChildren: 0.2,\n    },\n  },\n};\n\nconst itemVariants = {\n  hidden: { opacity: 0, y: 10, filter: \"blur(4px)\" },\n  visible: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: 0.3, ease: \"easeIn\" },\n  },\n  exit: {\n    opacity: 0,\n    y: -10,\n    filter: \"blur(4px)\",\n    transition: { duration: 0.2 },\n  },\n};\n"
  },
  {
    "path": "apps/cms/src/components/media/media-table-toolbar.tsx",
    "content": "\"use client\";\n\nimport { FilterResetIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport {\n  FunnelSimpleIcon,\n  MagnifyingGlassIcon,\n  PlusIcon,\n  RowsIcon,\n  SortAscendingIcon,\n  SquaresFourIcon,\n  TrashIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport type { Table } from \"@tanstack/react-table\";\nimport { motion } from \"motion/react\";\nimport { useEffect, useState } from \"react\";\nimport { useDebounce } from \"@/hooks/use-debounce\";\nimport { useLocalStorage } from \"@/hooks/use-localstorage\";\nimport { useMediaPageFilters } from \"@/lib/search-params\";\nimport type { Media, MediaFilterType, MediaSort } from \"@/types/media\";\nimport { isMediaFilterType, isMediaSort, toMediaType } from \"@/utils/media\";\nimport { FileUploadInput } from \"./file-upload-input\";\n\ninterface MediaTableToolbarProps {\n  disabled?: boolean;\n  isUploading: boolean;\n  onBulkDelete: () => void;\n  onUpload?: (files: FileList) => void;\n  selectedCount: number;\n  table: Table<Media>;\n}\n\ntype MediaViewType = \"table\" | \"grid\";\n\nconst typeOptions: Array<{ label: string; value: MediaFilterType }> = [\n  { label: \"All\", value: \"all\" },\n  { label: \"Images\", value: \"image\" },\n  { label: \"Videos\", value: \"video\" },\n  { label: \"Audio\", value: \"audio\" },\n  { label: \"Documents\", value: \"document\" },\n];\n\nconst sortOptions: Array<{\n  column: \"createdAt\" | \"name\";\n  desc: boolean;\n  label: string;\n  value: MediaSort;\n}> = [\n  {\n    column: \"createdAt\",\n    desc: true,\n    label: \"Newest first\",\n    value: \"createdAt_desc\",\n  },\n  {\n    column: \"createdAt\",\n    desc: false,\n    label: \"Oldest first\",\n    value: \"createdAt_asc\",\n  },\n  { column: \"name\", desc: false, label: \"Name A-Z\", value: \"name_asc\" },\n  { column: \"name\", desc: true, label: \"Name Z-A\", value: \"name_desc\" },\n];\n\nexport function MediaTableToolbar({\n  disabled = false,\n  isUploading,\n  onBulkDelete,\n  onUpload,\n  selectedCount,\n  table,\n}: MediaTableToolbarProps) {\n  const [{ search, sort, type }, setSearchParams] = useMediaPageFilters();\n  const [viewType, setViewType] = useLocalStorage<MediaViewType>(\n    \"mediaViewType\",\n    \"table\"\n  );\n  const [draftSearch, setDraftSearch] = useState(search);\n  const debouncedSearch = useDebounce(draftSearch.trim(), 300);\n\n  const isDisabled = disabled || isUploading;\n  const activeFilterCount = toMediaType(type) ? 1 : 0;\n  const hasActiveFilters =\n    search.trim() !== \"\" || type !== \"all\" || sort !== \"createdAt_desc\";\n\n  const handleSortChange = (value: MediaSort | null) => {\n    if (!(value && isMediaSort(value))) {\n      return;\n    }\n    const nextSort = sortOptions.find((option) => option.value === value);\n    if (!nextSort) {\n      return;\n    }\n    table.setSorting([{ id: nextSort.column, desc: nextSort.desc }]);\n  };\n\n  useEffect(() => {\n    setDraftSearch(search);\n  }, [search]);\n\n  useEffect(() => {\n    if (debouncedSearch === search) {\n      return;\n    }\n    setSearchParams({ page: 1, search: debouncedSearch });\n  }, [debouncedSearch, search, setSearchParams]);\n\n  const resetFilters = () => {\n    setDraftSearch(\"\");\n    table.setSorting([{ id: \"createdAt\", desc: true }]);\n    setSearchParams({\n      page: 1,\n      search: \"\",\n      sort: \"createdAt_desc\",\n      type: \"all\",\n    });\n  };\n\n  return (\n    <section className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <div className=\"relative\">\n          <MagnifyingGlassIcon\n            className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n            size={16}\n          />\n          <Input\n            aria-label=\"Search media by name\"\n            className=\"h-9 w-full rounded-[12px] px-8 shadow-none sm:w-72\"\n            disabled={isDisabled}\n            onChange={(event) => setDraftSearch(event.target.value)}\n            placeholder=\"Search media...\"\n            value={draftSearch}\n          />\n          {draftSearch && (\n            <button\n              className=\"-translate-y-1/2 absolute top-1/2 right-3\"\n              disabled={isDisabled}\n              onClick={() => {\n                setDraftSearch(\"\");\n                setSearchParams({ page: 1, search: \"\" });\n              }}\n              type=\"button\"\n            >\n              <XIcon className=\"size-4\" />\n              <span className=\"sr-only\">Clear search</span>\n            </button>\n          )}\n        </div>\n\n        <Select\n          aria-label=\"Sort media\"\n          disabled={isDisabled}\n          items={sortOptions}\n          onValueChange={handleSortChange}\n          value={sort}\n        >\n          <SelectTrigger className=\"h-9 rounded-[12px] font-normal shadow-none\">\n            <SortAscendingIcon className=\"text-muted-foreground\" />\n            <span>Sort</span>\n            <span className=\"rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[10px]\">\n              1\n            </span>\n          </SelectTrigger>\n          <SelectContent>\n            {sortOptions.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                {option.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n\n        <Select\n          aria-label=\"Filter by file type\"\n          disabled={isDisabled}\n          items={typeOptions}\n          onValueChange={(value) => {\n            if (isMediaFilterType(value as MediaFilterType)) {\n              setSearchParams({\n                page: 1,\n                type: value as MediaFilterType,\n              });\n            }\n          }}\n          value={type}\n        >\n          <SelectTrigger className=\"h-9 rounded-[12px] font-normal shadow-none\">\n            <FunnelSimpleIcon className=\"text-muted-foreground\" />\n            <span>Filter</span>\n            {activeFilterCount > 0 && (\n              <span className=\"rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[10px]\">\n                {activeFilterCount}\n              </span>\n            )}\n          </SelectTrigger>\n          <SelectContent>\n            {typeOptions.map((option) => (\n              <SelectItem key={option.value} value={option.value}>\n                {option.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n\n        {hasActiveFilters && (\n          <Tooltip>\n            <TooltipTrigger\n              render={\n                <Button\n                  aria-label=\"Reset filters\"\n                  className=\"h-9 w-9 rounded-[12px] p-0 shadow-none\"\n                  disabled={isDisabled}\n                  onClick={resetFilters}\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <HugeiconsIcon icon={FilterResetIcon} size={16} />\n                </Button>\n              }\n            />\n            <TooltipContent side=\"top\">Reset filters</TooltipContent>\n          </Tooltip>\n        )}\n\n        {selectedCount > 0 && (\n          <div className=\"flex h-9 items-center gap-1 rounded-lg bg-surface p-0.5\">\n            <Button\n              className=\"h-8 rounded-md px-2 font-medium text-xs\"\n              disabled={isDisabled}\n              onClick={() => table.resetRowSelection()}\n              size=\"xs\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <XIcon />\n              {selectedCount} selected\n            </Button>\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    aria-label={`Delete selected (${selectedCount})`}\n                    className=\"size-8 rounded-md\"\n                    disabled={isDisabled}\n                    onClick={onBulkDelete}\n                    size=\"icon-sm\"\n                    type=\"button\"\n                    variant=\"destructive\"\n                  >\n                    <TrashIcon aria-hidden=\"true\" size={16} />\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                <p>Delete selected ({selectedCount})</p>\n              </TooltipContent>\n            </Tooltip>\n          </div>\n        )}\n      </div>\n      <div className=\"flex items-center justify-end gap-2\">\n        {/* <div className=\"flex gap-0.5 rounded-xl bg-surface p-0.5 dark:bg-accent/50\">\n          <Tooltip>\n            <TooltipTrigger\n              render={\n                <Button\n                  className=\"relative h-8 w-9 rounded-[10px]\"\n                  disabled\n                  onClick={() => setViewType(\"grid\")}\n                  size=\"icon-sm\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  {viewType === \"grid\" && (\n                    <motion.div\n                      className=\"absolute inset-0 rounded-[10px] bg-background shadow-sm\"\n                      layoutId=\"mediaViewToggleHighlight\"\n                      transition={{\n                        type: \"spring\",\n                        bounce: 0.2,\n                        duration: 0.4,\n                      }}\n                    />\n                  )}\n                  <SquaresFourIcon className=\"relative z-10\" size={16} />\n                  <span className=\"sr-only\">Grid view</span>\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>Grid view coming soon</p>\n            </TooltipContent>\n          </Tooltip>\n          <Tooltip>\n            <TooltipTrigger\n              render={\n                <Button\n                  className=\"relative h-8 w-9 rounded-[10px]\"\n                  onClick={() => setViewType(\"table\")}\n                  size=\"icon-sm\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  {viewType === \"table\" && (\n                    <motion.div\n                      className=\"absolute inset-0 rounded-[10px] bg-background shadow-sm\"\n                      layoutId=\"mediaViewToggleHighlight\"\n                      transition={{\n                        type: \"spring\",\n                        bounce: 0.2,\n                        duration: 0.4,\n                      }}\n                    />\n                  )}\n                  <RowsIcon className=\"relative z-10\" size={16} />\n                  <span className=\"sr-only\">Table view</span>\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>Table view</p>\n            </TooltipContent>\n          </Tooltip>\n        </div> */}\n        {onUpload && (\n          <FileUploadInput\n            className=\"h-9 w-full sm:w-auto\"\n            isUploading={isUploading}\n            onUpload={onUpload}\n            variant=\"icon\"\n          >\n            <PlusIcon className=\"size-4\" />\n            <span>Upload</span>\n          </FileUploadInput>\n        )}\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/upload-modal.tsx",
    "content": "\"use client\";\n\nimport { FileImportIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport Image from \"next/image\";\nimport { useState } from \"react\";\nimport { MediaDropzone } from \"@/components/shared/dropzone\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { Media } from \"@/types/media\";\nimport { AsyncButton } from \"../ui/async-button\";\n\ninterface MediaUploadModalProps {\n  isOpen: boolean;\n  setIsOpen: (isOpen: boolean) => void;\n  onUploadComplete?: (media: Media) => void;\n}\n\nexport function MediaUploadModal({\n  isOpen,\n  setIsOpen,\n  onUploadComplete,\n}: MediaUploadModalProps) {\n  const [file, setFile] = useState<File | undefined>();\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: uploadMedia, isPending: isUploading } = useMutation({\n    mutationFn: async (file: File) => {\n      const media = await uploadFile({\n        file,\n        type: \"media\",\n      });\n      return media;\n    },\n    onSuccess: (data: Media) => {\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.BILLING_USAGE(workspaceId),\n        });\n      }\n      toast.success(\"Media uploaded successfully!\");\n      if (onUploadComplete && data) {\n        onUploadComplete(data);\n      }\n      setFile(undefined);\n      setIsOpen(false);\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const handleUpload = () => {\n    if (file) {\n      uploadMedia(file);\n    }\n  };\n\n  return (\n    <Dialog\n      onOpenChange={(open) => {\n        setIsOpen(open);\n        if (!open) {\n          setFile(undefined);\n        }\n      }}\n      open={isOpen}\n    >\n      <DialogContent\n        className=\"max-h-[90vh] max-w-4xl overflow-y-auto\"\n        variant=\"card\"\n      >\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={FileImportIcon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Upload Media\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogBody>\n          <div className=\"flex flex-col gap-4\">\n            {file ? (\n              <div className=\"flex flex-col gap-4\">\n                <div className=\"relative flex h-[400px] w-full items-center justify-center overflow-hidden rounded-md\">\n                  {file.type.startsWith(\"image/\") ? (\n                    <Image\n                      alt=\"cover preview\"\n                      className=\"h-full w-full rounded-md object-contain\"\n                      src={URL.createObjectURL(file)}\n                      unoptimized\n                    />\n                  ) : (\n                    // biome-ignore lint/a11y/useMediaCaption: <>\n                    <video\n                      className=\"h-full w-full rounded-md object-contain\"\n                      controls\n                      src={URL.createObjectURL(file)}\n                    />\n                  )}\n                </div>\n                <div className=\"flex items-center justify-end gap-2\">\n                  <Button\n                    disabled={isUploading}\n                    onClick={() => setFile(undefined)}\n                    variant=\"outline\"\n                  >\n                    Cancel\n                  </Button>\n                  <AsyncButton isLoading={isUploading} onClick={handleUpload}>\n                    Upload\n                  </AsyncButton>\n                </div>\n              </div>\n            ) : (\n              <MediaDropzone\n                className=\"flex h-64 w-full cursor-pointer items-center justify-center rounded-md border border-dashed bg-background\"\n                multiple={false}\n                onFilesAccepted={(files: File[]) => setFile(files[0])}\n              />\n            )}\n          </div>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/media/video-player.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type { VideoHTMLAttributes } from \"react\";\nimport { useMemo, useRef } from \"react\";\n\nexport type VideoPlayerProps = VideoHTMLAttributes<HTMLVideoElement> & {\n  preview?: boolean;\n};\n\nconst tRegex = /t=(\\d+(?:\\.\\d+)?)/;\n\nexport const VideoPlayer = ({\n  className,\n  preview = true,\n  ...props\n}: VideoPlayerProps) => {\n  const videoRef = useRef<HTMLVideoElement>(null);\n\n  const initialTime = useMemo(() => {\n    const src = (props.src ?? \"\") as string;\n    const hashIndex = src.indexOf(\"#\");\n    if (hashIndex === -1) {\n      return 0;\n    }\n    const hash = src.slice(hashIndex + 1);\n    const tMatch = hash.match(tRegex);\n    return tMatch ? Number.parseFloat(tMatch[1] ?? \"0\") : 0;\n  }, [props.src]);\n\n  const handleMouseOver = () => {\n    if (!preview) {\n      return;\n    }\n\n    videoRef.current?.play();\n  };\n\n  const handleMouseOut = () => {\n    if (!preview) {\n      return;\n    }\n\n    if (videoRef.current) {\n      videoRef.current.pause();\n      videoRef.current.currentTime = initialTime;\n    }\n  };\n\n  const handleFocus = () => {\n    if (!preview) {\n      return;\n    }\n\n    videoRef.current?.play();\n  };\n\n  const handleBlur = () => {\n    if (!preview) {\n      return;\n    }\n\n    if (videoRef.current) {\n      videoRef.current.pause();\n      videoRef.current.currentTime = initialTime;\n    }\n  };\n\n  return (\n    <video\n      className={cn(\n        preview ? \"absolute inset-0 size-full object-cover\" : \"object-contain\",\n        \"transition-opacity duration-200\",\n        preview && \"group-hover:opacity-90\",\n        className\n      )}\n      loop={preview}\n      muted={preview}\n      onBlur={handleBlur}\n      onFocus={handleFocus}\n      onMouseOut={handleMouseOut}\n      onMouseOver={handleMouseOver}\n      preload=\"metadata\"\n      ref={videoRef}\n      tabIndex={0}\n      {...props}\n    />\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/nav/announcements.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { MegaphoneIcon } from \"@phosphor-icons/react\";\n\nexport function Announcements() {\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button className=\"size-7 rounded-full\" size=\"icon\" variant=\"ghost\">\n            <MegaphoneIcon className=\"size-4\" />\n            <span className=\"sr-only\">Announcements</span>\n          </Button>\n        }\n      />\n      <DropdownMenuContent align=\"end\" className=\"w-56\">\n        <div className=\"grid min-h-28 place-content-center\">\n          <div className=\"flex flex-col items-center justify-center gap-3 text-muted-foreground\">\n            <p className=\"text-sm\">No announcements yet</p>\n          </div>\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/app-breadcrumb.tsx",
    "content": "\"use client\";\n\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@marble/ui/components/breadcrumb\";\nimport { usePathname } from \"next/navigation\";\nimport React from \"react\";\n\nconst formatSegment = (segment: string) =>\n  segment\n    .replace(/-/g, \" \")\n    .split(\" \")\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(\" \");\n\nexport function AppBreadcrumb() {\n  const pathname = usePathname();\n  const segments = pathname.split(\"/\").filter(Boolean);\n\n  if (segments.length === 0) {\n    return (\n      <Breadcrumb>\n        <BreadcrumbList>\n          <BreadcrumbItem>\n            <BreadcrumbPage className=\"text-sm\">Dashboard</BreadcrumbPage>\n          </BreadcrumbItem>\n        </BreadcrumbList>\n      </Breadcrumb>\n    );\n  }\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink className=\"text-sm\" href=\"/\">\n            Dashboard\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        {segments.map((segment, index) => (\n          <React.Fragment key={segment}>\n            <BreadcrumbSeparator>\n              <span className=\"select-none\">/</span>\n            </BreadcrumbSeparator>\n            <BreadcrumbItem>\n              {index === segments.length - 1 ? (\n                <BreadcrumbPage>{formatSegment(segment)}</BreadcrumbPage>\n              ) : (\n                <BreadcrumbLink\n                  className=\"text-sm\"\n                  href={`/${segments.slice(0, index + 1).join(\"/\")}`}\n                >\n                  {formatSegment(segment)}\n                </BreadcrumbLink>\n              )}\n            </BreadcrumbItem>\n          </React.Fragment>\n        ))}\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/app-sidebar.tsx",
    "content": "\"use client\";\n\nimport {\n  ArrowLeft01Icon,\n  Settings01Icon,\n  SidebarLeftIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  getSidebarKeyboardShortcutLabel,\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarHeader,\n  SidebarMenu,\n  SidebarMenuButton,\n  useSidebar,\n} from \"@marble/ui/components/sidebar\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\nimport { NavExtra } from \"./nav-extra\";\nimport { NavMain } from \"./nav-main\";\nimport { NavSettings } from \"./nav-settings\";\nimport { SidebarFooterContent } from \"./sidebar-footer-content\";\nimport { WhatsNewCard } from \"./whats-new-card\";\nimport { WorkspaceSwitcher } from \"./workspace-switcher\";\n\nconst sidebarToggleTransition = {\n  bounce: 0.18,\n  duration: 0.8,\n  type: \"spring\",\n} as const;\n\nexport function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {\n  const pathname = usePathname();\n  const params = useParams<{ workspace: string }>();\n  const { open } = useSidebar();\n  const shouldReduceMotion = useReducedMotion();\n  const isSettingsRoute = pathname.startsWith(`/${params.workspace}/settings`);\n\n  const mainVariants = {\n    initial: shouldReduceMotion\n      ? { opacity: 1, x: 0 }\n      : { opacity: 0, x: \"-100%\" },\n    animate: { opacity: 1, x: 0 },\n    exit: shouldReduceMotion\n      ? { opacity: 1, x: 0 }\n      : { opacity: 0, x: \"-100%\" },\n  };\n\n  const settingsVariants = {\n    initial: shouldReduceMotion\n      ? { opacity: 1, x: 0 }\n      : { opacity: 0, x: \"100%\" },\n    animate: { opacity: 1, x: 0 },\n    exit: shouldReduceMotion ? { opacity: 1, x: 0 } : { opacity: 0, x: \"100%\" },\n  };\n\n  const transition = { duration: 0.3, type: \"spring\", bounce: 0.15 };\n\n  return (\n    <Sidebar\n      collapsible=\"icon\"\n      {...props}\n      className=\"overflow-hidden border-none\"\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {isSettingsRoute ? (\n          <motion.div\n            animate=\"animate\"\n            className=\"flex flex-1 flex-col\"\n            exit=\"exit\"\n            initial=\"initial\"\n            key=\"settings\"\n            transition={transition}\n            variants={settingsVariants}\n          >\n            <SidebarHeader className={cn(!open && \"items-center\")}>\n              <div\n                className={cn(\n                  \"flex w-full min-w-0 items-center gap-2\",\n                  open ? \"justify-between\" : \"justify-center\"\n                )}\n              >\n                <SidebarMenu className={cn(open ? \"min-w-0 flex-1\" : \"w-auto\")}>\n                  <SidebarMenuButton\n                    className={cn(\n                      \"h-9 border border-transparent transition-colors duration-200 hover:bg-sidebar-accent\",\n                      !open && \"justify-center gap-0\"\n                    )}\n                    render={\n                      <Link href={`/${params.workspace}`}>\n                        <HugeiconsIcon icon={ArrowLeft01Icon} />\n                        {open && <span>Back</span>}\n                      </Link>\n                    }\n                    tooltip=\"Back\"\n                  />\n                </SidebarMenu>\n                <SidebarCollapseTrigger />\n              </div>\n            </SidebarHeader>\n            <SidebarContent className=\"gap-0\">\n              <NavSettings />\n            </SidebarContent>\n            <SidebarFooter className=\"gap-0 p-0\">\n              <SidebarGroup className=\"px-3\">\n                <SidebarMenu>\n                  <NavExtra asMenuButton />\n                </SidebarMenu>\n              </SidebarGroup>\n            </SidebarFooter>\n          </motion.div>\n        ) : (\n          <motion.div\n            animate=\"animate\"\n            className=\"flex flex-1 flex-col\"\n            exit=\"exit\"\n            initial=\"initial\"\n            key=\"main\"\n            transition={transition}\n            variants={mainVariants}\n          >\n            <SidebarHeader>\n              <div\n                className={cn(\n                  \"flex w-full min-w-0 items-center gap-2\",\n                  open ? \"justify-between\" : \"justify-center\"\n                )}\n              >\n                <WorkspaceSwitcher />\n                <SidebarCollapseTrigger />\n              </div>\n            </SidebarHeader>\n            <SidebarContent>\n              <NavMain />\n            </SidebarContent>\n            <SidebarFooter className=\"gap-0 p-0\">\n              <WhatsNewCard />\n              <SidebarGroup className=\"px-3\">\n                <SidebarMenu>\n                  <SidebarMenuButton\n                    className={cn(\n                      \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent hover:text-accent-foreground\",\n                      !open && \"justify-center gap-0\"\n                    )}\n                    render={\n                      <Link href={`/${params.workspace}/settings/general`}>\n                        <HugeiconsIcon icon={Settings01Icon} />\n                        {open && <span>Settings</span>}\n                      </Link>\n                    }\n                    tooltip=\"Settings\"\n                  />\n                </SidebarMenu>\n              </SidebarGroup>\n              <SidebarFooterContent />\n            </SidebarFooter>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </Sidebar>\n  );\n}\n\nfunction SidebarCollapseTrigger() {\n  const { isMobile, open, toggleSidebar } = useSidebar();\n  const motionProps = isMobile\n    ? {\n        animate: { opacity: 1 },\n        exit: { opacity: 1 },\n        initial: { opacity: 1 },\n        transition: { duration: 0 },\n      }\n    : {\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        initial: { opacity: 0 },\n        transition: sidebarToggleTransition,\n      };\n\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      {open && (\n        <motion.div\n          className=\"z-100 flex h-9 w-9 shrink-0 items-center justify-center\"\n          key=\"sidebar-sidebar-toggle\"\n          layoutId={isMobile ? undefined : \"main-sidebar-toggle\"}\n          {...motionProps}\n        >\n          <Tooltip>\n            <TooltipTrigger\n              delay={400}\n              render={\n                <SidebarMenuButton\n                  aria-label=\"Collapse sidebar\"\n                  className=\"h-9 w-9 shrink-0 cursor-pointer justify-center border border-transparent p-0\"\n                  onClick={toggleSidebar}\n                  type=\"button\"\n                >\n                  <HugeiconsIcon icon={SidebarLeftIcon} />\n                </SidebarMenuButton>\n              }\n            />\n            <TooltipContent>\n              <p>Collapse Sidebar ({getSidebarKeyboardShortcutLabel()})</p>\n            </TooltipContent>\n          </Tooltip>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/create-workspace-dialog.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { FolderAddIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useRouter } from \"next/navigation\";\nimport { Controller, useForm } from \"react-hook-form\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { TimezoneSelector } from \"@/components/ui/timezone-selector\";\nimport { organization } from \"@/lib/auth/client\";\nimport { timezones } from \"@/lib/constants\";\nimport {\n  type CreateWorkspaceValues,\n  workspaceSchema,\n} from \"@/lib/validations/workspace\";\nimport { generateSlug } from \"@/utils/string\";\n\ninterface CreateWorkspaceDialogProps {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n}\n\nexport const CreateWorkspaceDialog = (props: CreateWorkspaceDialogProps) => {\n  const router = useRouter();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    reset,\n    formState: { errors, isSubmitting },\n  } = useForm<CreateWorkspaceValues>({\n    resolver: zodResolver(workspaceSchema),\n    defaultValues: {\n      name: \"\",\n      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n    },\n  });\n\n  async function onSubmit(payload: CreateWorkspaceValues) {\n    const { error } = await organization.checkSlug({\n      slug: payload.slug,\n    });\n\n    if (error) {\n      toast.error(error.message);\n      return;\n    }\n\n    try {\n      const { data, error } = await organization.create({\n        name: payload.name,\n        slug: payload.slug,\n        timezone: payload.timezone,\n        logo: `https://api.dicebear.com/9.x/glass/svg?seed=${payload.name}`,\n      });\n\n      if (error) {\n        toast.error(error.message);\n        return;\n      }\n\n      if (data) {\n        await organization.setActive({\n          organizationId: data.id,\n        });\n        props.setOpen(false);\n        reset();\n        router.push(`/${data.slug}`);\n        toast.success(\"Workspace created\");\n      }\n    } catch (error) {\n      console.error(\"Failed to create workspace\", error);\n      toast.error(\"Failed to create workspace\");\n    }\n  }\n\n  return (\n    <Dialog onOpenChange={props.setOpen} open={props.open}>\n      <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={FolderAddIcon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Create Workspace\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogDescription className=\"sr-only\">\n          Set up your new workspace.\n        </DialogDescription>\n        <DialogBody>\n          <form\n            className=\"flex flex-col gap-3\"\n            onSubmit={handleSubmit(onSubmit)}\n          >\n            <div className=\"flex flex-col gap-2\">\n              <Label className=\"sr-only\" htmlFor=\"name\">\n                Name\n              </Label>\n              <Input\n                id=\"name\"\n                placeholder=\"Name\"\n                {...register(\"name\", {\n                  onChange: (e) => {\n                    if (e.target.value) {\n                      setValue(\"slug\", generateSlug(e.target.value));\n                    }\n                  },\n                })}\n              />\n              {errors.name && (\n                <ErrorMessage>{errors.name.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label className=\"sr-only\" htmlFor=\"slug\">\n                Slug\n              </Label>\n              <div className=\"flex w-full overflow-hidden rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] placeholder:text-muted-foreground focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30\">\n                <span className=\"border-r bg-muted p-2\">\n                  {process.env.NEXT_PUBLIC_APP_URL?.split(\"//\")[1]}/\n                </span>\n                <input\n                  id=\"slug\"\n                  placeholder=\"Slug\"\n                  {...register(\"slug\")}\n                  autoComplete=\"off\"\n                  className=\"w-full bg-transparent px-2 py-2 outline-none ring-0\"\n                />\n              </div>\n              {errors.slug && (\n                <ErrorMessage>{errors.slug.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"flex flex-col gap-2\">\n              <Label className=\"sr-only\" htmlFor=\"timezone\">\n                Timezone\n              </Label>\n              <Controller\n                control={control}\n                name=\"timezone\"\n                render={({ field }) => (\n                  <TimezoneSelector\n                    onValueChange={field.onChange}\n                    placeholder=\"Select timezone...\"\n                    timezones={timezones}\n                    value={field.value}\n                  />\n                )}\n              />\n              {errors.timezone && (\n                <ErrorMessage>{errors.timezone.message}</ErrorMessage>\n              )}\n            </div>\n\n            <DialogFooter className=\"mt-2\">\n              <DialogClose size=\"sm\">Close</DialogClose>\n              <AsyncButton isLoading={isSubmitting} size=\"sm\" type=\"submit\">\n                Create\n              </AsyncButton>\n            </DialogFooter>\n          </form>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/nav/nav-extra.tsx",
    "content": "\"use client\";\nimport { HelpCircleIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { SidebarMenuButton, useSidebar } from \"@marble/ui/components/sidebar\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  ArrowUpRightIcon,\n  BookOpenIcon,\n  BugIcon,\n  NewspaperClippingIcon,\n} from \"@phosphor-icons/react\";\nimport { Discord, XFormerlyTwitter } from \"../icons/social\";\n\nconst communityLinks = [\n  {\n    label: \"Discord\",\n    href: \"https://discord.marblecms.com\",\n    icon: <Discord className=\"size-4\" />,\n  },\n  {\n    label: \"Twitter\",\n    href: \"https://x.com/usemarblecms\",\n    icon: <XFormerlyTwitter className=\"size-4\" />,\n  },\n];\n\nconst resourceLinks = [\n  {\n    label: \"Blog\",\n    href: \"https://marblecms.com/blog\",\n    icon: <NewspaperClippingIcon className=\"size-4\" />,\n  },\n  {\n    label: \"Documentation\",\n    href: \"https://docs.marblecms.com\",\n    icon: <BookOpenIcon className=\"size-4\" />,\n  },\n  {\n    label: \"Report an issue\",\n    href: \"https://github.com/usemarble/marble/issues\",\n    icon: <BugIcon className=\"size-4\" />,\n  },\n];\n\ninterface NavExtraProps {\n  asMenuButton?: boolean;\n}\n\nexport function NavExtra({ asMenuButton = false }: NavExtraProps) {\n  const { open } = useSidebar();\n  const PopoverContentElement = (\n    <PopoverContent\n      className=\"w-52 divide-y p-0\"\n      side={asMenuButton ? \"left\" : \"right\"}\n      sideOffset={6}\n    >\n      <div className=\"p-2\">\n        <h3 className=\"px-2 py-1.5 font-medium text-foreground/90 text-sm\">\n          Get in touch\n        </h3>\n        <ul className=\"flex flex-col\">\n          {communityLinks.map((link) => (\n            <li key={link.label}>\n              <a\n                className={cn(\n                  buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n                  \"w-full justify-start gap-2 text-muted-foreground\"\n                )}\n                href={link.href}\n                rel=\"noopener noreferrer\"\n                target=\"_blank\"\n              >\n                {link.icon}\n                {link.label}\n                <ArrowUpRightIcon className=\"ml-auto size-4\" />\n              </a>\n            </li>\n          ))}\n        </ul>\n      </div>\n      <div className=\"p-2\">\n        <h3 className=\"px-2 py-1.5 font-medium text-foreground/90 text-sm\">\n          Resources\n        </h3>\n        <ul className=\"flex flex-col\">\n          {resourceLinks.map((link) => (\n            <li key={link.label}>\n              <a\n                className={cn(\n                  buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n                  \"w-full justify-start gap-2 text-muted-foreground\"\n                )}\n                href={link.href}\n                rel=\"noopener noreferrer\"\n                target=\"_blank\"\n              >\n                {link.icon}\n                {link.label}\n                <ArrowUpRightIcon className=\"ml-auto size-4\" />\n              </a>\n            </li>\n          ))}\n        </ul>\n      </div>\n    </PopoverContent>\n  );\n\n  if (asMenuButton) {\n    return (\n      <Popover>\n        <PopoverTrigger\n          render={\n            <SidebarMenuButton\n              className={cn(\n                \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent hover:text-accent-foreground\",\n                !open && \"justify-center gap-0\"\n              )}\n            >\n              <HugeiconsIcon icon={HelpCircleIcon} />\n              {open && <span>Help</span>}\n            </SidebarMenuButton>\n          }\n        />\n        {PopoverContentElement}\n      </Popover>\n    );\n  }\n\n  return (\n    <Popover>\n      <Tooltip>\n        <TooltipTrigger\n          delay={300}\n          render={\n            <PopoverTrigger\n              render={\n                <Button\n                  aria-label=\"Get in touch\"\n                  className=\"rounded-lg\"\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  <HugeiconsIcon icon={HelpCircleIcon} />\n                </Button>\n              }\n            />\n          }\n        />\n        <TooltipContent>\n          <p>Help</p>\n        </TooltipContent>\n      </Tooltip>\n      {PopoverContentElement}\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/nav-main.tsx",
    "content": "\"use client\";\n\nimport {\n  Files01Icon,\n  Home01Icon,\n  Image02Icon,\n  Package01Icon,\n  Tag01Icon,\n  Users,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  SidebarGroup,\n  SidebarGroupLabel,\n  SidebarMenu,\n  SidebarMenuButton,\n  useSidebar,\n} from \"@marble/ui/components/sidebar\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\n\nconst items = [\n  {\n    name: \"Posts\",\n    url: \"posts\",\n    icon: Files01Icon,\n  },\n  {\n    name: \"Categories\",\n    url: \"categories\",\n    icon: Package01Icon,\n  },\n  {\n    name: \"Tags\",\n    url: \"tags\",\n    icon: Tag01Icon,\n  },\n  {\n    name: \"Media\",\n    url: \"media\",\n    icon: Image02Icon,\n  },\n  {\n    name: \"Authors\",\n    url: \"authors\",\n    icon: Users,\n  },\n];\n\nexport function NavMain() {\n  const pathname = usePathname();\n  const params = useParams<{ workspace: string }>();\n  const { open } = useSidebar();\n\n  const isActive = (url: string) => pathname === `/${params.workspace}/${url}`;\n\n  const isOverviewActive = pathname === `/${params.workspace}`;\n\n  return (\n    <SidebarGroup className=\"px-3\">\n      <SidebarGroupLabel className=\"sr-only\">Workspace</SidebarGroupLabel>\n      <SidebarMenu>\n        <SidebarMenuButton\n          className={cn(\n            \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent\",\n            !open && \"justify-center gap-0\",\n            isOverviewActive\n              ? \"bg-sidebar-accent text-foreground\"\n              : \"hover:text-accent-foreground\"\n          )}\n          render={\n            <Link href={`/${params.workspace}`}>\n              <HugeiconsIcon icon={Home01Icon} />\n              {open && <span>Home</span>}\n            </Link>\n          }\n          tooltip=\"Home\"\n        />\n        {items.map((item) => (\n          <SidebarMenuButton\n            className={cn(\n              \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent\",\n              !open && \"justify-center gap-0\",\n              isActive(item.url)\n                ? \"bg-sidebar-accent text-foreground\"\n                : \"hover:text-accent-foreground\"\n            )}\n            key={item.name}\n            render={\n              <Link href={`/${params.workspace}/${item.url}`}>\n                <HugeiconsIcon icon={item.icon} />\n                {open && <span>{item.name}</span>}\n              </Link>\n            }\n            tooltip={item.name}\n          />\n        ))}\n      </SidebarMenu>\n    </SidebarGroup>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/nav-settings.tsx",
    "content": "\"use client\";\n\nimport {\n  CreditCardIcon,\n  DatabaseIcon,\n  Key01Icon,\n  Notification01Icon,\n  PaintBoardIcon,\n  Settings01Icon,\n  UserIcon,\n  UserMultipleIcon,\n  WebhookIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  SidebarGroup,\n  SidebarGroupLabel,\n  SidebarMenu,\n  SidebarMenuButton,\n  useSidebar,\n} from \"@marble/ui/components/sidebar\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport Link from \"next/link\";\nimport { useParams, usePathname } from \"next/navigation\";\n\nconst accountItems = [\n  {\n    name: \"Profile\",\n    url: \"settings/account\",\n    icon: UserIcon,\n  },\n  {\n    name: \"Notifications\",\n    url: \"settings/notifications\",\n    icon: Notification01Icon,\n  },\n  {\n    name: \"Appearance\",\n    url: \"settings/appearance\",\n    icon: PaintBoardIcon,\n  },\n];\n\nconst workspaceItems = [\n  {\n    name: \"General\",\n    url: \"settings/general\",\n    icon: Settings01Icon,\n  },\n  {\n    name: \"Members\",\n    url: \"settings/members\",\n    icon: UserMultipleIcon,\n  },\n  {\n    name: \"Billing\",\n    url: \"settings/billing\",\n    icon: CreditCardIcon,\n  },\n];\n\nconst developerItems = [\n  {\n    name: \"API Keys\",\n    url: \"settings/keys\",\n    icon: Key01Icon,\n  },\n  {\n    name: \"Custom Fields\",\n    url: \"settings/fields\",\n    icon: DatabaseIcon,\n  },\n  {\n    name: \"Webhooks\",\n    url: \"settings/webhooks\",\n    icon: WebhookIcon,\n  },\n];\n\nexport function NavSettings() {\n  const pathname = usePathname();\n  const params = useParams<{ workspace: string }>();\n  const { open } = useSidebar();\n\n  const isActive = (url: string) => pathname === `/${params.workspace}/${url}`;\n\n  return (\n    <>\n      {/* Workspace Section */}\n      <SidebarGroup className=\"px-3\">\n        <SidebarGroupLabel>Workspace</SidebarGroupLabel>\n        <SidebarMenu>\n          {workspaceItems.map((item) => (\n            <SidebarMenuButton\n              className={cn(\n                \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent\",\n                !open && \"justify-center gap-0\",\n                isActive(item.url)\n                  ? \"bg-sidebar-accent text-foreground\"\n                  : \"hover:text-accent-foreground\"\n              )}\n              key={item.name}\n              render={\n                <Link href={`/${params.workspace}/${item.url}`}>\n                  <HugeiconsIcon icon={item.icon} />\n                  {open && <span>{item.name}</span>}\n                </Link>\n              }\n              tooltip={item.name}\n            />\n          ))}\n        </SidebarMenu>\n      </SidebarGroup>\n\n      {/* Developers Section */}\n      <SidebarGroup className=\"px-3\">\n        <SidebarGroupLabel>Developers</SidebarGroupLabel>\n        <SidebarMenu>\n          {developerItems.map((item) => (\n            <SidebarMenuButton\n              className={cn(\n                \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent\",\n                !open && \"justify-center gap-0\",\n                isActive(item.url)\n                  ? \"bg-sidebar-accent text-foreground\"\n                  : \"hover:text-accent-foreground\"\n              )}\n              key={item.name}\n              render={\n                <Link href={`/${params.workspace}/${item.url}`}>\n                  <HugeiconsIcon icon={item.icon} />\n                  {open && <span>{item.name}</span>}\n                </Link>\n              }\n              tooltip={item.name}\n            />\n          ))}\n        </SidebarMenu>\n      </SidebarGroup>\n\n      {/* Account Section */}\n      <SidebarGroup className=\"px-3\">\n        <SidebarGroupLabel>Account</SidebarGroupLabel>\n        <SidebarMenu>\n          {accountItems.map((item) => (\n            <SidebarMenuButton\n              className={cn(\n                \"border border-transparent transition-colors duration-200 hover:bg-sidebar-accent\",\n                !open && \"justify-center gap-0\",\n                isActive(item.url)\n                  ? \"bg-sidebar-accent text-foreground\"\n                  : \"hover:text-accent-foreground\"\n              )}\n              key={item.name}\n              render={\n                <Link href={`/${params.workspace}/${item.url}`}>\n                  <HugeiconsIcon icon={item.icon} />\n                  {open && <span>{item.name}</span>}\n                </Link>\n              }\n              tooltip={item.name}\n            />\n          ))}\n        </SidebarMenu>\n      </SidebarGroup>\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/nav-user.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { useSidebar } from \"@marble/ui/components/sidebar\";\nimport { Skeleton } from \"@marble/ui/components/skeleton\";\nimport { SignOutIcon, UserIcon } from \"@phosphor-icons/react\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useUser } from \"@/providers/user\";\n\nexport function NavUser() {\n  const { isMobile } = useSidebar();\n  const { user, signOut, isFetchingUser } = useUser();\n  const params = useParams<{ workspace: string }>();\n\n  if (!user || isFetchingUser) {\n    return <Skeleton className=\"size-8 shrink-0 rounded-full border\" />;\n  }\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger className=\"rounded-full p-1 transition-colors hover:bg-sidebar-accent\">\n        <Avatar className=\"size-7 cursor-pointer rounded-full\">\n          <AvatarImage\n            alt={user?.name || \"users profile image\"}\n            src={user?.image || undefined}\n          />\n          <AvatarFallback className=\"rounded-lg\">\n            {user?.name?.charAt(0)}\n          </AvatarFallback>\n        </Avatar>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        align=\"start\"\n        className=\"w-(--radix-dropdown-menu-trigger-width) min-w-52 rounded-lg text-sidebar-foreground\"\n        side={isMobile ? \"bottom\" : \"top\"}\n        sideOffset={5}\n      >\n        <DropdownMenuGroup>\n          <DropdownMenuLabel className=\"p-0 font-normal\">\n            <div className=\"flex items-center gap-2 px-1 py-1.5 text-left text-sm\">\n              <Avatar className=\"size-7\">\n                <AvatarImage\n                  alt={user?.name || \"users profile image\"}\n                  src={user?.image || undefined}\n                />\n                <AvatarFallback className=\"rounded-lg\">\n                  {user?.name?.charAt(0)}\n                </AvatarFallback>\n              </Avatar>\n              <div className=\"grid flex-1 text-left text-sm leading-tight\">\n                <span className=\"truncate font-medium text-sm\">\n                  {user?.name}\n                </span>\n                <span className=\"truncate text-muted-foreground text-xs\">\n                  {user?.email}\n                </span>\n              </div>\n            </div>\n          </DropdownMenuLabel>\n        </DropdownMenuGroup>\n        <DropdownMenuSeparator />\n        <DropdownMenuGroup>\n          <DropdownMenuItem>\n            <Link\n              className=\"flex w-full items-center gap-4\"\n              href={`/${params.workspace}/settings/account`}\n            >\n              <UserIcon className=\"size-4\" />\n              Account\n            </Link>\n          </DropdownMenuItem>\n        </DropdownMenuGroup>\n        <DropdownMenuItem onClick={signOut} variant=\"destructive\">\n          <SignOutIcon className=\"mr-1.5 size-4\" />\n          Log Out\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/sidebar-footer-content.tsx",
    "content": "\"use client\";\n\nimport { useSidebar } from \"@marble/ui/components/sidebar\";\nimport { NavExtra } from \"./nav-extra\";\nimport { NavUser } from \"./nav-user\";\nimport { ThemeToggle } from \"./theme-toggle\";\n\nexport function SidebarFooterContent() {\n  const { state } = useSidebar();\n  const isCollapsed = state === \"collapsed\";\n\n  if (isCollapsed) {\n    return (\n      <div className=\"flex justify-center p-1\">\n        <NavUser />\n      </div>\n    );\n  }\n\n  return (\n    <section className=\"flex items-center justify-between gap-2 p-2\">\n      <NavUser />\n      <div className=\"flex items-center gap-1\">\n        <ThemeToggle />\n        <NavExtra />\n      </div>\n    </section>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/theme-toggle.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { DesktopIcon, MoonIcon, SunIcon } from \"@phosphor-icons/react\";\nimport { useTheme } from \"next-themes\";\nimport { useSyncExternalStore } from \"react\";\n\nconst themes = [\"light\", \"dark\", \"system\"] as const;\ntype Theme = (typeof themes)[number];\n\nconst emptySubscribe = () => () => {\n  return;\n};\n\nexport function ThemeToggle() {\n  const { theme, setTheme } = useTheme();\n  const mounted = useSyncExternalStore(\n    emptySubscribe,\n    () => true,\n    () => false\n  );\n\n  const cycleTheme = () => {\n    const currentIndex = themes.indexOf(theme as Theme);\n    const nextIndex = (currentIndex + 1) % themes.length;\n    const nextTheme = themes[nextIndex] || \"system\";\n    setTheme(nextTheme);\n  };\n\n  const getIcon = () => {\n    if (!mounted) {\n      return <SunIcon className=\"size-4\" />;\n    }\n\n    switch (theme) {\n      case \"light\":\n        return <SunIcon className=\"size-4\" />;\n      case \"dark\":\n        return <MoonIcon className=\"size-4\" />;\n      default:\n        return <DesktopIcon className=\"size-4\" />;\n    }\n  };\n\n  const getTooltipText = () => {\n    if (!mounted) {\n      return \"Theme\";\n    }\n\n    switch (theme) {\n      case \"light\":\n        return \"Light mode\";\n      case \"dark\":\n        return \"Dark mode\";\n      default:\n        return \"System theme\";\n    }\n  };\n\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        delay={300}\n        render={\n          <Button\n            aria-label=\"Toggle theme\"\n            className=\"rounded-lg\"\n            onClick={cycleTheme}\n            size=\"icon\"\n            type=\"button\"\n            variant=\"ghost\"\n          >\n            {getIcon()}\n          </Button>\n        }\n      />\n      <TooltipContent side=\"top\">\n        <p>{getTooltipText()}</p>\n      </TooltipContent>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/upgrade-card.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { useSidebar } from \"@marble/ui/components/sidebar\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { usePlan } from \"@/hooks/use-plan\";\nimport { checkout } from \"@/lib/auth/client\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nexport function UpgradeCard() {\n  const { state } = useSidebar();\n  const { isHobbyPlan } = usePlan();\n  const { isOwner, activeWorkspace } = useWorkspace();\n  const [isLoading, setIsLoading] = useState(false);\n  const isCollapsed = state === \"collapsed\";\n  const shouldReduceMotion = useReducedMotion();\n\n  const wasCollapsed = useRef(isCollapsed);\n  const shouldAnimate =\n    !shouldReduceMotion && wasCollapsed.current && !isCollapsed;\n\n  useEffect(() => {\n    wasCollapsed.current = isCollapsed;\n  }, [isCollapsed]);\n\n  if (!isHobbyPlan || !isOwner || isCollapsed) {\n    return null;\n  }\n\n  const handleUpgrade = async () => {\n    if (!activeWorkspace?.id) {\n      return;\n    }\n\n    setIsLoading(true);\n\n    try {\n      await checkout({\n        slug: \"pro\",\n        referenceId: activeWorkspace.id,\n      });\n    } catch (error) {\n      console.error(error);\n    }\n    setIsLoading(false);\n  };\n\n  return (\n    <motion.div\n      animate={{ opacity: 1, x: 0 }}\n      className=\"p-2\"\n      initial={shouldAnimate ? { opacity: 0, x: \"-100%\" } : false}\n      transition={{ duration: 0.4 }}\n    >\n      <div className=\"group relative w-full overflow-hidden rounded-xl border bg-background p-3 text-left shadow-xs\">\n        <div className=\"relative z-10 flex flex-col gap-3\">\n          <div className=\"space-y-2.5\">\n            <h4 className=\"font-medium text-sm leading-none tracking-tight\">\n              Upgrade to Pro\n            </h4>\n            <p className=\"text-muted-foreground text-xs leading-tight\">\n              Unlock higher limits, invite team members, and get more storage.\n            </p>\n          </div>\n          <AsyncButton\n            className=\"rounded-[2px]\"\n            isLoading={isLoading}\n            onClick={handleUpgrade}\n            size=\"xs\"\n          >\n            Start 3 day free trial\n          </AsyncButton>\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/whats-new-card.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { useSidebar } from \"@marble/ui/components/sidebar\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { XIcon } from \"@phosphor-icons/react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\nimport { useEffect, useRef } from \"react\";\nimport { useLocalStorage } from \"@/hooks/use-localstorage\";\n\nexport function WhatsNewCard() {\n  const { state } = useSidebar();\n  const params = useParams<{ workspace: string }>();\n  const [isDismissed, setIsDismissed] = useLocalStorage(\n    \"sidebar-whats-new-custom-fields-dismissed\",\n    false\n  );\n  const isCollapsed = state === \"collapsed\";\n  const shouldReduceMotion = useReducedMotion();\n\n  const wasCollapsed = useRef(isCollapsed);\n  const shouldAnimate =\n    !shouldReduceMotion && wasCollapsed.current && !isCollapsed;\n\n  useEffect(() => {\n    wasCollapsed.current = isCollapsed;\n  }, [isCollapsed]);\n\n  if (isCollapsed || isDismissed) {\n    return null;\n  }\n\n  const fieldsHref = `/${params.workspace}/settings/fields`;\n\n  return (\n    <motion.div\n      animate={{ opacity: 1, x: 0 }}\n      className=\"p-2\"\n      initial={shouldAnimate ? { opacity: 0, x: \"-100%\" } : false}\n      transition={{ duration: 0.4 }}\n    >\n      <section className=\"relative overflow-hidden rounded-[20px] border border-sidebar-border/80 bg-background p-1\">\n        <Button\n          aria-label=\"Dismiss what’s new card\"\n          className=\"absolute top-3 right-3 z-20 size-6 text-foreground\"\n          onClick={() => setIsDismissed(true)}\n          size=\"icon-xs\"\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          <XIcon className=\"size-3.5\" />\n        </Button>\n        <div className=\"flex flex-col gap-3\">\n          <div className=\"overflow-hidden rounded-[16px] border border-sidebar-border/70 bg-sidebar-accent/20\">\n            <Image\n              alt=\"Schema-style preview of Marble custom fields\"\n              className=\"h-auto w-full rounded-[16px] dark:hidden\"\n              height={200}\n              priority={false}\n              src=\"/custom-fields-card-light.svg\"\n              width={400}\n            />\n            <Image\n              alt=\"Schema-style preview of Marble custom fields\"\n              className=\"hidden h-auto w-full rounded-[16px] dark:block\"\n              height={200}\n              priority={false}\n              src=\"/custom-fields-card-dark.svg\"\n              width={400}\n            />\n          </div>\n          <div className=\"flex flex-col gap-1.5 px-2 pb-2\">\n            <div className=\"flex items-center gap-2\">\n              <h4 className=\"font-medium text-[13px] text-foreground leading-none tracking-tight\">\n                Custom Fields\n              </h4>\n              <Badge className=\"text-[10px]\" variant=\"positive\">\n                New\n              </Badge>\n            </div>\n            <p\n              className={cn(\n                \"text-[12px] text-muted-foreground\",\n                \"overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]\"\n              )}\n            >\n              Extend your post metadata by defining custom fields.\n            </p>\n            <Link\n              className=\"mt-1 w-fit font-medium text-[12px] text-primary underline-offset-4 transition-colors hover:text-primary/85 hover:underline\"\n              href={fieldsHref}\n            >\n              Configure Fields\n            </Link>\n          </div>\n        </div>\n      </section>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/nav/workspace-switcher.tsx",
    "content": "\"use client\";\n\nimport { ArrowDown01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { buttonVariants } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport {\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  useSidebar,\n} from \"@marble/ui/components/sidebar\";\nimport { Skeleton } from \"@marble/ui/components/skeleton\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CheckIcon, PlusIcon } from \"@phosphor-icons/react\";\nimport { useState } from \"react\";\nimport { getWorkspacePlan } from \"@/lib/plans\";\nimport type { Workspace } from \"@/types/workspace\";\nimport { useWorkspace } from \"../../providers/workspace\";\nimport { CreateWorkspaceDialog } from \"./create-workspace-dialog\";\n\nexport function WorkspaceSwitcher() {\n  const { isMobile, state } = useSidebar();\n  const isCollapsed = state === \"collapsed\";\n  const {\n    activeWorkspace,\n    updateActiveWorkspace,\n    workspaceList,\n    isFetchingWorkspace,\n  } = useWorkspace();\n\n  const [dialogOpen, setDialogOpen] = useState(false);\n\n  const ownedWorkspaces =\n    workspaceList?.filter(\n      (workspace) => workspace.currentUserRole === \"owner\"\n    ) || [];\n\n  const sharedWorkspaces =\n    workspaceList?.filter(\n      (workspace) => workspace.currentUserRole !== \"owner\"\n    ) || [];\n\n  async function switchWorkspace(org: Workspace) {\n    if (org.slug === activeWorkspace?.slug) {\n      return;\n    }\n\n    try {\n      await updateActiveWorkspace(org);\n    } catch (error) {\n      console.error(\"Failed to switch workspace:\", error);\n    }\n  }\n\n  const showSkeleton = !activeWorkspace;\n  const currentPlan = getWorkspacePlan(activeWorkspace?.subscription);\n  const dropdownItemClass = cn(\n    buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n    \"relative w-full justify-start gap-2 rounded-md font-normal text-[13px]\"\n  );\n  const workspaceRowClass = cn(\n    buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n    \"grid w-full grid-cols-[1.25rem_minmax(0,1fr)_1rem] items-center justify-start gap-2 rounded-md text-left font-normal text-[13px]\"\n  );\n\n  return (\n    <SidebarMenu className={cn(isCollapsed ? \"w-auto\" : \"min-w-0 flex-1\")}>\n      <SidebarMenuItem>\n        <DropdownMenu>\n          {activeWorkspace && !showSkeleton ? (\n            <DropdownMenuTrigger\n              disabled={isFetchingWorkspace}\n              nativeButton={false}\n              render={\n                <SidebarMenuButton\n                  className={cn(\n                    \"h-9 w-full max-w-full cursor-pointer border border-transparent px-2 py-1.5 transition hover:bg-sidebar-accent data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\",\n                    isCollapsed && \"min-w-0 justify-center rounded-full p-1\"\n                  )}\n                  disabled={isFetchingWorkspace}\n                  render={<div />}\n                >\n                  <Avatar className={cn(\"size-6.5\", isCollapsed && \"size-6\")}>\n                    <AvatarImage\n                      className=\"rounded-[4px]\"\n                      src={activeWorkspace.logo || undefined}\n                    />\n                    <AvatarFallback className=\"border bg-sidebar-accent text-xs\">\n                      {activeWorkspace.name.charAt(0)}\n                    </AvatarFallback>\n                  </Avatar>\n                  {!isCollapsed && (\n                    <>\n                      <div className=\"flex min-w-0 flex-1 items-center gap-2 text-left text-sm leading-tight\">\n                        <span className=\"min-w-0 truncate text-ellipsis font-medium text-sm\">\n                          {activeWorkspace?.name}\n                        </span>\n                        <Badge\n                          className=\"shrink-0 px-1.5 py-0 text-[10px] capitalize\"\n                          variant={currentPlan === \"pro\" ? \"paid\" : \"free\"}\n                        >\n                          {currentPlan === \"hobby\" ? \"free\" : currentPlan}\n                        </Badge>\n                      </div>\n                      <HugeiconsIcon\n                        className=\"size-3 shrink-0\"\n                        icon={ArrowDown01Icon}\n                      />\n                    </>\n                  )}\n                </SidebarMenuButton>\n              }\n            />\n          ) : (\n            <div\n              className={cn(\n                \"flex items-center rounded-md border bg-sidebar-accent\",\n                isCollapsed ? \"size-10 justify-center p-1\" : \"gap-2 p-2\"\n              )}\n            >\n              <Skeleton\n                className={cn(\n                  \"shrink-0 rounded-md border\",\n                  isCollapsed ? \"size-6\" : \"size-8\"\n                )}\n              />\n              {!isCollapsed && (\n                <>\n                  <div className=\"flex w-full flex-col gap-1\">\n                    <Skeleton className=\"h-3 w-3/4 border\" />\n                    <Skeleton className=\"h-3 w-1/2 border\" />\n                  </div>\n                  <Skeleton className=\"ml-auto size-4 rounded-md border\" />\n                </>\n              )}\n            </div>\n          )}\n          <DropdownMenuContent\n            align=\"start\"\n            className=\"w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg\"\n            side={isMobile ? \"bottom\" : \"right\"}\n            sideOffset={4}\n          >\n            {ownedWorkspaces.length > 0 && (\n              <DropdownMenuGroup>\n                <DropdownMenuLabel className=\"text-muted-foreground text-xs\">\n                  Your Workspaces\n                </DropdownMenuLabel>\n                {ownedWorkspaces.map((org) => (\n                  <DropdownMenuItem\n                    className=\"p-0 focus:bg-transparent\"\n                    key={org.id}\n                  >\n                    <button\n                      className={workspaceRowClass}\n                      onClick={() => switchWorkspace(org)}\n                      type=\"button\"\n                    >\n                      <Avatar className=\"size-5 rounded-[0.2rem]\">\n                        <AvatarImage src={org.logo || undefined} />\n                        <AvatarFallback className=\"text-[10px]\">\n                          {org.name.slice(0, 2)}\n                        </AvatarFallback>\n                      </Avatar>\n                      <span className=\"min-w-0 truncate text-[13px]\">\n                        {org.name}\n                      </span>\n                      <CheckIcon\n                        className={cn(\n                          \"size-4 justify-self-end text-muted-foreground\",\n                          activeWorkspace?.id === org.id\n                            ? \"opacity-100\"\n                            : \"opacity-0\"\n                        )}\n                      />\n                    </button>\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuGroup>\n            )}\n\n            {sharedWorkspaces.length > 0 && (\n              <DropdownMenuGroup>\n                {ownedWorkspaces.length > 0 && <DropdownMenuSeparator />}\n                <DropdownMenuLabel className=\"text-muted-foreground text-xs\">\n                  Shared workspaces\n                </DropdownMenuLabel>\n                {sharedWorkspaces.map((org) => (\n                  <DropdownMenuItem\n                    className=\"p-0 focus:bg-transparent\"\n                    key={org.id}\n                  >\n                    <button\n                      className={workspaceRowClass}\n                      onClick={() => switchWorkspace(org)}\n                      type=\"button\"\n                    >\n                      <Avatar className=\"size-5 rounded-[0.2rem]\">\n                        <AvatarImage src={org.logo || undefined} />\n                        <AvatarFallback className=\"text-[10px]\">\n                          {org.name.slice(0, 2)}\n                        </AvatarFallback>\n                      </Avatar>\n                      <span className=\"min-w-0 truncate text-[13px]\">\n                        {org.name}\n                      </span>\n                      <CheckIcon\n                        className={cn(\n                          \"size-4 justify-self-end text-muted-foreground\",\n                          activeWorkspace?.id === org.id\n                            ? \"opacity-100\"\n                            : \"opacity-0\"\n                        )}\n                      />\n                    </button>\n                  </DropdownMenuItem>\n                ))}\n              </DropdownMenuGroup>\n            )}\n\n            <DropdownMenuSeparator />\n            <DropdownMenuItem className=\"p-0 focus:bg-transparent\">\n              <button\n                className={dropdownItemClass}\n                onClick={() => setDialogOpen(true)}\n                type=\"button\"\n              >\n                <div className=\"flex size-5 items-center justify-center rounded-md border bg-background\">\n                  <PlusIcon className=\"size-3.5\" />\n                </div>\n                <div className=\"font-medium text-[13px]\">Create Workspace</div>\n              </button>\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n        <CreateWorkspaceDialog open={dialogOpen} setOpen={setDialogOpen} />\n      </SidebarMenuItem>\n    </SidebarMenu>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/columns.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { CaretUpDownIcon, ImageIcon } from \"@phosphor-icons/react\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport Image from \"next/image\";\nimport { formatCalendarDate } from \"@/utils/string\";\nimport PostActions from \"./post-actions\";\n\nexport interface Post {\n  id: string;\n  title: string;\n  coverImage: string | null;\n  status: \"published\" | \"draft\";\n  featured: boolean;\n  publishedAt: Date;\n  updatedAt: Date;\n  category: {\n    id: string;\n    name: string;\n  };\n  authors: Array<{\n    id: string;\n    name: string;\n    image: string | null;\n  }>;\n}\n\nexport const columns: ColumnDef<Post>[] = [\n  {\n    id: \"category\",\n    accessorFn: (row) => row.category.id,\n    filterFn: (row, _columnId, filterValue) => {\n      if (!filterValue) {\n        return true;\n      }\n      return row.original.category.id === filterValue;\n    },\n    enableHiding: true,\n  },\n  {\n    accessorKey: \"title\",\n    header: \"Post\",\n    cell: ({ row }) => {\n      const { category, coverImage, title } = row.original;\n      return (\n        <div className=\"flex min-w-0 max-w-82 items-center gap-3\">\n          <div className=\"relative size-11 shrink-0 overflow-hidden rounded-md bg-muted\">\n            {coverImage ? (\n              <Image\n                alt=\"\"\n                className=\"size-full object-cover\"\n                height={48}\n                src={coverImage}\n                unoptimized\n                width={48}\n              />\n            ) : (\n              <div className=\"grid size-full place-items-center border border-dashed bg-[length:8px_8px] bg-[linear-gradient(45deg,transparent_25%,rgba(0,0,0,0.05)_25%,rgba(0,0,0,0.05)_50%,transparent_50%,transparent_75%,rgba(0,0,0,0.05)_75%,rgba(0,0,0,0.05))] dark:bg-[linear-gradient(45deg,transparent_25%,rgba(255,255,255,0.05)_25%,rgba(255,255,255,0.05)_50%,transparent_50%,transparent_75%,rgba(255,255,255,0.05)_75%,rgba(255,255,255,0.05))]\">\n                <ImageIcon\n                  className=\"size-5 text-muted-foreground\"\n                  weight=\"duotone\"\n                />\n              </div>\n            )}\n          </div>\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"truncate font-medium text-xs\">{title}</p>\n            <p className=\"truncate text-muted-foreground text-xs\">\n              {category.name}\n            </p>\n          </div>\n        </div>\n      );\n    },\n  },\n  {\n    accessorKey: \"status\",\n    enableSorting: false,\n    header: \"Status\",\n    cell: ({ row }) => {\n      const status = row.original.status;\n      return (\n        <Badge\n          className=\"rounded-[6px] text-xs\"\n          variant={status === \"published\" ? \"positive\" : \"pending\"}\n        >\n          {status === \"published\" ? \"Published\" : \"Draft\"}\n        </Badge>\n      );\n    },\n  },\n  {\n    accessorKey: \"publishedAt\",\n    header: ({ column }) => (\n      <Button\n        className=\"-ml-2 h-8 gap-1.5 rounded-md px-2 font-medium text-muted-foreground text-xs shadow-none hover:bg-background hover:text-foreground active:scale-100 dark:hover:bg-accent dark:hover:text-muted-foreground\"\n        onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n        size=\"sm\"\n        variant=\"ghost\"\n      >\n        Published On\n        <CaretUpDownIcon className=\"size-3.5 opacity-70\" />\n      </Button>\n    ),\n    cell: ({ row }) => (\n      <span className=\"text-muted-foreground text-xs\">\n        {formatCalendarDate(new Date(row.original.publishedAt), \"MMM d, yyyy\")}\n      </span>\n    ),\n  },\n  {\n    accessorKey: \"updatedAt\",\n    header: ({ column }) => (\n      <Button\n        className=\"-ml-2 h-8 gap-1.5 rounded-md px-2 font-medium text-muted-foreground text-xs shadow-none hover:bg-background hover:text-foreground active:scale-100 dark:hover:bg-accent dark:hover:text-muted-foreground\"\n        onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n        size=\"sm\"\n        variant=\"ghost\"\n      >\n        Last Updated\n        <CaretUpDownIcon className=\"size-3.5 opacity-70\" />\n      </Button>\n    ),\n    cell: ({ row }) => (\n      <span className=\"text-muted-foreground text-xs\">\n        {format(row.original.updatedAt, \"MMM d, yyyy\")}\n      </span>\n    ),\n  },\n  {\n    id: \"actions\",\n    cell: ({ row }) => {\n      const post = row.original;\n\n      return (\n        <div className=\"flex justify-end\">\n          <PostActions post={post} />\n        </div>\n      );\n    },\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/posts/data-grid.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Badge } from \"@marble/ui/components/badge\";\nimport {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { CalendarIcon, ClockClockwiseIcon } from \"@phosphor-icons/react\";\nimport { format } from \"date-fns\";\nimport Link from \"next/link\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport { formatCalendarDate } from \"@/utils/string\";\nimport type { Post } from \"./columns\";\nimport PostActions from \"./post-actions\";\n\ninterface DataGridProps {\n  data: Post[];\n}\n\nexport function DataGrid({ data }: DataGridProps) {\n  const { activeWorkspace } = useWorkspace();\n\n  if (!activeWorkspace) {\n    return null;\n  }\n\n  if (data.length === 0) {\n    return (\n      <div className=\"flex h-96 items-center justify-center\">\n        <p className=\"text-muted-foreground\">No posts found.</p>\n      </div>\n    );\n  }\n\n  return (\n    <ul className=\"grid auto-rows-fr gap-6 md:grid-cols-2\">\n      {data.map((post) => (\n        <li className=\"h-full\" key={post.id}>\n          <Card className=\"h-full gap-0 rounded-[20px] border-none bg-surface p-2 pb-0\">\n            <Link\n              className=\"flex h-full min-h-[170px] flex-col rounded-[12px] bg-background p-5 shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n              href={`/${activeWorkspace?.slug}/editor/p/${post.id}`}\n            >\n              <CardHeader className=\"gap-y-0 px-0 pb-3\">\n                <div className=\"flex items-start justify-between gap-8\">\n                  <CardTitle className=\"mr-2 line-clamp-2 flex-1 font-medium leading-[1.4]\">\n                    {post.title}\n                  </CardTitle>\n                  <div className=\"isolate\">\n                    <PostActions post={post} view=\"grid\" />\n                  </div>\n                </div>\n              </CardHeader>\n              <CardContent className=\"mt-auto flex items-center justify-between px-0 pt-0\">\n                <div className=\"space-y-3\">\n                  <Badge\n                    variant={\n                      post.status === \"published\" ? \"positive\" : \"pending\"\n                    }\n                  >\n                    {post.status === \"published\" ? \"Published\" : \"Draft\"}\n                  </Badge>\n                </div>\n                <div className=\"flex items-center gap-3 text-muted-foreground\">\n                  <Tooltip>\n                    <TooltipTrigger\n                      render={\n                        <CalendarIcon className=\"size-5 cursor-default\" />\n                      }\n                    />\n                    <TooltipContent>\n                      Published:{\" \"}\n                      {formatCalendarDate(\n                        new Date(post.publishedAt),\n                        \"MMM dd, yyyy\"\n                      )}\n                    </TooltipContent>\n                  </Tooltip>\n\n                  <Tooltip>\n                    <TooltipTrigger\n                      render={\n                        <ClockClockwiseIcon className=\"size-5 cursor-default\" />\n                      }\n                    />\n                    <TooltipContent>\n                      Last Updated: {format(post.updatedAt, \"MMM dd, yyyy\")}\n                    </TooltipContent>\n                  </Tooltip>\n                </div>\n              </CardContent>\n            </Link>\n            <CardFooter className=\"px-2 py-2.5\">\n              <ul className=\"-space-x-2 flex items-center\">\n                {post.authors.map((author) => (\n                  <li className=\"flex items-center\" key={author.id}>\n                    <Tooltip>\n                      <TooltipTrigger\n                        render={\n                          <Avatar className=\"size-8 border-2 border-background\">\n                            <AvatarImage src={author.image || undefined} />\n                            <AvatarFallback>\n                              {author.name.charAt(0)}\n                            </AvatarFallback>\n                          </Avatar>\n                        }\n                      />\n                      <TooltipContent>\n                        <p className=\"max-w-64 text-xs\">{author.name}</p>\n                      </TooltipContent>\n                    </Tooltip>\n                  </li>\n                ))}\n              </ul>\n            </CardFooter>\n          </Card>\n        </li>\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/data-table.tsx",
    "content": "\"use client\";\n\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport {\n  flexRender,\n  type Row,\n  type Table as TableType,\n} from \"@tanstack/react-table\";\nimport { useRouter } from \"next/navigation\";\nimport type { MouseEvent } from \"react\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { Post } from \"./columns\";\n\nexport interface DataTableProps<TData> {\n  table: TableType<TData>;\n  rows: Row<TData>[];\n}\n\nexport function DataTable<TData>({ table, rows }: DataTableProps<TData>) {\n  const router = useRouter();\n  const { activeWorkspace } = useWorkspace();\n\n  const shouldIgnoreRowClick = (event: MouseEvent) => {\n    const target = event.target;\n    return (\n      target instanceof HTMLElement &&\n      Boolean(\n        target.closest(\n          \"button, a, input, textarea, select, [data-no-row-click], [role='button'], [role='checkbox'], [role='menuitem']\"\n        )\n      )\n    );\n  };\n\n  const handleRowClick = (post: Post, event: MouseEvent) => {\n    if (\n      shouldIgnoreRowClick(event) ||\n      (event.target as HTMLElement).closest('[data-actions-cell=\"true\"]')\n    ) {\n      return;\n    }\n    router.push(`/${activeWorkspace?.slug}/editor/p/${post.id}`);\n  };\n\n  return (\n    <div className=\"overflow-hidden rounded-[20px] bg-surface p-1 [&_[data-slot=table-container]]:overflow-x-auto [&_[data-slot=table-container]]:overflow-y-hidden\">\n      <Table className=\"-mb-1 h-fit border-separate border-spacing-y-1\">\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow\n              className=\"border-0 text-[13px] hover:bg-transparent\"\n              key={headerGroup.id}\n            >\n              {headerGroup.headers.map((header) => (\n                <TableHead\n                  className={getHeaderClassName(header.column.id)}\n                  key={header.id}\n                >\n                  {header.isPlaceholder\n                    ? null\n                    : flexRender(\n                        header.column.columnDef.header,\n                        header.getContext()\n                      )}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {rows?.length ? (\n            rows.map((row) => (\n              <TableRow\n                className=\"cursor-pointer border-0 bg-background hover:bg-background/80 data-[state=selected]:bg-background\"\n                data-state={row.getIsSelected() && \"selected\"}\n                key={row.id}\n                onClick={(event) => handleRowClick(row.original as Post, event)}\n              >\n                {row.getVisibleCells().map((cell) => (\n                  <TableCell\n                    className={getCellClassName(cell.column.id)}\n                    data-no-row-click={\n                      cell.column.id === \"actions\" ? true : undefined\n                    }\n                    key={cell.id}\n                    {...(cell.column.id === \"actions\" && {\n                      \"data-actions-cell\": \"true\",\n                      onClick: (e: MouseEvent) => e.stopPropagation(),\n                    })}\n                  >\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow className=\"border-0 bg-background\">\n              <TableCell\n                className=\"h-28 rounded-[14px] text-center text-muted-foreground text-sm\"\n                colSpan={table.getVisibleLeafColumns().length}\n              >\n                No posts found. Try adjusting your filters.\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n\nfunction getHeaderClassName(columnId: string) {\n  switch (columnId) {\n    case \"title\":\n      return \"min-w-72 pr-3 text-muted-foreground\";\n    case \"status\":\n      return \"px-3 text-muted-foreground\";\n    case \"publishedAt\":\n    case \"updatedAt\":\n      return \"hidden px-3 text-muted-foreground md:table-cell\";\n    case \"actions\":\n      return \"sr-only w-12 px-3 text-right text-muted-foreground\";\n    default:\n      return \"px-3 text-muted-foreground\";\n  }\n}\n\nfunction getCellClassName(columnId: string) {\n  switch (columnId) {\n    case \"title\":\n      return \"rounded-l-[14px] px-3 py-2\";\n    case \"publishedAt\":\n    case \"updatedAt\":\n      return \"hidden px-3 py-2 md:table-cell\";\n    case \"actions\":\n      return \"rounded-r-[14px] px-3 py-2\";\n    default:\n      return \"px-3 py-2\";\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/data-view.tsx",
    "content": "\"use client\";\n\nimport { FileImportIcon, FilterResetIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  MagnifyingGlassIcon,\n  PlusIcon,\n  RowsIcon,\n  SquaresFourIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport {\n  type ColumnDef,\n  getCoreRowModel,\n  getSortedRowModel,\n  type OnChangeFn,\n  type PaginationState,\n  type SortingState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { motion } from \"motion/react\";\nimport dynamic from \"next/dynamic\";\nimport Link from \"next/link\";\nimport {\n  type ComponentType,\n  type JSX,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport { DataTablePagination } from \"@/components/ui/data-table-pagination\";\nimport { useDebounce } from \"@/hooks/use-debounce\";\nimport { useLocalStorage } from \"@/hooks/use-localstorage\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { POST_SORTS, usePostPageFilters } from \"@/lib/search-params\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { Post } from \"./columns\";\nimport type { DataTableProps } from \"./data-table\";\n\ninterface Category {\n  id: string;\n  name: string;\n}\n\nconst DataGrid = dynamic(\n  () => import(\"./data-grid\").then((mod) => ({ default: mod.DataGrid })),\n  {\n    ssr: false,\n  }\n) as ComponentType<{ data: Post[] }>;\n\nconst DataTable = dynamic(\n  () => import(\"./data-table\").then((mod) => ({ default: mod.DataTable })),\n  { ssr: false }\n) as <TData>(props: DataTableProps<TData>) => JSX.Element;\n\nconst PostsImportModal = dynamic(\n  () =>\n    import(\"@/components/posts/import-modal\").then((m) => m.PostsImportModal),\n  { ssr: false }\n);\n\ninterface DataViewProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n  isFetching?: boolean;\n  pageCount: number;\n  totalCount: number;\n}\n\ntype ViewType = \"table\" | \"grid\";\n\nexport function PostDataView<TData, TValue>({\n  columns,\n  data,\n  isFetching = false,\n  pageCount,\n  totalCount,\n}: DataViewProps<TData, TValue>) {\n  const [\n    { category, page, perPage, search: initialSearch, sort, status },\n    setSearchParams,\n  ] = usePostPageFilters();\n  const [search, setSearch] = useState(initialSearch);\n  const debouncedSearch = useDebounce(search.trim(), 300);\n  const [viewType, setViewType] = useLocalStorage<ViewType | null>(\n    \"viewType\",\n    \"table\"\n  );\n\n  const { activeWorkspace } = useWorkspace();\n  const [importOpen, setImportOpen] = useState(false);\n\n  useEffect(() => {\n    setSearch(initialSearch);\n  }, [initialSearch]);\n\n  useEffect(() => {\n    if (debouncedSearch === initialSearch) {\n      return;\n    }\n    setSearchParams({ page: 1, search: debouncedSearch });\n  }, [debouncedSearch, initialSearch, setSearchParams]);\n\n  const { data: categories = [] } = useQuery<Category[]>({\n    queryKey: QUERY_KEYS.CATEGORIES(activeWorkspace?.id ?? \"\"),\n    queryFn: async () => {\n      const res = await fetch(\"/api/categories\");\n      if (!res.ok) {\n        throw new Error(\"Failed to fetch categories\");\n      }\n      return res.json();\n    },\n    enabled: !!activeWorkspace?.id,\n  });\n  const pagination = useMemo<PaginationState>(\n    () => ({\n      pageIndex: Math.max(0, page - 1),\n      pageSize: perPage,\n    }),\n    [page, perPage]\n  );\n\n  const sorting = useMemo<SortingState>(() => {\n    const [id, direction] = sort.split(\"_\");\n    return [{ id: id ?? \"createdAt\", desc: direction !== \"asc\" }];\n  }, [sort]);\n\n  const onPaginationChange: OnChangeFn<PaginationState> = (updaterOrValue) => {\n    const nextPagination =\n      typeof updaterOrValue === \"function\"\n        ? updaterOrValue(pagination)\n        : updaterOrValue;\n\n    setSearchParams({\n      page: nextPagination.pageIndex + 1,\n      perPage: nextPagination.pageSize,\n    });\n  };\n\n  const onSortingChange: OnChangeFn<SortingState> = (updaterOrValue) => {\n    const nextSorting =\n      typeof updaterOrValue === \"function\"\n        ? updaterOrValue(sorting)\n        : updaterOrValue;\n    const [firstSort] = nextSorting;\n    const nextSort = firstSort\n      ? `${firstSort.id}_${firstSort.desc ? \"desc\" : \"asc\"}`\n      : \"createdAt_desc\";\n\n    setSearchParams({\n      page: 1,\n      sort: POST_SORTS.includes(nextSort as (typeof POST_SORTS)[number])\n        ? (nextSort as (typeof POST_SORTS)[number])\n        : \"createdAt_desc\",\n    });\n  };\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getSortedRowModel: getSortedRowModel(),\n    manualFiltering: true,\n    manualPagination: true,\n    manualSorting: true,\n    onPaginationChange,\n    onSortingChange,\n    pageCount,\n    state: {\n      pagination,\n      sorting,\n    },\n    initialState: {\n      columnVisibility: {\n        category: false,\n      },\n    },\n  });\n\n  const categoryOptions = [\n    { label: \"All Categories\", value: \"all\" },\n    ...categories.map((c) => ({ label: c.name, value: c.id })),\n  ];\n  const statusOptions = [\n    { label: \"All Statuses\", value: \"all\" },\n    { label: \"Published\", value: \"published\" },\n    { label: \"Draft\", value: \"draft\" },\n  ];\n  const hasActiveFilters =\n    category !== \"all\" ||\n    status !== \"all\" ||\n    sort !== \"createdAt_desc\" ||\n    search.trim() !== \"\";\n\n  const resetFilters = () => {\n    setSearch(\"\");\n    setSearchParams({\n      category: \"all\",\n      page: 1,\n      search: \"\",\n      sort: \"createdAt_desc\",\n      status: \"all\",\n    });\n  };\n\n  return (\n    <div>\n      {/* table controls */}\n      <div className=\"mb-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-2\">\n            <div className=\"relative\">\n              <MagnifyingGlassIcon\n                className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n                size={16}\n              />\n              <Input\n                className=\"h-9 w-full rounded-[12px] px-8 shadow-none sm:w-72\"\n                onChange={(event) => {\n                  setSearch(event.target.value);\n                }}\n                placeholder=\"Search posts...\"\n                value={search}\n              />\n              {search && (\n                <button\n                  className=\"-translate-y-1/2 absolute top-1/2 right-3\"\n                  onClick={() => {\n                    setSearch(\"\");\n                    setSearchParams({ page: 1, search: \"\" });\n                  }}\n                  type=\"button\"\n                >\n                  <XIcon className=\"size-4\" />\n                  <span className=\"sr-only\">Clear search</span>\n                </button>\n              )}\n            </div>\n            <Select\n              items={categoryOptions}\n              onValueChange={(value) => {\n                setSearchParams({\n                  category: value,\n                  page: 1,\n                });\n              }}\n              value={category}\n            >\n              <SelectTrigger className=\"h-9 w-36 rounded-[12px] shadow-none\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {categoryOptions.map((option) => (\n                  <SelectItem key={option.value} value={option.value}>\n                    {option.label}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            <Select\n              items={statusOptions}\n              onValueChange={(value) => {\n                setSearchParams({\n                  page: 1,\n                  status: value as \"all\" | \"published\" | \"draft\",\n                });\n              }}\n              value={status}\n            >\n              <SelectTrigger className=\"h-9 w-32 rounded-[12px] shadow-none\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                {statusOptions.map((option) => (\n                  <SelectItem key={option.value} value={option.value}>\n                    {option.label}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n            {hasActiveFilters && (\n              <Tooltip>\n                <TooltipTrigger\n                  render={\n                    <Button\n                      aria-label=\"Reset filters\"\n                      className=\"h-9 w-9 rounded-[12px] p-0 shadow-none\"\n                      onClick={resetFilters}\n                      type=\"button\"\n                      variant=\"outline\"\n                    >\n                      <HugeiconsIcon icon={FilterResetIcon} size={16} />\n                    </Button>\n                  }\n                />\n                <TooltipContent side=\"top\">Reset filters</TooltipContent>\n              </Tooltip>\n            )}\n          </div>\n        </div>\n        <div className=\"flex flex-wrap items-center justify-between gap-2 sm:justify-end\">\n          <div className=\"flex gap-0.5 rounded-xl bg-surface p-0.5 dark:bg-accent/50\">\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    className=\"relative h-8 w-9 rounded-[10px]\"\n                    onClick={() => setViewType(\"table\")}\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                  >\n                    {viewType === \"table\" && (\n                      <motion.div\n                        className=\"absolute inset-0 rounded-[10px] bg-background shadow-sm\"\n                        layoutId=\"viewToggleHighlight\"\n                        transition={{\n                          type: \"spring\",\n                          bounce: 0.2,\n                          duration: 0.4,\n                        }}\n                      />\n                    )}\n                    <RowsIcon className=\"relative z-10\" size={16} />\n                    <span className=\"sr-only\">Table View</span>\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                <p>Table View</p>\n              </TooltipContent>\n            </Tooltip>\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    className=\"relative h-8 w-9 rounded-[10px]\"\n                    onClick={() => setViewType(\"grid\")}\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                  >\n                    {viewType === \"grid\" && (\n                      <motion.div\n                        className=\"absolute inset-0 rounded-[10px] bg-background shadow-sm\"\n                        layoutId=\"viewToggleHighlight\"\n                        transition={{\n                          type: \"spring\",\n                          bounce: 0.2,\n                          duration: 0.4,\n                        }}\n                      />\n                    )}\n                    <SquaresFourIcon className=\"relative z-10\" size={16} />\n                    <span className=\"sr-only\">Grid View</span>\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                <p>Grid View</p>\n              </TooltipContent>\n            </Tooltip>\n          </div>\n\n          <div className=\"flex gap-2\">\n            <Link\n              className={buttonVariants({ variant: \"default\" })}\n              href={`/${activeWorkspace?.slug}/editor/p/new`}\n            >\n              <PlusIcon size={16} />\n              <span>New Post</span>\n            </Link>\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    aria-label=\"Upload\"\n                    onClick={() => setImportOpen(true)}\n                    variant=\"default\"\n                  >\n                    <HugeiconsIcon\n                      icon={FileImportIcon}\n                      size={16}\n                      strokeWidth={2}\n                    />\n                  </Button>\n                }\n              />\n              <TooltipContent side=\"top\">Upload</TooltipContent>\n            </Tooltip>\n          </div>\n        </div>\n      </div>\n      {/* data table */}\n      <div\n        className={cn(\n          \"transition-opacity duration-150\",\n          isFetching && \"pointer-events-none opacity-50\"\n        )}\n      >\n        {viewType === \"table\" ? (\n          <div className=\"flex flex-col gap-3\">\n            <DataTable rows={table.getRowModel().rows} table={table} />\n            <DataTablePagination\n              canNextPage={pagination.pageIndex + 1 < pageCount}\n              canPreviousPage={pagination.pageIndex > 0}\n              itemLabel=\"post\"\n              onPageChange={(pageIndex) => {\n                setSearchParams({ page: pageIndex + 1 });\n              }}\n              pageCount={pageCount}\n              pageIndex={pagination.pageIndex}\n              rowCount={data.length}\n              selectedCount={0}\n              totalCount={totalCount}\n              visibleCount={table.getRowModel().rows.length}\n            />\n          </div>\n        ) : (\n          <div className=\"flex flex-col gap-3\">\n            <DataGrid\n              data={\n                table.getRowModel().rows.map((row) => row.original) as Post[]\n              }\n            />\n            <DataTablePagination\n              canNextPage={pagination.pageIndex + 1 < pageCount}\n              canPreviousPage={pagination.pageIndex > 0}\n              itemLabel=\"post\"\n              onPageChange={(pageIndex) => {\n                setSearchParams({ page: pageIndex + 1 });\n              }}\n              pageCount={pageCount}\n              pageIndex={pagination.pageIndex}\n              rowCount={data.length}\n              selectedCount={0}\n              totalCount={totalCount}\n              visibleCount={table.getRowModel().rows.length}\n            />\n          </div>\n        )}\n      </div>\n      <PostsImportModal open={importOpen} setOpen={setImportOpen} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/import-item-form.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { DialogClose } from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { CheckIcon } from \"@phosphor-icons/react\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { CategorySelector } from \"@/components/editor/fields/category-selector\";\nimport { DescriptionField } from \"@/components/editor/fields/description-field\";\nimport { PublishDateField } from \"@/components/editor/fields/publish-date-field\";\nimport { SlugField } from \"@/components/editor/fields/slug-field\";\nimport { StatusField } from \"@/components/editor/fields/status-field\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport {\n  type PostImportValues,\n  type PostValues,\n  postSchema,\n} from \"@/lib/validations/post\";\n\ninterface ImportItemFormProps {\n  name: string;\n  initialData: Partial<PostValues>;\n  onImport: (payload: PostImportValues) => void;\n  isImporting: boolean;\n}\n\nfunction isFormValid(values: Partial<PostValues>): boolean {\n  return !!(\n    values.title?.trim() &&\n    values.slug?.trim() &&\n    values.description?.trim() &&\n    values.category?.trim() &&\n    values.status &&\n    values.publishedAt\n  );\n}\n\nexport function ImportItemForm({\n  name,\n  initialData,\n  onImport,\n  isImporting,\n}: ImportItemFormProps) {\n  const form = useForm<PostValues>({\n    // biome-ignore lint/suspicious/noExplicitAny: Zod 4 + react-hook-form type inference issue with z.coerce.date()\n    resolver: zodResolver(postSchema) as any,\n    defaultValues: {\n      title: initialData.title || \"\",\n      slug: initialData.slug || \"\",\n      description: initialData.description || \"\",\n      status: initialData.status || \"draft\",\n      publishedAt: initialData.publishedAt || new Date(),\n      category: initialData.category || \"\",\n      // Provide placeholders to satisfy schema-only fields not shown in this import form\n      authors: [],\n      content: initialData.content || \"\",\n      contentJson: \"placeholder content json\",\n    },\n    mode: \"onChange\",\n  });\n\n  const {\n    register,\n    control,\n    handleSubmit,\n    watch,\n    formState: { errors, isSubmitting },\n  } = form;\n\n  const watchedValues = watch();\n  const isValid = isFormValid(watchedValues);\n\n  async function onSubmit(values: PostValues) {\n    try {\n      const markdown = values.content;\n\n      if (!markdown || markdown.trim().length === 0) {\n        throw new Error(\"No content found to import\");\n      }\n\n      const payload: PostImportValues = {\n        title: values.title,\n        slug: values.slug,\n        description: values.description,\n        status: values.status,\n        featured: values.featured || false,\n        publishedAt: values.publishedAt,\n        category: values.category,\n        content: markdown,\n        coverImage: undefined,\n        tags: [],\n      };\n\n      onImport(payload);\n    } catch (e) {\n      const message = e instanceof Error ? e.message : \"Failed to upload\";\n      toast.error(message);\n    }\n  }\n\n  return (\n    <form\n      className=\"mx-auto flex w-full max-w-96 flex-col gap-4\"\n      onSubmit={handleSubmit(onSubmit)}\n    >\n      <div className=\"grid gap-4\">\n        <StatusField control={control} />\n        <div className=\"flex flex-col gap-1.5\">\n          <Label className=\"font-medium text-xs\" htmlFor={`title-${name}`}>\n            Title\n          </Label>\n          <Input\n            id={`title-${name}`}\n            {...register(\"title\")}\n            className=\"bg-editor-field\"\n            placeholder=\"Title\"\n          />\n          {errors.title && <ErrorMessage>{errors.title.message}</ErrorMessage>}\n        </div>\n\n        <DescriptionField control={control} />\n        <SlugField control={control} />\n        <CategorySelector control={control} />\n        <PublishDateField control={control} />\n      </div>\n\n      <div className=\"mt-6 flex justify-end gap-2\">\n        <DialogClose size=\"sm\">Close</DialogClose>\n        <AsyncButton\n          disabled={isSubmitting || isImporting || !isValid}\n          isLoading={isSubmitting || isImporting}\n          size=\"sm\"\n          type=\"submit\"\n        >\n          <CheckIcon className=\"size-4\" />\n          Confirm\n        </AsyncButton>\n      </div>\n    </form>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/import-modal.tsx",
    "content": "\"use client\";\n\nimport { FileImportIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport matter from \"gray-matter\";\nimport { type Dispatch, type SetStateAction, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport * as z from \"zod\";\nimport { Dropzone } from \"@/components/shared/dropzone\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { PostImportValues } from \"@/lib/validations/post\";\nimport { ImportItemForm } from \"./import-item-form\";\n\nconst parseSchema = z.object({\n  title: z.string().optional(),\n  slug: z.string().optional(),\n  description: z.string().optional(),\n  status: z.preprocess(\n    (val) => (val === \"published\" ? \"published\" : \"draft\"),\n    z.enum([\"published\", \"draft\"])\n  ),\n  publishedAt: z.union([z.string(), z.number(), z.date()]).optional(),\n  category: z.string().optional(),\n});\n\ninterface ImportState {\n  file: File | null;\n  status: \"idle\" | \"parsing\" | \"ready\" | \"error\";\n  error?: string;\n  parsedData?: Partial<PostImportValues>;\n}\n\nexport function PostsImportModal({\n  open,\n  setOpen,\n}: {\n  open: boolean;\n  setOpen: Dispatch<SetStateAction<boolean>>;\n}) {\n  const [importState, setImportState] = useState<ImportState>({\n    file: null,\n    status: \"idle\",\n  });\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const { mutate: importPost, isPending: isImporting } = useMutation({\n    mutationFn: async (payload: PostImportValues) => {\n      try {\n        const res = await fetch(\"/api/posts/import\", {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify(payload),\n        });\n\n        if (!res.ok) {\n          const err = await res.json().catch(() => ({}));\n          throw new Error(err.error || \"Failed to import post\");\n        }\n\n        const responseData = await res.json();\n        return responseData;\n      } catch (error) {\n        throw new Error(\n          error instanceof Error ? error.message : \"Failed to import post\"\n        );\n      }\n    },\n    onSuccess: async () => {\n      toast.success(\"Post created\");\n      if (workspaceId) {\n        await queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.POSTS(workspaceId),\n        });\n      }\n      setOpen(false);\n      setImportState({ file: null, status: \"idle\" });\n    },\n    onError: (error: Error) => {\n      toast.error(error.message);\n      updateImportState({ status: \"error\", error: error.message });\n    },\n  });\n\n  function updateImportState(patch: Partial<ImportState>) {\n    setImportState((prev) => ({ ...prev, ...patch }));\n  }\n\n  async function parseFile(file: File) {\n    const ext = (file.name.split(\".\").pop() || \"\").toLowerCase();\n\n    try {\n      updateImportState({ status: \"parsing\" });\n\n      const raw = await file.text();\n      let parsedData: Partial<PostImportValues> = {\n        status: \"draft\",\n        publishedAt: new Date(),\n      };\n\n      if (ext === \"md\" || ext === \"mdx\") {\n        const parsed = matter(raw);\n        const fm = parsed.data ?? {};\n\n        const validated = parseSchema.safeParse(fm);\n        if (!validated.success) {\n          updateImportState({\n            status: \"error\",\n            error: `Invalid frontmatter: ${validated.error.issues[0]?.message}`,\n          });\n          return;\n        }\n\n        const data = validated.data;\n        parsedData = {\n          title: data.title || \"\",\n          slug: data.slug || \"\",\n          description: data.description || \"\",\n          content: parsed.content || \"\",\n          status: data.status || \"draft\",\n          publishedAt: data.publishedAt\n            ? new Date(data.publishedAt)\n            : new Date(),\n          category: data.category || \"\",\n        };\n      } else {\n        updateImportState({ status: \"error\", error: \"Unsupported file type\" });\n        return;\n      }\n\n      updateImportState({ status: \"ready\", parsedData });\n    } catch (e) {\n      const message = e instanceof Error ? e.message : \"Failed to process file\";\n      updateImportState({ status: \"error\", error: message });\n    }\n  }\n\n  return (\n    <Dialog onOpenChange={setOpen} open={open}>\n      <DialogContent\n        className=\"grid grid-rows-[auto_1fr] sm:h-[580px] sm:max-w-4xl\"\n        variant=\"card\"\n      >\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={FileImportIcon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              {importState.status === \"ready\"\n                ? \"Review Metadata\"\n                : \"Import Content\"}\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogDescription className=\"sr-only\">\n          {importState.status === \"ready\" && importState.file\n            ? `We've parsed metadata from your file. Please review and complete the details.`\n            : \"Import content into your workspace. You can import a .md/.mdx file.\"}\n        </DialogDescription>\n        <DialogBody className=\"overflow-hidden\">\n          {importState.status === \"idle\" && (\n            <Dropzone\n              accept={{\n                \"text/markdown\": [\".md\", \".mdx\"],\n              }}\n              className=\"flex h-full w-full flex-1 cursor-pointer items-center justify-center rounded-md border border-dashed bg-editor-field\"\n              onFilesAccepted={(accepted) => {\n                if (accepted.length > 0) {\n                  const file = accepted[0];\n                  if (file) {\n                    updateImportState({ file, status: \"parsing\" });\n                    parseFile(file);\n                  }\n                }\n              }}\n              placeholder={{\n                idle: \"Drag & drop a .md/.mdx file, or click to select\",\n                active: \"Drop the file here...\",\n                subtitle: \"We will parse frontmatter and content\",\n              }}\n            />\n          )}\n          {importState.status !== \"idle\" && (\n            <div className=\"scrollbar-custom flex flex-col gap-4 overflow-y-auto pt-4\">\n              {importState.status === \"parsing\" && (\n                <div className=\"flex items-center justify-center py-8\">\n                  <div className=\"text-muted-foreground text-sm\">\n                    Parsing file...\n                  </div>\n                </div>\n              )}\n\n              {importState.status === \"error\" && (\n                <div className=\"flex items-center justify-center py-8\">\n                  <div className=\"text-destructive text-sm\">\n                    {importState.error ?? \"Error processing file\"}\n                  </div>\n                </div>\n              )}\n\n              {importState.status === \"ready\" &&\n                importState.parsedData &&\n                importState.file && (\n                  <ImportItemForm\n                    initialData={importState.parsedData}\n                    isImporting={isImporting}\n                    name={importState.file.name}\n                    onImport={importPost}\n                  />\n                )}\n            </div>\n          )}\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/post-actions.tsx",
    "content": "import {\n  Delete02Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport Link from \"next/link\";\nimport { useState } from \"react\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { Post } from \"./columns\";\nimport { DeletePostModal } from \"./post-modals\";\n\ninterface PostTableActionsProps {\n  post: Post;\n  view?: \"table\" | \"grid\";\n}\n\nexport default function PostActions({\n  post,\n  view = \"table\",\n}: PostTableActionsProps) {\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n  const { activeWorkspace } = useWorkspace();\n\n  if (!activeWorkspace) {\n    return null;\n  }\n\n  const handleCardButtonClick = (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setShowDeleteModal(true);\n  };\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button\n              className={cn(\n                \"size-8 p-0\",\n                view === \"grid\" &&\n                  \"rounded-full bg-surface hover:bg-primary/10 hover:text-primary dark:bg-accent/50 dark:hover:text-accent-foreground\"\n              )}\n              onClick={(e) => {\n                e.stopPropagation();\n                e.preventDefault();\n              }}\n              variant=\"ghost\"\n            >\n              <span className=\"sr-only\">Open menu</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent\n          align={view === \"grid\" ? \"center\" : \"end\"}\n          className=\"text-muted-foreground shadow-sm\"\n        >\n          <DropdownMenuItem>\n            <Link\n              className=\"flex w-full cursor-default items-center gap-2\"\n              href={`/${activeWorkspace?.slug}/editor/p/${post.id}`}\n            >\n              <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n              <span>Edit</span>\n            </Link>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={(e) => handleCardButtonClick(e)}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon icon={Delete02Icon} size={16} />\n            <span>Delete</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <DeletePostModal\n        id={post.id}\n        open={showDeleteModal}\n        setOpen={setShowDeleteModal}\n        view={view}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/posts/post-modals.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { AsyncButton } from \"../ui/async-button\";\n\nexport const DeletePostModal = ({\n  open,\n  setOpen,\n  id,\n  // view,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  id: string;\n  view: \"table\" | \"grid\";\n}) => {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: deletePost, isPending } = useMutation({\n    mutationFn: async (postId: string) => {\n      const res = await fetch(`/api/posts/${postId}`, {\n        method: \"DELETE\",\n      });\n\n      if (!res.ok) {\n        const error = await res.json().catch(() => ({}));\n        throw new Error(error.error || \"Failed to delete post\");\n      }\n    },\n    onSuccess: () => {\n      toast.success(\"Post deleted\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.POSTS(workspaceId),\n        });\n      }\n      setOpen(false);\n    },\n    onError: (error) => {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to delete post.\"\n      );\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      {/* this is simply to prevent clicks on the dialog from bubbling up and clcikng the post link */}\n      <AlertDialogContent onClick={(e) => e.stopPropagation()} variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete Post?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            Deleting this post will remove it from your workspace, and will no\n            longer be accessible via the API. This cannot be undone.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel\n              disabled={isPending}\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                setOpen(false);\n              }}\n              size=\"sm\"\n            >\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deletePost(id);\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/settings/account.tsx",
    "content": "import { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Checkbox } from \"@marble/ui/components/checkbox\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { Separator } from \"@marble/ui/components/separator\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { CircleNotchIcon } from \"@phosphor-icons/react\";\nimport { useRouter } from \"next/navigation\";\nimport { useForm } from \"react-hook-form\";\nimport { type ProfileData, profileSchema } from \"@/lib/validations/settings\";\nimport { useUser } from \"@/providers/user\";\nimport { ErrorMessage } from \"../ui/error-message\";\n\ninterface AccountFormProps {\n  email: string;\n  name: string;\n}\n\nfunction AccountForm({ name, email }: AccountFormProps) {\n  const {\n    register,\n    handleSubmit,\n    formState: { errors, isSubmitting, isDirty },\n  } = useForm<ProfileData>({\n    resolver: zodResolver(profileSchema),\n    defaultValues: { name: name || \"\", email: email || \"\" },\n  });\n\n  const router = useRouter();\n  const { updateUser } = useUser();\n\n  const onSubmit = async (formData: ProfileData) => {\n    try {\n      await updateUser(formData);\n      toast.success(\"Account details updated\");\n      router.refresh();\n    } catch (error) {\n      console.log(error);\n      toast.error(\"Something went wrong\");\n    }\n  };\n\n  return (\n    <form className=\"space-y-8\" onSubmit={handleSubmit(onSubmit)}>\n      <section className=\"mt-5 grid grid-cols-2 gap-6\">\n        <div className=\"flex flex-col gap-2\">\n          <Label>Name</Label>\n          <Input {...register(\"name\")} />\n          {errors.name && <ErrorMessage>{errors.name.message}</ErrorMessage>}\n        </div>\n        <div className=\"flex flex-col gap-2\">\n          <Label>Email</Label>\n          <Input\n            {...register(\"email\")}\n            className=\"cursor-not-allowed\"\n            readOnly\n          />\n          {errors.email && <ErrorMessage>{errors.email.message}</ErrorMessage>}\n        </div>\n      </section>\n      <Separator />\n      <section className=\"space-y-8\">\n        <div>\n          <h1 className=\"font-semibold text-lg\">Notifications</h1>\n          <p className=\"text-muted-foreground text-sm\">\n            Manage your personal notification settings for this workspace. Read\n            the governance documentation to learn more.\n          </p>\n        </div>\n        <ul className=\"flex flex-col gap-6\">\n          <li className=\"flex gap-4\">\n            <Checkbox checked disabled id=\"newsletter\" />{\" \"}\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"newsletter\">Receive newsletter</Label>\n              <p className=\"text-muted-foreground text-sm\">\n                I want to receive updates about relevant products or services.\n              </p>\n            </div>\n          </li>\n          <li className=\"flex gap-4\">\n            <Checkbox id=\"member\" />{\" \"}\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"member\">Member activities</Label>\n              <p className=\"text-muted-foreground text-sm\">\n                Stay informed and receive notifications when team members join\n                or leave this workspace.\n              </p>\n            </div>\n          </li>\n          <li className=\"flex gap-4\">\n            <Checkbox id=\"publish\" />{\" \"}\n            <div className=\"flex flex-col gap-2\">\n              <Label htmlFor=\"publish\">Publishing activities</Label>\n              <p className=\"text-muted-foreground text-sm\">\n                Receive notifications when scheduled articles are published.\n              </p>\n            </div>\n          </li>\n        </ul>\n      </section>\n      <Separator />\n      <section className=\"flex w-full justify-end gap-4\">\n        <Button\n          className=\"flex w-20 items-center gap-2 self-end\"\n          disabled={!isDirty || isSubmitting}\n          type=\"submit\"\n        >\n          {isSubmitting ? <CircleNotchIcon className=\"animate-spin\" /> : \"Save\"}\n        </Button>\n      </section>\n    </form>\n  );\n}\n\nexport default AccountForm;\n"
  },
  {
    "path": "apps/cms/src/components/settings/delete-account.tsx",
    "content": "import { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { authClient } from \"@/lib/auth/client\";\nimport { useUser } from \"@/providers/user\";\nimport { AsyncButton } from \"../ui/async-button\";\n\nexport function DeleteAccountModal() {\n  const router = useRouter();\n  const { signOut } = useUser();\n\n  const { mutate: deleteAccount, isPending } = useMutation({\n    mutationFn: async () => {\n      await authClient.deleteUser();\n    },\n    onSuccess: async () => {\n      toast.success(\"Account deleted successfully.\");\n      signOut();\n      router.push(\"/\");\n    },\n    onError: (error) => {\n      toast.error(error.message || \"Failed to delete account.\");\n    },\n  });\n\n  return (\n    <AlertDialog>\n      <AlertDialogTrigger\n        render={<Button variant=\"destructive\">Delete Account</Button>}\n      />\n      <AlertDialogContent variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete account?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            This action cannot be undone. This will permanently delete your\n            account, your workspaces and all associated data within.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel\n              className=\"shadow-none\"\n              disabled={isPending}\n              size=\"sm\"\n            >\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteAccount();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/fields/delete.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { organization } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nexport function Delete() {\n  const { activeWorkspace, isOwner, workspaceList } = useWorkspace();\n  const { updateActiveWorkspace } = useWorkspace();\n  const router = useRouter();\n  const queryClient = useQueryClient();\n  const [confirmationText, setConfirmationText] = useState(\"\");\n\n  const CONFIRMATION_PHRASE = \"delete my workspace\";\n  const isConfirmationValid = confirmationText === CONFIRMATION_PHRASE;\n\n  const { mutate: deleteWorkspace, isPending } = useMutation({\n    mutationFn: async ({ organizationId }: { organizationId: string }) => {\n      await organization.delete({\n        organizationId,\n      });\n    },\n    onSuccess: async () => {\n      const remainingWorkspaces = workspaceList?.filter(\n        (org) => org.id !== activeWorkspace?.id\n      );\n\n      if (!remainingWorkspaces || remainingWorkspaces.length === 0) {\n        router.push(\"/new\");\n        return;\n      }\n\n      // Invalidate the workspace list query since we lost one\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE_LIST,\n      });\n\n      // Get the first remaining workspace\n      const nextWorkspace = remainingWorkspaces[0];\n\n      // If there are no remaining workspaces, redirect to the new workspace page\n      if (!nextWorkspace) {\n        router.push(\"/new\");\n        return;\n      }\n\n      // Set the first remaining workspace as active and redirect\n      await updateActiveWorkspace(nextWorkspace);\n      router.push(`/${nextWorkspace.slug}`);\n    },\n    onError: () => {\n      toast.error(\"Failed to delete workspace.\");\n    },\n  });\n\n  if (!isOwner) {\n    return null;\n  }\n\n  return (\n    <SettingsSection\n      description=\"Permanently delete your workspace and all associated data within. This action cannot be undone.\"\n      title=\"Delete Workspace\"\n    >\n      <div className=\"flex flex-col gap-3 rounded-[14px] bg-background px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between\">\n        <div className=\"flex min-w-0 items-center gap-3\">\n          <div className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive\">\n            <HugeiconsIcon icon={Alert02Icon} size={18} strokeWidth={2} />\n          </div>\n          <p className=\"text-muted-foreground text-sm\">\n            Workspace deletion is permanent.\n          </p>\n        </div>\n        <AlertDialog>\n          <AlertDialogTrigger\n            render={<Button variant=\"destructive\">Delete Workspace</Button>}\n          />\n          <AlertDialogContent variant=\"card\">\n            <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n              <div className=\"flex flex-1 items-center gap-2\">\n                <HugeiconsIcon\n                  className=\"text-destructive\"\n                  icon={Alert02Icon}\n                  size={18}\n                  strokeWidth={2}\n                />\n                <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n                  Delete workspace?\n                </AlertDialogTitle>\n              </div>\n              <AlertDialogX />\n            </AlertDialogHeader>\n            <AlertDialogBody>\n              <AlertDialogDescription className=\"text-balance\">\n                This action cannot be undone. This will permanently delete your\n                workspace and all associated data within.\n              </AlertDialogDescription>\n              <div className=\"space-y-4 py-2\">\n                <div className=\"space-y-2\">\n                  <Label className=\"text-sm\" htmlFor=\"confirmation-input\">\n                    To confirm, type{\" \"}\n                    <span className=\"font-mono font-semibold\">\n                      \"{CONFIRMATION_PHRASE}\"\n                    </span>{\" \"}\n                    below\n                  </Label>\n                  <Input\n                    className={cn(\n                      !isConfirmationValid &&\n                        \"focus-visible:border-destructive focus-visible:ring-destructive/50\"\n                    )}\n                    id=\"confirmation-input\"\n                    onChange={(e) => setConfirmationText(e.target.value)}\n                    placeholder={CONFIRMATION_PHRASE}\n                    value={confirmationText}\n                  />\n                </div>\n              </div>\n              <AlertDialogFooter>\n                <AlertDialogCancel\n                  onClick={() => setConfirmationText(\"\")}\n                  size=\"sm\"\n                >\n                  Cancel\n                </AlertDialogCancel>\n                <AsyncButton\n                  disabled={!isConfirmationValid}\n                  isLoading={isPending || !activeWorkspace?.id}\n                  onClick={() => {\n                    if (isConfirmationValid && activeWorkspace?.id) {\n                      deleteWorkspace({ organizationId: activeWorkspace.id });\n                    }\n                  }}\n                  size=\"sm\"\n                  variant=\"destructive\"\n                >\n                  Delete Workspace\n                </AsyncButton>\n              </AlertDialogFooter>\n            </AlertDialogBody>\n          </AlertDialogContent>\n        </AlertDialog>\n      </div>\n    </SettingsSection>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/fields/id.tsx",
    "content": "\"use client\";\n\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { useId } from \"react\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nexport function Id() {\n  const { activeWorkspace } = useWorkspace();\n  const linkId = useId();\n\n  return (\n    <SettingsSection\n      description=\"Unique identifier of your workspace on Marble.\"\n      title=\"Workspace ID\"\n    >\n      <div className=\"flex items-center gap-2 rounded-[14px] bg-background px-4 py-3.5\">\n        <div className=\"min-w-0 flex-1\">\n          <Label className=\"sr-only\" htmlFor={linkId}>\n            Workspace ID\n          </Label>\n          <Input id={linkId} readOnly value={activeWorkspace?.id || \"\"} />\n        </div>\n        <CopyButton\n          textToCopy={activeWorkspace?.id || \"\"}\n          toastMessage=\"ID copied to clipboard.\"\n        />\n      </div>\n    </SettingsSection>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/fields/logo.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CircleNotchIcon,\n  ImageIcon,\n  UploadSimpleIcon,\n} from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useId, useState } from \"react\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { organization } from \"@/lib/auth/client\";\nimport { uploadFile } from \"@/lib/media/upload\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nexport function Logo() {\n  const { activeWorkspace, isOwner } = useWorkspace();\n  const fileId = useId();\n  const queryClient = useQueryClient();\n  const [logoUrl, setLogoUrl] = useState(activeWorkspace?.logo);\n\n  const { mutate: updateLogo } = useMutation({\n    mutationFn: async ({\n      organizationId,\n      logoUrl,\n    }: {\n      organizationId: string;\n      logoUrl: string;\n    }) => {\n      const res = await organization.update({\n        organizationId,\n        data: {\n          logo: logoUrl,\n        },\n      });\n      if (res?.error) {\n        throw new Error(res.error.message);\n      }\n      return res;\n    },\n    onSuccess: (_, variables) => {\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE(variables.organizationId),\n      });\n      toast.success(\"Logo updated\");\n    },\n    onError: (error) => {\n      const errorMessage =\n        error instanceof Error\n          ? error.message\n          : \"Failed to update workspace logo\";\n      toast.error(errorMessage);\n      console.error(\"Failed to update workspace logo:\", error);\n    },\n  });\n\n  const { mutate: uploadLogo, isPending: isUpdatingLogo } = useMutation({\n    mutationFn: (file: File) => uploadFile({ file, type: \"logo\" }),\n    onSuccess: (data) => {\n      const { url } = data;\n      if (!url || !activeWorkspace?.id) {\n        return;\n      }\n\n      setLogoUrl(url);\n      toast.success(\"Upload complete\");\n      updateLogo({\n        organizationId: activeWorkspace.id,\n        logoUrl: url,\n      });\n    },\n    onError: (error: unknown) => {\n      console.error(\"Upload error:\", error);\n      if (error instanceof Error) {\n        toast.error(error.message);\n        return;\n      }\n      toast.error(\"An unknown error occurred while uploading the logo.\");\n    },\n  });\n\n  return (\n    <SettingsSection\n      description=\"Upload a logo for your workspace. PNG, JPG, JPEG, and WEBP files are supported. Square images work best.\"\n      title=\"Workspace Logo\"\n    >\n      <div className=\"flex items-center gap-6 rounded-[14px] bg-background px-4 py-3.5\">\n        <Label\n          className={cn(\n            \"group relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-full border\",\n            (isUpdatingLogo || !isOwner) && \"pointer-events-none\",\n            !isOwner && \"opacity-50\"\n          )}\n          htmlFor={fileId}\n        >\n          <Avatar className=\"size-16\">\n            <AvatarImage src={logoUrl || undefined} />\n            <AvatarFallback>\n              <ImageIcon className=\"size-4\" />\n            </AvatarFallback>\n          </Avatar>\n          <input\n            accept=\"image/*\"\n            className=\"sr-only\"\n            disabled={!isOwner}\n            id={fileId}\n            onChange={(e) => {\n              const file = e.target.files?.[0];\n              if (file && !isUpdatingLogo && isOwner) {\n                uploadLogo(file);\n              }\n            }}\n            title=\"Upload logo\"\n            type=\"file\"\n          />\n          <div\n            className={cn(\n              \"absolute inset-0 flex size-full items-center justify-center bg-background/50 backdrop-blur-xs transition-opacity duration-300\",\n              isUpdatingLogo\n                ? \"opacity-100\"\n                : \"opacity-0 group-hover:opacity-100\"\n            )}\n          >\n            {isUpdatingLogo ? (\n              <CircleNotchIcon className=\"size-4 animate-spin\" />\n            ) : (\n              <UploadSimpleIcon className=\"size-4\" />\n            )}\n          </div>\n        </Label>\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          <Input readOnly value={logoUrl || \"\"} />\n          <CopyButton\n            textToCopy={logoUrl || \"\"}\n            toastMessage=\"Logo URL copied to clipboard.\"\n          />\n        </div>\n      </div>\n    </SettingsSection>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/fields/name.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useId } from \"react\";\nimport type { SubmitErrorHandler } from \"react-hook-form\";\nimport { useForm } from \"react-hook-form\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { organization } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { type NameValues, nameSchema } from \"@/lib/validations/workspace\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nexport function Name() {\n  const router = useRouter();\n  const { activeWorkspace, isOwner } = useWorkspace();\n  const queryClient = useQueryClient();\n  const nameId = useId();\n\n  const nameForm = useForm<NameValues>({\n    resolver: zodResolver(nameSchema),\n    defaultValues: { name: activeWorkspace?.name || \"\" },\n  });\n\n  const { mutate: updateName, isPending } = useMutation({\n    mutationFn: async ({\n      organizationId,\n      data,\n    }: {\n      organizationId: string;\n      data: NameValues;\n    }) => {\n      const res = await organization.update({\n        organizationId,\n        data: {\n          name: data.name,\n        },\n      });\n      if (res?.error) {\n        throw new Error(res.error.message);\n      }\n      return res;\n    },\n    onSuccess: (_, variables) => {\n      toast.success(\"Workspace name updated\");\n      nameForm.reset({ name: nameForm.getValues(\"name\") });\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE(variables.organizationId),\n      });\n      router.refresh();\n    },\n    onError: (error) => {\n      const errorMessage =\n        error instanceof Error\n          ? error.message\n          : \"Failed to update workspace name\";\n      toast.error(errorMessage);\n      console.error(\"Failed to update workspace name:\", error);\n    },\n  });\n\n  const onNameSubmit = (data: NameValues) => {\n    // need to work on proper permissons later\n    if (!isOwner || !activeWorkspace?.id) {\n      return;\n    }\n    updateName({\n      organizationId: activeWorkspace.id,\n      data: { name: data.name.trim() },\n    });\n  };\n\n  const onNameInvalid: SubmitErrorHandler<NameValues> = (errors) => {\n    const message = errors.name?.message;\n    if (message) {\n      toast.error(message);\n    }\n  };\n\n  return (\n    <SettingsSection\n      description=\"The name of your workspace on Marble, typically your website's name.\"\n      title=\"Workspace Name\"\n    >\n      <form\n        className=\"flex flex-col gap-2 rounded-[14px] bg-background px-4 py-3.5\"\n        onSubmit={nameForm.handleSubmit(onNameSubmit, onNameInvalid)}\n      >\n        <div className=\"flex flex-col gap-2 sm:flex-row sm:items-start\">\n          <div className=\"min-w-0 flex-1\">\n            <Label className=\"sr-only\" htmlFor={nameId}>\n              Name\n            </Label>\n            <Input\n              id={nameId}\n              {...nameForm.register(\"name\")}\n              disabled={!isOwner}\n              placeholder=\"Technology\"\n            />\n            {nameForm.formState.errors.name && (\n              <ErrorMessage>\n                {nameForm.formState.errors.name.message}\n              </ErrorMessage>\n            )}\n          </div>\n          <AsyncButton\n            className=\"w-20 self-end\"\n            disabled={!isOwner || !nameForm.formState.isDirty}\n            isLoading={isPending}\n            type=\"submit\"\n          >\n            Save\n          </AsyncButton>\n        </div>\n      </form>\n    </SettingsSection>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/fields/slug.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useId } from \"react\";\nimport { useForm } from \"react-hook-form\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { organization } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { type SlugValues, slugSchema } from \"@/lib/validations/workspace\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport { generateSlug } from \"@/utils/string\";\n\nexport function Slug() {\n  const router = useRouter();\n  const { activeWorkspace, isOwner } = useWorkspace();\n  const queryClient = useQueryClient();\n  const slugId = useId();\n  const slugForm = useForm<SlugValues>({\n    resolver: zodResolver(slugSchema),\n    defaultValues: { slug: activeWorkspace?.slug || \"\" },\n  });\n\n  const { mutate: updateSlug, isPending } = useMutation({\n    mutationFn: async ({\n      organizationId,\n      payload,\n    }: {\n      organizationId: string;\n      payload: SlugValues;\n    }) => {\n      const { data, error } = await organization.checkSlug({\n        slug: payload.slug,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      if (!data.status) {\n        slugForm.setError(\"slug\", { message: \"Slug is already taken\" });\n        throw new Error(\"Slug is already taken\");\n      }\n\n      const res = await organization.update({\n        organizationId,\n        data: {\n          slug: payload.slug,\n        },\n      });\n      if (res?.error) {\n        throw new Error(res.error.message);\n      }\n      return res;\n    },\n    onSuccess: (data, variables) => {\n      if (!data) {\n        return;\n      }\n\n      toast.success(\"Workspace slug updated\");\n      slugForm.reset({ slug: data.data?.slug });\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE(variables.organizationId),\n      });\n      router.replace(`/${data.data?.slug}/settings/general`);\n      router.refresh();\n    },\n    onError: (error) => {\n      const errorMessage =\n        error instanceof Error\n          ? error.message\n          : \"Failed to update workspace slug\";\n      if (errorMessage !== \"Slug is already taken\") {\n        toast.error(errorMessage);\n        console.error(\"Failed to update workspace slug:\", error);\n      }\n    },\n  });\n\n  const onSlugSubmit = (payload: SlugValues) => {\n    if (!isOwner || !activeWorkspace?.id) {\n      return;\n    }\n    const cleanSlug = generateSlug(payload.slug);\n    updateSlug({\n      organizationId: activeWorkspace.id,\n      payload: { slug: cleanSlug },\n    });\n  };\n\n  return (\n    <SettingsSection\n      description=\"Your unique workspace slug. Used in your workspace URL.\"\n      title=\"Workspace Slug\"\n    >\n      <form\n        className=\"flex flex-col gap-2 rounded-[14px] bg-background px-4 py-3.5 sm:flex-row sm:items-start\"\n        onSubmit={slugForm.handleSubmit(onSlugSubmit)}\n      >\n        <div className=\"min-w-0 flex-1\">\n          <Label className=\"sr-only\" htmlFor={slugId}>\n            Slug\n          </Label>\n          <Input\n            id={slugId}\n            {...slugForm.register(\"slug\")}\n            disabled={!isOwner}\n            placeholder=\"workspace\"\n          />\n          {slugForm.formState.errors.slug && (\n            <ErrorMessage>\n              {slugForm.formState.errors.slug.message}\n            </ErrorMessage>\n          )}\n        </div>\n        <AsyncButton\n          className=\"w-20 self-end\"\n          disabled={!isOwner || !slugForm.formState.isDirty}\n          isLoading={isPending}\n          type=\"submit\"\n        >\n          Save\n        </AsyncButton>\n      </form>\n    </SettingsSection>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/fields/timezone.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useForm } from \"react-hook-form\";\nimport type { z } from \"zod\";\nimport { SettingsSection } from \"@/components/settings/section\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { TimezoneSelector } from \"@/components/ui/timezone-selector\";\nimport { organization } from \"@/lib/auth/client\";\nimport { timezones } from \"@/lib/constants\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type TimezoneValues,\n  timezoneSchema,\n} from \"@/lib/validations/workspace\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\nexport function Timezone() {\n  const router = useRouter();\n  const { activeWorkspace, isOwner } = useWorkspace();\n  const queryClient = useQueryClient();\n\n  const timezoneForm = useForm<TimezoneValues>({\n    resolver: zodResolver(timezoneSchema),\n    defaultValues: { timezone: activeWorkspace?.timezone || \"UTC\" },\n  });\n\n  const { mutate: updateTimezone, isPending } = useMutation({\n    mutationFn: async ({\n      organizationId,\n      data,\n    }: {\n      organizationId: string;\n      data: z.infer<typeof timezoneSchema>;\n    }) => {\n      const res = await organization.update({\n        organizationId,\n        data: {\n          timezone: data.timezone,\n        },\n      });\n      if (res?.error) {\n        throw new Error(res.error.message);\n      }\n      return res;\n    },\n    onSuccess: (_, variables) => {\n      toast.success(\"Updated timezone\");\n      timezoneForm.reset({\n        timezone: timezoneForm.getValues(\"timezone\"),\n      });\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE(variables.organizationId),\n      });\n      router.refresh();\n    },\n    onError: (error) => {\n      const errorMessage =\n        error instanceof Error\n          ? error.message\n          : \"Failed to update workspace timezone\";\n      toast.error(errorMessage);\n      console.error(\"Failed to update workspace timezone:\", error);\n    },\n  });\n\n  const onTimezoneSubmit = async (data: TimezoneValues) => {\n    if (!isOwner || !activeWorkspace?.id) {\n      return;\n    }\n    updateTimezone({\n      organizationId: activeWorkspace.id,\n      data,\n    });\n  };\n\n  return (\n    <SettingsSection\n      description=\"The timezone of your workspace. Changes affect scheduled posts.\"\n      title=\"Workspace Timezone\"\n    >\n      <form\n        className=\"flex flex-col gap-2 rounded-[14px] bg-background px-4 py-3.5 sm:flex-row sm:items-start\"\n        onSubmit={timezoneForm.handleSubmit(onTimezoneSubmit)}\n      >\n        <div className=\"min-w-0 flex-1\">\n          <Label className=\"sr-only\" htmlFor=\"timezone\">\n            Timezone\n          </Label>\n          <TimezoneSelector\n            disabled={!isOwner}\n            onValueChange={(value) => {\n              timezoneForm.setValue(\"timezone\", value, {\n                shouldDirty: true,\n              });\n              timezoneForm.trigger(\"timezone\");\n            }}\n            placeholder=\"Select timezone...\"\n            timezones={timezones}\n            value={timezoneForm.watch(\"timezone\")}\n          />\n          {timezoneForm.formState.errors.timezone && (\n            <ErrorMessage>\n              {timezoneForm.formState.errors.timezone.message}\n            </ErrorMessage>\n          )}\n        </div>\n        <AsyncButton\n          className=\"w-20 self-end\"\n          disabled={!isOwner || !timezoneForm.formState.isDirty}\n          isLoading={isPending}\n          type=\"submit\"\n        >\n          Save\n        </AsyncButton>\n      </form>\n    </SettingsSection>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/section.tsx",
    "content": "import { Card, CardDescription, CardTitle } from \"@marble/ui/components/card\";\nimport type { ReactNode } from \"react\";\n\nexport function SettingsSection({\n  title,\n  description,\n  children,\n}: {\n  title: string;\n  description: string;\n  children: ReactNode;\n}) {\n  return (\n    <Card className=\"gap-1 rounded-[20px] border-none bg-surface p-1.5\">\n      <div className=\"flex flex-col gap-0.5 px-4 py-2\">\n        <CardTitle className=\"font-medium text-sm\">{title}</CardTitle>\n        <CardDescription className=\"text-[13px]\">{description}</CardDescription>\n      </div>\n      {children}\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/settings/theme.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { CaretDownIcon } from \"@phosphor-icons/react\";\nimport { useTheme } from \"next-themes\";\n\nconst themes = [\n  {\n    name: \"Light\",\n    label: \"Light\",\n  },\n  {\n    name: \"Dark\",\n    label: \"Dark\",\n  },\n  {\n    name: \"System\",\n    label: \"System\",\n  },\n];\n\nexport function ThemeSwitch() {\n  const { setTheme, theme } = useTheme();\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        render={\n          <Button variant=\"outline\">\n            <span className=\"capitalize\">{theme}</span>\n            <CaretDownIcon className=\"ml-2 size-4 text-muted-foreground\" />\n          </Button>\n        }\n      />\n      <DropdownMenuContent align=\"end\">\n        {themes.map((item) => (\n          <DropdownMenuItem\n            className=\"cursor-pointer\"\n            key={item.name.toLowerCase()}\n            onClick={() => setTheme(item.name.toLowerCase())}\n          >\n            {item.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/share/prose.tsx",
    "content": "/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: <> */\nimport { cn } from \"@marble/ui/lib/utils\";\n\ntype ProseProps = React.HTMLAttributes<HTMLElement> & {\n  as?: \"article\";\n  html?: string;\n};\n\nfunction Prose({ children, html, className }: ProseProps) {\n  return (\n    <article\n      className={cn(\n        \"prose dark:prose-invert mx-auto prose-img:rounded-xl prose-p:text-justify prose-h1:font-bold prose-headings:font-abc-favorit prose-headings:font-normal prose-h1:text-xl prose-a:hover:text-primary\",\n        className\n      )}\n    >\n      {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}\n    </article>\n  );\n}\n\nexport default Prose;\n"
  },
  {
    "path": "apps/cms/src/components/share/screens.tsx",
    "content": "import { buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  ArrowArcLeftIcon,\n  LinkBreakIcon,\n} from \"@phosphor-icons/react/dist/ssr\";\nimport Link from \"next/link\";\n\nexport function LinkExpired() {\n  return (\n    <main className=\"flex h-screen flex-col items-center justify-center\">\n      <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-surface dark:bg-accent/50\">\n        <LinkBreakIcon className=\"size-6 dark:text-accent-foreground\" />\n      </div>\n      <h1 className=\"font-bold text-4xl\">Expired</h1>\n      <p className=\"mt-2 text-muted-foreground text-xl\">\n        This link has expired and is no longer accessible.\n      </p>\n\n      <Link className={cn(\"mt-6\", buttonVariants())} href=\"/\">\n        <ArrowArcLeftIcon className=\"size-4\" /> Go to Dashboard\n      </Link>\n    </main>\n  );\n}\n\nexport function LinkNotFound() {\n  return (\n    <main className=\"flex h-screen flex-col items-center justify-center\">\n      <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-surface dark:bg-accent/50\">\n        <LinkBreakIcon className=\"size-6 dark:text-accent-foreground\" />\n      </div>\n      <h1 className=\"font-bold text-4xl\">Link Not Found</h1>\n      <p className=\"mt-2 text-muted-foreground text-xl\">\n        This share link could not be found or is no longer valid.\n      </p>\n\n      <Link className={cn(\"mt-6\", buttonVariants())} href=\"/\">\n        <ArrowArcLeftIcon className=\"size-4\" /> Go to Dashboard\n      </Link>\n    </main>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/shared/container.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\n\ninterface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {\n  children: React.ReactNode;\n}\n\nfunction Container({ className, children, ...props }: ContainerProps) {\n  return (\n    <div\n      className={cn(\n        \"mx-auto h-full w-full max-w-(--breakpoint-2xl) px-6 md:px-12 lg:px-20\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport default Container;\n"
  },
  {
    "path": "apps/cms/src/components/shared/dropzone.tsx",
    "content": "\"use client\";\n\nimport { Image02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { type DropzoneOptions, useDropzone } from \"react-dropzone\";\nimport { IMAGE_DROPZONE_ACCEPT, MEDIA_DROPZONE_ACCEPT } from \"@/lib/constants\";\n\ninterface DropzoneProps {\n  onFilesAccepted: (files: File[]) => void;\n  className?: string;\n  multiple?: boolean;\n  accept?: DropzoneOptions[\"accept\"];\n  maxSize?: number;\n  children?: React.ReactNode;\n  disabled?: boolean;\n  placeholder?: {\n    idle: string;\n    active: string;\n    subtitle?: string;\n  };\n}\n\nexport function Dropzone({\n  onFilesAccepted,\n  className,\n  multiple = false,\n  accept,\n  maxSize,\n  children,\n  disabled = false,\n  placeholder = {\n    idle: \"Drag & drop files here, or click to select\",\n    active: \"Drop the files here...\",\n    subtitle: \"Supports JPEG, PNG, GIF, WebP, AVIF\",\n  },\n}: DropzoneProps) {\n  const {\n    getRootProps,\n    getInputProps,\n    isDragActive,\n    isDragReject,\n    fileRejections,\n  } = useDropzone({\n    accept,\n    multiple,\n    maxSize,\n    disabled,\n    onDrop: (acceptedFiles) => {\n      if (acceptedFiles.length > 0) {\n        onFilesAccepted(acceptedFiles);\n      }\n    },\n  });\n\n  const hasErrors = fileRejections.length > 0;\n\n  return (\n    <div className=\"h-full w-full\">\n      <div\n        {...getRootProps()}\n        className={cn(\n          \"flex w-full cursor-pointer items-center justify-center rounded-md border border-dashed bg-background transition-colors\",\n          isDragActive && !isDragReject && \"border-primary bg-primary/5\",\n          isDragReject && \"border-destructive bg-destructive/10\",\n          hasErrors && \"border-destructive bg-destructive/5\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n      >\n        <input {...getInputProps()} />\n        {children || (\n          <div className=\"flex flex-col items-center gap-2 p-6 text-muted-foreground\">\n            {/* Fetch media */}\n            <HugeiconsIcon icon={Image02Icon} />\n            <div className=\"flex flex-col items-center text-center\">\n              <p className=\"font-medium text-sm\">\n                {isDragReject\n                  ? \"Unsupported file type\"\n                  : isDragActive\n                    ? placeholder.active\n                    : placeholder.idle}\n              </p>\n              {placeholder.subtitle && (\n                <p className=\"mt-1 text-muted-foreground/70 text-xs\">\n                  {placeholder.subtitle}\n                </p>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Error messages */}\n      {hasErrors && (\n        <div className=\"mt-2 space-y-1 text-destructive text-sm\">\n          {fileRejections.map(({ file, errors }) => {\n            const fileType = file.name.split(\".\").pop();\n            const message =\n              errors[0]?.code === \"file-invalid-type\"\n                ? `File type \".${fileType}\" is not supported.`\n                : errors[0]?.message;\n\n            return (\n              <p key={file.name}>\n                <span className=\"font-medium\">{file.name}:</span> {message}\n              </p>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n\ninterface MediaDropzoneProps\n  extends Omit<DropzoneProps, \"accept\" | \"placeholder\"> {\n  placeholder?: {\n    idle?: string;\n    active?: string;\n    subtitle?: string;\n  };\n}\n\nconst EMPTY_PLACEHOLDER: MediaDropzoneProps[\"placeholder\"] = {};\n\nexport function MediaDropzone({\n  placeholder = EMPTY_PLACEHOLDER,\n  ...props\n}: MediaDropzoneProps) {\n  return (\n    <Dropzone\n      accept={MEDIA_DROPZONE_ACCEPT}\n      placeholder={{\n        idle:\n          placeholder?.idle || \"Drag & drop a media here, or click to select\",\n        active: placeholder?.active || \"Drop the media here...\",\n        subtitle:\n          placeholder?.subtitle ||\n          \"Supports all common image and video formats\",\n      }}\n      {...props}\n    />\n  );\n}\n\nexport function ImageDropzone({\n  placeholder = EMPTY_PLACEHOLDER,\n  ...props\n}: MediaDropzoneProps) {\n  return (\n    <Dropzone\n      accept={{ \"image/*\": IMAGE_DROPZONE_ACCEPT }}\n      placeholder={{\n        idle:\n          placeholder?.idle || \"Drag & drop an image here, or click to select\",\n        active: placeholder?.active || \"Drop the image here...\",\n        subtitle:\n          placeholder?.subtitle || \"Supports JPEG, PNG, GIF, WebP, AVIF\",\n      }}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/shared/icons.tsx",
    "content": "import type { IconProps } from \"@/types/icons\";\n\nexport const AcmeLogo = ({ ...props }: IconProps) => (\n  <svg fill=\"none\" height=\"36\" viewBox=\"0 0 32 32\" width=\"36\" {...props}>\n    <title>Acme Logo</title>\n    <path\n      clipRule=\"evenodd\"\n      d=\"M17.6482 10.1305L15.8785 7.02583L7.02979 22.5499H10.5278L17.6482 10.1305ZM19.8798 14.0457L18.11 17.1983L19.394 19.4511H16.8453L15.1056 22.5499H24.7272L19.8798 14.0457Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nexport const Github = ({ ...props }: IconProps) => (\n  <svg\n    fill=\"currentColor\"\n    role=\"img\"\n    viewBox=\"0 0 24 24\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>GitHub</title>\n    <path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" />\n  </svg>\n);\n\nexport const Discord = ({ ...props }: IconProps) => (\n  <svg\n    fill=\"currentColor\"\n    role=\"img\"\n    viewBox=\"0 0 24 24\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Discord</title>\n    <path d=\"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\" />\n  </svg>\n);\n\nexport const Slack = ({ ...props }: IconProps) => (\n  <svg\n    fill=\"currentColor\"\n    role=\"img\"\n    viewBox=\"0 0 24 24.048\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Slack</title>\n    <g clipRule=\"evenodd\" fillRule=\"evenodd\">\n      <path\n        d=\"M8.799 0A2.4 2.4 0 0 0 6.4 2.404a2.4 2.4 0 0 0 2.4 2.404h2.4V2.405A2.403 2.403 0 0 0 8.799 0q.001 0 0 0m0 6.413H2.4A2.4 2.4 0 0 0 0 8.817a2.4 2.4 0 0 0 2.399 2.405h6.4a2.4 2.4 0 0 0 2.4-2.404 2.4 2.4 0 0 0-2.4-2.405\"\n        fill=\"#36c5f0\"\n      />\n      <path\n        d=\"M24 8.817a2.4 2.4 0 0 0-2.4-2.404 2.4 2.4 0 0 0-2.4 2.404v2.405h2.4A2.4 2.4 0 0 0 24 8.817m-6.4 0V2.404A2.403 2.403 0 0 0 15.201 0a2.4 2.4 0 0 0-2.4 2.404v6.413a2.4 2.4 0 0 0 2.399 2.405 2.4 2.4 0 0 0 2.4-2.405\"\n        fill=\"#2eb67d\"\n      />\n      <path\n        d=\"M15.2 24.048a2.4 2.4 0 0 0 2.4-2.404 2.4 2.4 0 0 0-2.4-2.404h-2.4v2.404a2.403 2.403 0 0 0 2.4 2.404m0-6.414h6.4A2.4 2.4 0 0 0 24 15.23a2.4 2.4 0 0 0-2.399-2.405h-6.4a2.4 2.4 0 0 0-2.4 2.404 2.4 2.4 0 0 0 2.399 2.405\"\n        fill=\"#ecb22e\"\n      />\n      <path\n        d=\"M0 15.23a2.4 2.4 0 0 0 2.4 2.404 2.4 2.4 0 0 0 2.4-2.404v-2.404H2.4A2.4 2.4 0 0 0 0 15.23m6.4 0v6.413a2.4 2.4 0 0 0 2.399 2.405 2.4 2.4 0 0 0 2.4-2.404v-6.412A2.4 2.4 0 1 0 6.4 15.23\"\n        fill=\"#e01e5a\"\n      />\n    </g>\n  </svg>\n);\nexport const Google = ({ ...props }: IconProps) => (\n  <svg\n    fill=\"currentColor\"\n    role=\"img\"\n    viewBox=\"0 0 24 24\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>Google</title>\n    <path d=\"M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z\" />\n  </svg>\n);\n\nexport const X = ({ ...props }: IconProps) => (\n  <svg\n    fill=\"currentColor\"\n    role=\"img\"\n    viewBox=\"0 0 24 24\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <title>X</title>\n    <path d=\"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z\" />\n  </svg>\n);\n"
  },
  {
    "path": "apps/cms/src/components/shared/page-loader.tsx",
    "content": "\"use client\";\n\nimport { SpinnerIcon } from \"@phosphor-icons/react\";\n\nfunction PageLoader() {\n  return (\n    <div aria-busy=\"true\" className=\"grid h-full w-full place-content-center\">\n      <div className=\"p-2\">\n        <SpinnerIcon className=\"size-5 animate-spin transition\" />\n      </div>\n    </div>\n  );\n}\n\nexport default PageLoader;\n"
  },
  {
    "path": "apps/cms/src/components/shared/pending-state.tsx",
    "content": "import type { PropsWithChildren, ReactNode } from \"react\";\n\nexport type PendingStateProps = PropsWithChildren<{\n  icon: React.ElementType;\n  title?: string;\n  description?: ReactNode;\n  learnMore?: string;\n}>;\n\nexport function PendingState({\n  icon: Icon,\n  title,\n  description,\n  learnMore,\n  children,\n}: PendingStateProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-y-4\">\n      <div className=\"flex size-16 items-center justify-center rounded-2xl border border-gray-200 bg-gray-50\">\n        <Icon className=\"size-6\" />\n      </div>\n      {title && (\n        <p className=\"text-center font-medium text-base text-gray-950\">\n          {title}\n        </p>\n      )}\n      {description && (\n        <p className=\"max-w-sm text-balance text-center text-gray-500 text-sm\">\n          {description}{\" \"}\n          {learnMore && (\n            <a\n              className=\"underline underline-offset-2 hover:text-gray-800\"\n              href={learnMore}\n              rel=\"noreferrer\"\n              target=\"_blank\"\n            >\n              Learn more ↗\n            </a>\n          )}\n        </p>\n      )}\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/tags/columns.tsx",
    "content": "\"use client\";\n\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport TableActions from \"./table-actions\";\n\nexport interface Tag {\n  id: string;\n  name: string;\n  slug: string;\n  description?: string | null;\n  postsCount: number;\n}\n\nexport const columns: ColumnDef<Tag>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"Name\",\n  },\n  {\n    accessorKey: \"slug\",\n    header: \"Slug\",\n  },\n  {\n    accessorKey: \"postsCount\",\n    header: () => <div className=\"text-center\">Posts</div>,\n    cell: ({ row }) => (\n      <p className=\"text-center\">{row.getValue(\"postsCount\")}</p>\n    ),\n  },\n  {\n    id: \"actions\",\n    header: () => <div className=\"flex justify-end pr-10\">Actions</div>,\n    cell: ({ row }) => {\n      const tag = row.original;\n\n      return (\n        <div className=\"flex justify-end pr-10\">\n          <TableActions {...tag} />\n        </div>\n      );\n    },\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/tags/data-table.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport { MagnifyingGlassIcon, PlusIcon, XIcon } from \"@phosphor-icons/react\";\nimport {\n  type ColumnDef,\n  type ColumnFiltersState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  type SortingState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useState } from \"react\";\nimport { TagModal } from \"./tag-modals\";\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n}\n\nexport function DataTable<TData, TValue>({\n  columns,\n  data,\n}: DataTableProps<TData, TValue>) {\n  const [sorting, _setSorting] = useState<SortingState>([]);\n\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n\n  const [showCreateModal, setShowCreateModal] = useState(false);\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    onColumnFiltersChange: setColumnFilters,\n    getFilteredRowModel: getFilteredRowModel(),\n    state: {\n      sorting,\n      columnFilters,\n    },\n  });\n\n  return (\n    <div>\n      <div className=\"flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"relative\">\n          <MagnifyingGlassIcon\n            className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n            size={16}\n          />\n          <Input\n            className=\"w-full px-8 sm:w-72\"\n            onChange={(event) =>\n              table.getColumn(\"name\")?.setFilterValue(event.target.value)\n            }\n            placeholder=\"Search tags...\"\n            value={(table.getColumn(\"name\")?.getFilterValue() as string) ?? \"\"}\n          />\n          {(table.getColumn(\"name\")?.getFilterValue() as string) && (\n            <button\n              className=\"absolute top-3 right-3\"\n              onClick={() => table.getColumn(\"name\")?.setFilterValue(\"\")}\n              type=\"button\"\n            >\n              <XIcon className=\"size-4\" />\n              <span className=\"sr-only\">Clear search</span>\n            </button>\n          )}\n        </div>\n        <div>\n          <Button onClick={() => setShowCreateModal(true)}>\n            <PlusIcon size={16} />\n            <span>Create Tag</span>\n          </Button>\n        </div>\n      </div>\n\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  data-state={row.getIsSelected() && \"selected\"}\n                  key={row.id}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  className=\"h-96 text-center\"\n                  colSpan={columns.length}\n                >\n                  No tags to show.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      <TagModal\n        mode=\"create\"\n        open={showCreateModal}\n        setOpen={setShowCreateModal}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/tags/table-actions.tsx",
    "content": "import {\n  Delete02Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { useState } from \"react\";\nimport type { Tag } from \"./columns\";\nimport { DeleteTagModal, TagModal } from \"./tag-modals\";\n\nexport default function TableActions(props: Tag) {\n  const [showUpdateModal, setShowUpdateModal] = useState(false);\n  const [showDeleteModal, setShowDeleteModal] = useState(false);\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button className=\"size-8 p-0\" variant=\"ghost\">\n              <span className=\"sr-only\">Open menu</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent\n          align=\"end\"\n          className=\"text-muted-foreground shadow-sm\"\n        >\n          <DropdownMenuItem onClick={() => setShowUpdateModal(true)}>\n            <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n            <span>Edit</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setShowDeleteModal(true)}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon icon={Delete02Icon} size={16} />\n            <span>Delete</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      {showUpdateModal && (\n        <TagModal\n          mode=\"update\"\n          open={showUpdateModal}\n          setOpen={setShowUpdateModal}\n          tagData={{ ...props }}\n        />\n      )}\n\n      <DeleteTagModal\n        id={props.id}\n        name={props.name}\n        open={showDeleteModal}\n        setOpen={setShowDeleteModal}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/tags/tag-modals.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Alert02Icon, Tag01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useForm } from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { type CreateTagValues, tagSchema } from \"@/lib/validations/workspace\";\nimport { generateSlug } from \"@/utils/string\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport type { Tag } from \"./columns\";\n\nexport function TagModal({\n  open,\n  setOpen,\n  mode = \"create\",\n  tagData = { name: \"\", slug: \"\", description: \"\" },\n  onTagCreated,\n}: {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  mode?: \"create\" | \"update\";\n  tagData?: Partial<Tag>;\n  onTagCreated?: (tag: { id: string; name: string; slug: string }) => void;\n}) {\n  const queryClient = useQueryClient();\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    reset,\n    formState: { errors, isSubmitting },\n  } = useForm<CreateTagValues>({\n    resolver: zodResolver(tagSchema),\n    defaultValues: {\n      name: tagData.name || \"\",\n      slug: tagData.slug || \"\",\n      description: tagData.description || \"\",\n    },\n  });\n\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: createTag, isPending: isCreating } = useMutation({\n    mutationFn: async (data: CreateTagValues) => {\n      const res = await fetch(\"/api/tags\", {\n        method: \"POST\",\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to create tag\");\n      }\n\n      return res.json();\n    },\n    onSuccess: (data) => {\n      onTagCreated?.(data);\n      setOpen(false);\n      toast.success(\"Tag created successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.TAGS(workspaceId),\n        });\n      }\n      reset();\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const { mutate: updateTag, isPending: isUpdating } = useMutation({\n    mutationFn: async (data: CreateTagValues) => {\n      const res = await fetch(`/api/tags/${tagData.id}`, {\n        method: \"PATCH\",\n        body: JSON.stringify(data),\n      });\n\n      if (!res.ok) {\n        const err = await res.json().catch(() => ({}));\n        throw new Error(err.error || \"Failed to update tag\");\n      }\n\n      return res.json();\n    },\n    onSuccess: () => {\n      setOpen(false);\n      toast.success(\"Tag updated successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.TAGS(workspaceId),\n        });\n      }\n    },\n    onError: (error) => {\n      toast.error(error.message);\n    },\n  });\n\n  const onSubmit = async (data: CreateTagValues) => {\n    if (!workspaceId) {\n      toast.error(\"No active workspace\");\n      return;\n    }\n\n    if (mode === \"update\" && !tagData.id) {\n      toast.error(\"Tag ID is missing - cannot update tag\");\n      return;\n    }\n\n    if (mode === \"create\") {\n      createTag(data);\n    } else {\n      updateTag(data);\n    }\n  };\n\n  return (\n    <Dialog onOpenChange={setOpen} open={open}>\n      <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={Tag01Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              {mode === \"create\" ? \"Create Tag\" : \"Update Tag\"}\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogBody>\n          <form\n            className=\"mt-2 flex flex-col gap-3\"\n            onSubmit={handleSubmit(onSubmit)}\n          >\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"tag-name\">Name</Label>\n              <Input\n                id=\"tag-name\"\n                {...register(\"name\", {\n                  onChange: (e) => {\n                    if (mode === \"create\") {\n                      setValue(\"slug\", generateSlug(e.target.value));\n                    }\n                  },\n                })}\n                placeholder=\"The name of the tag\"\n              />\n              {errors.name && (\n                <ErrorMessage>{errors.name.message}</ErrorMessage>\n              )}\n            </div>\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"tag-slug\">Slug</Label>\n              <Input\n                id=\"tag-slug\"\n                {...register(\"slug\", {\n                  onChange: (e) => {\n                    setValue(\n                      \"slug\",\n                      generateSlug(e.target.value, { trimEdges: false }),\n                      { shouldValidate: true }\n                    );\n                  },\n                })}\n                placeholder=\"unique-identifier\"\n              />\n              {errors.slug && (\n                <ErrorMessage>{errors.slug.message}</ErrorMessage>\n              )}\n            </div>\n            <div className=\"grid flex-1 gap-2\">\n              <Label htmlFor=\"tag-description\">Description</Label>\n              <Textarea\n                id=\"tag-description\"\n                {...register(\"description\")}\n                placeholder=\"An optional description of the tag\"\n              />\n            </div>\n            <DialogFooter>\n              <DialogClose size=\"sm\">Cancel</DialogClose>\n              <AsyncButton\n                className=\"gap-2\"\n                isLoading={isSubmitting || isCreating || isUpdating}\n                size=\"sm\"\n                type=\"submit\"\n              >\n                {mode === \"create\" ? \"Create\" : \"Update\"}\n              </AsyncButton>\n            </DialogFooter>\n          </form>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nexport const DeleteTagModal = ({\n  open,\n  setOpen,\n  id,\n  name,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  id: string;\n  name: string;\n}) => {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const { mutate: deleteTag, isPending } = useMutation({\n    mutationFn: async () => {\n      const res = await fetch(`/api/tags/${id}`, {\n        method: \"DELETE\",\n      });\n\n      if (!res.ok) {\n        const errorText = await res.text().catch(() => \"Unknown error\");\n        throw new Error(\n          `Failed to delete tag: ${res.status} ${res.statusText} - ${errorText}`\n        );\n      }\n\n      return true;\n    },\n    onSuccess: () => {\n      toast.success(\"Tag deleted successfully\");\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.TAGS(workspaceId),\n        });\n      }\n      setOpen(false);\n    },\n    onError: () => {\n      toast.error(\"Failed to delete tag.\");\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete \"{name}\"?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription>\n            This will permanently delete this tag from your list and you can no\n            longer use this in articles.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isPending} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteTag();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/team/columns.tsx",
    "content": "\"use client\";\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Badge } from \"@marble/ui/components/badge\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport TableActions from \"./table-actions\";\n\ntype UserRole = \"owner\" | \"admin\" | \"member\";\n\nexport interface TeamMemberRow {\n  id: string;\n  type: \"member\";\n  name: string | null;\n  email: string;\n  image: string | null;\n  role: UserRole;\n  status: \"accepted\";\n  userId?: string | null;\n  joinedAt?: Date | null;\n}\n\nexport const columns: ColumnDef<TeamMemberRow>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"User\",\n    cell: ({ row }) => {\n      const item = row.original;\n      const displayName = item.name || item.email;\n      const avatarFallback = displayName?.charAt(0).toUpperCase() || \"?\";\n\n      return (\n        <div className=\"flex items-center gap-3\">\n          <Avatar className=\"size-8\">\n            <AvatarImage src={item.image || undefined} />\n            <AvatarFallback>{avatarFallback}</AvatarFallback>\n          </Avatar>\n          <div className=\"flex flex-col\">\n            <span className=\"font-medium text-sm\">{displayName}</span>\n            <span className=\"text-muted-foreground text-xs\">{item.email}</span>\n          </div>\n        </div>\n      );\n    },\n  },\n  {\n    accessorKey: \"role\",\n    header: \"Role\",\n    cell: ({ row }) => {\n      const item = row.original;\n\n      return (\n        <Badge className=\"capitalize\" variant=\"outline\">\n          {item.role}\n        </Badge>\n      );\n    },\n  },\n  {\n    id: \"actions\",\n    header: () => <div className=\"flex justify-end pr-10\">Actions</div>,\n    cell: ({ row, table }) => {\n      const meta = table.options.meta as {\n        currentUserRole: UserRole | undefined;\n        currentUserId: string | undefined;\n      };\n      return (\n        <div className=\"flex justify-end pr-10\">\n          <TableActions\n            {...row.original}\n            currentUserId={meta?.currentUserId}\n            currentUserRole={meta?.currentUserRole}\n          />\n        </div>\n      );\n    },\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/components/team/data-table.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { MagnifyingGlassIcon, XIcon } from \"@phosphor-icons/react\";\nimport {\n  type ColumnDef,\n  type ColumnFiltersState,\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useState } from \"react\";\nimport { InviteButton } from \"./invite-button\";\n\ntype UserRole = \"owner\" | \"admin\" | \"member\" | undefined;\n\ninterface DataTableProps<TData, TValue> {\n  columns: ColumnDef<TData, TValue>[];\n  data: TData[];\n  currentUserRole: UserRole;\n  currentUserId: string | undefined;\n  setShowInviteModal: (open: boolean) => void;\n  setShowLeaveWorkspaceModal: (open: boolean) => void;\n}\n\nexport function TeamDataTable<TData, TValue>({\n  columns,\n  data,\n  currentUserRole,\n  currentUserId,\n  setShowInviteModal,\n  setShowLeaveWorkspaceModal,\n}: DataTableProps<TData, TValue>) {\n  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    onColumnFiltersChange: setColumnFilters,\n    getFilteredRowModel: getFilteredRowModel(),\n    state: {\n      columnFilters,\n    },\n    meta: {\n      currentUserRole,\n      currentUserId,\n    },\n  });\n\n  return (\n    <div>\n      <div className=\"flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between\">\n        <div className=\"relative\">\n          <MagnifyingGlassIcon\n            className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n            size={16}\n          />\n          <Input\n            className=\"w-full px-8 sm:w-72\"\n            onChange={(event) =>\n              table.getColumn(\"name\")?.setFilterValue(event.target.value)\n            }\n            placeholder=\"Search team members...\"\n            value={(table.getColumn(\"name\")?.getFilterValue() as string) ?? \"\"}\n          />\n          {(table.getColumn(\"name\")?.getFilterValue() as string) && (\n            <button\n              className=\"absolute top-3 right-3\"\n              onClick={() => table.getColumn(\"name\")?.setFilterValue(\"\")}\n              type=\"button\"\n            >\n              <XIcon className=\"size-4\" />\n              <span className=\"sr-only\">Clear search</span>\n            </button>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-4\">\n          <InviteButton onInvite={() => setShowInviteModal(true)} />\n          {currentUserRole === \"owner\" ? (\n            <TooltipProvider>\n              <Tooltip>\n                <TooltipTrigger\n                  delay={0}\n                  render={\n                    <Button\n                      className=\"cursor-not-allowed opacity-50\"\n                      variant=\"outline\"\n                    >\n                      <span>Leave Team</span>\n                    </Button>\n                  }\n                />\n                <TooltipContent>\n                  <p className=\"text-xs\">\n                    You cannot leave your own workspace.\n                  </p>\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n          ) : (\n            <Button\n              onClick={() => setShowLeaveWorkspaceModal(true)}\n              variant=\"outline\"\n            >\n              <span>Leave Team</span>\n            </Button>\n          )}\n        </div>\n      </div>\n\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id}>\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  data-state={row.getIsSelected() && \"selected\"}\n                  key={row.id}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  className=\"h-24 text-center\"\n                  colSpan={columns.length}\n                >\n                  No results.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/team/invite-button.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { PlusIcon } from \"@phosphor-icons/react\";\nimport { useState } from \"react\";\nimport { UpgradeModal } from \"@/components/billing/upgrade-modal\";\nimport { usePlan } from \"@/hooks/use-plan\";\n\ninterface InviteButtonProps {\n  onInvite: () => void;\n}\n\nexport function InviteButton({ onInvite }: InviteButtonProps) {\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n  const { canInvite, remainingSlots, isHobbyPlan, planLimits } = usePlan();\n\n  const handleInviteClick = () => {\n    if (canInvite) {\n      onInvite();\n    } else {\n      setShowUpgradeModal(true);\n    }\n  };\n\n  const getTooltipContent = () => {\n    if (remainingSlots === 0) {\n      return \"You've reached your limit. Upgrade to invite more members.\";\n    }\n    return `You can invite ${remainingSlots} more member${remainingSlots === 1 ? \"\" : \"s\"}`;\n  };\n\n  if (!canInvite) {\n    return (\n      <>\n        <Tooltip>\n          <TooltipTrigger\n            delay={0}\n            render={\n              <Button\n                className={isHobbyPlan ? \"\" : \"opacity-50\"}\n                onClick={handleInviteClick}\n                variant={isHobbyPlan ? \"default\" : \"outline\"}\n              >\n                <PlusIcon className=\"size-4\" />\n                Invite\n              </Button>\n            }\n          />\n          <TooltipContent>\n            <p className=\"text-xs\">{getTooltipContent()}</p>\n          </TooltipContent>\n        </Tooltip>\n\n        <UpgradeModal\n          feature=\"team-members\"\n          isOpen={showUpgradeModal}\n          onClose={() => setShowUpgradeModal(false)}\n        />\n      </>\n    );\n  }\n\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger\n          delay={0}\n          render={\n            <Button onClick={onInvite}>\n              <PlusIcon className=\"size-4\" />\n              <span>Invite</span>\n            </Button>\n          }\n        />\n        <TooltipContent>\n          <p className=\"text-xs\">{getTooltipContent()}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/team/invite-modal.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { UserAdd01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useForm } from \"react-hook-form\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { organization } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { type InviteData, inviteSchema } from \"@/lib/validations/auth\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport { AsyncButton } from \"../ui/async-button\";\n\nexport const InviteModal = ({\n  open,\n  setOpen,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n}) => {\n  const { refreshActiveWorkspace } = useWorkspace();\n  const queryClient = useQueryClient();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    formState: { errors },\n    reset,\n  } = useForm<InviteData>({\n    resolver: zodResolver(inviteSchema),\n    defaultValues: {\n      role: \"member\",\n    },\n  });\n\n  const inviteMutation = useMutation({\n    mutationFn: async (data: InviteData) => {\n      const { data: result, error } = await organization.inviteMember({\n        email: data.email,\n        role: data.role,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      return result;\n    },\n    onSuccess: async () => {\n      toast.success(\"Invitation sent successfully\");\n      setOpen(false);\n      reset();\n      await queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE_LIST,\n      });\n      await refreshActiveWorkspace();\n    },\n    onError: (error) => {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to send invitation\"\n      );\n    },\n  });\n\n  const onSubmit = (data: InviteData) => {\n    inviteMutation.mutate(data);\n  };\n\n  return (\n    <Dialog onOpenChange={setOpen} open={open}>\n      <DialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-muted-foreground\"\n              icon={UserAdd01Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Invite Member\n            </DialogTitle>\n          </div>\n          <DialogX />\n        </DialogHeader>\n        <DialogDescription className=\"sr-only\">\n          Invite a team member to your workspace.\n        </DialogDescription>\n        <DialogBody>\n          <form\n            className=\"flex flex-col gap-3\"\n            onSubmit={handleSubmit(onSubmit)}\n          >\n            <div className=\"grid flex-1 gap-2\">\n              <Label className=\"sr-only\" htmlFor=\"email\">\n                Email\n              </Label>\n\n              <Input\n                id=\"email\"\n                {...register(\"email\")}\n                placeholder=\"teammate@company.com\"\n                type=\"email\"\n              />\n              {errors.email && (\n                <ErrorMessage>{errors.email.message}</ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid flex-1 gap-2\">\n              <Label className=\"sr-only\" htmlFor=\"role\">\n                Role\n              </Label>\n              <Select\n                defaultValue=\"member\"\n                onValueChange={(value) => {\n                  if (value === \"member\" || value === \"admin\") {\n                    setValue(\"role\", value);\n                  }\n                }}\n              >\n                <SelectTrigger className=\"w-full\">\n                  <SelectValue className=\"capitalize\" />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"member\">Member</SelectItem>\n                  <SelectItem value=\"admin\">Admin</SelectItem>\n                </SelectContent>\n              </Select>\n              {errors.role && (\n                <ErrorMessage>{errors.role.message}</ErrorMessage>\n              )}\n            </div>\n\n            <DialogFooter className=\"mt-4\">\n              <DialogClose size=\"sm\">Close</DialogClose>\n              <AsyncButton\n                isLoading={inviteMutation.isPending}\n                size=\"sm\"\n                type=\"submit\"\n              >\n                Invite\n              </AsyncButton>\n            </DialogFooter>\n          </form>\n        </DialogBody>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "apps/cms/src/components/team/invite-section.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Card, CardDescription, CardTitle } from \"@marble/ui/components/card\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport {\n  ArrowsClockwiseIcon,\n  DotsThreeVerticalIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { organization } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\ninterface Invite {\n  id: string;\n  email: string;\n  role: string | null;\n  status: string;\n  expiresAt: string | Date;\n  inviterId: string;\n}\n\ninterface InviteSectionProps {\n  invitations: Invite[];\n}\n\nexport function InviteSection({ invitations }: InviteSectionProps) {\n  const { refreshActiveWorkspace } = useWorkspace();\n  const queryClient = useQueryClient();\n\n  const pendingInvitations = invitations.filter(\n    (invitation) => invitation.status === \"pending\"\n  );\n\n  const resendInviteMutation = useMutation({\n    mutationFn: async ({\n      email,\n      role,\n    }: {\n      inviteId: string;\n      email: string;\n      role: string;\n    }) => {\n      const { data, error } = await organization.inviteMember({\n        email,\n        role: role as \"owner\" | \"admin\" | \"member\",\n        resend: true,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      return data;\n    },\n    onMutate: () => {\n      toast.loading(\"Resending invitation...\", {\n        id: \"resend-invitation\",\n      });\n    },\n    onSuccess: async (_data, _variables) => {\n      toast.success(\"Invitation resent successfully\", {\n        id: \"resend-invitation\",\n      });\n\n      await queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE_LIST,\n      });\n      await refreshActiveWorkspace();\n    },\n    onError: (error, _variables) => {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to resend invitation\",\n        {\n          id: \"resend-invitation\",\n        }\n      );\n    },\n  });\n\n  const cancelInviteMutation = useMutation({\n    mutationFn: async (inviteId: string) => {\n      const { data, error } = await organization.cancelInvitation({\n        invitationId: inviteId,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      return data;\n    },\n    onMutate: () => {\n      toast.loading(\"Canceling invitation...\", {\n        id: \"cancel-invitation\",\n      });\n    },\n    onSuccess: async (_data, _variables) => {\n      toast.success(\"Invitation canceled successfully\", {\n        id: \"cancel-invitation\",\n      });\n\n      await queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE_LIST,\n      });\n      await refreshActiveWorkspace();\n    },\n    onError: (error, _variables) => {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to cancel invitation\",\n        {\n          id: \"cancel-invitation\",\n        }\n      );\n    },\n  });\n\n  const handleResendInvite = (invitation: Invite) => {\n    resendInviteMutation.mutate({\n      inviteId: invitation.id,\n      email: invitation.email,\n      role: invitation.role || \"member\",\n    });\n  };\n\n  const handleCancelInvite = (invitation: Invite) => {\n    cancelInviteMutation.mutate(invitation.id);\n  };\n\n  if (pendingInvitations.length === 0) {\n    return null;\n  }\n\n  return (\n    <Card className=\"rounded-[20px] border-none bg-surface p-2.5\">\n      <div className=\"flex flex-col gap-6 rounded-[12px] bg-background p-6 shadow-xs\">\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle className=\"text-lg\">Pending Invitations</CardTitle>\n            <CardDescription className=\"sr-only\">\n              {pendingInvitations.length} invitation\n              {pendingInvitations.length !== 1 ? \"s\" : \"\"} waiting for response\n            </CardDescription>\n          </div>\n        </div>\n        <div className=\"space-y-3 divide-y\">\n          {pendingInvitations.map((invitation) => (\n            <div\n              className=\"flex items-center justify-between rounded-sm border p-3\"\n              key={invitation.id}\n            >\n              <div className=\"flex items-center gap-3\">\n                <div className=\"flex size-8 items-center justify-center rounded-full bg-muted\">\n                  <span className=\"font-medium text-sm\">\n                    {invitation.email.charAt(0).toUpperCase()}\n                  </span>\n                </div>\n                <div className=\"flex items-center gap-2\">\n                  <p className=\"font-medium text-sm\">{invitation.email}</p>\n                  <Badge className=\"text-xs capitalize\" variant=\"outline\">\n                    {invitation.role || \"member\"}\n                  </Badge>\n                </div>\n              </div>\n\n              <DropdownMenu>\n                <DropdownMenuTrigger\n                  render={\n                    <Button\n                      className=\"size-8 p-0\"\n                      disabled={\n                        resendInviteMutation.isPending ||\n                        cancelInviteMutation.isPending\n                      }\n                      variant=\"ghost\"\n                    >\n                      <span className=\"sr-only\">Open menu</span>\n                      <DotsThreeVerticalIcon size={16} weight=\"bold\" />\n                    </Button>\n                  }\n                />\n                <DropdownMenuContent align=\"end\">\n                  <DropdownMenuItem\n                    disabled={\n                      resendInviteMutation.isPending ||\n                      cancelInviteMutation.isPending\n                    }\n                    onClick={() => handleResendInvite(invitation)}\n                  >\n                    <ArrowsClockwiseIcon className=\"size-4\" />\n                    Resend Invite\n                  </DropdownMenuItem>\n                  <DropdownMenuItem\n                    disabled={\n                      resendInviteMutation.isPending ||\n                      cancelInviteMutation.isPending\n                    }\n                    onClick={() => handleCancelInvite(invitation)}\n                    variant=\"destructive\"\n                  >\n                    <XIcon className=\"size-4\" />\n                    Cancel Invite\n                  </DropdownMenuItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </div>\n          ))}\n        </div>\n      </div>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/team/leave-workspace.tsx",
    "content": "import { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { organization, useListOrganizations } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\n\ninterface ListOrganizationResponse {\n  // biome-ignore lint/suspicious/noExplicitAny: <>\n  metadata?: any;\n  name: string;\n  slug: string;\n  logo?: string | null | undefined | undefined;\n  createdAt: Date;\n  id: string;\n}\n\ninterface LeaveWorkspaceModalProps {\n  id: string;\n  name: string;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n}\n\nexport function LeaveWorkspaceModal({\n  id,\n  name,\n  open,\n  setOpen,\n}: LeaveWorkspaceModalProps) {\n  const [isLeavingWorkspace, setIsLeavingWorkspace] = useState(false);\n  const { updateActiveWorkspace } = useWorkspace();\n  const { data: organizations } = useListOrganizations();\n  const queryClient = useQueryClient();\n  const router = useRouter();\n\n  const handleLeaveWorkspace = async () => {\n    setIsLeavingWorkspace(true);\n\n    try {\n      const { error } = await organization.leave({\n        organizationId: id,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      toast.success(\"You have left the workspace.\");\n      queryClient.invalidateQueries({\n        queryKey: QUERY_KEYS.WORKSPACE_LIST,\n      });\n\n      const remainingWorkspaces = organizations?.filter(\n        (org: ListOrganizationResponse) => org.id !== id\n      );\n\n      if (!remainingWorkspaces || remainingWorkspaces.length === 0) {\n        router.push(\"/new\");\n        return;\n      }\n\n      const nextWorkspace = remainingWorkspaces[0];\n      if (!nextWorkspace) {\n        router.push(\"/new\");\n        return;\n      }\n\n      await updateActiveWorkspace(nextWorkspace);\n      router.push(`/${nextWorkspace.slug}`);\n    } catch (error) {\n      console.error(\"Failed to leave workspace:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to leave workspace.\"\n      );\n    } finally {\n      setIsLeavingWorkspace(false);\n    }\n  };\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Leave {name}?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            Once you leave the workspace, you will no longer have access to it\n            until you are invited again.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel className=\"min-w-20\" size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              className=\"min-w-20\"\n              isLoading={isLeavingWorkspace}\n              onClick={handleLeaveWorkspace}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Leave\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/team/profile-sheet.tsx",
    "content": "import {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@marble/ui/components/avatar\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@marble/ui/components/sheet\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { CalendarIcon } from \"@phosphor-icons/react\";\nimport { useEffect, useState } from \"react\";\nimport { organization } from \"@/lib/auth/client\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport type { TeamMemberRow } from \"./columns\";\n\ninterface ProfileSheetProps {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  member: TeamMemberRow;\n}\n\nexport function ProfileSheet({ open, setOpen, member }: ProfileSheetProps) {\n  const { refreshActiveWorkspace } = useWorkspace();\n  const [role, setRole] = useState(member.role);\n  const [loading, setLoading] = useState(false);\n\n  const settingsChanges = role !== member.role;\n\n  useEffect(() => {\n    setRole(member.role);\n  }, [member.role]);\n\n  async function handleSave() {\n    setLoading(true);\n    try {\n      const { error } = await organization.updateMemberRole({\n        memberId: member.id,\n        role,\n      });\n\n      if (error) {\n        throw new Error(error.message);\n      }\n\n      await refreshActiveWorkspace();\n      toast.success(\"Role updated\");\n      setOpen(false);\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to update role\"\n      );\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  return (\n    <Sheet onOpenChange={setOpen} open={open}>\n      <SheetContent className=\"overflow-y-auto\">\n        <SheetHeader className=\"p-6\">\n          <SheetTitle>Profile</SheetTitle>\n          <SheetDescription>\n            Manage {member.name}&apos;s access to the workspace.\n          </SheetDescription>\n        </SheetHeader>\n        <div className=\"flex h-full flex-col justify-between\">\n          <div className=\"grid flex-1 auto-rows-min gap-6 px-6\">\n            <div className=\"grid gap-3\">\n              <div className=\"flex gap-3\">\n                <Avatar className=\"size-24 rounded-lg\">\n                  <AvatarImage src={member.image ?? undefined} />\n                  <AvatarFallback>\n                    {member.name?.charAt(0).toUpperCase()}\n                  </AvatarFallback>\n                </Avatar>\n                <div className=\"flex flex-col gap-1 pt-1\">\n                  <p className=\"font-medium\">{member.name}</p>\n                  <p className=\"text-muted-foreground text-sm\">\n                    {member.email}\n                  </p>\n                  <div className=\"flex items-center gap-1 text-muted-foreground\">\n                    <CalendarIcon className=\"size-4\" />\n                    <p className=\"text-sm\">\n                      Joined{\" \"}\n                      {new Date(\n                        member.joinedAt ?? new Date()\n                      ).toLocaleDateString()}\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <div className=\"grid gap-3\">\n              <div className=\"flex items-center justify-between gap-6\">\n                <Label>Role</Label>\n                <Select\n                  onValueChange={(role) => setRole(role as \"admin\" | \"member\")}\n                  value={role}\n                >\n                  <SelectTrigger className=\"w-[220px]\">\n                    <SelectValue className=\"capitalize\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"admin\">Admin</SelectItem>\n                    <SelectItem value=\"member\">Member</SelectItem>\n                  </SelectContent>\n                </Select>\n              </div>\n            </div>\n          </div>\n\n          <SheetFooter className=\"flex justify-end gap-2 p-6\">\n            <AsyncButton\n              className=\"min-w-[100px]\"\n              disabled={!settingsChanges}\n              isLoading={loading}\n              onClick={handleSave}\n            >\n              Save\n            </AsyncButton>\n          </SheetFooter>\n        </div>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/team/table-actions.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport {\n  DotsThreeVerticalIcon,\n  ShieldCheckIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport { useState } from \"react\";\nimport type { TeamMemberRow } from \"./columns\";\nimport { ProfileSheet } from \"./profile-sheet\";\nimport { RemoveMemberModal } from \"./team-modals\";\n\ninterface TableActionsProps extends TeamMemberRow {\n  currentUserRole: \"owner\" | \"admin\" | \"member\" | undefined;\n  currentUserId: string | undefined;\n}\n\nexport default function TableActions(props: TableActionsProps) {\n  const [showRemoveModal, setShowRemoveModal] = useState(false);\n  const [showProfileSheet, setShowProfileSheet] = useState(false);\n  const { currentUserRole, currentUserId, role, userId } = props;\n\n  const isCurrentUser = currentUserId === userId;\n\n  if (role === \"owner\") {\n    return null;\n  }\n\n  if (isCurrentUser) {\n    return null;\n  }\n\n  if (currentUserRole === \"member\") {\n    return null;\n  }\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button className=\"size-8 p-0\" variant=\"ghost\">\n              <span className=\"sr-only\">Open menu</span>\n              <DotsThreeVerticalIcon size={16} weight=\"bold\" />\n            </Button>\n          }\n        />\n        <DropdownMenuContent align=\"end\" className=\"text-muted-foreground\">\n          <DropdownMenuItem onClick={() => setShowProfileSheet(true)}>\n            <ShieldCheckIcon className=\"size-4\" />\n            Manage Access\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setShowRemoveModal(true)}\n            variant=\"destructive\"\n          >\n            <TrashIcon className=\"size-4\" />\n            Remove Member\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n\n      <RemoveMemberModal\n        member={props}\n        open={showRemoveModal}\n        setOpen={setShowRemoveModal}\n      />\n      <ProfileSheet\n        member={props}\n        open={showProfileSheet}\n        setOpen={setShowProfileSheet}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/team/team-modals.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useState } from \"react\";\nimport { organization } from \"@/lib/auth/client\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport { AsyncButton } from \"../ui/async-button\";\nimport type { TeamMemberRow } from \"./columns\";\n\ninterface RemoveMemberModalProps {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  member: TeamMemberRow;\n}\n\nexport function RemoveMemberModal({\n  open,\n  setOpen,\n  member,\n}: RemoveMemberModalProps) {\n  const [loading, setLoading] = useState(false);\n  const { activeWorkspace } = useWorkspace();\n  async function removeMember() {\n    if (!activeWorkspace?.id) {\n      toast.error(\"No active workspace found\");\n      return;\n    }\n\n    setLoading(true);\n    try {\n      await organization.removeMember({\n        memberIdOrEmail: member.id,\n        organizationId: activeWorkspace.id,\n        fetchOptions: {\n          onSuccess: () => {\n            toast.success(\"Member removed successfully\");\n            setOpen(false);\n          },\n        },\n      });\n    } catch (_error) {\n      toast.error(\"Failed to remove member\");\n    }\n    setLoading(false);\n  }\n\n  return (\n    <AlertDialog onOpenChange={setOpen} open={open}>\n      <AlertDialogContent variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Remove {member.name || member.email}?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription className=\"text-balance\">\n            This action will revoke their access to the workspace permanently.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={loading} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              disabled={loading}\n              isLoading={loading}\n              onClick={removeMember}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Remove\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/async-button.tsx",
    "content": "\"use client\";\n\nimport { Button, type buttonVariants } from \"@marble/ui/components/button\";\nimport type { VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport { ButtonLoadingSpinner } from \"./loading-spinner\";\n\ntype AsyncButtonProps = React.ComponentPropsWithRef<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    /**\n     * Whether the button is in a loading state\n     */\n    isLoading?: boolean;\n    /**\n     * Text to display when not loading\n     */\n    children: ReactNode;\n    /**\n     * Optional loading text to display when loading (takes priority over children)\n     */\n    loadingText?: string;\n    /**\n     * Whether to keep the original children text while loading\n     * If false (default), only spinner is shown\n     * If true, shows spinner + children\n     * Note: loadingText takes priority over this prop\n     */\n    keepTextWhileLoading?: boolean;\n    /**\n     * Optional ref for the underlying button element\n     */\n    ref?: RefObject<HTMLButtonElement | null>;\n  };\n\nconst AsyncButton = ({\n  children,\n  isLoading = false,\n  loadingText,\n  keepTextWhileLoading = false,\n  disabled,\n  variant = \"default\",\n  className,\n  ref,\n  ...props\n}: AsyncButtonProps) => {\n  const renderLoadingContent = () => {\n    // Priority: loadingText > keepTextWhileLoading > spinner only\n    if (loadingText) {\n      return (\n        <>\n          <ButtonLoadingSpinner className=\"mr-2\" variant={variant} />\n          {loadingText}\n        </>\n      );\n    }\n\n    if (keepTextWhileLoading) {\n      return (\n        <>\n          <ButtonLoadingSpinner className=\"mr-2\" variant={variant} />\n          {children}\n        </>\n      );\n    }\n\n    // Default: spinner only\n    return <ButtonLoadingSpinner variant={variant} />;\n  };\n\n  return (\n    <Button\n      className={className}\n      disabled={disabled || isLoading}\n      ref={ref}\n      variant={variant}\n      {...props}\n    >\n      {isLoading ? renderLoadingContent() : children}\n    </Button>\n  );\n};\n\nAsyncButton.displayName = \"AsyncButton\";\n\nexport { AsyncButton, type AsyncButtonProps };\n"
  },
  {
    "path": "apps/cms/src/components/ui/copy-button.tsx",
    "content": "\"use client\";\n\n// biome-ignore lint/style/useImportType: buttonVariants needed for typeof in type definition\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CheckIcon, CopyIcon } from \"@phosphor-icons/react\";\nimport type { VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { useOptimistic, useTransition } from \"react\";\n\ntype ButtonProps = React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants>;\n\nexport function CopyButton({\n  textToCopy,\n  toastMessage,\n  className,\n  ...props\n}: { textToCopy: string; toastMessage?: string } & Omit<\n  ButtonProps,\n  \"onClick\" | \"children\"\n>) {\n  const [state, setState] = useOptimistic<\"idle\" | \"copied\">(\"idle\");\n  const [, startTransition] = useTransition();\n\n  return (\n    <Button\n      className={cn(\"size-9 shadow-none\", className)}\n      size=\"icon\"\n      variant=\"outline\"\n      {...props}\n      onClick={() => {\n        startTransition(async () => {\n          if (!textToCopy) {\n            return;\n          }\n          await navigator.clipboard.writeText(textToCopy);\n          if (toastMessage) {\n            toast.success(toastMessage);\n          }\n          setState(\"copied\");\n          await new Promise((resolve) => setTimeout(resolve, 1500));\n        });\n      }}\n    >\n      <span className=\"sr-only\">Copy</span>\n      {state === \"idle\" ? (\n        <CopyIcon className=\"size-4\" />\n      ) : (\n        <CheckIcon className=\"size-4\" />\n      )}\n    </Button>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/data-table-pagination.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  CaretDoubleLeftIcon,\n  CaretDoubleRightIcon,\n  CaretLeftIcon,\n  CaretRightIcon,\n} from \"@phosphor-icons/react\";\n\ninterface DataTablePaginationProps {\n  canNextPage: boolean;\n  canPreviousPage: boolean;\n  onPageChange: (pageIndex: number) => void;\n  pageCount: number;\n  pageIndex: number;\n  rowCount: number;\n  selectedCount: number;\n  totalCount: number;\n  mediaCount?: number;\n  visibleCount?: number;\n  itemLabel?: string;\n}\n\nexport function DataTablePagination({\n  canNextPage,\n  canPreviousPage,\n  onPageChange,\n  pageCount,\n  pageIndex,\n  rowCount,\n  totalCount,\n  mediaCount,\n  visibleCount,\n  itemLabel = \"media item\",\n}: DataTablePaginationProps) {\n  const safePageCount = Math.max(1, pageCount);\n  const safePageIndex = Math.min(Math.max(0, pageIndex), safePageCount - 1);\n  const itemCount = visibleCount ?? mediaCount ?? rowCount;\n\n  return (\n    <div className=\"flex items-center justify-between px-2\">\n      <p className=\"text-muted-foreground text-xs\">\n        Showing {itemCount} of {totalCount} {itemLabel}\n        {totalCount === 1 ? \"\" : \"s\"}.\n      </p>\n      {/* <div className=\"flex-1 text-muted-foreground text-xs\">\n        {selectedCount} of {rowCount} row(s) selected.\n      </div> */}\n      <div className=\"flex items-center gap-2\">\n        <div className=\"flex w-[100px] items-center justify-center font-medium text-xs\">\n          Page {safePageIndex + 1} of {safePageCount}\n        </div>\n        <div className=\"flex items-center space-x-2\">\n          <Button\n            className=\"hidden h-8 w-8 p-0 lg:flex\"\n            disabled={!canPreviousPage}\n            onClick={() => onPageChange(0)}\n            type=\"button\"\n            variant=\"outline\"\n          >\n            <span className=\"sr-only\">Go to first page</span>\n            <CaretDoubleLeftIcon />\n          </Button>\n          <Button\n            className=\"h-8 w-8 p-0\"\n            disabled={!canPreviousPage}\n            onClick={() => onPageChange(safePageIndex - 1)}\n            type=\"button\"\n            variant=\"outline\"\n          >\n            <span className=\"sr-only\">Go to previous page</span>\n            <CaretLeftIcon />\n          </Button>\n          <Button\n            className=\"h-8 w-8 p-0\"\n            disabled={!canNextPage}\n            onClick={() => onPageChange(safePageIndex + 1)}\n            type=\"button\"\n            variant=\"outline\"\n          >\n            <span className=\"sr-only\">Go to next page</span>\n            <CaretRightIcon />\n          </Button>\n          <Button\n            className=\"hidden h-8 w-8 p-0 lg:flex\"\n            disabled={!canNextPage}\n            onClick={() => onPageChange(safePageCount - 1)}\n            type=\"button\"\n            variant=\"outline\"\n          >\n            <span className=\"sr-only\">Go to last page</span>\n            <CaretDoubleRightIcon />\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/error-message.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\n\ninterface ErrorMessageProps\n  extends React.HTMLAttributes<HTMLParagraphElement> {}\n\nconst ErrorMessage = ({ children, className, ...props }: ErrorMessageProps) => (\n  <p\n    aria-live=\"polite\"\n    className={cn(\"px-1 font-medium text-destructive text-xs\", className)}\n    {...props}\n  >\n    {children}\n  </p>\n);\n\nErrorMessage.displayName = \"ErrorMessage\";\n\nexport { ErrorMessage };\n"
  },
  {
    "path": "apps/cms/src/components/ui/gauge.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { motion } from \"motion/react\";\nimport { nanoid } from \"nanoid\";\nimport { useId, useRef } from \"react\";\n\ninterface GaugeProps {\n  value: number;\n  min?: number;\n  max?: number;\n  label?: string;\n  size?: number;\n  strokeWidth?: number;\n  colors?: string[];\n  className?: string;\n  valueClassName?: string;\n  labelClassName?: string;\n  formatValue?: (value: number) => string;\n  onAnimationComplete?: () => void;\n  animate?: boolean;\n}\n\nexport function Gauge({\n  value,\n  min = 0,\n  max = 100,\n  label,\n  size = 200,\n  strokeWidth = 12,\n  colors = [\"#ef4444\", \"#f97316\", \"#eab308\", \"#22c55e\"],\n  className,\n  valueClassName,\n  labelClassName,\n  formatValue = (val) => val.toFixed(1),\n  onAnimationComplete,\n  animate = false,\n}: GaugeProps) {\n  const normalizedValue = Math.max(min, Math.min(max, value));\n  const percentage = (normalizedValue - min) / (max - min);\n\n  const radius = (size - strokeWidth) / 2;\n  const center = size / 2;\n  const startAngle = Math.PI;\n  const endAngle = 0;\n\n  // Track completed animations\n  const completedAnimations = useRef(new Set<string>());\n  const totalAnimations = useRef(animate ? (label ? 3 : 2) : 0); // path, value, label (if exists)\n\n  const handleAnimationComplete = (animationKey: string) => {\n    if (!animate || !onAnimationComplete) {\n      return;\n    }\n\n    completedAnimations.current.add(animationKey);\n\n    if (completedAnimations.current.size === totalAnimations.current) {\n      onAnimationComplete();\n    }\n  };\n\n  const createArcPath = (\n    startAngle: number,\n    endAngle: number,\n    radius: number\n  ) => {\n    const start = {\n      x: center + radius * Math.cos(startAngle),\n      y: center + radius * Math.sin(startAngle),\n    };\n    const end = {\n      x: center + radius * Math.cos(endAngle),\n      y: center + radius * Math.sin(endAngle),\n    };\n\n    const largeArcFlag = Math.abs(endAngle - startAngle) > Math.PI ? 1 : 0;\n    return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${end.x} ${end.y}`;\n  };\n\n  const fullArcPath = createArcPath(startAngle, endAngle, radius);\n  const gradientId = useId();\n  const circumference = Math.PI * radius;\n\n  return (\n    <div\n      className={cn(\"relative inline-flex flex-col items-center\", className)}\n    >\n      <svg\n        className=\"overflow-visible\"\n        height={size * 0.6}\n        viewBox={`0 0 ${size} ${size * 0.6}`}\n        width={size}\n      >\n        <title>Gauge</title>\n        <defs>\n          <linearGradient id={gradientId} x1=\"0%\" x2=\"100%\" y1=\"0%\" y2=\"0%\">\n            {colors.map((color, index) => (\n              <stop\n                key={nanoid()}\n                offset={`${(index / (colors.length - 1)) * 100}%`}\n                stopColor={color}\n              />\n            ))}\n          </linearGradient>\n        </defs>\n\n        <path\n          d={fullArcPath}\n          fill=\"none\"\n          opacity={0.15}\n          stroke=\"hsl(var(--muted-foreground))\"\n          strokeLinecap=\"round\"\n          strokeWidth={strokeWidth}\n        />\n\n        <motion.path\n          animate={{ strokeDashoffset: circumference * (1 - percentage) }}\n          d={fullArcPath}\n          fill=\"none\"\n          initial={\n            animate\n              ? { strokeDashoffset: circumference }\n              : { strokeDashoffset: circumference * (1 - percentage) }\n          }\n          onAnimationComplete={() => handleAnimationComplete(\"path\")}\n          stroke={`url(#${gradientId})`}\n          strokeDasharray={circumference}\n          strokeLinecap=\"round\"\n          strokeWidth={strokeWidth}\n          transition={{\n            duration: 1.5,\n            ease: \"easeInOut\",\n            delay: 0.2,\n          }}\n        />\n      </svg>\n\n      <div className=\"absolute inset-0 mt-8 flex flex-col items-center justify-center\">\n        <motion.div\n          animate={{ opacity: 1, scale: 1 }}\n          className={cn(\"font-bold text-4xl text-foreground\", valueClassName)}\n          initial={\n            animate ? { opacity: 0, scale: 0.5 } : { opacity: 1, scale: 1 }\n          }\n          onAnimationComplete={() => handleAnimationComplete(\"value\")}\n          transition={{ duration: 0.8, delay: 0.5 }}\n        >\n          <motion.span\n            animate={{ opacity: 1 }}\n            initial={animate ? { opacity: 0 } : { opacity: 1 }}\n            transition={{ duration: 0.5, delay: 1 }}\n          >\n            {formatValue(normalizedValue)}\n          </motion.span>\n        </motion.div>\n        {label && (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            className={cn(\"mt-1 text-muted-foreground text-sm\", labelClassName)}\n            initial={animate ? { opacity: 0, y: 10 } : { opacity: 1, y: 0 }}\n            onAnimationComplete={() => handleAnimationComplete(\"label\")}\n            transition={{ duration: 0.6, delay: 0.8 }}\n          >\n            {label}\n          </motion.div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/hidden-scrollbar.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\nimport type React from \"react\";\n\ninterface HiddenScrollbarProps extends React.HTMLAttributes<HTMLDivElement> {\n  orientation?: \"vertical\" | \"horizontal\";\n}\n\nexport function HiddenScrollbar({\n  className,\n  children,\n  orientation = \"vertical\",\n  ...props\n}: HiddenScrollbarProps) {\n  return (\n    <div\n      className={cn(\n        \"scrollbar-hide overflow-y-auto\",\n        // Hide scrollbar for different browsers\n        \"[&::-webkit-scrollbar]:hidden\",\n        \"[-ms-overflow-style:none]\",\n        \"[scrollbar-width:none]\",\n        orientation === \"vertical\" && \"overflow-y-auto\",\n        orientation === \"horizontal\" && \"overflow-x-auto\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/last-used-badge.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\nimport type { HTMLAttributes } from \"react\";\n\nexport interface LastUsedBadgeProps extends HTMLAttributes<HTMLSpanElement> {\n  show?: boolean;\n  text?: string;\n  variant?: string;\n  position?: string;\n}\n\nexport function LastUsedBadge({\n  className,\n  variant: _variant,\n  position: _position,\n  show = false,\n  text,\n  ...props\n}: LastUsedBadgeProps) {\n  if (!show) {\n    return null;\n  }\n\n  return (\n    <span\n      className={cn(\n        \"-translate-y-1/2 pointer-events-none absolute top-1/2 right-3 text-[11px] text-muted-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {text ?? \"Last Used\"}\n    </span>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/loading-spinner.tsx",
    "content": "\"use client\";\n\n// biome-ignore lint/style/useImportType: buttonVariants needed for typeof in type definition\nimport { buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type { VariantProps } from \"class-variance-authority\";\n\nexport function LoadingSpinner({ className }: { className?: string }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\"size-4 animate-spin text-foreground\", className)}\n      fill=\"none\"\n      viewBox=\"0 0 100 101\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z\"\n        fill=\"currentColor\"\n        opacity=\"0.2\"\n      />\n      <path\n        d=\"M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z\"\n        fill=\"currentColor\"\n        opacity=\"1\"\n      />\n    </svg>\n  );\n}\n\nexport function ButtonLoadingSpinner({\n  variant = \"default\",\n  className,\n}: {\n  variant?: VariantProps<typeof buttonVariants>[\"variant\"];\n  className?: string;\n}) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-4 animate-spin\",\n        variant === \"default\" && \"text-primary-foreground\",\n        variant === \"destructive\" && \"text-white\",\n        variant === \"outline\" && \"text-foreground\",\n        variant === \"secondary\" && \"text-secondary-foreground\",\n        variant === \"ghost\" && \"text-foreground\",\n        variant === \"link\" && \"text-primary\",\n        className\n      )}\n      fill=\"none\"\n      viewBox=\"0 0 100 101\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        d=\"M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z\"\n        fill=\"currentColor\"\n        opacity=\"0.2\"\n      />\n      <path\n        d=\"M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/segmented-progress.tsx",
    "content": "\"use client\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { nanoid } from \"nanoid\";\n\ninterface SegmentedProgressProps {\n  value: number;\n  max?: number;\n  segments?: number;\n  className?: string;\n  segmentClassName?: string;\n  filledColor?: string;\n  unfilledColor?: string;\n  width?: number;\n}\n\nexport function SegmentedProgress({\n  value,\n  max = 100,\n  segments = 20,\n  className,\n  segmentClassName,\n  filledColor = \"bg-emerald-500\",\n  unfilledColor = \"bg-gray-600\",\n  width = 320,\n}: SegmentedProgressProps) {\n  const percentage = Math.min(Math.max((value / max) * 100, 0), 100);\n  const filledSegments = Math.round((percentage / 100) * segments);\n\n  const gapWidth = 4;\n  const totalGapWidth = (segments - 1) * gapWidth;\n  const segmentWidth = Math.max(2, (width - totalGapWidth) / segments);\n\n  return (\n    <div className={cn(\"space-y-3\", className)}>\n      <div className=\"flex items-end gap-1\" style={{ width: `${width}px` }}>\n        {Array.from({ length: segments }, (_, index) => (\n          <div\n            className={cn(\n              \"h-8 rounded-sm transition-colors duration-200\",\n              index < filledSegments ? filledColor : unfilledColor,\n              segmentClassName\n            )}\n            key={nanoid()}\n            style={{ width: `${segmentWidth}px` }}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/ui/timezone-selector.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@marble/ui/components/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CaretUpDownIcon, CheckIcon } from \"@phosphor-icons/react\";\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport { getTimeZones } from \"@vvo/tzdb\";\nimport { Cron } from \"croner\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\ninterface TimezoneOption {\n  value: string;\n  label: string;\n  currentTime: string;\n  countryName: string;\n  countryCode: string;\n}\n\ninterface TimezoneSelectorProps {\n  value?: string;\n  onValueChange?: (value: string) => void;\n  disabled?: boolean;\n  placeholder?: string;\n  timezones: string[];\n}\n\nexport function TimezoneSelector({\n  value,\n  onValueChange,\n  disabled,\n  placeholder = \"Select timezone...\",\n  timezones,\n}: TimezoneSelectorProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [timeUpdate, setTimeUpdate] = useState(0);\n  const [query, setQuery] = useState(\"\");\n  const parentRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    const cronJob = new Cron(\"* * * * *\", () => {\n      setTimeUpdate((prev) => prev + 1);\n    });\n\n    return () => cronJob.stop();\n  }, []);\n\n  const tzdbData = useMemo(() => {\n    try {\n      return getTimeZones();\n    } catch (err) {\n      console.error(\"Could not load tzdb data:\", err);\n      return [];\n    }\n  }, []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: We use the state to retrigger the effect\n  const timezoneOptions = useMemo<TimezoneOption[]>(() => {\n    try {\n      const now = new Date();\n\n      return timezones\n        .map((timezone) => {\n          try {\n            const formatter = new Intl.DateTimeFormat(\"en-US\", {\n              timeZone: timezone,\n              hour: \"2-digit\",\n              minute: \"2-digit\",\n              hour12: true,\n            });\n\n            const currentTime = formatter.format(now);\n\n            // Find country information from tzdb\n            const tzInfo = tzdbData.find(\n              (tz) => tz.name === timezone || tz.group.includes(timezone)\n            );\n\n            return {\n              value: timezone,\n              label: timezone.replace(/_/g, \" \"),\n              currentTime,\n              countryName: tzInfo?.countryName || \"Unknown\",\n              countryCode: tzInfo?.countryCode || \"XX\",\n            };\n          } catch (_error) {\n            return {\n              value: timezone,\n              label: timezone.replace(/_/g, \" \"),\n              currentTime: \"N/A\",\n              countryName: \"Unknown\",\n              countryCode: \"XX\",\n            };\n          }\n        })\n        .sort((a, b) => a.label.localeCompare(b.label));\n    } catch (error) {\n      console.error(\"Error generating timezone options:\", error);\n      return [];\n    }\n  }, [timezones, timeUpdate]);\n\n  const filteredOptions = useMemo(() => {\n    const q = query.trim().toLowerCase();\n    if (!q) {\n      return timezoneOptions;\n    }\n    return timezoneOptions.filter((opt) =>\n      `${opt.label} ${opt.value} ${opt.countryName}`.toLowerCase().includes(q)\n    );\n  }, [timezoneOptions, query]);\n\n  const virtual = useVirtualizer({\n    count: filteredOptions.length,\n    getScrollElement: () => parentRef.current,\n    estimateSize: () => 48,\n  });\n\n  // Triggers a re-render when the popover opens, this ensures that the virtualizer has a valid ref to the scroll element\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    const id = requestAnimationFrame(() => virtual.measure());\n    return () => cancelAnimationFrame(id);\n  }, [isOpen, virtual]);\n\n  const selectedTimezone = timezoneOptions.find(\n    (option) => option.value === value\n  );\n\n  return (\n    <Popover onOpenChange={setIsOpen} open={isOpen}>\n      <PopoverTrigger\n        render={\n          <Button\n            className=\"w-full items-center justify-between gap-2 shadow-none active:scale-100\"\n            disabled={disabled}\n            onClick={() => !disabled && setIsOpen(!isOpen)}\n            type=\"button\"\n            variant=\"outline\"\n          >\n            <div\n              className={cn(\n                \"flex flex-col items-start\",\n                !selectedTimezone && \"text-muted-foreground\"\n              )}\n            >\n              {selectedTimezone ? (\n                <div className=\"flex gap-2\">\n                  <span>{selectedTimezone.label}</span>\n                  <Badge className=\"bg-muted font-light\" variant=\"outline\">\n                    {selectedTimezone.currentTime}\n                  </Badge>\n                </div>\n              ) : (\n                <span>{placeholder}</span>\n              )}\n            </div>\n            <CaretUpDownIcon className=\"size-4 shrink-0 opacity-50\" />\n          </Button>\n        }\n      />\n      <PopoverContent align=\"center\" className=\"w-[370px] p-0\">\n        <Command shouldFilter={false}>\n          <CommandInput\n            onValueChange={(v) => setQuery(v)}\n            placeholder=\"Search timezones...\"\n            value={query}\n          />\n          <CommandList ref={parentRef}>\n            <CommandEmpty>No timezone found.</CommandEmpty>\n            <CommandGroup\n              className=\"relative\"\n              style={{ height: `${virtual.getTotalSize()}px` }}\n            >\n              {virtual.getVirtualItems().map((row) => {\n                // biome-ignore lint/style/noNonNullAssertion: known not null\n                const option = filteredOptions[row.index]!;\n\n                return (\n                  <CommandItem\n                    className=\"absolute top-0 left-0 w-full\"\n                    key={option.value}\n                    onSelect={() => {\n                      onValueChange?.(option.value);\n                      setIsOpen(false);\n                    }}\n                    style={{\n                      height: `${row.size}px`,\n                      transform: `translateY(${row.start}px)`,\n                    }}\n                    value={`${option.label} ${option.value} ${option.countryName}`}\n                  >\n                    <div className=\"flex w-full items-center justify-between\">\n                      <div className=\"flex flex-col\">\n                        <span>{option.label}</span>\n                        <span className=\"text-muted-foreground text-xs\">\n                          {option.countryName}\n                        </span>\n                      </div>\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"text-muted-foreground text-sm\">\n                          {option.currentTime}\n                        </span>\n                        <CheckIcon\n                          className={cn(\n                            \"h-4 w-4\",\n                            value === option.value ? \"opacity-100\" : \"opacity-0\"\n                          )}\n                        />\n                      </div>\n                    </div>\n                  </CommandItem>\n                );\n              })}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/create-webhook.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Checkbox } from \"@marble/ui/components/checkbox\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n} from \"@marble/ui/components/sheet\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { BracketsCurlyIcon, PlusIcon } from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useId, useState } from \"react\";\nimport { useForm, useWatch } from \"react-hook-form\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { VALID_DISCORD_DOMAINS, VALID_SLACK_DOMAINS } from \"@/lib/constants\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type PayloadFormat,\n  type WebhookEvent,\n  type WebhookFormValues,\n  webhookEvents,\n  webhookSchema,\n} from \"@/lib/validations/webhook\";\nimport { Discord, Slack } from \"../shared/icons\";\n\nconst formatOptions = [\n  {\n    label: (\n      <>\n        <BracketsCurlyIcon className=\"text-amber-500\" weight=\"bold\" />\n        JSON\n      </>\n    ),\n    value: \"json\",\n  },\n  {\n    label: (\n      <>\n        <Discord fill=\"#5865F2\" />\n        Discord\n      </>\n    ),\n    value: \"discord\",\n  },\n  {\n    label: (\n      <>\n        <Slack />\n        Slack\n      </>\n    ),\n    value: \"slack\",\n  },\n];\n\ninterface CreateWebhookSheetProps {\n  children?: React.ReactNode;\n}\n\nfunction CreateWebhookSheet({ children }: CreateWebhookSheetProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n  const masterCheckboxId = useId();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    reset,\n    formState: { errors },\n  } = useForm<WebhookFormValues>({\n    resolver: zodResolver(webhookSchema),\n    defaultValues: {\n      name: \"\",\n      endpoint: \"\",\n      events: [],\n      format: \"json\",\n    },\n  });\n\n  const watchedEvents = useWatch({ control, name: \"events\" });\n  const watchedFormat = useWatch({ control, name: \"format\" });\n\n  const router = useRouter();\n\n  const detectFormat = (url: string): PayloadFormat => {\n    const endpoint = url.trim();\n    if (!endpoint) {\n      return \"json\";\n    }\n    try {\n      const urlObj = new URL(endpoint);\n      if (VALID_DISCORD_DOMAINS.some((domain) => urlObj.hostname === domain)) {\n        return \"discord\";\n      }\n      if (VALID_SLACK_DOMAINS.some((domain) => urlObj.hostname === domain)) {\n        return \"slack\";\n      }\n    } catch {\n      // invalid URL\n    }\n    return \"json\";\n  };\n\n  const { mutate: createWebhook, isPending: isCreating } = useMutation({\n    mutationFn: (data: WebhookFormValues) =>\n      fetch(\"/api/webhooks\", {\n        method: \"POST\",\n        body: JSON.stringify(data),\n      }),\n    onSuccess: () => {\n      toast.success(\"Webhook created successfully\");\n      reset();\n      setIsOpen(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.WEBHOOKS(workspaceId),\n        });\n      }\n      router.refresh();\n    },\n    onError: () => {\n      toast.error(\"Failed to create webhook\");\n    },\n  });\n\n  const handleEventToggle = (eventId: WebhookEvent, checked: boolean) => {\n    const currentEvents = watchedEvents || [];\n    if (checked) {\n      setValue(\"events\", [...currentEvents, eventId]);\n    } else {\n      setValue(\n        \"events\",\n        currentEvents.filter((id) => id !== eventId)\n      );\n    }\n  };\n\n  const handleMasterCheckboxToggle = (checked: boolean) => {\n    if (checked) {\n      const allEventIds = webhookEvents.map((event) => event.id);\n      setValue(\"events\", allEventIds);\n    } else {\n      setValue(\"events\", []);\n    }\n  };\n\n  const getMasterCheckboxState = () => {\n    const currentEvents = watchedEvents || [];\n    const totalEvents = webhookEvents.length;\n\n    return currentEvents.length === totalEvents;\n  };\n\n  const onSubmit = (data: WebhookFormValues) => {\n    createWebhook(data);\n  };\n\n  return (\n    <Sheet onOpenChange={setIsOpen} open={isOpen}>\n      <SheetTrigger\n        render={\n          (children as React.ReactElement) || (\n            <Button>\n              <PlusIcon className=\"mr-2 size-4\" />\n              New Webhook\n            </Button>\n          )\n        }\n      />\n      <SheetContent className=\"overflow-y-auto\">\n        <SheetHeader className=\"p-6\">\n          <SheetTitle className=\"font-medium text-xl\">New Webhook</SheetTitle>\n          <SheetDescription className=\"sr-only\">\n            Set the endpoint and select the events you want to receive\n            notifications for.\n          </SheetDescription>\n        </SheetHeader>\n        <form\n          className=\"flex h-full flex-col justify-between\"\n          onSubmit={handleSubmit(onSubmit)}\n        >\n          <div className=\"mb-5 grid flex-1 auto-rows-min gap-6 px-6\">\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"name\">Name</Label>\n\n              <Input id=\"name\" placeholder=\"My Webhook\" {...register(\"name\")} />\n              {errors.name && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.name.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"endpoint\">URL</Label>\n\n              <Input\n                id=\"endpoint\"\n                placeholder=\"https://marblecms.com/webhooks/\"\n                {...register(\"endpoint\", {\n                  onChange: (e) => {\n                    setValue(\"format\", detectFormat(e.target.value));\n                  },\n                })}\n              />\n              {errors.endpoint && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.endpoint.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"format\">Format</Label>\n              <Select\n                items={formatOptions}\n                onValueChange={(value) => {\n                  if (value) {\n                    setValue(\"format\", value);\n                  }\n                }}\n                value={watchedFormat}\n              >\n                <SelectTrigger className=\"w-full shadow-none\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {formatOptions.map((option) => (\n                    <SelectItem key={option.value} value={option.value}>\n                      {option.label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              {errors.format && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.format.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <div className=\"mb-2 flex items-end justify-between\">\n                <Label>Events</Label>\n                <a\n                  className=\"ml-2 flex cursor-pointer items-center text-primary text-xs hover:underline\"\n                  href=\"https://docs.marblecms.com/guides/features/webhooks\"\n                  rel=\"noopener noreferrer\"\n                  target=\"_blank\"\n                >\n                  <span>View Schemas</span>\n                </a>\n              </div>\n              <div className=\"grid gap-1\">\n                <div className=\"flex items-center space-x-3\">\n                  <Checkbox\n                    checked={getMasterCheckboxState()}\n                    id={masterCheckboxId}\n                    onCheckedChange={(checked) =>\n                      handleMasterCheckboxToggle(checked as boolean)\n                    }\n                  />\n                  <div className=\"flex-1\">\n                    <Label\n                      className=\"cursor-pointer font-medium text-sm\"\n                      htmlFor={masterCheckboxId}\n                    >\n                      Select all events\n                    </Label>\n                  </div>\n                </div>\n                {webhookEvents.map((event) => (\n                  <div className=\"flex items-center space-x-3\" key={event.id}>\n                    <Checkbox\n                      checked={watchedEvents?.includes(event.id) || false}\n                      id={event.id}\n                      onCheckedChange={(checked) =>\n                        handleEventToggle(event.id, checked as boolean)\n                      }\n                    />\n                    <div className=\"flex-1\">\n                      <Label\n                        className=\"cursor-pointer font-medium text-sm\"\n                        htmlFor={event.id}\n                      >\n                        {event.label}\n                      </Label>\n                    </div>\n                  </div>\n                ))}\n              </div>\n              {errors.events && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.events.message}\n                </ErrorMessage>\n              )}\n            </div>\n          </div>\n\n          <SheetFooter className=\"p-6\">\n            <AsyncButton\n              className=\"w-full\"\n              isLoading={isCreating}\n              type=\"submit\"\n            >\n              Create webhook\n            </AsyncButton>\n          </SheetFooter>\n        </form>\n      </SheetContent>\n    </Sheet>\n  );\n}\n\nexport default CreateWebhookSheet;\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/delete-webhook.tsx",
    "content": "\"use client\";\n\nimport { Alert02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertDialog,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogX,\n} from \"@marble/ui/components/alert-dialog\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\n\ninterface DeleteWebhookModalProps {\n  webhookId: string;\n  webhookName: string;\n  onDelete: () => void;\n  isOpen: boolean;\n  onOpenChange: (isOpen: boolean) => void;\n}\n\nexport function DeleteWebhookModal({\n  webhookId,\n  webhookName,\n  onDelete,\n  isOpen,\n  onOpenChange,\n}: DeleteWebhookModalProps) {\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n\n  const { mutate: deleteWebhook, isPending } = useMutation({\n    mutationFn: () =>\n      fetch(`/api/webhooks/${webhookId}`, {\n        method: \"DELETE\",\n      }),\n    onSuccess: () => {\n      toast.success(\"Webhook deleted successfully\");\n      onDelete();\n      onOpenChange(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.WEBHOOKS(workspaceId),\n        });\n      }\n    },\n    onError: () => {\n      toast.error(\"Failed to delete webhook\");\n    },\n  });\n\n  return (\n    <AlertDialog onOpenChange={onOpenChange} open={isOpen}>\n      <AlertDialogContent className=\"sm:max-w-md\" variant=\"card\">\n        <AlertDialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n          <div className=\"flex flex-1 items-center gap-2\">\n            <HugeiconsIcon\n              className=\"text-destructive\"\n              icon={Alert02Icon}\n              size={18}\n              strokeWidth={2}\n            />\n            <AlertDialogTitle className=\"font-medium text-muted-foreground text-sm\">\n              Delete \"{webhookName}\"?\n            </AlertDialogTitle>\n          </div>\n          <AlertDialogX />\n        </AlertDialogHeader>\n        <AlertDialogBody>\n          <AlertDialogDescription>\n            This will permanently delete this webhook from your workspace and\n            stop delivering events to its URL.\n          </AlertDialogDescription>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={isPending} size=\"sm\">\n              Cancel\n            </AlertDialogCancel>\n            <AsyncButton\n              isLoading={isPending}\n              onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                e.preventDefault();\n                deleteWebhook();\n              }}\n              size=\"sm\"\n              variant=\"destructive\"\n            >\n              Delete\n            </AsyncButton>\n          </AlertDialogFooter>\n        </AlertDialogBody>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/edit-webhook.tsx",
    "content": "\"use client\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { Checkbox } from \"@marble/ui/components/checkbox\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@marble/ui/components/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@marble/ui/components/sheet\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { BracketsCurlyIcon } from \"@phosphor-icons/react\";\nimport { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport { useEffect, useId } from \"react\";\nimport { useForm, useWatch } from \"react-hook-form\";\nimport { AsyncButton } from \"@/components/ui/async-button\";\nimport { ErrorMessage } from \"@/components/ui/error-message\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { VALID_DISCORD_DOMAINS, VALID_SLACK_DOMAINS } from \"@/lib/constants\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport {\n  type PayloadFormat,\n  type WebhookEvent,\n  type WebhookFormValues,\n  webhookEvents,\n  webhookSchema,\n} from \"@/lib/validations/webhook\";\nimport type { Webhook } from \"@/types/webhook\";\nimport { Discord, Slack } from \"../shared/icons\";\n\nconst formatOptions = [\n  {\n    label: (\n      <>\n        <BracketsCurlyIcon className=\"text-amber-500\" weight=\"bold\" />\n        JSON\n      </>\n    ),\n    value: \"json\",\n  },\n  {\n    label: (\n      <>\n        <Discord fill=\"#5865F2\" />\n        Discord\n      </>\n    ),\n    value: \"discord\",\n  },\n  {\n    label: (\n      <>\n        <Slack />\n        Slack\n      </>\n    ),\n    value: \"slack\",\n  },\n];\n\ninterface EditWebhookSheetProps {\n  webhook: Webhook;\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport function EditWebhookSheet({\n  webhook,\n  isOpen,\n  onOpenChange,\n}: EditWebhookSheetProps) {\n  const workspaceId = useWorkspaceId();\n  const queryClient = useQueryClient();\n  const masterCheckboxId = useId();\n\n  const {\n    register,\n    handleSubmit,\n    setValue,\n    control,\n    reset,\n    formState: { errors },\n  } = useForm<WebhookFormValues>({\n    resolver: zodResolver(webhookSchema),\n    defaultValues: {\n      name: webhook.name,\n      endpoint: webhook.url,\n      events: webhook.events as WebhookEvent[],\n      format: webhook.format as PayloadFormat,\n    },\n  });\n\n  useEffect(() => {\n    if (isOpen && webhook) {\n      reset({\n        name: webhook.name,\n        endpoint: webhook.url,\n        events: webhook.events as WebhookEvent[],\n        format: webhook.format as PayloadFormat,\n      });\n    }\n  }, [isOpen, webhook, reset]);\n\n  const watchedEvents = useWatch({ control, name: \"events\" });\n  const watchedFormat = useWatch({ control, name: \"format\" });\n\n  const router = useRouter();\n\n  const detectFormat = (url: string): PayloadFormat => {\n    const endpoint = url.trim();\n    if (!endpoint) {\n      return \"json\";\n    }\n    try {\n      const urlObj = new URL(endpoint);\n      if (VALID_DISCORD_DOMAINS.some((domain) => urlObj.hostname === domain)) {\n        return \"discord\";\n      }\n      if (VALID_SLACK_DOMAINS.some((domain) => urlObj.hostname === domain)) {\n        return \"slack\";\n      }\n    } catch {\n      // invalid URL\n    }\n    return \"json\";\n  };\n\n  const { mutate: updateWebhook, isPending: isUpdating } = useMutation({\n    mutationFn: (data: WebhookFormValues) =>\n      fetch(`/api/webhooks/${webhook.id}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(data),\n      }),\n    onSuccess: async (response) => {\n      if (!response.ok) {\n        const error = await response.json().catch(() => ({\n          error: \"Failed to update webhook\",\n        }));\n        throw new Error(error.error || \"Failed to update webhook\");\n      }\n      toast.success(\"Webhook updated successfully\");\n      onOpenChange(false);\n      if (workspaceId) {\n        queryClient.invalidateQueries({\n          queryKey: QUERY_KEYS.WEBHOOKS(workspaceId),\n        });\n      }\n      router.refresh();\n    },\n    onError: (error) => {\n      const errorMessage =\n        error instanceof Error ? error.message : \"Failed to update webhook\";\n      toast.error(errorMessage);\n    },\n  });\n\n  const handleEventToggle = (eventId: WebhookEvent, checked: boolean) => {\n    const currentEvents = watchedEvents || [];\n    if (checked) {\n      setValue(\"events\", [...currentEvents, eventId]);\n    } else {\n      setValue(\n        \"events\",\n        currentEvents.filter((id) => id !== eventId)\n      );\n    }\n  };\n\n  const handleMasterCheckboxToggle = (checked: boolean) => {\n    if (checked) {\n      const allEventIds = webhookEvents.map((event) => event.id);\n      setValue(\"events\", allEventIds);\n    } else {\n      setValue(\"events\", []);\n    }\n  };\n\n  const getMasterCheckboxState = () => {\n    const currentEvents = watchedEvents || [];\n    const totalEvents = webhookEvents.length;\n\n    return currentEvents.length === totalEvents;\n  };\n\n  const onSubmit = (data: WebhookFormValues) => {\n    updateWebhook(data);\n  };\n\n  return (\n    <Sheet onOpenChange={onOpenChange} open={isOpen}>\n      <SheetContent className=\"overflow-y-auto\">\n        <SheetHeader className=\"p-6\">\n          <SheetTitle className=\"font-medium text-xl\">Edit Webhook</SheetTitle>\n          <SheetDescription className=\"sr-only\">\n            Update the endpoint and select the events you want to receive\n            notifications for.\n          </SheetDescription>\n        </SheetHeader>\n        <form\n          className=\"flex h-full flex-col justify-between\"\n          onSubmit={handleSubmit(onSubmit)}\n        >\n          <div className=\"mb-5 grid flex-1 auto-rows-min gap-6 px-6\">\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"name\">Name</Label>\n\n              <Input id=\"name\" placeholder=\"My Webhook\" {...register(\"name\")} />\n              {errors.name && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.name.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"endpoint\">URL</Label>\n\n              <Input\n                id=\"endpoint\"\n                placeholder=\"https://marblecms.com/webhooks/\"\n                {...register(\"endpoint\", {\n                  onChange: (e) => {\n                    setValue(\"format\", detectFormat(e.target.value));\n                  },\n                })}\n              />\n              {errors.endpoint && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.endpoint.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <Label htmlFor=\"format\">Format</Label>\n              <Select\n                items={formatOptions}\n                onValueChange={(value) => {\n                  if (value) {\n                    setValue(\"format\", value);\n                  }\n                }}\n                value={watchedFormat}\n              >\n                <SelectTrigger className=\"w-full shadow-none\">\n                  <SelectValue />\n                </SelectTrigger>\n                <SelectContent>\n                  {formatOptions.map((option) => (\n                    <SelectItem key={option.value} value={option.value}>\n                      {option.label}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n              {errors.format && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.format.message}\n                </ErrorMessage>\n              )}\n            </div>\n\n            <div className=\"grid gap-3\">\n              <div className=\"mb-2 flex items-end justify-between\">\n                <Label>Events</Label>\n                <a\n                  className=\"ml-2 flex cursor-pointer items-center text-primary text-xs hover:underline\"\n                  href=\"https://docs.marblecms.com/guides/features/webhooks\"\n                  rel=\"noopener noreferrer\"\n                  target=\"_blank\"\n                >\n                  <span>View Schemas</span>\n                </a>\n              </div>\n              <div className=\"grid gap-1\">\n                <div className=\"flex items-center space-x-3\">\n                  <Checkbox\n                    checked={getMasterCheckboxState()}\n                    id={masterCheckboxId}\n                    onCheckedChange={(checked) =>\n                      handleMasterCheckboxToggle(checked as boolean)\n                    }\n                  />\n                  <div className=\"flex-1\">\n                    <Label\n                      className=\"cursor-pointer font-medium text-sm\"\n                      htmlFor={masterCheckboxId}\n                    >\n                      Select all events\n                    </Label>\n                  </div>\n                </div>\n                {webhookEvents.map((event) => (\n                  <div className=\"flex items-center space-x-3\" key={event.id}>\n                    <Checkbox\n                      checked={watchedEvents?.includes(event.id) || false}\n                      id={event.id}\n                      onCheckedChange={(checked) =>\n                        handleEventToggle(event.id, checked as boolean)\n                      }\n                    />\n                    <div className=\"flex-1\">\n                      <Label\n                        className=\"cursor-pointer font-medium text-sm\"\n                        htmlFor={event.id}\n                      >\n                        {event.label}\n                      </Label>\n                    </div>\n                  </div>\n                ))}\n              </div>\n              {errors.events && (\n                <ErrorMessage className=\"text-sm\">\n                  {errors.events.message}\n                </ErrorMessage>\n              )}\n            </div>\n          </div>\n\n          <SheetFooter className=\"p-6\">\n            <AsyncButton\n              className=\"w-full\"\n              isLoading={isUpdating}\n              type=\"submit\"\n            >\n              Update webhook\n            </AsyncButton>\n          </SheetFooter>\n        </form>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/webhook-actions.tsx",
    "content": "\"use client\";\n\nimport {\n  CancelCircleIcon,\n  CheckmarkCircle02Icon,\n  Copy01Icon,\n  Delete02Icon,\n  Loading03Icon,\n  MailSend01Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport dynamic from \"next/dynamic\";\nimport { useState } from \"react\";\nimport type { Webhook } from \"@/types/webhook\";\n\nconst DeleteWebhookModal = dynamic(() =>\n  import(\"@/components/webhooks/delete-webhook\").then(\n    (mod) => mod.DeleteWebhookModal\n  )\n);\n\nconst EditWebhookSheet = dynamic(() =>\n  import(\"@/components/webhooks/edit-webhook\").then(\n    (mod) => mod.EditWebhookSheet\n  )\n);\n\ninterface WebhookActionsProps {\n  webhook: Webhook;\n  isToggling: boolean;\n  onDelete: () => void;\n  onToggle: (data: { id: string; enabled: boolean }) => void;\n}\n\nexport function WebhookActions({\n  webhook,\n  isToggling,\n  onDelete,\n  onToggle,\n}: WebhookActionsProps) {\n  const [isDeleteOpen, setIsDeleteOpen] = useState(false);\n  const [isEditOpen, setIsEditOpen] = useState(false);\n  const [isSendingTest, setIsSendingTest] = useState(false);\n\n  const handleCopySecret = () => {\n    navigator.clipboard.writeText(webhook.secret);\n    toast.success(\"Secret copied to clipboard\");\n  };\n\n  const handleSendTest = async () => {\n    setIsSendingTest(true);\n\n    try {\n      const response = await fetch(`/api/webhooks/${webhook.id}/test`, {\n        method: \"POST\",\n      });\n      const result = await response.json().catch(() => null);\n\n      if (!response.ok) {\n        throw new Error(result?.error ?? \"Failed to send test webhook\");\n      }\n\n      toast.success(\"Test webhook queued\");\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to send test webhook\"\n      );\n    } finally {\n      setIsSendingTest(false);\n    }\n  };\n\n  return (\n    <>\n      <DropdownMenu>\n        <DropdownMenuTrigger\n          render={\n            <Button className=\"size-8 p-0\" variant=\"ghost\">\n              <span className=\"sr-only\">Open webhook actions</span>\n              <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n            </Button>\n          }\n        />\n        <DropdownMenuContent\n          align=\"end\"\n          className=\"text-muted-foreground shadow-sm\"\n        >\n          {webhook.format === \"json\" ? (\n            <DropdownMenuItem onClick={handleCopySecret}>\n              <HugeiconsIcon icon={Copy01Icon} size={16} />\n              <span>Copy Secret</span>\n            </DropdownMenuItem>\n          ) : undefined}\n          <DropdownMenuItem\n            disabled={isSendingTest || isToggling}\n            onClick={handleSendTest}\n          >\n            {isSendingTest ? (\n              <HugeiconsIcon\n                className=\"animate-spin\"\n                icon={Loading03Icon}\n                size={16}\n              />\n            ) : (\n              <HugeiconsIcon icon={MailSend01Icon} size={16} />\n            )}\n            <span>Send Test</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            disabled={isToggling}\n            onClick={() =>\n              onToggle({ id: webhook.id, enabled: !webhook.enabled })\n            }\n          >\n            {webhook.enabled ? (\n              <HugeiconsIcon icon={CancelCircleIcon} size={16} />\n            ) : (\n              <HugeiconsIcon icon={CheckmarkCircle02Icon} size={16} />\n            )}\n            <span>{webhook.enabled ? \"Disable\" : \"Enable\"}</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            disabled={isToggling}\n            onClick={() => setIsEditOpen(true)}\n          >\n            <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n            <span>Edit</span>\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            disabled={isToggling}\n            onClick={() => setIsDeleteOpen(true)}\n            variant=\"destructive\"\n          >\n            <HugeiconsIcon icon={Delete02Icon} size={16} />\n            <span>Delete</span>\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n      <EditWebhookSheet\n        isOpen={isEditOpen}\n        onOpenChange={setIsEditOpen}\n        webhook={webhook}\n      />\n      <DeleteWebhookModal\n        isOpen={isDeleteOpen}\n        onDelete={onDelete}\n        onOpenChange={setIsDeleteOpen}\n        webhookId={webhook.id}\n        webhookName={webhook.name}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/webhook-card.tsx",
    "content": "\"use client\";\n\nimport {\n  Copy01Icon,\n  Delete02Icon,\n  Loading03Icon,\n  MailSend01Icon,\n  MoreVerticalIcon,\n  PencilEdit02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Card, CardDescription, CardTitle } from \"@marble/ui/components/card\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport { toast } from \"@marble/ui/components/sonner\";\nimport { Switch } from \"@marble/ui/components/switch\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { format } from \"date-fns\";\nimport dynamic from \"next/dynamic\";\nimport { useState } from \"react\";\nimport type { Webhook } from \"@/types/webhook\";\n\nconst DeleteWebhookModal = dynamic(() =>\n  import(\"@/components/webhooks/delete-webhook\").then(\n    (mod) => mod.DeleteWebhookModal\n  )\n);\n\nconst EditWebhookSheet = dynamic(() =>\n  import(\"@/components/webhooks/edit-webhook\").then(\n    (mod) => mod.EditWebhookSheet\n  )\n);\n\ninterface WebhookCardProps {\n  webhook: Webhook;\n  onToggle: (data: { id: string; enabled: boolean }) => void;\n  onDelete: () => void;\n  isToggling: boolean;\n  toggleVariables?: { id: string; enabled: boolean };\n}\n\nexport function WebhookCard({\n  webhook,\n  onToggle,\n  onDelete,\n  isToggling,\n  toggleVariables,\n}: WebhookCardProps) {\n  const [isDeleteOpen, setIsDeleteOpen] = useState(false);\n  const [isEditOpen, setIsEditOpen] = useState(false);\n  const [isSendingTest, setIsSendingTest] = useState(false);\n  const handleCopySecret = (secret: string) => {\n    navigator.clipboard.writeText(secret);\n    toast.success(\"Secret copied to clipboard\");\n  };\n\n  const handleSendTest = async () => {\n    setIsSendingTest(true);\n\n    try {\n      const response = await fetch(`/api/webhooks/${webhook.id}/test`, {\n        method: \"POST\",\n      });\n      const result = await response.json().catch(() => null);\n\n      if (!response.ok) {\n        throw new Error(result?.error ?? \"Failed to send test webhook\");\n      }\n\n      toast.success(\"Test webhook queued\");\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to send test webhook\"\n      );\n    } finally {\n      setIsSendingTest(false);\n    }\n  };\n\n  const isCurrentlyToggling = isToggling && toggleVariables?.id === webhook.id;\n\n  return (\n    <li>\n      <Card className=\"rounded-[20px] border-none bg-surface p-2\">\n        <div className=\"flex flex-col gap-6 rounded-[12px] bg-background p-6 shadow-xs\">\n          <div className=\"flex items-start justify-between\">\n            <div className=\"flex flex-1 flex-col gap-1\">\n              <CardTitle className=\"font-medium text-lg\">\n                {webhook.name}\n              </CardTitle>\n              <CardDescription className=\"line-clamp-1 break-all font-mono text-sm\">\n                {webhook.url}\n              </CardDescription>\n            </div>\n            <DropdownMenu>\n              <DropdownMenuTrigger\n                render={\n                  <Button className=\"size-8 p-0\" variant=\"ghost\">\n                    <span className=\"sr-only\">Open menu</span>\n                    <HugeiconsIcon icon={MoreVerticalIcon} size={16} />\n                  </Button>\n                }\n              />\n              <DropdownMenuContent\n                align=\"end\"\n                className=\"text-muted-foreground shadow-sm\"\n              >\n                {webhook.format === \"json\" ? (\n                  <DropdownMenuItem\n                    onClick={() => handleCopySecret(webhook.secret)}\n                  >\n                    <HugeiconsIcon icon={Copy01Icon} size={16} />\n                    <span>Copy Secret</span>\n                  </DropdownMenuItem>\n                ) : undefined}\n                <DropdownMenuItem\n                  disabled={isSendingTest || isToggling}\n                  onClick={handleSendTest}\n                >\n                  {isSendingTest ? (\n                    <HugeiconsIcon\n                      className=\"animate-spin\"\n                      icon={Loading03Icon}\n                      size={16}\n                    />\n                  ) : (\n                    <HugeiconsIcon icon={MailSend01Icon} size={16} />\n                  )}\n                  <span>Send Test</span>\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  disabled={isToggling}\n                  onClick={() => setIsEditOpen(true)}\n                >\n                  <HugeiconsIcon icon={PencilEdit02Icon} size={16} />\n                  <span>Edit</span>\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  disabled={isToggling}\n                  onClick={() => setIsDeleteOpen(true)}\n                  variant=\"destructive\"\n                >\n                  <HugeiconsIcon icon={Delete02Icon} size={16} />\n                  <span>Delete</span>\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n\n          <div className=\"flex items-center justify-between\">\n            <p className=\"text-muted-foreground text-sm\">\n              Created {format(new Date(webhook.createdAt), \"MMM d, yyyy\")}\n            </p>\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <div>\n                    <Switch\n                      checked={webhook.enabled}\n                      disabled={isCurrentlyToggling}\n                      onCheckedChange={(checked) =>\n                        onToggle({\n                          id: webhook.id,\n                          enabled: checked,\n                        })\n                      }\n                    />\n                  </div>\n                }\n              />\n              <TooltipContent>\n                {webhook.enabled ? \"Disable webhook\" : \"Enable webhook\"}\n              </TooltipContent>\n            </Tooltip>\n          </div>\n        </div>\n      </Card>\n      <EditWebhookSheet\n        isOpen={isEditOpen}\n        onOpenChange={setIsEditOpen}\n        webhook={webhook}\n      />\n      <DeleteWebhookModal\n        isOpen={isDeleteOpen}\n        onDelete={onDelete}\n        onOpenChange={setIsDeleteOpen}\n        webhookId={webhook.id}\n        webhookName={webhook.name}\n      />\n    </li>\n  );\n}\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/webhook-columns.tsx",
    "content": "\"use client\";\n\nimport { Badge } from \"@marble/ui/components/badge\";\nimport { Checkbox } from \"@marble/ui/components/checkbox\";\nimport type { ColumnDef } from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport type { Webhook } from \"@/types/webhook\";\nimport { WebhookActions } from \"./webhook-actions\";\n\ninterface WebhookColumnsOptions {\n  isToggling: boolean;\n  onDelete: () => void;\n  onToggle: (data: { id: string; enabled: boolean }) => void;\n  toggleVariables?: { id: string; enabled: boolean };\n}\n\nfunction formatWebhookFormat(formatValue: string) {\n  return formatValue.toUpperCase();\n}\n\nexport function getWebhookColumns({\n  isToggling,\n  onDelete,\n  onToggle,\n  toggleVariables,\n}: WebhookColumnsOptions): ColumnDef<Webhook>[] {\n  return [\n    {\n      id: \"select\",\n      header: ({ table }) => (\n        <Checkbox\n          aria-checked={\n            table.getIsSomePageRowsSelected() &&\n            !table.getIsAllPageRowsSelected()\n              ? \"mixed\"\n              : undefined\n          }\n          aria-label={\n            table.getIsAllPageRowsSelected() ? \"Deselect all\" : \"Select all\"\n          }\n          checked={table.getIsAllPageRowsSelected()}\n          onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}\n        />\n      ),\n      cell: ({ row }) => (\n        <Checkbox\n          aria-label={`Select ${row.original.name}`}\n          checked={row.getIsSelected()}\n          onCheckedChange={(value) => row.toggleSelected(!!value)}\n        />\n      ),\n      enableHiding: false,\n      enableSorting: false,\n      size: 40,\n    },\n    {\n      accessorKey: \"name\",\n      header: \"Name\",\n      cell: ({ row }) => (\n        <div className=\"min-w-0 max-w-64\">\n          <p className=\"truncate font-medium text-xs\">{row.original.name}</p>\n          <p className=\"text-muted-foreground text-xs\">\n            {formatWebhookFormat(row.original.format)}\n          </p>\n        </div>\n      ),\n      filterFn: \"includesString\",\n    },\n    {\n      accessorKey: \"url\",\n      header: \"Endpoint\",\n      cell: ({ row }) => (\n        <p className=\"max-w-80 truncate break-all font-mono text-muted-foreground text-xs\">\n          {row.original.url}\n        </p>\n      ),\n      filterFn: \"includesString\",\n    },\n    {\n      accessorKey: \"enabled\",\n      header: \"Status\",\n      cell: ({ row }) => (\n        <Badge\n          className=\"rounded-[6px] text-xs\"\n          variant={row.original.enabled ? \"positive\" : \"neutral\"}\n        >\n          {row.original.enabled ? \"Enabled\" : \"Disabled\"}\n        </Badge>\n      ),\n      enableSorting: false,\n    },\n    {\n      accessorKey: \"createdAt\",\n      header: \"Created\",\n      cell: ({ row }) => (\n        <span className=\"text-muted-foreground text-xs\">\n          {format(new Date(row.original.createdAt), \"MMM d, yyyy\")}\n        </span>\n      ),\n    },\n    {\n      id: \"actions\",\n      cell: ({ row }) => {\n        const webhook = row.original;\n        const isCurrentlyToggling =\n          isToggling && toggleVariables?.id === webhook.id;\n\n        return (\n          <div className=\"flex justify-end\">\n            <WebhookActions\n              isToggling={isCurrentlyToggling}\n              onDelete={onDelete}\n              onToggle={onToggle}\n              webhook={webhook}\n            />\n          </div>\n        );\n      },\n      enableHiding: false,\n      enableSorting: false,\n      size: 48,\n    },\n  ];\n}\n"
  },
  {
    "path": "apps/cms/src/components/webhooks/webhook-data-table.tsx",
    "content": "\"use client\";\n\nimport { WebhookIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@marble/ui/components/table\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { MagnifyingGlassIcon, PlusIcon, XIcon } from \"@phosphor-icons/react\";\nimport {\n  flexRender,\n  getCoreRowModel,\n  getFilteredRowModel,\n  type RowSelectionState,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { useMemo, useState } from \"react\";\nimport CreateWebhookSheet from \"@/components/webhooks/create-webhook\";\nimport type { Webhook } from \"@/types/webhook\";\nimport { getWebhookColumns } from \"./webhook-columns\";\n\ninterface WebhookDataTableProps {\n  isToggling: boolean;\n  onDelete: () => void;\n  onToggle: (data: { id: string; enabled: boolean }) => void;\n  toggleVariables?: { id: string; enabled: boolean };\n  webhooks: Webhook[];\n}\n\nexport function WebhookDataTable({\n  isToggling,\n  onDelete,\n  onToggle,\n  toggleVariables,\n  webhooks,\n}: WebhookDataTableProps) {\n  const [globalFilter, setGlobalFilter] = useState(\"\");\n  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\n\n  const columns = useMemo(\n    () =>\n      getWebhookColumns({\n        isToggling,\n        onDelete,\n        onToggle,\n        toggleVariables,\n      }),\n    [isToggling, onDelete, onToggle, toggleVariables]\n  );\n\n  const table = useReactTable({\n    data: webhooks,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getFilteredRowModel: getFilteredRowModel(),\n    getRowId: (row) => row.id,\n    globalFilterFn: (row, _columnId, filterValue) => {\n      const search = String(filterValue).toLowerCase().trim();\n      if (!search) {\n        return true;\n      }\n      const webhook = row.original;\n      return [\n        webhook.name,\n        webhook.url,\n        webhook.format,\n        webhook.enabled ? \"enabled\" : \"disabled\",\n      ].some((value) => value.toLowerCase().includes(search));\n    },\n    onGlobalFilterChange: setGlobalFilter,\n    onRowSelectionChange: setRowSelection,\n    state: {\n      globalFilter,\n      rowSelection,\n    },\n  });\n\n  const rows = table.getRowModel().rows;\n  const selectedCount = table.getSelectedRowModel().rows.length;\n\n  return (\n    <div className=\"flex flex-col gap-4\">\n      <section className=\"flex flex-col gap-3 md:flex-row md:items-center md:justify-between\">\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <div className=\"relative\">\n            <MagnifyingGlassIcon\n              className=\"-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground\"\n              size={16}\n            />\n            <Input\n              aria-label=\"Search webhooks\"\n              className=\"h-9 w-full rounded-[12px] px-8 shadow-none sm:w-72\"\n              onChange={(event) => setGlobalFilter(event.target.value)}\n              placeholder=\"Search webhooks...\"\n              value={globalFilter}\n            />\n            {globalFilter && (\n              <button\n                className=\"-translate-y-1/2 absolute top-1/2 right-3\"\n                onClick={() => setGlobalFilter(\"\")}\n                type=\"button\"\n              >\n                <XIcon className=\"size-4\" />\n                <span className=\"sr-only\">Clear search</span>\n              </button>\n            )}\n          </div>\n\n          {selectedCount > 0 && (\n            <div className=\"flex h-9 items-center rounded-lg bg-surface p-0.5\">\n              <Button\n                className=\"h-8 rounded-md px-2 font-medium text-xs\"\n                onClick={() => table.resetRowSelection()}\n                size=\"xs\"\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <XIcon />\n                {selectedCount} selected\n              </Button>\n            </div>\n          )}\n        </div>\n        <div className=\"flex items-center justify-end gap-2\">\n          <CreateWebhookSheet>\n            <Button className=\"h-9 w-full sm:w-auto\">\n              <PlusIcon className=\"size-4\" />\n              Add Endpoint\n            </Button>\n          </CreateWebhookSheet>\n        </div>\n      </section>\n\n      <div className=\"overflow-hidden rounded-[20px] bg-surface p-1 [&_[data-slot=table-container]]:overflow-x-auto [&_[data-slot=table-container]]:overflow-y-hidden\">\n        <Table className=\"-mb-1 h-fit border-separate border-spacing-y-1\">\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow\n                className=\"border-0 text-[13px] hover:bg-transparent\"\n                key={headerGroup.id}\n              >\n                {headerGroup.headers.map((header) => (\n                  <TableHead\n                    className={getHeaderClassName(header.column.id)}\n                    key={header.id}\n                  >\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext()\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {rows.length ? (\n              rows.map((row) => (\n                <TableRow\n                  className=\"border-0 bg-background hover:bg-background/80 data-[state=selected]:bg-background\"\n                  data-state={row.getIsSelected() && \"selected\"}\n                  key={row.id}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell\n                      className={cn(getCellClassName(cell.column.id))}\n                      data-no-row-click={\n                        cell.column.id === \"select\" ||\n                        cell.column.id === \"toggle\" ||\n                        cell.column.id === \"actions\"\n                          ? true\n                          : undefined\n                      }\n                      key={cell.id}\n                    >\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext()\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow className=\"border-0 bg-background\">\n                <TableCell\n                  className=\"h-28 rounded-[14px] text-center text-muted-foreground text-sm\"\n                  colSpan={table.getVisibleLeafColumns().length}\n                >\n                  No webhooks found. Try adjusting your search.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  );\n}\n\nexport function WebhooksEmptyState() {\n  return (\n    <div className=\"flex max-w-80 flex-col items-center gap-4\">\n      <div>\n        <HugeiconsIcon className=\"size-16\" icon={WebhookIcon} />\n      </div>\n      <div className=\"flex flex-col items-center gap-4 text-center\">\n        <p className=\"text-muted-foreground text-sm\">\n          Webhooks let you run actions on your server when events happen in your\n          workspace.\n        </p>\n        <CreateWebhookSheet>\n          <Button>\n            <PlusIcon size={16} />\n            <span>New Webhook</span>\n          </Button>\n        </CreateWebhookSheet>\n      </div>\n    </div>\n  );\n}\n\nfunction getHeaderClassName(columnId: string) {\n  switch (columnId) {\n    case \"select\":\n      return \"w-10 px-3\";\n    case \"name\":\n      return \"min-w-56 pr-3 text-muted-foreground\";\n    case \"endpoint\":\n      return \"min-w-80 px-3 text-muted-foreground\";\n    case \"enabled\":\n      return \"px-3 text-muted-foreground\";\n    case \"toggle\":\n      return \"px-3 text-muted-foreground\";\n    case \"createdAt\":\n      return \"hidden px-3 text-muted-foreground lg:table-cell\";\n    case \"actions\":\n      return \"sr-only w-12 px-3 text-right text-muted-foreground\";\n    default:\n      return \"px-3 text-muted-foreground\";\n  }\n}\n\nfunction getCellClassName(columnId: string) {\n  switch (columnId) {\n    case \"select\":\n      return \"rounded-l-[14px] px-3 py-2\";\n    case \"name\":\n      return \"px-3 py-2\";\n    case \"endpoint\":\n      return \"px-3 py-2\";\n    case \"createdAt\":\n      return \"hidden px-3 py-2 lg:table-cell\";\n    case \"actions\":\n      return \"rounded-r-[14px] px-3 py-2\";\n    default:\n      return \"px-3 py-2\";\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-debounce.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport function useDebounce<T>(value: T, delay: number): T {\n  const [debouncedValue, setDebouncedValue] = useState<T>(value);\n\n  useEffect(() => {\n    const handler = setTimeout(() => {\n      setDebouncedValue(value);\n    }, delay);\n\n    return () => {\n      clearTimeout(handler);\n    };\n  }, [value, delay]);\n\n  return debouncedValue;\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-isomorphic-layout-effect.ts",
    "content": "import { useEffect, useLayoutEffect } from \"react\";\n\n/**\n * Custom hook that uses either `useLayoutEffect` or `useEffect` based on the environment (client-side or server-side).\n * @param {Function} effect - The effect function to be executed.\n * @param {Array<any>} [dependencies] - An array of dependencies for the effect (optional).\n */\nexport const useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n"
  },
  {
    "path": "apps/cms/src/hooks/use-localstorage.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nfunction getItemFromLocalStorage(key: string) {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  const item = window.localStorage.getItem(key);\n  if (item) {\n    return JSON.parse(item);\n  }\n\n  return null;\n}\n\nexport function useLocalStorage<T>(\n  key: string,\n  initialValue: T\n): [T, (value: T) => void] {\n  const [storedValue, setStoredValue] = useState(\n    getItemFromLocalStorage(key) ?? initialValue\n  );\n\n  useEffect(() => {\n    // Retrieve from localStorage\n    const item = getItemFromLocalStorage(key);\n    if (item) {\n      setStoredValue(item);\n    }\n  }, [key]);\n\n  const setValue = (value: T) => {\n    // Save state\n    setStoredValue(value);\n    // Save to localStorage\n    window.localStorage.setItem(key, JSON.stringify(value));\n  };\n\n  return [storedValue, setValue];\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-media-actions.ts",
    "content": "import { useQueryClient } from \"@tanstack/react-query\";\nimport { useWorkspaceId } from \"@/hooks/use-workspace-id\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { MediaQueryKey } from \"@/types/media\";\n\nexport function useMediaActions(mediaQueryKey: MediaQueryKey) {\n  const queryClient = useQueryClient();\n  const workspaceId = useWorkspaceId();\n\n  const handleActionComplete = () => {\n    if (!workspaceId) {\n      return;\n    }\n    queryClient.invalidateQueries({ queryKey: mediaQueryKey, exact: true });\n\n    const allMediaPrefixKey = QUERY_KEYS.MEDIA(workspaceId);\n    queryClient.invalidateQueries({\n      queryKey: allMediaPrefixKey,\n      exact: false,\n    });\n  };\n\n  const handleUploadComplete = () => handleActionComplete();\n  // Delete handlers don't need to invalidate - the delete modal already\n  // does optimistic updates directly to the cache via setQueriesData\n  const handleDeleteComplete = (_id: string) => {\n    return;\n  };\n  const handleBulkDeleteComplete = (_ids: string[]) => {\n    return;\n  };\n\n  return {\n    handleUploadComplete,\n    handleDeleteComplete,\n    handleBulkDeleteComplete,\n  };\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-media-query.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nfunction getDevice(): \"mobile\" | \"tablet\" | \"desktop\" | null {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  return window.matchMedia(\"(min-width: 1024px)\").matches\n    ? \"desktop\"\n    : window.matchMedia(\"(min-width: 640px)\").matches\n      ? \"tablet\"\n      : \"mobile\";\n}\n\nfunction getDimensions() {\n  if (typeof window === \"undefined\") {\n    return null;\n  }\n\n  return { width: window.innerWidth, height: window.innerHeight };\n}\n\nexport function useMediaQuery() {\n  const [device, setDevice] = useState<\"mobile\" | \"tablet\" | \"desktop\" | null>(\n    getDevice()\n  );\n  const [dimensions, setDimensions] = useState<{\n    width: number;\n    height: number;\n  } | null>(getDimensions());\n\n  useEffect(() => {\n    const checkDevice = () => {\n      setDevice(getDevice());\n      setDimensions(getDimensions());\n    };\n\n    // Initial detection\n    checkDevice();\n\n    // Listener for windows resize\n    window.addEventListener(\"resize\", checkDevice);\n\n    // Cleanup listener\n    return () => {\n      window.removeEventListener(\"resize\", checkDevice);\n    };\n  }, []);\n\n  return {\n    device,\n    width: dimensions?.width,\n    height: dimensions?.height,\n    isMobile: device === \"mobile\",\n    isTablet: device === \"tablet\",\n    isDesktop: device === \"desktop\",\n  };\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-mobile.tsx",
    "content": "import { useSyncExternalStore } from \"react\";\n\nconst MOBILE_BREAKPOINT = 768;\n\nfunction subscribe(callback: () => void) {\n  const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\n  mql.addEventListener(\"change\", callback);\n  return () => mql.removeEventListener(\"change\", callback);\n}\n\nfunction getSnapshot() {\n  return window.innerWidth < MOBILE_BREAKPOINT;\n}\n\nfunction getServerSnapshot() {\n  return false;\n}\n\nexport function useIsMobile() {\n  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-plan.ts",
    "content": "import { useQuery } from \"@tanstack/react-query\";\nimport { useMemo } from \"react\";\nimport {\n  canInviteMoreMembers,\n  canPerformAction,\n  getPlanLimits,\n  getRemainingMemberSlots,\n  getWorkspacePlan,\n  isOverLimit,\n  type PlanLimits,\n  type PlanType,\n} from \"@/lib/plans\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport { useWorkspace } from \"@/providers/workspace\";\nimport type { UsageDashboardData } from \"@/types/dashboard\";\n\ninterface BillingUsage {\n  media: number;\n}\n\nexport function usePlan() {\n  const { activeWorkspace } = useWorkspace();\n\n  const currentPlan: PlanType = useMemo(\n    () => getWorkspacePlan(activeWorkspace?.subscription),\n    [activeWorkspace?.subscription]\n  );\n\n  const currentMemberCount = useMemo(\n    () => activeWorkspace?.members?.length || 0,\n    [activeWorkspace?.members]\n  );\n\n  const planLimits: PlanLimits = useMemo(\n    () => getPlanLimits(currentPlan),\n    [currentPlan]\n  );\n\n  const canInvite = useMemo(\n    () => canInviteMoreMembers(currentPlan, currentMemberCount),\n    [currentPlan, currentMemberCount]\n  );\n\n  const remainingSlots = useMemo(\n    () => getRemainingMemberSlots(currentPlan, currentMemberCount),\n    [currentPlan, currentMemberCount]\n  );\n\n  const canUseFeature = (feature: keyof PlanLimits[\"features\"]) =>\n    canPerformAction(currentPlan, feature);\n\n  const checkLimits = (usage: Parameters<typeof isOverLimit>[1]) =>\n    isOverLimit(currentPlan, usage);\n\n  const workspaceId = activeWorkspace?.id;\n\n  const { data } = useQuery({\n    queryKey: workspaceId\n      ? QUERY_KEYS.USAGE_DASHBOARD(workspaceId)\n      : [\"usage-dashboard\", \"disabled\"],\n    queryFn: async (): Promise<UsageDashboardData> => {\n      const response = await fetch(\"/api/metrics/usage\");\n      if (!response.ok) {\n        throw new Error(\"Failed to fetch usage metrics\");\n      }\n      return response.json();\n    },\n    enabled: Boolean(workspaceId),\n    staleTime: 1000 * 60 * 10,\n  });\n\n  const isHobbyPlan = currentPlan === \"hobby\";\n  const isProPlan = currentPlan === \"pro\";\n\n  return {\n    currentPlan,\n    planLimits,\n    currentMemberCount,\n    canInvite,\n    remainingSlots,\n    canUseFeature,\n    checkLimits,\n    isHobbyPlan,\n    isProPlan,\n    currentMediaUsage: data?.media.total ?? 0,\n    currentApiRequests: data?.api.totals.total ?? 0,\n  };\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-readability.ts",
    "content": "\"use client\";\n\nimport type { Editor } from \"@marble/editor\";\nimport { useQuery } from \"@tanstack/react-query\";\nimport { useMemo } from \"react\";\nimport {\n  calculateReadabilityScore,\n  generateSuggestions as generateLocalSuggestions,\n  getReadabilityLevel,\n} from \"@/utils/readability\";\n\ninterface UseReadabilityParams {\n  editor: Editor | null;\n  text: string;\n}\n\ntype ReadabilityLevel = ReturnType<typeof getReadabilityLevel>;\n\nexport interface ReadabilityResult {\n  wordCount: number;\n  sentenceCount: number;\n  wordsPerSentence: number;\n  readabilityScore: number;\n  readabilityLevel: ReadabilityLevel;\n  readingTime: number;\n  suggestions: string[];\n}\n\nconst READING_SPEED = 238;\n\nfunction countSyllablesForWord(word: string): number {\n  const lower = word.toLowerCase();\n  if (lower.length <= 3) {\n    return 1;\n  }\n  const withoutEs = lower.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/u, \"\");\n  const withoutY = withoutEs.replace(/^y/u, \"\");\n  const matches = withoutY.match(/[aeiouy]{1,2}/gu);\n  return matches ? matches.length : 1;\n}\n\nfunction computeMetrics(text: string, editor?: Editor | null) {\n  if (!text || text.trim().length === 0) {\n    return {\n      wordCount: 0,\n      sentenceCount: 0,\n      wordsPerSentence: 0,\n      readabilityScore: 0,\n      readabilityLevel: {\n        level: \"Unknown\",\n        description: \"Unknown\",\n      } as ReadabilityLevel,\n      readingTime: 0,\n    };\n  }\n\n  const words = text\n    .trim()\n    .split(/\\s+/u)\n    .filter((w) => w.length > 0);\n  const wordCount = editor?.storage?.characterCount?.words\n    ? editor.storage.characterCount.words()\n    : words.length;\n  const sentences = text\n    .split(/[.!?]+/)\n    .filter((sentence) => sentence.trim().length > 0);\n  const sentenceCount = sentences.length;\n  const wordsPerSentence =\n    sentenceCount > 0 ? Math.round(wordCount / sentenceCount) : 0;\n\n  let readabilityScore: number;\n  if (editor) {\n    readabilityScore = calculateReadabilityScore(editor);\n  } else {\n    const totalSyllables = words.reduce(\n      (acc, w) => acc + countSyllablesForWord(w),\n      0\n    );\n    if (sentenceCount === 0 || wordCount === 0) {\n      readabilityScore = 0;\n    } else {\n      const avgSentenceLength = wordCount / sentenceCount;\n      const avgSyllablesPerWord = totalSyllables / wordCount;\n      const score =\n        206.835 - 1.015 * avgSentenceLength - 84.6 * avgSyllablesPerWord;\n      readabilityScore = Math.max(0, Math.min(100, Math.round(score)));\n    }\n  }\n\n  const readabilityLevel = getReadabilityLevel(readabilityScore);\n  const readingTime = wordCount / READING_SPEED;\n\n  return {\n    wordCount,\n    sentenceCount,\n    wordsPerSentence,\n    readabilityScore,\n    readabilityLevel,\n    readingTime,\n  };\n}\n\nexport function useReadability({\n  editor,\n  text,\n}: UseReadabilityParams): ReadabilityResult {\n  const metrics = useMemo(() => computeMetrics(text, editor), [text, editor]);\n\n  const { data: localSuggestions = [] } = useQuery<string[]>({\n    queryKey: [\n      \"local-readability-suggestions\",\n      metrics.wordCount,\n      metrics.sentenceCount,\n      metrics.wordsPerSentence,\n      metrics.readabilityScore,\n    ],\n    staleTime: 5 * 60 * 1000,\n    refetchOnWindowFocus: false,\n    refetchOnReconnect: false,\n    retry: 0,\n    queryFn: async () =>\n      generateLocalSuggestions({\n        wordCount: metrics.wordCount,\n        sentenceCount: metrics.sentenceCount,\n        wordsPerSentence: metrics.wordsPerSentence,\n        readabilityScore: metrics.readabilityScore,\n      }),\n  });\n\n  return {\n    ...metrics,\n    suggestions: localSuggestions,\n  };\n}\n"
  },
  {
    "path": "apps/cms/src/hooks/use-workspace-id.ts",
    "content": "import { useWorkspace } from \"@/providers/workspace\";\n\n/**\n * Hook to get the current workspace ID consistently across the app.\n * Returns null if no active workspace is available.\n */\nexport function useWorkspaceId(): string | null {\n  const { activeWorkspace } = useWorkspace();\n  return activeWorkspace?.id || null;\n}\n"
  },
  {
    "path": "apps/cms/src/lib/actions/checks.ts",
    "content": "\"use server\";\n\nimport { randomBytes } from \"node:crypto\";\nimport { db } from \"@marble/db\";\nimport { APIError } from \"better-auth/api\";\nimport { getServerSession } from \"@/lib/auth/session\";\n\n/**\n * Check if a category slug is taken\n * @param slug - The slug of the category to check\n * @param workspaceId - The id of the workspace\n * @returns True if the slug is taken, false otherwise\n */\nexport async function checkCategorySlugAction(\n  slug: string,\n  workspaceId: string\n) {\n  const result = await db.category.findFirst({\n    where: { workspaceId, slug },\n  });\n\n  return !!result;\n}\n\n/**\n * Check if a category slug is taken for update\n * @param slug - The slug of the category to check\n * @param workspaceId - The id of the workspace\n * @param currentCategoryId - The id of the current category\n * @returns True if the slug is taken, false otherwise\n */\nexport async function checkCategorySlugForUpdateAction(\n  slug: string,\n  workspaceId: string,\n  currentCategoryId: string\n) {\n  const result = await db.category.findFirst({\n    where: {\n      workspaceId,\n      slug,\n      NOT: {\n        id: currentCategoryId,\n      },\n    },\n  });\n\n  return !!result;\n}\n\nexport async function checkTagSlugAction(slug: string, workspaceId: string) {\n  const result = await db.tag.findFirst({\n    where: { workspaceId, slug },\n  });\n\n  return !!result;\n}\n\nexport async function checkTagSlugForUpdateAction(\n  slug: string,\n  workspaceId: string,\n  currentTagId: string\n) {\n  const result = await db.tag.findFirst({\n    where: {\n      workspaceId,\n      slug,\n      NOT: {\n        id: currentTagId,\n      },\n    },\n  });\n\n  return !!result;\n}\n\n/**\n * Check if an author slug is taken\n * @param slug - The slug of the author to check\n * @param workspaceId - The id of the workspace\n * @returns True if the slug is taken, false otherwise\n */\nexport async function checkAuthorSlugAction(slug: string, workspaceId: string) {\n  const result = await db.author.findFirst({\n    where: { workspaceId, slug },\n  });\n\n  return !!result;\n}\n\n/**\n * Check if an author slug is taken for update\n * @param slug - The slug of the author to check\n * @param workspaceId - The id of the workspace\n * @param currentAuthorId - The id of the current author\n * @returns True if the slug is taken, false otherwise\n */\nexport async function checkAuthorSlugForUpdateAction(\n  slug: string,\n  workspaceId: string,\n  currentAuthorId: string\n) {\n  const result = await db.author.findFirst({\n    where: {\n      workspaceId,\n      slug,\n      NOT: {\n        id: currentAuthorId,\n      },\n    },\n  });\n\n  return !!result;\n}\n\n/**\n * Verify an invite\n * @param inviteId - The invite ID\n * @returns The invite email\n */\nexport async function verifyInvite(inviteId: string) {\n  const session = await getServerSession();\n  const invite = await db.invitation.findUnique({\n    where: { id: inviteId, email: session?.user.email },\n    select: { email: true, status: true, expiresAt: true },\n  });\n\n  if (!invite) {\n    throw new Error(\"Invite not found\");\n  }\n\n  if (invite.status !== \"pending\") {\n    throw new Error(\"Invite is no longer valid\");\n  }\n\n  if (invite.expiresAt < new Date()) {\n    throw new Error(\"Invite has expired\");\n  }\n\n  return invite.email;\n}\n\n/**\n * Generate a secure webhook secret (server action)\n * @returns The webhook secret\n */\nexport const generateWebhookSecretAction = async () => {\n  try {\n    const secret = randomBytes(32).toString(\"hex\");\n    return { success: true, secret };\n  } catch (error) {\n    console.error(\"Failed to generate webhook secret:\", error);\n    return { success: false, secret: null };\n  }\n};\n\nexport async function checkWorkspaceSlug(\n  slug: string,\n  currentWorkspaceId?: string\n) {\n  const session = await getServerSession();\n  if (!session?.user) {\n    throw new Error(\"Unauthorized\");\n  }\n\n  const workspace = await db.organization.findFirst({\n    where: {\n      slug,\n      NOT: currentWorkspaceId ? { id: currentWorkspaceId } : undefined,\n    },\n  });\n\n  return !!workspace; // Return true if slug is in use (workspace found)\n}\n\nexport async function checkPostSlugAvailable(\n  slug: string,\n  workspaceId: string | null\n) {\n  const session = await getServerSession();\n  if (!session?.user) {\n    throw new Error(\"Unauthorized\");\n  }\n  if (!workspaceId) {\n    throw new Error(\"Workspace ID is required\");\n  }\n  const post = await db.post.findFirst({\n    where: { workspaceId, slug: { equals: slug, mode: \"insensitive\" } },\n    select: { id: true },\n  });\n\n  return !post;\n}\n\nexport async function checkWorkspaceSubscriptionAction(workspaceId: string) {\n  const subscription = await db.subscription.findFirst({\n    where: {\n      workspaceId,\n      OR: [\n        { status: \"active\" },\n        { status: \"trialing\" },\n        {\n          status: \"canceled\",\n          cancelAtPeriodEnd: true,\n          currentPeriodEnd: { gt: new Date() },\n        },\n      ],\n    },\n    orderBy: { createdAt: \"desc\" },\n  });\n\n  return Boolean(subscription);\n}\n\nexport async function guardWorkspaceSubscriptionAction(\n  workspaceId: string,\n  message: string\n) {\n  const hasValidSubscription =\n    await checkWorkspaceSubscriptionAction(workspaceId);\n\n  if (!hasValidSubscription) {\n    throw new APIError(\"FORBIDDEN\", {\n      message,\n    });\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/actions/email.ts",
    "content": "\"use server\";\n\nimport {\n  sendDevEmail,\n  sendFounderEmail,\n  sendInviteEmail,\n  sendResetPassword,\n  sendUsageLimitEmail,\n  sendVerificationEmail,\n  sendWelcomeEmail,\n} from \"@marble/email\";\nimport { Resend } from \"resend\";\nimport { getServerSession } from \"../auth/session\";\n\nconst resendApiKey = process.env.RESEND_API_KEY;\nconst isDevelopment = process.env.NODE_ENV === \"development\";\nconst resend = resendApiKey ? new Resend(resendApiKey) : null;\n\ninterface SendInviteEmailProps {\n  inviteeEmail: string;\n  inviteeUsername?: string;\n  inviterName: string;\n  inviterEmail: string;\n  workspaceName: string;\n  inviteLink: string;\n}\n\nexport async function sendInviteEmailAction({\n  inviteeEmail,\n  inviterName,\n  inviterEmail,\n  workspaceName,\n  inviteLink,\n}: SendInviteEmailProps) {\n  if (!resend && isDevelopment) {\n    return sendDevEmail({\n      from: \"Marble <emails@marblecms.com>\",\n      to: inviteeEmail,\n      subject: `Join ${workspaceName} on Marble`,\n      text: \"This is a mock invite email\",\n      _mockContext: {\n        type: \"invite\",\n        data: {\n          inviteeEmail,\n          inviterName,\n          inviterEmail,\n          workspaceName,\n          inviteLink,\n        },\n      },\n    });\n  }\n\n  const session = await getServerSession();\n\n  if (!session) {\n    return { success: false, error: \"Unauthorized\" };\n  }\n\n  if (!resend) {\n    throw new Error(\"Resend API key not set\");\n  }\n\n  try {\n    await sendInviteEmail(resend, {\n      inviteeEmail,\n      inviterName,\n      inviterEmail,\n      workspaceName,\n      inviteLink,\n    });\n\n    return { success: true, message: \"Email sent successfully\" };\n  } catch (error) {\n    console.error(\"Detailed error sending email:\", error);\n    return { success: false, error: \"Failed to send email\" };\n  }\n}\n\nexport async function sendVerificationEmailAction({\n  userEmail,\n  otp,\n  type,\n}: {\n  userEmail: string;\n  otp: string;\n  type: \"sign-in\" | \"email-verification\" | \"forget-password\" | \"change-email\";\n}) {\n  if (!resend && isDevelopment) {\n    return sendDevEmail({\n      from: \"Marble <emails@marblecms.com>\",\n      to: userEmail,\n      text: \"This is a mock verification email\",\n      subject: \"Verify your email address\",\n      _mockContext: {\n        type: \"verification\",\n        data: { userEmail, otp, verificationType: type },\n      },\n    });\n  }\n\n  if (!resend) {\n    throw new Error(\"Resend API key not set\");\n  }\n\n  try {\n    await sendVerificationEmail(resend, {\n      userEmail,\n      otp,\n      type,\n    });\n\n    return { success: true, message: \"Email sent successfully\" };\n  } catch (error) {\n    console.error(\"Detailed error sending email:\", error);\n    return { success: false, error: \"Failed to send email\" };\n  }\n}\n\nexport async function sendResetPasswordAction({\n  userEmail,\n  resetLink,\n}: {\n  userEmail: string;\n  resetLink: string;\n}) {\n  if (!resend && isDevelopment) {\n    return sendDevEmail({\n      from: \"Marble <emails@marblecms.com>\",\n      to: userEmail,\n      text: \"This is a mock reset password email\",\n      subject: \"Reset Your Password\",\n      _mockContext: { type: \"reset\", data: { userEmail, resetLink } },\n    });\n  }\n\n  if (!resend) {\n    throw new Error(\"Resend API key not set\");\n  }\n\n  try {\n    await sendResetPassword(resend, {\n      userEmail,\n      resetLink,\n    });\n\n    return { success: true, message: \"Email sent successfully\" };\n  } catch (error) {\n    console.error(\"Detailed error sending email:\", error);\n    return { success: false, error: \"Failed to send email\" };\n  }\n}\n\nexport async function sendWelcomeEmailAction({\n  userEmail,\n}: {\n  userEmail: string;\n}) {\n  if (!resend && isDevelopment) {\n    return sendDevEmail({\n      from: \"Marble <emails@marblecms.com>\",\n      to: userEmail,\n      text: \"This is a mock welcome email\",\n      subject: \"Welcome to Marble!\",\n      _mockContext: { type: \"welcome\", data: { userEmail } },\n    });\n  }\n\n  if (!resend) {\n    throw new Error(\"Resend API key not set\");\n  }\n\n  try {\n    await sendWelcomeEmail(resend, {\n      userEmail,\n    });\n\n    return { success: true, message: \"Email sent successfully\" };\n  } catch (error) {\n    console.error(\"Detailed error sending email:\", error);\n    return { success: false, error: \"Failed to send email\" };\n  }\n}\n\nexport async function sendUsageLimitEmailAction({\n  userEmail,\n  userName,\n  featureName = \"Webhooks\",\n  usageAmount,\n  limitAmount,\n  workspaceId,\n}: {\n  userEmail: string;\n  userName?: string;\n  featureName?: string;\n  usageAmount: number;\n  limitAmount: number;\n  workspaceId?: string;\n}) {\n  if (!resend && isDevelopment) {\n    return sendDevEmail({\n      from: \"Marble <emails@marblecms.com>\",\n      to: userEmail,\n      text: `This is a mock usage limit email for ${featureName}`,\n      subject: `You're approaching your ${featureName} limit`,\n      _mockContext: {\n        type: \"usage-limit\",\n        data: {\n          userEmail,\n          userName,\n          featureName,\n          usageAmount,\n          limitAmount,\n          workspaceId,\n        },\n      },\n    });\n  }\n\n  if (!resend) {\n    throw new Error(\"Resend API key not set\");\n  }\n\n  try {\n    await sendUsageLimitEmail(resend, {\n      userEmail,\n      userName,\n      featureName,\n      usageAmount,\n      limitAmount,\n      workspaceId,\n    });\n\n    return { success: true, message: \"Usage limit email sent successfully\" };\n  } catch (error) {\n    console.error(\"Detailed error sending usage limit email:\", error);\n    return { success: false, error: \"Failed to send usage limit email\" };\n  }\n}\n\nexport async function sendFounderEmailAction({\n  userEmail,\n  scheduledAt,\n}: {\n  userEmail: string;\n  scheduledAt?: Date;\n}) {\n  if (!resend && isDevelopment) {\n    const scheduledInfo = scheduledAt\n      ? ` (scheduled for ${scheduledAt.toISOString()})`\n      : \"\";\n    return sendDevEmail({\n      from: \"Taqib <taqib@marblecms.com>\",\n      to: userEmail,\n      text: `This is a mock founder email${scheduledInfo}`,\n      subject: \"Thanks for trying Marble\",\n      _mockContext: { type: \"founder\", data: { userEmail, scheduledAt } },\n    });\n  }\n\n  if (!resend) {\n    throw new Error(\"Resend API key not set\");\n  }\n\n  try {\n    await sendFounderEmail(resend, {\n      userEmail,\n      scheduledAt,\n    });\n\n    return { success: true, message: \"Founder email sent successfully\" };\n  } catch (error) {\n    console.error(\"Detailed error sending founder email:\", error);\n    return { success: false, error: \"Failed to send founder email\" };\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/actions/user.ts",
    "content": "\"use server\";\n\nimport { PutObjectCommand } from \"@aws-sdk/client-s3\";\nimport { db } from \"@marble/db\";\nimport type { User } from \"better-auth\";\nimport { nanoid } from \"nanoid\";\nimport { isAllowedAvatarUrl } from \"@/lib/constants\";\nimport { R2_BUCKET_NAME, R2_PUBLIC_URL, r2 } from \"@/lib/r2\";\n\nexport async function storeUserImageAction(user: User) {\n  if (!user.image) {\n    return;\n  }\n\n  try {\n    // Validate the URL is from an allowed provider with HTTPS\n    if (!isAllowedAvatarUrl(user.image)) {\n      console.warn(`Avatar URL not from allowed host: ${user.image}`);\n      return;\n    }\n    const response = await fetch(user.image);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch image: ${response.statusText}`);\n    }\n\n    // Grab file type from headers\n    const contentType = response.headers.get(\"content-type\") || \"image/png\";\n\n    // Convert to Buffer for upload\n    const arrayBuffer = await response.arrayBuffer();\n    const buffer = Buffer.from(arrayBuffer);\n\n    const extension = contentType.split(\"/\")[1];\n    const key = `avatars/${user.id}/${nanoid()}.${extension}`;\n\n    await r2.send(\n      new PutObjectCommand({\n        Bucket: R2_BUCKET_NAME,\n        Key: key,\n        Body: buffer,\n        ContentType: contentType,\n        ContentLength: buffer.length,\n      })\n    );\n\n    const avatarUrl = `${R2_PUBLIC_URL}/${key}`;\n\n    await db.user.update({\n      where: {\n        id: user.id,\n      },\n      data: {\n        image: avatarUrl,\n      },\n    });\n\n    return { avatarUrl };\n  } catch (error) {\n    console.error(\"Failed to store user avatar:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/actions/workspace.ts",
    "content": "\"use server\";\n\nimport { db } from \"@marble/db\";\nimport type { User } from \"better-auth\";\nimport { APIError } from \"better-auth/api\";\nimport { nanoid } from \"nanoid\";\nimport { generateSlug } from \"@/utils/string\";\nimport type { Organization } from \"../auth/types\";\nimport {\n  nameSchema,\n  slugSchema,\n  timezoneSchema,\n} from \"../validations/workspace\";\n\nexport async function createAuthor(user: User, organization: Organization) {\n  try {\n    const existingAuthor = await db.author.findUnique({\n      where: {\n        workspaceId_userId: {\n          workspaceId: organization.id,\n          userId: user.id,\n        },\n      },\n    });\n\n    if (existingAuthor) {\n      console.log(\n        \"Author already exists for user\",\n        user.id,\n        \"in workspace\",\n        organization.id\n      );\n      return existingAuthor;\n    }\n\n    // Generate slug with nanoid suffix to ensure uniqueness\n    const baseSlug = generateSlug(user.name || user.email || \"user\");\n    const uniqueSlug = `${baseSlug}-${nanoid(6)}`;\n\n    // Create new author profile from user data\n    const author = await db.author.create({\n      data: {\n        name: user.name,\n        email: user.email,\n        slug: uniqueSlug,\n        image: user.image,\n        workspaceId: organization.id,\n        userId: user.id,\n        role: \"Member\",\n      },\n    });\n\n    console.log(\n      \"Created author for user\",\n      user.id,\n      \"in workspace\",\n      organization.id\n    );\n    return author;\n  } catch (error) {\n    console.error(\"Failed to create author:\", error);\n    throw new APIError(\"INTERNAL_SERVER_ERROR\", {\n      message: \"Failed to create author profile\",\n    });\n  }\n}\n\nexport async function validateWorkspaceSlug(slug: string | undefined) {\n  const { success } = slugSchema.safeParse({ slug });\n  if (!success) {\n    throw new APIError(\"BAD_REQUEST\", {\n      message: \"Invalid slug\",\n    });\n  }\n}\n\nexport async function validateWorkspaceName(name: string | undefined) {\n  const { success } = nameSchema.safeParse({ name });\n  if (!success) {\n    throw new APIError(\"BAD_REQUEST\", {\n      message: \"Invalid name\",\n    });\n  }\n}\n\nexport async function validateWorkspaceTimezone(timezone: string | undefined) {\n  const { success } = timezoneSchema.safeParse({ timezone });\n  if (!success) {\n    throw new APIError(\"BAD_REQUEST\", {\n      message: \"Invalid timezone\",\n    });\n  }\n}\n\ninterface ValidateWorkspace {\n  slug: string | undefined;\n  name: string | undefined;\n  timezone: string | undefined;\n}\n\nexport async function validateWorkspaceSchema({\n  slug,\n  name,\n  timezone,\n}: ValidateWorkspace) {\n  await validateWorkspaceSlug(slug);\n  await validateWorkspaceName(name);\n  await validateWorkspaceTimezone(timezone);\n}\n"
  },
  {
    "path": "apps/cms/src/lib/ai/readability.ts",
    "content": "import { aiReadabilityResponseSchema } from \"@/lib/validations/editor\";\n\nexport interface ReadabilityMetrics {\n  wordCount: number;\n  sentenceCount: number;\n  wordsPerSentence: number;\n  readabilityScore: number;\n  readingTime: number;\n}\n\nexport async function fetchAiReadabilityRaw(params: {\n  content: string;\n  metrics: ReadabilityMetrics;\n  postId?: string;\n  bypassCache?: boolean;\n}): Promise<string> {\n  const { content, metrics, postId, bypassCache } = params;\n  const headers: HeadersInit = { \"Content-Type\": \"application/json\" };\n  if (bypassCache) {\n    headers[\"x-bypass-cache\"] = \"true\";\n  }\n  const response = await fetch(\"/api/ai/suggestions\", {\n    method: \"POST\",\n    headers,\n    body: JSON.stringify({ content, metrics, postId }),\n  });\n\n  if (!response.ok) {\n    throw new Error(\"Failed to fetch AI suggestions\");\n  }\n\n  return response.text();\n}\n\nexport function parseStringArrayFromText(textBody: string): string[] {\n  const tryParseArray = (s: string): string[] | null => {\n    try {\n      const parsed = JSON.parse(s);\n      return Array.isArray(parsed) ? (parsed as string[]) : null;\n    } catch {\n      return null;\n    }\n  };\n\n  const asWhole = tryParseArray(textBody);\n  if (asWhole) {\n    return asWhole;\n  }\n\n  const lines = textBody\n    .split(/\\r?\\n/)\n    .map((l) => l.trim())\n    .filter(Boolean);\n  for (const line of lines) {\n    const arr = tryParseArray(line);\n    if (arr) {\n      return arr;\n    }\n  }\n  return [];\n}\n\nexport async function fetchAiReadabilitySuggestionsStrings(params: {\n  content: string;\n  metrics: ReadabilityMetrics;\n  postId?: string;\n  bypassCache?: boolean;\n}): Promise<string[]> {\n  const text = await fetchAiReadabilityRaw({\n    content: params.content,\n    metrics: params.metrics,\n    postId: params.postId,\n    bypassCache: params.bypassCache,\n  });\n  return parseStringArrayFromText(text);\n}\n\nexport async function fetchAiReadabilitySuggestionsObject(params: {\n  content: string;\n  metrics: ReadabilityMetrics;\n  postId?: string;\n  bypassCache?: boolean;\n}): Promise<{\n  suggestions: { text: string; explanation?: string; textReference?: string }[];\n}> {\n  const text = await fetchAiReadabilityRaw({\n    content: params.content,\n    metrics: params.metrics,\n    postId: params.postId,\n    bypassCache: params.bypassCache,\n  });\n\n  let json: unknown = null;\n  try {\n    json = JSON.parse(text);\n  } catch {\n    const chunk = text\n      .split(/\\r?\\n/)\n      .map((l) => l.trim())\n      .filter(Boolean)\n      .find((l) => l.startsWith(\"{\") && l.endsWith(\"}\"));\n    if (chunk) {\n      json = JSON.parse(chunk);\n    }\n  }\n\n  const parsed = aiReadabilityResponseSchema.safeParse(json);\n  if (!parsed.success) {\n    return { suggestions: [] };\n  }\n  return parsed.data;\n}\n"
  },
  {
    "path": "apps/cms/src/lib/auth/access.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\nimport { getServerSession } from \"./session\";\n\nexport async function requireActiveWorkspaceAccess() {\n  try {\n    const sessionData = await getServerSession();\n    const workspaceId = sessionData?.session.activeOrganizationId;\n\n    if (!sessionData || !workspaceId) {\n      return {\n        ok: false,\n        response: NextResponse.json(\n          { error: \"Not authenticated\" },\n          { status: 401 }\n        ),\n      } as const;\n    }\n\n    const member = await db.member.findFirst({\n      where: {\n        organizationId: workspaceId,\n        userId: sessionData.user.id,\n      },\n      select: {\n        id: true,\n        role: true,\n        userId: true,\n        organizationId: true,\n      },\n    });\n\n    if (!member) {\n      return {\n        ok: false,\n        response: NextResponse.json(\n          { error: \"You no longer have access to this workspace\" },\n          { status: 403 }\n        ),\n      } as const;\n    }\n\n    return {\n      ok: true,\n      member,\n      sessionData,\n      workspaceId,\n    } as const;\n  } catch (error) {\n    console.error(\"Error requiring workspace access\", error);\n\n    return {\n      ok: false,\n      response: NextResponse.json(\n        { error: \"Internal server error\" },\n        { status: 500 }\n      ),\n    } as const;\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/auth/client.ts",
    "content": "import { toast } from \"@marble/ui/components/sonner\";\nimport { polarClient } from \"@polar-sh/better-auth/client\";\nimport {\n  emailOTPClient,\n  inferOrgAdditionalFields,\n  organizationClient,\n} from \"better-auth/client/plugins\";\nimport { createAuthClient } from \"better-auth/react\";\nimport type { auth } from \"./server\";\n\nexport const authClient = createAuthClient({\n  baseURL: process.env.NEXT_PUBLIC_APP_URL || \"http://localhost:3000\",\n  plugins: [\n    organizationClient({ schema: inferOrgAdditionalFields<typeof auth>() }),\n    emailOTPClient(),\n    polarClient(),\n  ],\n  fetchOptions: {\n    onError(e) {\n      if (e.error.status === 429) {\n        toast.error(\"Too many requests. Please try again later.\");\n      }\n    },\n  },\n});\n\nexport const {\n  signUp,\n  signIn,\n  signOut,\n  useSession,\n  organization,\n  useListOrganizations,\n  useActiveOrganization,\n  emailOtp,\n  checkout,\n} = authClient;\n"
  },
  {
    "path": "apps/cms/src/lib/auth/redirect.ts",
    "content": "export function safeRedirectPath(\n  value: string | null | undefined,\n  fallback = \"/\"\n) {\n  if (!value?.startsWith(\"/\") || value.startsWith(\"//\")) {\n    return fallback;\n  }\n\n  try {\n    const url = new URL(value, \"https://marble.local\");\n    if (url.origin !== \"https://marble.local\") {\n      return fallback;\n    }\n    return `${url.pathname}${url.search}${url.hash}`;\n  } catch {\n    return fallback;\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/auth/server.ts",
    "content": "import { db } from \"@marble/db\";\nimport {\n  checkout,\n  polar,\n  portal,\n  usage,\n  webhooks,\n} from \"@polar-sh/better-auth\";\nimport { Polar } from \"@polar-sh/sdk\";\nimport { prismaAdapter } from \"better-auth/adapters/prisma\";\nimport {\n  APIError,\n  createAuthMiddleware,\n  getSessionFromCtx,\n} from \"better-auth/api\";\nimport { betterAuth } from \"better-auth/minimal\";\nimport { nextCookies } from \"better-auth/next-js\";\nimport { emailOTP, organization } from \"better-auth/plugins\";\nimport { customAlphabet } from \"nanoid\";\nimport {\n  sendFounderEmailAction,\n  sendInviteEmailAction,\n  sendResetPasswordAction,\n  sendVerificationEmailAction,\n  sendWelcomeEmailAction,\n} from \"@/lib/actions/email\";\nimport { storeUserImageAction } from \"@/lib/actions/user\";\nimport { handleCustomerCreated } from \"@/lib/polar/customer.created\";\nimport { handleSubscriptionCanceled } from \"@/lib/polar/subscription.canceled\";\nimport { handleSubscriptionCreated } from \"@/lib/polar/subscription.created\";\nimport { handleSubscriptionRevoked } from \"@/lib/polar/subscription.revoked\";\nimport { handleSubscriptionUpdated } from \"@/lib/polar/subscription.updated\";\nimport { getLastActiveWorkspaceOrNewOneToSetAsActive } from \"@/lib/queries/workspace\";\nimport { guardWorkspaceSubscriptionAction } from \"../actions/checks\";\nimport {\n  createAuthor,\n  validateWorkspaceName,\n  validateWorkspaceSchema,\n  validateWorkspaceSlug,\n  validateWorkspaceTimezone,\n} from \"../actions/workspace\";\nimport { redis } from \"../redis\";\n\nconst nanoid = customAlphabet(\"abcdefghijklmnopqrstuvwxyz0123456789\", 6);\n\nconst polarClient = new Polar({\n  accessToken: process.env.POLAR_ACCESS_TOKEN,\n  server: process.env.NODE_ENV === \"production\" ? \"production\" : \"sandbox\",\n});\n\nfunction getCheckoutReferenceId(body: unknown) {\n  if (!(body && typeof body === \"object\" && \"referenceId\" in body)) {\n    return;\n  }\n\n  const { referenceId } = body as { referenceId?: unknown };\n  return typeof referenceId === \"string\" ? referenceId : undefined;\n}\n\nexport const auth = betterAuth({\n  database: prismaAdapter(db, {\n    provider: \"postgresql\",\n  }),\n  hooks: {\n    before: createAuthMiddleware(async (ctx) => {\n      if (ctx.path !== \"/checkout\") {\n        return;\n      }\n\n      const referenceId = getCheckoutReferenceId(ctx.body);\n\n      if (!referenceId) {\n        return;\n      }\n\n      const session = await getSessionFromCtx(ctx);\n\n      if (!session) {\n        throw new APIError(\"UNAUTHORIZED\", {\n          message: \"You must be logged in to checkout\",\n        });\n      }\n\n      // Polar stores referenceId as checkout metadata, so verify the client-supplied workspace before it can attach a subscription there.\n      const member = await db.member.findFirst({\n        where: {\n          organizationId: referenceId,\n          userId: session.user.id,\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      if (member?.role !== \"owner\") {\n        throw new APIError(\"FORBIDDEN\", {\n          message: \"Only workspace owners can start checkout\",\n        });\n      }\n    }),\n  },\n  experimental: {\n    joins: true,\n  },\n  secondaryStorage: {\n    get: async (key) => await redis.get(key),\n    set: async (key, value, ttl) => {\n      if (ttl) {\n        await redis.set(key, value, { ex: ttl });\n      } else {\n        await redis.set(key, value);\n      }\n    },\n    delete: async (key) => {\n      await redis.del(key);\n    },\n  },\n  session: {\n    storeSessionInDatabase: true,\n    preserveSessionInDatabase: true,\n  },\n  emailAndPassword: {\n    enabled: true,\n    sendResetPassword: async ({ user, url }, _request) => {\n      await sendResetPasswordAction({\n        userEmail: user.email,\n        resetLink: url,\n      });\n    },\n    // requireEmailVerification: true,\n    // autoSignIn: true\n    // ideally that would prevent a session being created on signup\n    // problem is after otp verification user has to login again and\n    // I don't really like the experience so we'll allow session creation\n    // but block unverified users via the middleware\n  },\n  socialProviders: {\n    google: {\n      clientId: process.env.GOOGLE_CLIENT_ID || \"\",\n      clientSecret: process.env.GOOGLE_CLIENT_SECRET || \"\",\n    },\n    github: {\n      clientId: process.env.GITHUB_ID || \"\",\n      clientSecret: process.env.GITHUB_SECRET || \"\",\n    },\n  },\n  advanced: {\n    database: {\n      generateId: false,\n    },\n  },\n  organization: {\n    modelName: \"workspace\",\n  },\n  plugins: [\n    polar({\n      client: polarClient,\n      createCustomerOnSignUp: process.env.NODE_ENV === \"production\",\n      authenticatedUsersOnly: true,\n      use: [\n        portal(),\n        usage(),\n        checkout({\n          products: [\n            {\n              productId: process.env.POLAR_HOBBY_PRODUCT_ID || \"\",\n              slug: \"hobby\",\n            },\n            {\n              productId: process.env.POLAR_PRO_PRODUCT_ID || \"\",\n              slug: \"pro\",\n            },\n            {\n              productId: process.env.POLAR_PRO_YEARLY_PRODUCT_ID || \"\",\n              slug: \"pro-yearly\",\n            },\n          ],\n          successUrl: process.env.POLAR_SUCCESS_URL || \"\",\n        }),\n        webhooks({\n          secret: process.env.POLAR_WEBHOOK_SECRET || \"\",\n          onCustomerCreated: async (payload) => {\n            await handleCustomerCreated(payload);\n          },\n          onSubscriptionCreated: async (payload) => {\n            await handleSubscriptionCreated(payload);\n          },\n          onSubscriptionUpdated: async (payload) => {\n            await handleSubscriptionUpdated(payload);\n          },\n          onSubscriptionCanceled: async (payload) => {\n            await handleSubscriptionCanceled(payload);\n          },\n          onSubscriptionRevoked: async (payload) => {\n            await handleSubscriptionRevoked(payload);\n          },\n        }),\n      ],\n    }),\n    organization({\n      // membershipLimit: 10,\n      // check plan limits and set membershipLimit\n      schema: {\n        organization: {\n          additionalFields: {\n            timezone: {\n              type: \"string\",\n              input: true,\n              required: false,\n            },\n          },\n        },\n      },\n      async sendInvitationEmail(data) {\n        const inviteLink = `${process.env.NEXT_PUBLIC_APP_URL}/join/${data.id}`;\n        await sendInviteEmailAction({\n          inviteeEmail: data.email,\n          inviterName: data.inviter.user.name,\n          inviterEmail: data.inviter.user.email,\n          workspaceName: data.organization.name,\n          inviteLink,\n        });\n      },\n      organizationHooks: {\n        afterCreateOrganization: async ({ organization, user }) => {\n          await createAuthor(user, organization);\n        },\n        afterAcceptInvitation: async ({ user, organization }) => {\n          await createAuthor(user, organization);\n        },\n        beforeCreateOrganization: async ({ organization }) => {\n          await validateWorkspaceSchema({\n            slug: organization.slug,\n            name: organization.name,\n            timezone: organization.timezone,\n          });\n        },\n        beforeUpdateOrganization: async ({ organization }) => {\n          if (organization.slug) {\n            await validateWorkspaceSlug(organization.slug);\n          }\n          if (organization.name) {\n            await validateWorkspaceName(organization.name);\n          }\n          if (organization.timezone) {\n            await validateWorkspaceTimezone(organization.timezone);\n          }\n        },\n        beforeCreateInvitation: async ({ organization }) => {\n          await guardWorkspaceSubscriptionAction(\n            organization.id,\n            \"Upgrade to Pro to invite team members\"\n          );\n        },\n        // beforeAddMember: async ({ organization }) => {\n        //   await guardWorkspaceSubscriptionAction(\n        //     organization.id,\n        //     \"Upgrade to Pro to add team members\"\n        //   );\n        // },\n      },\n    }),\n    emailOTP({\n      async sendVerificationOTP({ email, otp, type }) {\n        await sendVerificationEmailAction({\n          userEmail: email,\n          otp,\n          type,\n        });\n      },\n    }),\n    nextCookies(),\n  ],\n\n  databaseHooks: {\n    // To set active organization when a session is created\n    // This works but only when user isnt a new user i.e they already have an organization\n    // for new users the middleware redirects them to create a workspace (organization)\n    session: {\n      create: {\n        before: async (session) => {\n          try {\n            const organization =\n              await getLastActiveWorkspaceOrNewOneToSetAsActive(session.userId);\n            return {\n              data: {\n                ...session,\n                activeOrganizationId: organization?.id || null,\n              },\n            };\n          } catch (_error) {\n            // If there's an error, create the session without an active org\n            return { data: session };\n          }\n        },\n      },\n    },\n    user: {\n      create: {\n        after: async (user) => {\n          await storeUserImageAction(user);\n\n          if (user.email) {\n            try {\n              await sendWelcomeEmailAction({\n                userEmail: user.email,\n              });\n            } catch (err) {\n              console.error(\"Failed to send welcome email:\", err);\n            }\n\n            try {\n              const scheduledAt = new Date(Date.now() + 24 * 60 * 60 * 1000);\n              await sendFounderEmailAction({\n                userEmail: user.email,\n                scheduledAt,\n              });\n            } catch (err) {\n              console.error(\"Failed to schedule founder email:\", err);\n            }\n          }\n\n          const email = user.email || \"\";\n          const raw = email.split(\"@\")[0] || \"\";\n          const base = raw\n            .toLowerCase()\n            .replace(/[^a-z0-9]/g, \"\")\n            .slice(0, 20);\n\n          const slug = `${base || \"marble\"}-${nanoid()}`;\n\n          await auth.api.createOrganization({\n            body: {\n              name: \"Personal\",\n              slug,\n              timezone: \"Europe/London\",\n              userId: user.id,\n              logo: `https://api.dicebear.com/9.x/glass/svg?seed=${slug}`,\n            },\n          });\n        },\n      },\n    },\n  },\n  user: {\n    deleteUser: {\n      enabled: true,\n    },\n  },\n});\n"
  },
  {
    "path": "apps/cms/src/lib/auth/session.ts",
    "content": "import { headers } from \"next/headers\";\nimport { auth } from \"./server\";\n\ninterface GetServerSessionOptions {\n  allowUnverified?: boolean;\n}\n\nexport async function getServerSession(options: GetServerSessionOptions = {}) {\n  try {\n    const session = await auth.api.getSession({\n      headers: await headers(),\n    });\n\n    if (!(options.allowUnverified ?? false) && !session?.user.emailVerified) {\n      return null;\n    }\n\n    return session;\n  } catch (error) {\n    console.error(\"Error getting server session\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/auth/types.ts",
    "content": "import type { authClient } from \"./client\";\nimport type { auth } from \"./server\";\n\nexport type Session = typeof auth.$Infer.Session;\nexport type ActiveOrganization = typeof authClient.$Infer.ActiveOrganization;\nexport type Organization = typeof authClient.$Infer.Organization;\nexport type Invitation = typeof authClient.$Infer.Invitation;\n"
  },
  {
    "path": "apps/cms/src/lib/auth/workspace.ts",
    "content": "import { headers } from \"next/headers\";\nimport { auth } from \"@/lib/auth/server\";\nimport { authClient } from \"./client\";\n\n/**\n * Sets the active workspace on the **server** side.\n *\n * This updates the user's server session to mark the given workspace slug\n * as the currently active organization. Should only be called in\n * **server components** or server-side functions.\n *\n * @param slug - The slug of the workspace to set as active.\n */\nexport async function setActiveWorkspace(slug: string) {\n  await auth.api.setActiveOrganization({\n    headers: await headers(),\n    body: {\n      organizationSlug: slug,\n    },\n  });\n}\n\n/**\n * Sets the active workspace on the **client** side.\n *\n * This updates the active organization in the user's client-side auth context.\n * Typically called from client components (e.g. workspace switchers)\n * after a user changes their active workspace in the UI.\n *\n * @param slug - The slug of the workspace to set as active.\n */\nexport async function setClientActiveWorkspace(slug: string) {\n  await authClient.organization.setActive({\n    organizationSlug: slug,\n  });\n}\n"
  },
  {
    "path": "apps/cms/src/lib/blurhash.ts",
    "content": "import { decode } from \"blurhash\";\n\nconst cache = new Map<string, string>();\nconst MAX_CACHE_ENTRIES = 200;\n\nconst DEFAULT_WIDTH = 32;\nconst DEFAULT_HEIGHT = 32;\n\nexport function blurhashToDataUrl(\n  blurHash: string,\n  width = DEFAULT_WIDTH,\n  height = DEFAULT_HEIGHT\n) {\n  if (typeof document === \"undefined\") {\n    return undefined;\n  }\n\n  const trimmedBlurHash = blurHash.trim();\n  const cacheKey = `${trimmedBlurHash}:${width}x${height}`;\n  const cachedDataUrl = cache.get(cacheKey);\n\n  if (cachedDataUrl) {\n    cache.delete(cacheKey);\n    cache.set(cacheKey, cachedDataUrl);\n    return cachedDataUrl;\n  }\n\n  try {\n    const pixels = decode(trimmedBlurHash, width, height);\n    const canvas = document.createElement(\"canvas\");\n    const context = canvas.getContext(\"2d\");\n\n    if (!context) {\n      return undefined;\n    }\n\n    canvas.width = width;\n    canvas.height = height;\n\n    const imageData = context.createImageData(width, height);\n    imageData.data.set(pixels);\n    context.putImageData(imageData, 0, 0);\n\n    const dataUrl = canvas.toDataURL(\"image/png\");\n    if (cache.size >= MAX_CACHE_ENTRIES) {\n      const oldestKey = cache.keys().next().value;\n      if (oldestKey) {\n        cache.delete(oldestKey);\n      }\n    }\n    cache.set(cacheKey, dataUrl);\n    return dataUrl;\n  } catch {\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/cache/invalidate.ts",
    "content": "/**\n * Cache invalidation utility for CMS\n * Calls the internal API endpoint to invalidate cache when content changes\n * Only runs in production environment\n */\n\ntype CacheResource = \"posts\" | \"categories\" | \"tags\" | \"authors\" | \"usage\";\n\n/**\n * Invalidate cache for a workspace and optionally a specific resource\n * Fire-and-forget: doesn't await or block the response\n */\nexport function invalidateCache(\n  workspaceId: string,\n  resource?: CacheResource\n): void {\n  if (process.env.NODE_ENV !== \"production\") {\n    return;\n  }\n\n  const apiUrl = process.env.MARBLE_API_URL;\n  const systemSecret = process.env.SYSTEM_SECRET;\n\n  if (!apiUrl || !systemSecret) {\n    console.warn(\n      \"[CacheInvalidation] Missing API_URL or SYSTEM_SECRET, skipping cache invalidation\"\n    );\n    return;\n  }\n\n  fetch(`${apiUrl}/cache/invalidate`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"X-System-Secret\": systemSecret,\n    },\n    body: JSON.stringify({\n      workspaceId,\n      ...(resource && { resource }),\n    }),\n  }).catch((error) => {\n    console.error(\"[CacheInvalidation] Failed to invalidate cache:\", error);\n  });\n}\n"
  },
  {
    "path": "apps/cms/src/lib/cache.ts",
    "content": "import { redis } from \"./redis\";\n\nconst CACHE_PREFIX = \"cms:cache\";\nconst DEFAULT_TTL = 300; // 5 minutes\n\n/**\n * Cache utilities for resource pages\n */\nexport const cache = {\n  /**\n   * Get a cached value by key\n   */\n  async get<T>(key: string): Promise<T | null> {\n    try {\n      return await redis.get<T>(key);\n    } catch (error) {\n      console.error(`[Cache] GET error for ${key}:`, error);\n      return null;\n    }\n  },\n\n  /**\n   * Set a cached value with optional TTL\n   */\n  async set<T>(key: string, value: T, ttl = DEFAULT_TTL): Promise<void> {\n    try {\n      await redis.set(key, value, { ex: ttl });\n    } catch (error) {\n      console.error(`[Cache] SET error for ${key}:`, error);\n    }\n  },\n\n  /**\n   * Cache-aside pattern: get from cache or fetch and cache\n   */\n  async getOrSet<T>(\n    key: string,\n    fetcher: () => Promise<T>,\n    ttl = DEFAULT_TTL\n  ): Promise<T> {\n    try {\n      const cached = await redis.get<T>(key);\n      if (cached !== null) {\n        return cached;\n      }\n\n      const fresh = await fetcher();\n      await redis.set(key, fresh, { ex: ttl });\n      return fresh;\n    } catch (error) {\n      console.error(`[Cache] getOrSet error for ${key}:`, error);\n      return fetcher();\n    }\n  },\n\n  /**\n   * Invalidate cache keys matching a pattern using SCAN + DEL\n   */\n  async invalidatePattern(pattern: string): Promise<number> {\n    try {\n      let cursor: string | number = \"0\";\n      const allKeys: string[] = [];\n      const batchSize = 100;\n\n      // Use SCAN to iterate through keys matching the pattern\n      do {\n        const result: [string, string[]] = await redis.scan(cursor, {\n          match: pattern,\n          count: batchSize,\n        });\n        cursor = result[0];\n        const keys = result[1];\n        if (Array.isArray(keys)) {\n          allKeys.push(...keys);\n        }\n      } while (String(cursor) !== \"0\");\n\n      // Delete keys in batches\n      let deletedCount = 0;\n      for (let i = 0; i < allKeys.length; i += batchSize) {\n        const batch = allKeys.slice(i, i + batchSize);\n        if (batch.length > 0) {\n          const deleted = await redis.del(...batch);\n          deletedCount += deleted;\n        }\n      }\n\n      if (deletedCount > 0) {\n        console.log(`[Cache] INVALIDATE: ${pattern} (${deletedCount} keys)`);\n      }\n      return deletedCount;\n    } catch (error) {\n      console.error(`[Cache] INVALIDATE error for ${pattern}:`, error);\n      return 0;\n    }\n  },\n\n  /**\n   * Invalidate all media cache for a workspace\n   */\n  async invalidateMedia(workspaceId: string): Promise<number> {\n    return this.invalidatePattern(`${CACHE_PREFIX}:media:${workspaceId}:*`);\n  },\n\n  /**\n   * Generate a cache key from parts\n   */\n  key(...parts: string[]): string {\n    return [CACHE_PREFIX, ...parts].join(\":\");\n  },\n};\n"
  },
  {
    "path": "apps/cms/src/lib/constants.ts",
    "content": "export const VALID_DISCORD_DOMAINS = [\n  \"discord.com\",\n  \"canary.discord.com\",\n  \"ptb.discord.com\",\n];\n\nexport const VALID_SLACK_DOMAINS = [\"hooks.slack.com\"];\n\nexport const timezones = Intl.supportedValuesOf(\"timeZone\");\n\nexport const IMAGE_DROPZONE_ACCEPT = [\n  \".jpeg\",\n  \".jpg\",\n  \".png\",\n  \".gif\",\n  \".webp\",\n  \".avif\",\n];\n\nexport const MEDIA_DROPZONE_ACCEPT = {\n  \"image/*\": [\".jpeg\", \".jpg\", \".png\", \".gif\", \".webp\", \".avif\", \".svg\"],\n  \"video/*\": [\n    \".mp4\",\n    \".mov\",\n    \".qt\",\n    \".avi\",\n    \".wmv\",\n    \".flv\",\n    \".mpeg\",\n    \".mpg\",\n    \".webm\",\n    \"\",\n  ],\n};\n\nexport const ALLOWED_RASTER_MIME_TYPES = [\n  \"image/jpeg\",\n  \"image/png\",\n  \"image/gif\",\n  \"image/webp\",\n  \"image/avif\",\n] as const;\n\nexport const ALLOWED_VIDEO_MIME_TYPES = [\n  \"video/mp4\",\n  \"video/webm\",\n  \"video/ogg\",\n  \"video/quicktime\",\n] as const;\n\nexport const ALLOWED_MIME_TYPES = [\n  ...ALLOWED_RASTER_MIME_TYPES,\n  \"image/svg+xml\",\n  ...ALLOWED_VIDEO_MIME_TYPES,\n] as const;\n\nexport type AllowedRasterMimeType = (typeof ALLOWED_RASTER_MIME_TYPES)[number];\nexport type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];\n\nexport const MAX_AVATAR_FILE_SIZE = 5 * 1024 * 1024;\nexport const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024;\nexport const MAX_MEDIA_FILE_SIZE = 250 * 1024 * 1024;\n\nexport const WORKSPACE_SCOPED_PREFIXES = [\n  \"ai-readability-suggestions\",\n  \"authors\",\n  \"billing-usage\",\n  \"categories\",\n  \"posts\",\n  \"publishing-metrics\",\n  \"tags\",\n  \"team\",\n  \"usage-dashboard\",\n  \"media\",\n  \"webhooks\",\n  \"keys\",\n] as const;\n\nexport type WorkspaceScopedPrefix = (typeof WORKSPACE_SCOPED_PREFIXES)[number];\n\nexport const ALLOWED_AVATAR_HOSTS = [\n  \"avatars.githubusercontent.com\",\n  \"googleusercontent.com\",\n] as const;\n\n/**\n * Validates if a URL is from an allowed avatar host with HTTPS protocol\n */\nexport function isAllowedAvatarUrl(url: string): boolean {\n  try {\n    const parsedUrl = new URL(url);\n\n    // Enforce HTTPS protocol\n    if (parsedUrl.protocol !== \"https:\") {\n      return false;\n    }\n\n    const hostname = parsedUrl.hostname;\n\n    // Check if hostname matches exactly or is a subdomain of allowed hosts\n    return ALLOWED_AVATAR_HOSTS.some(\n      (allowedHost) =>\n        hostname === allowedHost || hostname.endsWith(`.${allowedHost}`)\n    );\n  } catch {\n    // Invalid URL\n    return false;\n  }\n}\n\nexport const SOCIAL_PLATFORMS = {\n  x: \"x\",\n  github: \"github\",\n  facebook: \"facebook\",\n  instagram: \"instagram\",\n  youtube: \"youtube\",\n  tiktok: \"tiktok\",\n  linkedin: \"linkedin\",\n  website: \"website\",\n  onlyfans: \"onlyfans\",\n  discord: \"discord\",\n  bluesky: \"bluesky\",\n} as const;\n\nexport type SocialPlatform = keyof typeof SOCIAL_PLATFORMS;\n\nexport const PLATFORM_DOMAINS = {\n  x: [\"twitter.com\", \"x.com\"],\n  github: [\"github.com\"],\n  facebook: [\"facebook.com\", \"fb.com\"],\n  instagram: [\"instagram.com\"],\n  youtube: [\"youtube.com\", \"youtu.be\"],\n  tiktok: [\"tiktok.com\"],\n  linkedin: [\"linkedin.com\"],\n  onlyfans: [\"onlyfans.com\"],\n  discord: [\"discord.com\"],\n  bluesky: [\"bsky.app\"],\n} as const;\n\nexport const MEDIA_SORT_BY = [\"createdAt\", \"name\"] as const;\nexport const SORT_DIRECTIONS = [\"asc\", \"desc\"] as const;\n\nexport const MEDIA_SORTS = MEDIA_SORT_BY.flatMap((field) =>\n  SORT_DIRECTIONS.map((direction) => `${field}_${direction}` as const)\n);\n\nexport const MEDIA_TYPES = [\"image\", \"video\", \"audio\", \"document\"] as const;\n\nexport const MEDIA_FILTER_TYPES = [\"all\", ...MEDIA_TYPES] as const;\n\nexport const MEDIA_LIMIT = 20;\nexport const POST_LIMIT = 20;\n\n/**\n * Reserved workspace slugs that cannot be used for workspace creation\n * to prevent conflicts with system routes and Next.js internals\n */\nexport const RESERVED_WORKSPACE_SLUGS = [\n  // Auth routes\n  \"login\",\n  \"register\",\n  \"reset\",\n  \"verify\",\n  \"join\",\n  \"invite\",\n  \"auth\",\n  // System routes\n  \"api\",\n  \"new\",\n  \"share\",\n  \"settings\",\n  // API routes\n  \"account\",\n  \"accounts\",\n  \"ai\",\n  \"billing\",\n  \"complete\",\n  \"import\",\n  \"metrics\",\n  \"polar\",\n  \"preferences\",\n  \"publishing\",\n  \"suggestions\",\n  \"upload\",\n  \"usage\",\n  \"user\",\n  \"workspace\",\n  \"workspaces\",\n  \"success\",\n  // Workspace-level pages (dashboard routes)\n  \"posts\",\n  \"post\",\n  \"categories\",\n  \"category\",\n  \"tags\",\n  \"tag\",\n  \"authors\",\n  \"author\",\n  \"media\",\n  \"webhooks\",\n  \"webhook\",\n  \"hooks\",\n  \"hook\",\n  \"keys\",\n  \"key\",\n  \"editor\",\n  // Next.js internals\n  \"_next\",\n  \"static\",\n  \"favicon\",\n  \"robots\",\n  \"sitemap\",\n  // Future-proofing common patterns\n  \"admin\",\n  \"dashboard\",\n  \"app\",\n  \"www\",\n  \"blog\",\n  \"docs\",\n  \"help\",\n  \"support\",\n  \"about\",\n  \"contact\",\n  \"pricing\",\n  \"terms\",\n  \"privacy\",\n] as const;\n"
  },
  {
    "path": "apps/cms/src/lib/custom-fields.ts",
    "content": "import { z } from \"zod\";\nimport type { FieldType } from \"@/lib/validations/fields\";\n\nexport const customFieldsPayloadSchema = z.record(\n  z.string(),\n  z.union([z.string(), z.null()])\n);\n\nexport type CustomFieldPayload = z.infer<typeof customFieldsPayloadSchema>;\n\nexport interface CustomFieldValidationDefinition {\n  id: string;\n  key: string;\n  name: string;\n  type: FieldType;\n  required: boolean;\n  options?: Array<{\n    value: string;\n    label: string;\n  }>;\n}\n\nexport const SUPPORTED_CUSTOM_FIELD_TYPES = new Set<FieldType>([\n  \"text\",\n  \"number\",\n  \"boolean\",\n  \"date\",\n  \"richtext\",\n  \"select\",\n  \"multiselect\",\n]);\n\nfunction normalizeMultiselectValue(\n  rawValue: string,\n  options: Array<{ value: string; label: string }>\n): { success: true; value: string } | { success: false; message: string } {\n  let parsedValue: unknown;\n\n  try {\n    parsedValue = JSON.parse(rawValue);\n  } catch {\n    return {\n      success: false,\n      message: \"Multiselect fields must be a JSON array of option values\",\n    };\n  }\n\n  const result = z.array(z.string()).safeParse(parsedValue);\n\n  if (!result.success) {\n    return {\n      success: false,\n      message: \"Multiselect fields must be a JSON array of option values\",\n    };\n  }\n\n  const allowedValues = new Set(options.map((option) => option.value));\n  const uniqueValues: string[] = [];\n\n  for (const selectedValue of result.data) {\n    if (!allowedValues.has(selectedValue)) {\n      return {\n        success: false,\n        message: \"Selected values must match the configured options\",\n      };\n    }\n\n    if (!uniqueValues.includes(selectedValue)) {\n      uniqueValues.push(selectedValue);\n    }\n  }\n\n  return {\n    success: true,\n    value: JSON.stringify(uniqueValues),\n  };\n}\n\nexport function isRichTextContentEmpty(content: string) {\n  const plainText = content\n    .replace(/<br\\s*\\/?>/gi, \" \")\n    .replace(/<\\/?(p|div|li|ul|ol)>/gi, \" \")\n    .replace(/<[^>]+>/g, \" \")\n    .replace(/&nbsp;/gi, \" \")\n    .replace(/&#160;/gi, \" \")\n    .replace(/\\s+/g, \" \")\n    .trim();\n\n  return plainText.length === 0;\n}\n\nfunction isMultiselectValueEmpty(rawValue: string) {\n  let parsedValue: unknown;\n\n  try {\n    parsedValue = JSON.parse(rawValue);\n  } catch {\n    return false;\n  }\n\n  const result = z.array(z.string()).safeParse(parsedValue);\n\n  return result.success && result.data.length === 0;\n}\n\nconst fieldValueSchemas = {\n  text: z.string(),\n  number: z.coerce.number(),\n  boolean: z\n    .string()\n    .trim()\n    .toLowerCase()\n    .refine((value) => value === \"true\" || value === \"false\", {\n      message: \"Boolean fields must be true or false\",\n    })\n    .transform((value) => value === \"true\"),\n  date: z.iso.datetime({ offset: true }).or(z.iso.date()),\n  richtext: z.string(),\n  select: z.string(),\n  multiselect: z.string(),\n} as const satisfies Record<FieldType, z.ZodType>;\n\nexport function normalizeCustomFieldValue(\n  field: CustomFieldValidationDefinition,\n  value: string\n): { success: true; value: string } | { success: false; message: string } {\n  if (field.type === \"select\") {\n    const allowedValues = new Set(\n      (field.options ?? []).map((option) => option.value)\n    );\n\n    if (allowedValues.size === 0) {\n      return {\n        success: false,\n        message: \"Select fields must define at least one option\",\n      };\n    }\n\n    if (!allowedValues.has(value)) {\n      return {\n        success: false,\n        message: \"Selected value must match a configured option\",\n      };\n    }\n\n    return { success: true, value };\n  }\n\n  if (field.type === \"multiselect\") {\n    return normalizeMultiselectValue(value, field.options ?? []);\n  }\n\n  const result = fieldValueSchemas[field.type].safeParse(value);\n\n  if (!result.success) {\n    return {\n      success: false,\n      message: result.error.issues[0]?.message ?? \"Invalid field value\",\n    };\n  }\n\n  if (field.type === \"number\") {\n    return { success: true, value: String(result.data) };\n  }\n\n  if (field.type === \"boolean\") {\n    return { success: true, value: result.data ? \"true\" : \"false\" };\n  }\n\n  return { success: true, value: String(result.data).trim() };\n}\n\nexport function validateCustomFieldValue(\n  field: CustomFieldValidationDefinition,\n  rawValue: string | null | undefined\n):\n  | { success: true; value: string | null }\n  | { success: false; message: string } {\n  if (rawValue == null) {\n    return field.required\n      ? { success: false, message: `${field.name} is required` }\n      : { success: true, value: null };\n  }\n\n  const trimmedValue = rawValue.trim();\n\n  if (\n    trimmedValue === \"\" ||\n    (field.type === \"multiselect\" && isMultiselectValueEmpty(trimmedValue)) ||\n    (field.type === \"richtext\" && isRichTextContentEmpty(trimmedValue))\n  ) {\n    return field.required\n      ? { success: false, message: `${field.name} is required` }\n      : { success: true, value: null };\n  }\n\n  const normalized = normalizeCustomFieldValue(field, trimmedValue);\n\n  if (!normalized.success) {\n    return normalized;\n  }\n\n  return { success: true, value: normalized.value };\n}\n\nexport function resolveCustomFieldValues(\n  fields: CustomFieldValidationDefinition[],\n  input: Record<string, string | null | undefined>\n):\n  | {\n      success: true;\n      values: Array<{\n        fieldId: string;\n        fieldType: FieldType;\n        value: string | null;\n      }>;\n    }\n  | { success: false; error: Record<string, unknown> } {\n  const fieldsById = new Map(fields.map((field) => [field.id, field]));\n  const fieldIds = Object.keys(input);\n  const invalidIds = fieldIds.filter((fieldId) => !fieldsById.has(fieldId));\n\n  if (invalidIds.length > 0) {\n    return {\n      success: false,\n      error: {\n        error: \"Invalid field IDs\",\n        invalidIds,\n      },\n    };\n  }\n\n  const values: Array<{\n    fieldId: string;\n    fieldType: FieldType;\n    value: string | null;\n  }> = [];\n\n  for (const field of fields) {\n    const validation = validateCustomFieldValue(field, input[field.id]);\n\n    if (!validation.success) {\n      return {\n        success: false,\n        error: {\n          error: \"Invalid field value\",\n          fieldId: field.id,\n          key: field.key,\n          name: field.name,\n          type: field.type,\n          message: validation.message,\n        },\n      };\n    }\n\n    values.push({\n      fieldId: field.id,\n      fieldType: field.type,\n      value: validation.value,\n    });\n  }\n\n  return { success: true, values };\n}\n"
  },
  {
    "path": "apps/cms/src/lib/data/post.ts",
    "content": "import type { PostValues } from \"../validations/post\";\n\nfunction todayUTCMidnight() {\n  const now = new Date();\n  return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));\n}\n\nexport const emptyPost: PostValues = {\n  title: \"\",\n  slug: \"\",\n  status: \"draft\" as const,\n  content: \"\",\n  contentJson: JSON.stringify({ type: \"doc\", content: [] }),\n  coverImage: null,\n  description: \"\",\n  publishedAt: todayUTCMidnight(),\n  tags: [],\n  category: \"\",\n  authors: [],\n};\n"
  },
  {
    "path": "apps/cms/src/lib/events/dispatch.ts",
    "content": "import type {\n  EventPayload,\n  WorkspaceEventActorType,\n  WorkspaceEventResourceType,\n  WorkspaceEventType,\n} from \"@marble/events\";\n\ninterface EmitDashboardEventArgs {\n  type: WorkspaceEventType;\n  workspaceId: string;\n  resourceType: WorkspaceEventResourceType;\n  resourceId: string;\n  actorType?: WorkspaceEventActorType;\n  actorId?: string;\n  payload?: EventPayload;\n}\n\nexport function logDashboardEventError(error: unknown) {\n  console.error(\"[DashboardEvents] Failed to emit dashboard event:\", error);\n}\n\nexport async function emitDashboardEvent({\n  type,\n  workspaceId,\n  resourceType,\n  resourceId,\n  actorType = \"user\",\n  actorId,\n  payload = {},\n}: EmitDashboardEventArgs) {\n  const apiUrl = process.env.MARBLE_API_URL;\n  const systemSecret = process.env.SYSTEM_SECRET;\n\n  if (!apiUrl || !systemSecret) {\n    console.warn(\n      \"[DashboardEvents] Missing MARBLE_API_URL or SYSTEM_SECRET, skipping event emission\"\n    );\n    return;\n  }\n\n  const response = await fetch(`${apiUrl}/internal/events`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"X-System-Secret\": systemSecret,\n    },\n    body: JSON.stringify({\n      type,\n      workspaceId,\n      source: \"dashboard\",\n      resourceType,\n      resourceId,\n      actorType,\n      ...(actorId && { actorId }),\n      payload,\n    }),\n  });\n\n  if (!response.ok) {\n    throw new Error(\n      `Failed to emit ${type}: ${response.status} ${response.statusText}`\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/media/upload.ts",
    "content": "import axios from \"axios\";\nimport { encode } from \"blurhash\";\nimport type { PresignedUrlResponse, UploadType } from \"@/types/media\";\nimport { generateSlug } from \"@/utils/string\";\n\ninterface UploadMetadata {\n  mimeType?: string;\n  width?: number;\n  height?: number;\n  duration?: number;\n  blurHash?: string;\n}\n\nconst BLURHASH_RASTER_TYPES = new Set([\n  \"image/jpeg\",\n  \"image/png\",\n  \"image/gif\",\n  \"image/webp\",\n  \"image/avif\",\n]);\n\n/**\n * Requests a short-lived R2 PUT URL and storage key for the selected upload.\n */\nasync function getPresignedUrl(\n  file: File,\n  type: UploadType\n): Promise<PresignedUrlResponse> {\n  const response = await axios.post<PresignedUrlResponse>(\"/api/upload\", {\n    type,\n    fileType: file.type,\n    fileSize: file.size,\n  });\n\n  if (response.status !== 200) {\n    throw new Error(\"Failed to get presigned URL.\");\n  }\n\n  return response.data;\n}\n\n/**\n * Uploads the raw file bytes directly to R2 through the presigned URL.\n */\nasync function uploadToR2(presignedUrl: string, file: File) {\n  const response = await axios.put(presignedUrl, file, {\n    headers: {\n      \"Content-Type\": file.type,\n    },\n  });\n\n  if (response.status !== 200) {\n    throw new Error(response.data.error);\n  }\n}\n\n/**\n * Finalizes an upload by creating the Marble-side record with file metadata.\n */\nasync function completeUpload(\n  key: string,\n  file: File,\n  type: UploadType,\n  metadata: UploadMetadata\n) {\n  const filenameParts = file.name.split(\".\");\n  const extension = filenameParts.pop() || \"\";\n  const baseName = filenameParts.join(\".\");\n\n  // UX check to potentially avoid wasted uploads from overly long filenames.\n  // The server will still validate this, but truncating here is a bit better.\n  // It avoids us having orphaned files in the storage without a corresponding database record.\n  // Max length is 240 leaving space for extension and dot.\n  const maxBaseNameLength = 240;\n  const truncatedBaseName =\n    baseName.length > maxBaseNameLength\n      ? baseName.substring(0, maxBaseNameLength)\n      : baseName;\n\n  const sluggedName = generateSlug(truncatedBaseName);\n  const mediaName = `${sluggedName}.${extension}`;\n\n  const response = await axios.post(\"/api/upload/complete\", {\n    type,\n    key,\n    fileType: file.type,\n    fileSize: file.size,\n    name: mediaName,\n    ...metadata,\n  });\n\n  if (response.status !== 200) {\n    throw new Error(response.data.error);\n  }\n\n  return response.data;\n}\n\n/**\n * Loads a local image file into an HTMLImageElement so browser metadata and\n * pixels can be read before the file is uploaded.\n */\nfunction loadImage(file: File): Promise<HTMLImageElement> {\n  return new Promise((resolve, reject) => {\n    const image = new Image();\n    const objectUrl = URL.createObjectURL(file);\n\n    image.onload = () => {\n      URL.revokeObjectURL(objectUrl);\n      resolve(image);\n    };\n    image.onerror = () => {\n      URL.revokeObjectURL(objectUrl);\n      reject(new Error(\"Failed to read image metadata.\"));\n    };\n    image.src = objectUrl;\n  });\n}\n\n/**\n * Encodes a compact BlurHash placeholder from a downsized image preview.\n */\nfunction encodeBlurHash(image: HTMLImageElement) {\n  const width = Math.max(1, Math.round(image.naturalWidth));\n  const height = Math.max(1, Math.round(image.naturalHeight));\n  const maxDimension = 32;\n  const scale = Math.min(1, maxDimension / Math.max(width, height));\n  const canvasWidth = Math.max(1, Math.round(width * scale));\n  const canvasHeight = Math.max(1, Math.round(height * scale));\n  const canvas = document.createElement(\"canvas\");\n  const context = canvas.getContext(\"2d\");\n\n  if (!context) {\n    return undefined;\n  }\n\n  canvas.width = canvasWidth;\n  canvas.height = canvasHeight;\n  context.drawImage(image, 0, 0, canvasWidth, canvasHeight);\n\n  const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight);\n  return encode(imageData.data, canvasWidth, canvasHeight, 4, 3);\n}\n\n/**\n * Extracts raster image dimensions and, when supported, a BlurHash placeholder.\n */\nasync function getImageMetadata(file: File): Promise<UploadMetadata> {\n  const image = await loadImage(file);\n  const metadata: UploadMetadata = {\n    mimeType: file.type,\n    width: image.naturalWidth || undefined,\n    height: image.naturalHeight || undefined,\n  };\n\n  if (BLURHASH_RASTER_TYPES.has(file.type)) {\n    metadata.blurHash = encodeBlurHash(image);\n  }\n\n  return metadata;\n}\n\n/**\n * Extracts video dimensions and duration from browser media metadata.\n */\nfunction getVideoMetadata(file: File): Promise<UploadMetadata> {\n  return new Promise((resolve, reject) => {\n    const video = document.createElement(\"video\");\n    const objectUrl = URL.createObjectURL(file);\n\n    video.preload = \"metadata\";\n    video.onloadedmetadata = () => {\n      URL.revokeObjectURL(objectUrl);\n      resolve({\n        mimeType: file.type,\n        width: video.videoWidth || undefined,\n        height: video.videoHeight || undefined,\n        // Browser media duration is seconds; Marble stores media duration in milliseconds.\n        duration: Number.isFinite(video.duration)\n          ? Math.round(video.duration * 1000)\n          : undefined,\n      });\n    };\n    video.onerror = () => {\n      URL.revokeObjectURL(objectUrl);\n      reject(new Error(\"Failed to read video metadata.\"));\n    };\n    video.src = objectUrl;\n  });\n}\n\n/**\n * Builds the metadata payload sent to `/api/upload/complete`.\n */\nasync function getUploadMetadata(file: File): Promise<UploadMetadata> {\n  try {\n    if (file.type.startsWith(\"image/\")) {\n      return await getImageMetadata(file);\n    }\n    if (file.type.startsWith(\"video/\")) {\n      return await getVideoMetadata(file);\n    }\n  } catch (error) {\n    console.warn(\"Failed to extract upload metadata:\", error);\n  }\n\n  return { mimeType: file.type || undefined };\n}\n\n/**\n * Runs the dashboard upload flow: presign, extract metadata, upload to R2, and\n * create the Marble media record.\n */\nexport async function uploadFile({\n  file,\n  type,\n}: {\n  file: File;\n  type: UploadType;\n}) {\n  try {\n    const { url: presignedUrl, key } = await getPresignedUrl(file, type);\n    const metadata = await getUploadMetadata(file);\n    await uploadToR2(presignedUrl, file);\n    const result = await completeUpload(key, file, type, metadata);\n    return result;\n  } catch (error) {\n    console.error(\"Upload failed:\", error);\n    if (axios.isAxiosError(error) && error.response?.data?.error) {\n      throw new Error(error.response.data.error);\n    }\n    throw new Error(\"An unexpected error occurred during upload.\");\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/notifications.ts",
    "content": "export interface NotificationPreferences {\n  user: {\n    marketing: boolean;\n    product: boolean;\n  };\n  workspace: {\n    usageAlerts: boolean;\n    subscriptions: boolean;\n  };\n}\n\nexport interface NotificationToggleItem {\n  key: string;\n  scope: \"user\" | \"workspace\";\n  label: string;\n  description: string;\n}\n\nexport const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {\n  user: {\n    marketing: false,\n    product: true,\n  },\n  workspace: {\n    usageAlerts: true,\n    subscriptions: true,\n  },\n};\n\nexport const USER_NOTIFICATION_ITEMS: NotificationToggleItem[] = [\n  {\n    key: \"product\",\n    scope: \"user\",\n    label: \"Product updates\",\n    description:\n      \"New features, tips, and changelog digests to help you get the most out of Marble.\",\n  },\n  {\n    key: \"marketing\",\n    scope: \"user\",\n    label: \"Marketing\",\n    description:\n      \"Promotional offers, newsletters, and partner content. You can unsubscribe at any time.\",\n  },\n];\n\nexport const WORKSPACE_NOTIFICATION_ITEMS: NotificationToggleItem[] = [\n  {\n    key: \"usageAlerts\",\n    scope: \"workspace\",\n    label: \"Usage alerts\",\n    description:\n      \"Warnings when your workspace approaches or exceeds plan limits.\",\n  },\n  {\n    key: \"subscriptions\",\n    scope: \"workspace\",\n    label: \"Subscriptions\",\n    description:\n      \"Trial reminders, renewal confirmations, and plan change summaries.\",\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/lib/plans.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: Compatibility shim while plan helpers move to @marble/utils. */\nexport {\n  canInviteMoreMembers,\n  canPerformAction,\n  getPlanLimits,\n  getRemainingMemberSlots,\n  getWorkspacePlan,\n  isOverLimit,\n  isSubscriptionActive,\n  PLAN_LIMITS,\n  type PlanLimits,\n  type PlanType,\n} from \"@marble/utils\";\n"
  },
  {
    "path": "apps/cms/src/lib/polar/client.ts",
    "content": "import { Polar } from \"@polar-sh/sdk\";\n\nlet cachedPolarClient: Polar | null = null;\n\nexport function createPolarClient(): Polar | null {\n  if (!process.env.POLAR_ACCESS_TOKEN) {\n    return null;\n  }\n\n  if (!cachedPolarClient) {\n    cachedPolarClient = new Polar({\n      accessToken: process.env.POLAR_ACCESS_TOKEN,\n      server: process.env.NODE_ENV === \"production\" ? \"production\" : \"sandbox\",\n    });\n  }\n\n  return cachedPolarClient;\n}\n"
  },
  {
    "path": "apps/cms/src/lib/polar/customer.created.ts",
    "content": "\"use server\";\n\nimport type { WebhookCustomerCreatedPayload } from \"@polar-sh/sdk/models/components/webhookcustomercreatedpayload.js\";\n\nexport async function handleCustomerCreated(\n  payload: WebhookCustomerCreatedPayload\n) {\n  const { data: customer } = payload;\n  try {\n    console.log(\"Customer Created\", customer);\n  } catch (error) {\n    console.error(\"Error processing customer creation:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/polar/subscription.canceled.ts",
    "content": "\"use server\";\n\nimport { db } from \"@marble/db\";\nimport { SubscriptionStatus } from \"@marble/db/browser\";\nimport type { WebhookSubscriptionCanceledPayload } from \"@polar-sh/sdk/models/components/webhooksubscriptioncanceledpayload.js\";\nimport { isStalePolarEvent } from \"./utils\";\n\nexport async function handleSubscriptionCanceled(\n  payload: WebhookSubscriptionCanceledPayload\n) {\n  const { data: subscription } = payload;\n\n  const existingSubscription = await db.subscription.findUnique({\n    where: { polarId: subscription.id },\n  });\n\n  if (!existingSubscription) {\n    console.error(\n      `subscription.canceled webhook received for a subscription that does not exist: ${subscription.id}`\n    );\n    return;\n  }\n\n  if (\n    isStalePolarEvent(existingSubscription.lastPolarEventAt, payload.timestamp)\n  ) {\n    console.log(\n      `Ignoring stale subscription.canceled webhook for subscription ${subscription.id}`\n    );\n    return;\n  }\n\n  try {\n    const result = await db.subscription.updateMany({\n      where: {\n        polarId: subscription.id,\n        OR: [\n          { lastPolarEventAt: null },\n          { lastPolarEventAt: { lte: payload.timestamp } },\n        ],\n      },\n      data: {\n        status: SubscriptionStatus.canceled,\n        cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,\n        canceledAt: subscription.canceledAt\n          ? new Date(subscription.canceledAt)\n          : new Date(),\n        endsAt: subscription.endsAt ? new Date(subscription.endsAt) : null,\n        lastPolarEventAt: payload.timestamp,\n      },\n    });\n\n    if (result.count === 0) {\n      console.log(\n        `Ignoring stale subscription.canceled webhook for subscription ${subscription.id}`\n      );\n      return;\n    }\n\n    console.log(\n      `Successfully marked subscription ${subscription.id} as canceled for workspace ${existingSubscription.workspaceId}`\n    );\n  } catch (error) {\n    console.error(\"Error updating subscription to canceled in DB:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/polar/subscription.created.ts",
    "content": "\"use server\";\n\nimport { db } from \"@marble/db\";\nimport type { WebhookSubscriptionCreatedPayload } from \"@polar-sh/sdk/models/components/webhooksubscriptioncreatedpayload.js\";\nimport {\n  getPlanType,\n  getRecurringInterval,\n  getSubscriptionStatus,\n} from \"./utils\";\n\nexport async function handleSubscriptionCreated(\n  payload: WebhookSubscriptionCreatedPayload\n) {\n  const { data: subscription } = payload;\n  const workspaceId = subscription.metadata?.referenceId;\n  const userId = subscription.customer.externalId;\n\n  if (typeof workspaceId !== \"string\") {\n    console.error(\n      \"subscription.created webhook received without a string workspaceId in metadata.referenceId\"\n    );\n    return;\n  }\n\n  if (typeof userId !== \"string\") {\n    console.error(\n      \"subscription.created webhook received without a string userId in customer.externalId\"\n    );\n    return;\n  }\n\n  if (!subscription.currentPeriodStart) {\n    console.error(\n      \"subscription.created webhook received without a currentPeriodStart\"\n    );\n    return;\n  }\n\n  if (!subscription.currentPeriodEnd) {\n    console.error(\n      \"subscription.created webhook received without a currentPeriodEnd\"\n    );\n    return;\n  }\n\n  // Store validated dates to satisfy TypeScript\n  const currentPeriodStart = subscription.currentPeriodStart;\n  const currentPeriodEnd = subscription.currentPeriodEnd;\n\n  const userExists = await db.user.findUnique({ where: { id: userId } });\n  if (!userExists) {\n    console.error(`User with id ${userId} not found.`);\n    return;\n  }\n\n  const workspaceExists = await db.organization.findUnique({\n    where: { id: workspaceId },\n  });\n  if (!workspaceExists) {\n    console.error(`Workspace with id ${workspaceId} not found.`);\n    return;\n  }\n\n  const plan = getPlanType(subscription.product.name);\n  if (!plan) {\n    console.error(`Unknown plan: ${subscription.product.name}`);\n    return;\n  }\n\n  const status = getSubscriptionStatus(subscription.status);\n  if (!status) {\n    console.error(\n      `Unknown subscription status from Polar: ${subscription.status}`\n    );\n    return;\n  }\n\n  const recurringInterval = getRecurringInterval(\n    subscription.recurringInterval\n  );\n\n  try {\n    // Check if subscription already exists (upsert pattern)\n    const existingSubscription = await db.subscription.findUnique({\n      where: { polarId: subscription.id },\n    });\n\n    if (existingSubscription) {\n      console.log(\n        `Subscription ${subscription.id} already exists, skipping creation`\n      );\n      return;\n    }\n\n    // Create new subscription (allow multiple subscriptions per workspace)\n    await db.subscription.create({\n      data: {\n        polarId: subscription.id,\n        plan,\n        status,\n        currentPeriodStart: new Date(currentPeriodStart),\n        currentPeriodEnd: new Date(currentPeriodEnd),\n        cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || false,\n        userId,\n        workspaceId,\n        startedAt: subscription.startedAt\n          ? new Date(subscription.startedAt)\n          : null,\n        productId: subscription.productId || undefined,\n        amount: subscription.amount\n          ? Math.round(subscription.amount)\n          : undefined,\n        currency: subscription.currency || undefined,\n        discountId: subscription.discountId || undefined,\n        lastPolarEventAt: payload.timestamp,\n        recurringInterval,\n      },\n    });\n\n    console.log(\n      `Successfully created subscription ${subscription.id} for workspace ${workspaceId}`\n    );\n  } catch (error) {\n    console.error(\"Error creating subscription in DB:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/polar/subscription.revoked.ts",
    "content": "\"use server\";\n\nimport { db } from \"@marble/db\";\nimport { SubscriptionStatus } from \"@marble/db/browser\";\nimport type { WebhookSubscriptionRevokedPayload } from \"@polar-sh/sdk/models/components/webhooksubscriptionrevokedpayload.js\";\nimport { isStalePolarEvent } from \"./utils\";\n\nexport async function handleSubscriptionRevoked(\n  payload: WebhookSubscriptionRevokedPayload\n) {\n  const { data: subscription } = payload;\n\n  const existingSubscription = await db.subscription.findUnique({\n    where: { polarId: subscription.id },\n  });\n\n  if (!existingSubscription) {\n    console.error(\n      `subscription.revoked webhook received for a subscription that does not exist: ${subscription.id}`\n    );\n    return;\n  }\n\n  if (\n    isStalePolarEvent(existingSubscription.lastPolarEventAt, payload.timestamp)\n  ) {\n    console.log(\n      `Ignoring stale subscription.revoked webhook for subscription ${subscription.id}`\n    );\n    return;\n  }\n\n  try {\n    const result = await db.subscription.updateMany({\n      where: {\n        polarId: subscription.id,\n        OR: [\n          { lastPolarEventAt: null },\n          { lastPolarEventAt: { lte: payload.timestamp } },\n        ],\n      },\n      data: {\n        status: SubscriptionStatus.expired,\n        endedAt: subscription.endedAt\n          ? new Date(subscription.endedAt)\n          : new Date(),\n        lastPolarEventAt: payload.timestamp,\n      },\n    });\n\n    if (result.count === 0) {\n      console.log(\n        `Ignoring stale subscription.revoked webhook for subscription ${subscription.id}`\n      );\n      return;\n    }\n\n    console.log(\n      `Successfully marked subscription ${subscription.id} as revoked/expired for workspace ${existingSubscription.workspaceId}`\n    );\n  } catch (error) {\n    console.error(\"Error updating subscription to revoked in DB:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/polar/subscription.updated.ts",
    "content": "\"use server\";\n\nimport { db } from \"@marble/db\";\nimport type { WebhookSubscriptionUpdatedPayload } from \"@polar-sh/sdk/models/components/webhooksubscriptionupdatedpayload.js\";\nimport {\n  getPlanType,\n  getRecurringInterval,\n  getSubscriptionStatus,\n  isStalePolarEvent,\n} from \"./utils\";\n\nexport async function handleSubscriptionUpdated(\n  payload: WebhookSubscriptionUpdatedPayload\n) {\n  const { data: subscription } = payload;\n\n  const existingSubscription = await db.subscription.findUnique({\n    where: { polarId: subscription.id },\n  });\n\n  if (!existingSubscription) {\n    console.error(\n      `subscription.updated webhook received for a subscription that does not exist: ${subscription.id}`\n    );\n    return;\n  }\n\n  if (\n    isStalePolarEvent(existingSubscription.lastPolarEventAt, payload.timestamp)\n  ) {\n    console.log(\n      `Ignoring stale subscription.updated webhook for subscription ${subscription.id}`\n    );\n    return;\n  }\n\n  const plan = getPlanType(subscription.product.name);\n  if (!plan) {\n    console.error(`Unknown plan: ${subscription.product.name}`);\n    return;\n  }\n\n  const status = getSubscriptionStatus(subscription.status);\n  if (!status) {\n    console.error(\n      `Unknown subscription status from Polar: ${subscription.status}`\n    );\n    return;\n  }\n\n  if (!subscription.currentPeriodStart || !subscription.currentPeriodEnd) {\n    console.error(\n      \"subscription.updated webhook received without currentPeriodStart or currentPeriodEnd\"\n    );\n    return;\n  }\n\n  const recurringInterval = getRecurringInterval(\n    subscription.recurringInterval\n  );\n\n  try {\n    const result = await db.subscription.updateMany({\n      where: {\n        polarId: subscription.id,\n        OR: [\n          { lastPolarEventAt: null },\n          { lastPolarEventAt: { lte: payload.timestamp } },\n        ],\n      },\n      data: {\n        plan,\n        status,\n        currentPeriodStart: new Date(subscription.currentPeriodStart),\n        currentPeriodEnd: new Date(subscription.currentPeriodEnd),\n        cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,\n        canceledAt: subscription.canceledAt\n          ? new Date(subscription.canceledAt)\n          : null,\n        endedAt: subscription.endedAt ? new Date(subscription.endedAt) : null,\n        endsAt: subscription.endsAt ? new Date(subscription.endsAt) : null,\n        startedAt: subscription.startedAt\n          ? new Date(subscription.startedAt)\n          : null,\n        productId: subscription.productId || undefined,\n        amount: subscription.amount\n          ? Math.round(subscription.amount)\n          : undefined,\n        currency: subscription.currency || undefined,\n        discountId: subscription.discountId || undefined,\n        lastPolarEventAt: payload.timestamp,\n        recurringInterval,\n      },\n    });\n\n    if (result.count === 0) {\n      console.log(\n        `Ignoring stale subscription.updated webhook for subscription ${subscription.id}`\n      );\n      return;\n    }\n\n    console.log(\n      `Successfully updated subscription ${subscription.id} for workspace ${existingSubscription.workspaceId}`\n    );\n  } catch (error) {\n    console.error(\"Error updating subscription in DB:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/polar/utils.ts",
    "content": "import {\n  PlanType,\n  type SubscriptionRecurringInterval,\n  SubscriptionStatus,\n} from \"@marble/db/browser\";\n\nexport function isStalePolarEvent(\n  lastPolarEventAt: Date | null | undefined,\n  eventTimestamp: Date\n): boolean {\n  return !!lastPolarEventAt && lastPolarEventAt > eventTimestamp;\n}\n\nexport function getPlanType(productName: string): PlanType | null {\n  const plan = productName.toLowerCase();\n  if (plan === \"pro\") {\n    return PlanType.pro;\n  }\n  if (plan === \"hobby\") {\n    return PlanType.hobby;\n  }\n  return null;\n}\n\nexport function getSubscriptionStatus(\n  polarStatus: string\n): SubscriptionStatus | null {\n  switch (polarStatus) {\n    case \"active\":\n      return SubscriptionStatus.active;\n    case \"trialing\":\n      return SubscriptionStatus.trialing;\n    case \"canceled\":\n      return SubscriptionStatus.canceled;\n    case \"past_due\":\n    case \"incomplete\":\n    case \"unpaid\":\n      return SubscriptionStatus.past_due;\n    case \"incomplete_expired\":\n      return SubscriptionStatus.expired;\n    default:\n      return null;\n  }\n}\n\nexport function getRecurringInterval(\n  polarInterval: string | null | undefined\n): SubscriptionRecurringInterval {\n  if (!polarInterval) {\n    return \"month\";\n  }\n  const normalized = polarInterval.toLowerCase();\n  if (\n    normalized === \"day\" ||\n    normalized === \"week\" ||\n    normalized === \"month\" ||\n    normalized === \"year\"\n  ) {\n    return normalized;\n  }\n  return \"month\";\n}\n"
  },
  {
    "path": "apps/cms/src/lib/queries/keys.ts",
    "content": "export const QUERY_KEYS = {\n  // Workspace keys\n  WORKSPACE_LIST: [\"workspaces\"],\n  WORKSPACE: (id: string) => [\"workspace\", id],\n  WORKSPACE_BY_SLUG: (slug: string) => [\"workspace_by_slug\", slug],\n\n  // Workspace-scoped resources\n  POSTS: (workspaceId: string) => [\"posts\", workspaceId],\n  POST: (workspaceId: string, postId: string) => [\"posts\", workspaceId, postId],\n\n  TAGS: (workspaceId: string) => [\"tags\", workspaceId],\n  TAG: (workspaceId: string, tagId: string) => [\"tags\", workspaceId, tagId],\n\n  CATEGORIES: (workspaceId: string) => [\"categories\", workspaceId],\n  CATEGORY: (workspaceId: string, categoryId: string) => [\n    \"categories\",\n    workspaceId,\n    categoryId,\n  ],\n\n  MEDIA: (workspaceId: string) => [\"media\", workspaceId],\n  MEDIA_DETAIL: (workspaceId: string, mediaId: string) => [\n    \"media\",\n    workspaceId,\n    mediaId,\n  ],\n\n  TEAM: (workspaceId: string) => [\"team\", workspaceId],\n  AUTHORS: (workspaceId: string) => [\"authors\", workspaceId],\n\n  WEBHOOKS: (workspaceId: string) => [\"webhooks\", workspaceId],\n\n  CUSTOM_FIELDS: (workspaceId: string) => [\"custom-fields\", workspaceId],\n\n  KEYS: (workspaceId: string) => [\"keys\", workspaceId],\n\n  BILLING_USAGE: (workspaceId: string) => [\"billing-usage\", workspaceId],\n\n  USAGE_DASHBOARD: (workspaceId: string) => [\"usage-dashboard\", workspaceId],\n\n  AI_READABILITY_SUGGESTIONS: (workspaceId: string, contentKey: string) => [\n    \"ai-readability-suggestions\",\n    workspaceId,\n    contentKey,\n  ],\n\n  PUBLISHING_METRICS: (workspaceId: string) => [\n    \"publishing-metrics\",\n    workspaceId,\n  ],\n\n  // Globally scoped\n  USER: [\"user\"],\n  NOTIFICATION_PREFERENCES: [\"notification-preferences\"],\n};\n"
  },
  {
    "path": "apps/cms/src/lib/queries/user.ts",
    "content": "import { db } from \"@marble/db\";\nimport { getServerSession } from \"@/lib/auth/session\";\n\nexport async function getInitialUserData() {\n  try {\n    const sessionData = await getServerSession();\n\n    if (!sessionData || !sessionData.user) {\n      return { user: null, isAuthenticated: false };\n    }\n\n    const user = await db.user.findUnique({\n      where: { id: sessionData.user.id },\n    });\n\n    if (!user) {\n      return { user: null, isAuthenticated: false };\n    }\n\n    const activeOrganizationId = sessionData.session?.activeOrganizationId;\n\n    if (activeOrganizationId && typeof activeOrganizationId !== \"string\") {\n      console.warn(\n        \"Invalid activeOrganizationId type:\",\n        typeof activeOrganizationId\n      );\n      return { user: null, isAuthenticated: true };\n    }\n\n    const member = activeOrganizationId\n      ? await db.member.findFirst({\n          where: {\n            organizationId: activeOrganizationId,\n            userId: user.id,\n          },\n          include: {\n            organization: {\n              select: {\n                id: true,\n                name: true,\n                slug: true,\n              },\n            },\n          },\n        })\n      : null;\n\n    const userWithRole = {\n      ...user,\n      workspaceRole: member?.role || null,\n      activeWorkspace: member?.organization || null,\n    };\n\n    return { user: userWithRole, isAuthenticated: true };\n  } catch (error) {\n    console.error(\"Error fetching initial user data:\", error);\n    return { user: null, isAuthenticated: false };\n  }\n}\n\n// export async function getInitialUserData() {\n//   try {\n//     const sessionData = await getServerSession();\n\n//     if (!sessionData || !sessionData.user) {\n//       return { user: null, isAuthenticated: false };\n//     }\n\n//     console.log(\"sessionData at point of getting user data\", sessionData);\n\n//     const response = await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/user`);\n\n//     if (response.status === 200) {\n//       const userData = (await response.json()) as UserProfile;\n//       console.log(\"userData\", userData);\n//       return { user: userData, isAuthenticated: true };\n//     }\n//     // If API call fails, fall back to basic session data\n//     console.warn(\n//       \"Failed to fetch user data from API, falling back to session data\",\n//     );\n//     return { user: null, isAuthenticated: true };\n//   } catch (error) {\n//     console.error(\"Error fetching initial user data:\", error);\n//     return { user: null, isAuthenticated: false };\n//   }\n// }\n"
  },
  {
    "path": "apps/cms/src/lib/queries/workspace.ts",
    "content": "import { db } from \"@marble/db\";\nimport { SubscriptionStatus } from \"@marble/db/browser\";\nimport type { RequestCookies } from \"next/dist/compiled/@edge-runtime/cookies\";\nimport { getServerSession } from \"@/lib/auth/session\";\nimport { getWorkspacePlan } from \"@/lib/plans\";\nimport type { Workspace } from \"@/types/workspace\";\nimport { getLastVisitedWorkspace } from \"@/utils/workspace/client\";\n\n/**\n * Determines which workspace should be activated for a given user.\n *\n * The function checks, in order:\n *  1. The user's **last visited workspace** (stored in cookies),\n *     verifying they still have access to it.\n *  2. If not found or access was lost, the first workspace where the user is an **owner**.\n *  3. If still none, the first workspace where the user is a **member**.\n *\n * Returns the workspace's `slug` and `id`, or `undefined` if the user has no accessible workspaces.\n *\n * @param userId - The ID of the user to look up workspaces for.\n * @param cookies - Optional Next.js `RequestCookies` object, used to read the last visited workspace.\n * @returns An object containing `{ slug, id }` for the selected workspace, or `undefined` if none found.\n */\nexport async function getLastActiveWorkspaceOrNewOneToSetAsActive(\n  userId: string,\n  cookies?: RequestCookies\n) {\n  if (cookies) {\n    const lastVisitedWorkspaceSlug = getLastVisitedWorkspace(cookies);\n    if (lastVisitedWorkspaceSlug) {\n      // Check if user still has access to this workspace\n      const workspace = await db.organization.findFirst({\n        where: {\n          slug: lastVisitedWorkspaceSlug,\n          members: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: { slug: true, id: true },\n      });\n\n      if (workspace) {\n        return {\n          slug: workspace.slug,\n          id: workspace.id,\n        };\n      }\n    }\n  }\n\n  // If no last visited workspace or user lost access, try to find a workspace where user is owner\n  const ownerWorkspace = await db.organization.findFirst({\n    where: {\n      members: {\n        some: {\n          userId,\n          role: \"owner\",\n        },\n      },\n    },\n    select: { slug: true, id: true },\n  });\n\n  if (ownerWorkspace) {\n    return {\n      slug: ownerWorkspace.slug,\n      id: ownerWorkspace.id,\n    };\n  }\n\n  // If no owner workspace, check for any workspace user is a member of\n  const memberWorkspace = await db.organization.findFirst({\n    where: {\n      members: {\n        some: {\n          userId,\n        },\n      },\n    },\n    select: { slug: true, id: true },\n  });\n\n  if (memberWorkspace) {\n    return {\n      slug: memberWorkspace.slug,\n      id: memberWorkspace.id,\n    };\n  }\n}\n\n/**\n * Fetches the initial workspace data for the active user.\n *\n * If a workspace slug is provided, the function fetches that workspace.\n * Otherwise, it falls back to the user's currently active session workspace.\n *\n * @param {string} [workspaceSlug] - Optional slug of the workspace to fetch.\n * @returns {Promise<Workspace|null>} The workspace data or null if not found.\n */\nexport async function getInitialWorkspaceData(\n  workspaceSlug?: string\n): Promise<Workspace | null> {\n  try {\n    const session = await getServerSession();\n    const activeOrganizationId = session?.session?.activeOrganizationId;\n\n    if (!session?.user || (!activeOrganizationId && !workspaceSlug)) {\n      return null;\n    }\n\n    const workspace = await db.organization.findUnique({\n      where: workspaceSlug\n        ? { slug: workspaceSlug }\n        : { id: activeOrganizationId as string },\n      select: {\n        id: true,\n        name: true,\n        slug: true,\n        logo: true,\n        timezone: true,\n        createdAt: true,\n        members: {\n          select: {\n            id: true,\n            role: true,\n            userId: true,\n            organizationId: true,\n            createdAt: true,\n            user: {\n              select: { id: true, name: true, email: true, image: true },\n            },\n          },\n        },\n        invitations: {\n          select: {\n            id: true,\n            email: true,\n            role: true,\n            status: true,\n            organizationId: true,\n            inviterId: true,\n            expiresAt: true,\n          },\n        },\n        subscriptions: {\n          where: {\n            OR: [\n              { status: SubscriptionStatus.active },\n              { status: SubscriptionStatus.trialing },\n              {\n                status: SubscriptionStatus.canceled,\n                cancelAtPeriodEnd: true,\n                currentPeriodEnd: { gt: new Date() },\n              },\n            ],\n          },\n          orderBy: { createdAt: \"desc\" },\n          take: 1,\n          select: {\n            id: true,\n            status: true,\n            plan: true,\n            currentPeriodStart: true,\n            currentPeriodEnd: true,\n            cancelAtPeriodEnd: true,\n            canceledAt: true,\n          },\n        },\n      },\n    });\n\n    if (!workspace) {\n      return null;\n    }\n\n    const currentUserMember = workspace.members.find(\n      (member) => member.userId === session.user.id\n    );\n\n    if (!currentUserMember) {\n      return null;\n    }\n\n    const activeSubscription = workspace.subscriptions[0] || null;\n    const activePlan = getWorkspacePlan(activeSubscription);\n\n    return {\n      ...workspace,\n      currentUserRole: currentUserMember?.role || null,\n      subscription: activeSubscription\n        ? {\n            ...activeSubscription,\n            activePlan,\n          }\n        : null,\n    } as Workspace;\n  } catch (error) {\n    console.error(\"Error fetching initial workspace data:\", error);\n    return null;\n  }\n}\n\n/**\n * Validates whether the given workspace slug exists and the active user has access to it.\n *\n * @param slug - The workspace slug to validate.\n * @returns {Promise<boolean>} True if the workspace exists and the user is a member.\n */\nexport async function validateWorkspaceAccess(slug: string): Promise<boolean> {\n  const session = await getServerSession();\n  if (!session?.user) {\n    return false;\n  }\n\n  const workspace = await db.organization.findFirst({\n    where: {\n      slug,\n      members: { some: { userId: session.user.id } },\n    },\n    select: { id: true },\n  });\n\n  return Boolean(workspace);\n}\n"
  },
  {
    "path": "apps/cms/src/lib/r2.ts",
    "content": "import \"server-only\";\nimport { S3Client } from \"@aws-sdk/client-s3\";\n\nconst ACCESS_KEY_ID = process.env.CLOUDFLARE_ACCESS_KEY_ID;\nconst SECRET_ACCESS_KEY = process.env.CLOUDFLARE_SECRET_ACCESS_KEY;\nconst BUCKET_NAME = process.env.CLOUDFLARE_BUCKET_NAME;\nconst ENDPOINT = process.env.CLOUDFLARE_S3_ENDPOINT;\nconst PUBLIC_URL = process.env.CLOUDFLARE_PUBLIC_URL;\n\nif (\n  !ACCESS_KEY_ID ||\n  !SECRET_ACCESS_KEY ||\n  !BUCKET_NAME ||\n  !ENDPOINT ||\n  !PUBLIC_URL\n) {\n  throw new Error(\"Missing Cloudflare R2 environment variables\");\n}\n\nexport const r2 = new S3Client({\n  region: \"auto\",\n  endpoint: ENDPOINT,\n  credentials: {\n    accessKeyId: ACCESS_KEY_ID,\n    secretAccessKey: SECRET_ACCESS_KEY,\n  },\n});\n\nexport const R2_BUCKET_NAME = BUCKET_NAME;\nexport const R2_PUBLIC_URL = PUBLIC_URL;\n"
  },
  {
    "path": "apps/cms/src/lib/ratelimit.ts",
    "content": "import { Ratelimit } from \"@upstash/ratelimit\";\nimport { redis } from \"./redis\";\n\nexport const rateLimitHeaders = (\n  limit: number,\n  remaining: number,\n  reset: number\n): Headers =>\n  new Headers({\n    \"X-RateLimit-Limit\": limit.toString(),\n    \"X-RateLimit-Remaining\": remaining.toString(),\n    \"X-RateLimit-Reset\": reset.toString(),\n  });\n\nexport const aiSuggestionsRateLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(20, \"60 s\"),\n  ephemeralCache: new Map(),\n  prefix: \"ai-suggestions-rate-limit\",\n});\n\nexport const userAvatarUploadRateLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(5, \"30 m\"),\n  ephemeralCache: new Map(),\n  prefix: \"user-avatar-upload-rate-limit\",\n});\n"
  },
  {
    "path": "apps/cms/src/lib/redis.ts",
    "content": "import { Redis } from \"@upstash/redis\";\n\nexport const redis = new Redis({\n  url: process.env.REDIS_URL,\n  token: process.env.REDIS_TOKEN,\n});\n"
  },
  {
    "path": "apps/cms/src/lib/search-params.ts",
    "content": "import { useQueryStates } from \"nuqs\";\nimport {\n  createLoader,\n  createParser,\n  createSerializer,\n  type Options,\n  parseAsInteger,\n  parseAsString,\n  parseAsStringLiteral,\n} from \"nuqs/server\";\nimport {\n  MEDIA_FILTER_TYPES,\n  MEDIA_LIMIT,\n  MEDIA_SORT_BY,\n  MEDIA_TYPES,\n  POST_LIMIT,\n  SORT_DIRECTIONS,\n} from \"./constants\";\n\nfunction parseAsSort<const Field extends string>(fields: readonly Field[]) {\n  const parseAsField = parseAsStringLiteral(fields);\n  const parseAsDirection = parseAsStringLiteral(SORT_DIRECTIONS);\n  return createParser({\n    parse(query) {\n      const [field = \"\", direction = \"\"] = query.split(\"_\");\n      const parsedField = parseAsField.parse(field);\n      const parsedDirection = parseAsDirection.parse(direction);\n      if (!parsedField || !parsedDirection) {\n        return null;\n      }\n      return `${parsedField}_${parsedDirection}` as const;\n    },\n    serialize: String,\n  });\n}\n\nconst sortParser = parseAsSort(MEDIA_SORT_BY).withDefault(\"createdAt_desc\");\n\n// Page level search params\nconst mediaPageSearchParams = {\n  page: parseAsInteger.withDefault(1),\n  perPage: parseAsInteger.withDefault(MEDIA_LIMIT),\n  search: parseAsString.withDefault(\"\"),\n  sort: sortParser,\n  type: parseAsStringLiteral(MEDIA_FILTER_TYPES).withDefault(\"all\"),\n};\n\nexport const useMediaPageFilters = (options: Options = {}) =>\n  useQueryStates(mediaPageSearchParams, options);\n\n// React Query API endpoint level search params\nconst mediaApiSearchParams = {\n  sort: sortParser,\n  type: parseAsStringLiteral(MEDIA_TYPES),\n  page: parseAsInteger.withDefault(1),\n  perPage: parseAsInteger.withDefault(MEDIA_LIMIT),\n  search: parseAsString,\n};\n\nexport const loadMediaApiFilters = createLoader(mediaApiSearchParams);\n\nexport const getMediaApiUrl = createSerializer(mediaApiSearchParams, {\n  clearOnDefault: false,\n});\n\nconst mediaEditorApiSearchParams = {\n  sort: sortParser,\n  cursor: parseAsString,\n  limit: parseAsInteger.withDefault(MEDIA_LIMIT),\n};\n\nexport const loadMediaEditorApiFilters = createLoader(\n  mediaEditorApiSearchParams\n);\n\nexport const getMediaEditorApiUrl = createSerializer(\n  mediaEditorApiSearchParams,\n  {\n    clearOnDefault: false,\n  }\n);\n\nexport const POST_SORT_BY = [\n  \"createdAt\",\n  \"publishedAt\",\n  \"updatedAt\",\n  \"title\",\n] as const;\n\nexport const POST_SORTS = POST_SORT_BY.flatMap((field) =>\n  SORT_DIRECTIONS.map((direction) => `${field}_${direction}` as const)\n);\n\nconst postSortParser = parseAsSort(POST_SORT_BY).withDefault(\"createdAt_desc\");\n\nconst postPageSearchParams = {\n  category: parseAsString.withDefault(\"all\"),\n  page: parseAsInteger.withDefault(1),\n  perPage: parseAsInteger.withDefault(POST_LIMIT),\n  search: parseAsString.withDefault(\"\"),\n  sort: postSortParser,\n  status: parseAsStringLiteral([\"all\", \"published\", \"draft\"]).withDefault(\n    \"all\"\n  ),\n};\n\nexport const usePostPageFilters = (options: Options = {}) =>\n  useQueryStates(postPageSearchParams, options);\n\nexport const loadPostApiFilters = createLoader(postPageSearchParams);\n\nexport const getPostApiUrl = createSerializer(postPageSearchParams, {\n  clearOnDefault: false,\n});\n"
  },
  {
    "path": "apps/cms/src/lib/validations/auth.ts",
    "content": "import * as z from \"zod\";\n\n// auth form\nexport const credentialSchema = z.object({\n  email: z\n    .string()\n    .email({ message: \"Invalid email\" })\n    .min(1, { message: \"Email is required\" }),\n  password: z\n    .string()\n    .min(1, { message: \"Password is required\" })\n    .min(8, { message: \"Password must be more than 8 characters\" })\n    .max(32, { message: \"Password must be less than 32 characters\" }),\n});\nexport type CredentialData = z.infer<typeof credentialSchema>;\n\nexport const inviteSchema = z.object({\n  email: z.string().email({ message: \"Invalid email address\" }),\n  role: z.enum([\"admin\", \"member\"], { message: \"Please select a role\" }),\n});\nexport type InviteData = z.infer<typeof inviteSchema>;\n"
  },
  {
    "path": "apps/cms/src/lib/validations/authors.ts",
    "content": "import * as z from \"zod\";\nimport { SOCIAL_PLATFORMS, type SocialPlatform } from \"@/lib/constants\";\n\nconst socialLinkSchema = z.object({\n  id: z.string().optional().nullable(),\n  url: z\n    .string()\n    .min(1, { message: \"URL cannot be empty\" })\n    .transform((value) => {\n      // Auto-prepend https:// if no scheme is provided\n      return /^(https?:)?\\/\\//i.test(value) ? value : `https://${value}`;\n    })\n    .refine(\n      (value) => {\n        try {\n          const url = new URL(value);\n          return url.protocol === \"https:\" || url.protocol === \"http:\";\n        } catch {\n          return false;\n        }\n      },\n      {\n        message: \"Please enter a valid URL\",\n      }\n    )\n    .refine(\n      (value) => {\n        try {\n          const url = new URL(value);\n          return url.hostname.includes(\".\");\n        } catch {\n          return false;\n        }\n      },\n      {\n        message: \"Please enter a valid domain (e.g., example.com)\",\n      }\n    ),\n  platform: z.enum(\n    Object.keys(SOCIAL_PLATFORMS) as [SocialPlatform, ...SocialPlatform[]]\n  ),\n});\n\n// Author Schema\nexport const authorSchema = z.object({\n  name: z.string().trim().min(1, { message: \"Name cannot be empty\" }),\n  role: z\n    .string()\n    .trim()\n    .transform((v) => (v === \"\" ? undefined : v))\n    .optional(),\n  bio: z\n    .string()\n    .trim()\n    .transform((v) => (v === \"\" ? undefined : v))\n    .optional(),\n  image: z.string().nullable().optional(),\n  userId: z.string().nullable().optional(),\n  email: z\n    .string()\n    .email({ message: \"Please enter a valid email address\" })\n    .optional()\n    .or(z.literal(\"\")),\n  slug: z\n    .string()\n    .slugify()\n    .min(4, { message: \"Slug must be at least 4 characters\" })\n    .max(32, { message: \"Slug cannot be more than 32 characters\" }),\n  socials: z.array(socialLinkSchema).optional(),\n});\nexport type CreateAuthorValues = z.infer<typeof authorSchema>;\nexport type SocialLink = z.infer<typeof socialLinkSchema>;\n"
  },
  {
    "path": "apps/cms/src/lib/validations/editor.ts",
    "content": "import * as z from \"zod\";\n\nexport const aiReadabilityBodySchema = z.object({\n  content: z.string(),\n  metrics: z.object({\n    wordCount: z.number(),\n    sentenceCount: z.number(),\n    wordsPerSentence: z.number(),\n    readabilityScore: z.number(),\n    readingTime: z.number(),\n  }),\n  postId: z.string().optional(),\n});\n\nexport const aiReadabilityResponseSchema = z.object({\n  suggestions: z\n    .array(\n      z.object({\n        text: z.string().describe(\"The main suggestion text (1-2 sentences)\"),\n        explanation: z\n          .string()\n          .optional()\n          .describe(\"Brief explanation or example (optional, 1 sentence max)\"),\n        textReference: z\n          .string()\n          .optional()\n          .describe(\"Specific text snippet to highlight (optional)\"),\n      })\n    )\n    .describe(\n      \"Array of specific, actionable readability improvement suggestions\"\n    )\n    .max(8),\n});\n"
  },
  {
    "path": "apps/cms/src/lib/validations/fields.ts",
    "content": "import { z } from \"zod\";\n\nexport const fieldTypeEnum = z.enum([\n  \"text\",\n  \"number\",\n  \"boolean\",\n  \"date\",\n  \"richtext\",\n  \"select\",\n  \"multiselect\",\n]);\n\nexport const fieldOptionSchema = z.object({\n  value: z\n    .string()\n    .trim()\n    .min(1, { message: \"Option value cannot be empty\" })\n    .max(80, { message: \"Option value cannot be more than 80 characters\" }),\n  label: z\n    .string()\n    .trim()\n    .min(1, { message: \"Option label cannot be empty\" })\n    .max(80, { message: \"Option label cannot be more than 80 characters\" }),\n});\n\nexport const fieldOptionsSchema = z\n  .array(fieldOptionSchema)\n  .max(100, { message: \"Fields cannot have more than 100 options\" });\n\nfunction validateUniqueOptionValues(\n  options: Array<{ value: string; label: string }>,\n  ctx: z.RefinementCtx\n) {\n  const seenValues = new Set<string>();\n\n  for (const [index, option] of options.entries()) {\n    if (seenValues.has(option.value)) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"Option values must be unique\",\n        path: [\"options\", index, \"value\"],\n      });\n      continue;\n    }\n\n    seenValues.add(option.value);\n  }\n}\n\nfunction validateFieldOptions(\n  type: FieldType,\n  options: Array<{ value: string; label: string }>,\n  ctx: z.RefinementCtx\n) {\n  const requiresOptions = type === \"select\" || type === \"multiselect\";\n\n  if (requiresOptions && options.length === 0) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: \"Select fields must define at least one option\",\n      path: [\"options\"],\n    });\n  }\n\n  if (!requiresOptions && options.length > 0) {\n    ctx.addIssue({\n      code: z.ZodIssueCode.custom,\n      message: \"Only select and multiselect fields can define options\",\n      path: [\"options\"],\n    });\n  }\n\n  validateUniqueOptionValues(options, ctx);\n}\n\nexport const customFieldSchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, { message: \"Name cannot be empty\" })\n      .max(50, { message: \"Name cannot be more than 50 characters\" }),\n    description: z\n      .string()\n      .max(280, { message: \"Description cannot be more than 280 characters\" })\n      .optional(),\n    key: z\n      .string()\n      .min(1, { message: \"Key cannot be empty\" })\n      .max(50, { message: \"Key cannot be more than 50 characters\" })\n      .regex(/^[a-z0-9_]+$/, {\n        message:\n          \"Key can only contain lowercase letters, numbers, and underscores\",\n      }),\n    type: fieldTypeEnum,\n    required: z.boolean().optional(),\n    options: fieldOptionsSchema.optional(),\n  })\n  .superRefine((value, ctx) => {\n    validateFieldOptions(value.type, value.options ?? [], ctx);\n  });\n\nexport type CustomFieldFormValues = z.infer<typeof customFieldSchema>;\n\nexport const customFieldUpdateSchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, { message: \"Name cannot be empty\" })\n      .max(50, { message: \"Name cannot be more than 50 characters\" })\n      .optional(),\n    description: z\n      .string()\n      .max(280, { message: \"Description cannot be more than 280 characters\" })\n      .optional(),\n    key: z\n      .string()\n      .min(1, { message: \"Key cannot be empty\" })\n      .max(50, { message: \"Key cannot be more than 50 characters\" })\n      .regex(/^[a-z0-9_]+$/, {\n        message:\n          \"Key can only contain lowercase letters, numbers, and underscores\",\n      })\n      .optional(),\n    type: fieldTypeEnum.optional(),\n    required: z.boolean().optional(),\n    options: fieldOptionsSchema.optional(),\n  })\n  .superRefine((value, ctx) => {\n    if (value.options !== undefined) {\n      validateUniqueOptionValues(value.options, ctx);\n    }\n  });\n\nexport type CustomFieldUpdateValues = z.infer<typeof customFieldUpdateSchema>;\n\nexport type FieldType = z.infer<typeof fieldTypeEnum>;\nexport type FieldOptionInput = z.infer<typeof fieldOptionSchema>;\n"
  },
  {
    "path": "apps/cms/src/lib/validations/keys.ts",
    "content": "import * as z from \"zod\";\nimport { type ApiScope, VALID_SCOPES } from \"@/utils/keys\";\n\nexport const apiKeyTypeEnum = z.enum([\"public\", \"private\"]);\n\nexport const apiScopeEnum = z.enum(\n  VALID_SCOPES as unknown as [ApiScope, ...ApiScope[]]\n);\n\nexport const createApiKeySchema = z.object({\n  name: z\n    .string()\n    .min(1, { message: \"Name cannot be empty\" })\n    .max(50, { message: \"Name cannot be more than 50 characters\" }),\n  type: apiKeyTypeEnum,\n  scopes: z.array(apiScopeEnum).optional(),\n  expiresAt: z.coerce.date().optional().nullable(),\n});\n\nexport type CreateApiKeyValues = z.infer<typeof createApiKeySchema>;\n\nexport const updateApiKeySchema = z.object({\n  name: z\n    .string()\n    .min(1, { message: \"Name cannot be empty\" })\n    .max(50, { message: \"Name cannot be more than 50 characters\" })\n    .optional(),\n  scopes: z.array(apiScopeEnum).optional(),\n  expiresAt: z.coerce.date().optional().nullable(),\n  enabled: z.boolean().optional(),\n});\n\nexport type UpdateApiKeyValues = z.infer<typeof updateApiKeySchema>;\n\nexport type ApiKeyValues = CreateApiKeyValues | UpdateApiKeyValues;\n"
  },
  {
    "path": "apps/cms/src/lib/validations/post.ts",
    "content": "import { z } from \"zod\";\n\nconst validJsonString = z\n  .string()\n  .min(10)\n  .refine(\n    (value) => {\n      try {\n        JSON.parse(value);\n        return true;\n      } catch {\n        return false;\n      }\n    },\n    { message: \"Content JSON must be valid JSON\" }\n  );\n\nexport const postSchema = z.object({\n  title: z.string().min(1, { message: \"Title cannot be empty\" }),\n  coverImage: z.string().url().nullable().optional(),\n  description: z.string().min(1, { message: \"Description cannot be empty\" }),\n  slug: z.string().slugify().min(1, { message: \"Slug cannot be empty\" }),\n  content: z.string(),\n  contentJson: validJsonString,\n  tags: z.array(z.string().min(1)).optional(),\n  authors: z.array(z.string().min(1)).optional(),\n  category: z.string().min(1, { message: \"Category is required\" }),\n  status: z.enum([\"published\", \"draft\"]),\n  featured: z.boolean().default(false).optional(),\n  publishedAt: z.coerce.date(),\n});\n\nexport type PostValues = z.infer<typeof postSchema>;\n\nexport const postEditorSchema = postSchema.extend({\n  customFields: z.record(z.string(), z.string()).default({}),\n});\n\nexport type PostEditorValues = z.infer<typeof postEditorSchema>;\n\nexport const postUpsertSchema = postSchema.extend({\n  customFields: z\n    .record(z.string(), z.union([z.string(), z.null()]))\n    .default({}),\n});\n\nexport type PostUpsertValues = z.infer<typeof postUpsertSchema>;\n\nexport const shareLinkSchema = z.object({\n  postId: z.string().min(1, { message: \"Post ID is required\" }),\n});\n\nexport type ShareLinkValues = z.infer<typeof shareLinkSchema>;\n\n// Schema for importing posts where the client sends markdown only and\n// the server derives HTML and Tiptap JSON.\nexport const postImportSchema = z.object({\n  title: z.string().min(1, { message: \"Title cannot be empty\" }),\n  coverImage: z.string().url().nullable().optional(),\n  description: z.string().min(1, { message: \"Description cannot be empty\" }),\n  slug: z.string().slugify().min(1, { message: \"Slug cannot be empty\" }),\n  // Markdown content\n  content: z.string().min(1),\n  tags: z.array(z.string().min(1)).optional(),\n  // Authors optional; backend will fallback to current user's author\n  authors: z.array(z.string().min(1)).optional(),\n  category: z.string().min(1, { message: \"Category is required\" }),\n  status: z.enum([\"published\", \"draft\"]),\n  featured: z.boolean().default(false),\n  publishedAt: z.coerce.date(),\n});\n\nexport type PostImportValues = z.infer<typeof postImportSchema>;\n"
  },
  {
    "path": "apps/cms/src/lib/validations/settings.ts",
    "content": "import * as z from \"zod\";\n\nexport const profileSchema = z.object({\n  name: z.string().min(1, { message: \"Name cannot be blank\" }),\n  email: z.string().email().optional(),\n});\nexport type ProfileData = z.infer<typeof profileSchema>;\n\nexport const billingSchema = z.object({\n  name: z.string().min(1, { message: \"Name cannot be blank\" }),\n  email: z.string().email({ message: \"Invalid email\" }),\n  address: z.string().min(10, { message: \"Address too short\" }),\n  country: z.string().min(1, { message: \"Select a country\" }),\n  city: z.string().min(1, { message: \"Select a city\" }),\n  code: z.string().min(1, { message: \"Enter your zip / postal code\" }),\n});\nexport type BillingData = z.infer<typeof billingSchema>;\n"
  },
  {
    "path": "apps/cms/src/lib/validations/tags.ts",
    "content": "import { db } from \"@marble/db\";\nimport { NextResponse } from \"next/server\";\n\n/**\n * Validates and deduplicates tag IDs for a specific workspace\n * @param tagIds - Array of tag IDs to validate\n * @param workspaceId - The workspace ID to scope validation to\n * @returns Object containing validated unique tag IDs or error response\n */\nexport async function validateWorkspaceTags(\n  tagIds: string[] | undefined,\n  workspaceId: string\n): Promise<\n  | { success: true; uniqueTagIds: string[] }\n  | { success: false; response: NextResponse }\n> {\n  const uniqueTagIds = Array.from(new Set(tagIds ?? []));\n\n  if (uniqueTagIds.length) {\n    const valid = await db.tag.findMany({\n      where: {\n        id: { in: uniqueTagIds },\n        workspaceId,\n      },\n      select: { id: true },\n    });\n\n    if (valid.length !== uniqueTagIds.length) {\n      return {\n        success: false,\n        response: NextResponse.json(\n          { error: \"One or more tags are invalid for this workspace.\" },\n          { status: 400 }\n        ),\n      };\n    }\n  }\n\n  return { success: true, uniqueTagIds };\n}\n"
  },
  {
    "path": "apps/cms/src/lib/validations/upload.ts",
    "content": "import { z } from \"zod\";\nimport {\n  ALLOWED_MIME_TYPES,\n  ALLOWED_RASTER_MIME_TYPES,\n  type AllowedMimeType,\n  type AllowedRasterMimeType,\n  MAX_AVATAR_FILE_SIZE,\n  MAX_LOGO_FILE_SIZE,\n  MAX_MEDIA_FILE_SIZE,\n} from \"@/lib/constants\";\nimport type { UploadType } from \"@/types/media\";\n\nexport const uploadAvatarSchema = z.object({\n  type: z.literal(\"avatar\"),\n  fileType: z.coerce.string().nonempty(),\n  fileSize: z.coerce\n    .number()\n    .int()\n    .positive()\n    .max(MAX_AVATAR_FILE_SIZE, {\n      message: `Avatar image must be less than ${MAX_AVATAR_FILE_SIZE / 1024 / 1024}MB`,\n    }),\n});\n\nexport const uploadLogoSchema = z.object({\n  type: z.literal(\"logo\"),\n  fileType: z.coerce.string().nonempty(),\n  fileSize: z.coerce\n    .number()\n    .int()\n    .positive()\n    .max(MAX_LOGO_FILE_SIZE, {\n      message: `Logo image must be less than ${MAX_LOGO_FILE_SIZE / 1024 / 1024}MB`,\n    }),\n});\n\nexport const uploadMediaSchema = z.object({\n  type: z.literal(\"media\"),\n  fileType: z.coerce.string().nonempty(),\n  fileSize: z.coerce\n    .number()\n    .int()\n    .positive()\n    .max(MAX_MEDIA_FILE_SIZE, {\n      message: `Media file must be less than ${MAX_MEDIA_FILE_SIZE / 1024 / 1024}MB`,\n    }),\n});\n\nexport const uploadSchema = z.union([\n  uploadAvatarSchema,\n  uploadLogoSchema,\n  uploadMediaSchema,\n]);\n\nconst maxSizeByType = {\n  avatar: MAX_AVATAR_FILE_SIZE,\n  logo: MAX_LOGO_FILE_SIZE,\n  media: MAX_MEDIA_FILE_SIZE,\n};\n\nexport const completeAvatarSchema = z.object({\n  type: z.literal(\"avatar\"),\n  key: z\n    .string()\n    .min(3)\n    .max(1024)\n    .refine(\n      (k) =>\n        k.startsWith(\"avatars/\") && !k.includes(\"..\") && !k.startsWith(\"/\"),\n      \"Invalid key: must start with avatars/ and not contain path traversal\"\n    ),\n  fileType: z.string().min(1),\n  fileSize: z.coerce.number().int().positive().max(MAX_AVATAR_FILE_SIZE),\n});\n\nexport const completeLogoSchema = z.object({\n  type: z.literal(\"logo\"),\n  key: z\n    .string()\n    .min(3)\n    .max(1024)\n    .refine(\n      (k) => k.startsWith(\"logos/\") && !k.includes(\"..\") && !k.startsWith(\"/\"),\n      \"Invalid key: must start with logos/ and not contain path traversal\"\n    ),\n  fileType: z.string().min(1),\n  fileSize: z.coerce.number().int().positive().max(MAX_LOGO_FILE_SIZE),\n});\n\nexport const completeMediaSchema = z\n  .object({\n    type: z.literal(\"media\"),\n    key: z\n      .string()\n      .min(3)\n      .max(1024)\n      .refine(\n        (k) =>\n          k.startsWith(\"media/\") && !k.includes(\"..\") && !k.startsWith(\"/\"),\n        \"Invalid key: must start with media/ and not contain path traversal\"\n      ),\n    fileType: z.string().min(1),\n    fileSize: z.coerce.number().int().positive().max(MAX_MEDIA_FILE_SIZE),\n    name: z.string().min(1).max(255),\n    mimeType: z.string().min(1).max(255).optional(),\n    width: z.coerce.number().int().positive().optional(),\n    height: z.coerce.number().int().positive().optional(),\n    duration: z.coerce.number().int().nonnegative().optional(),\n    blurHash: z.string().min(6).max(128).optional(),\n  })\n  .refine(\n    ({ fileType, mimeType }) => mimeType === undefined || mimeType === fileType,\n    {\n      message: \"mimeType must match fileType\",\n      path: [\"mimeType\"],\n    }\n  );\n\nexport const completeSchema = z.union([\n  completeAvatarSchema,\n  completeLogoSchema,\n  completeMediaSchema,\n]);\n\nexport const DeleteSchema = z.object({\n  mediaIds: z.array(z.string()).min(1).max(100),\n});\n\nexport function validateUpload({\n  type,\n  fileType,\n  fileSize,\n}: {\n  type: UploadType;\n  fileType: string;\n  fileSize: number;\n}) {\n  const maxSize = maxSizeByType[type];\n  if (fileSize > maxSize) {\n    throw new Error(\n      `File size exceeds the maximum limit of ${maxSize / 1024 / 1024}MB for ${type}.`\n    );\n  }\n\n  switch (type) {\n    case \"avatar\":\n    case \"logo\":\n      if (\n        !ALLOWED_RASTER_MIME_TYPES.includes(fileType as AllowedRasterMimeType)\n      ) {\n        throw new Error(\n          `File type ${fileType} is not allowed for ${type}. Allowed raster types: ${ALLOWED_RASTER_MIME_TYPES.join(\", \")}`\n        );\n      }\n      break;\n    case \"media\":\n      if (!ALLOWED_MIME_TYPES.includes(fileType as AllowedMimeType)) {\n        throw new Error(\n          `File type ${fileType} is not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(\", \")}`\n        );\n      }\n      break;\n    default:\n      throw new Error(\"Invalid upload type.\");\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/lib/validations/webhook.ts",
    "content": "import { isSafeWebhookUrl } from \"@marble/utils\";\nimport { z } from \"zod\";\nimport { VALID_DISCORD_DOMAINS, VALID_SLACK_DOMAINS } from \"../constants\";\n\nexport const webhookEventEnum = z.enum([\n  \"post_published\",\n  \"post_unpublished\",\n  \"post_updated\",\n  \"post_deleted\",\n  \"category_created\",\n  \"category_updated\",\n  \"category_deleted\",\n  \"tag_created\",\n  \"tag_updated\",\n  \"tag_deleted\",\n  \"media_uploaded\",\n  \"media_updated\",\n  \"media_deleted\",\n  \"author_created\",\n  \"author_updated\",\n  \"author_deleted\",\n]);\n\nexport const payloadFormatEnum = z.enum([\"json\", \"discord\", \"slack\"]);\n\nexport const webhookSchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, { message: \"Name cannot be empty\" })\n      .max(50, { message: \"Name cannot be more than 50 characters\" }),\n    endpoint: z\n      .string()\n      .url({ message: \"Please enter a valid URL\" })\n      .refine(\n        (raw) => {\n          try {\n            return new URL(raw).protocol === \"https:\";\n          } catch {\n            return false;\n          }\n        },\n        { message: \"Webhook URL must use HTTPS\" }\n      )\n      .refine((raw) => isSafeWebhookUrl(raw), {\n        message: \"Webhook URL cannot target private or internal addresses\",\n      }),\n    events: z\n      .array(webhookEventEnum)\n      .min(1, { message: \"Please select at least one event\" }),\n    format: payloadFormatEnum,\n  })\n  .superRefine((data, ctx) => {\n    const hostname = new URL(data.endpoint).hostname;\n\n    switch (data.format) {\n      case \"discord\":\n        if (!VALID_DISCORD_DOMAINS.includes(hostname)) {\n          ctx.addIssue({\n            code: z.ZodIssueCode.custom,\n            message: `Discord webhook URL must be from one of: ${VALID_DISCORD_DOMAINS.join(\", \")}`,\n            path: [\"endpoint\"],\n          });\n        }\n        break;\n      case \"slack\":\n        if (!VALID_SLACK_DOMAINS.includes(hostname)) {\n          ctx.addIssue({\n            code: z.ZodIssueCode.custom,\n            message: `Slack webhook URL must be from: ${VALID_SLACK_DOMAINS.join(\", \")}`,\n            path: [\"endpoint\"],\n          });\n        }\n        break;\n      default:\n        break;\n    }\n  });\n\nexport type WebhookFormValues = z.infer<typeof webhookSchema>;\n\nexport const webhookUpdateSchema = z\n  .object({\n    name: z\n      .string()\n      .min(1, { message: \"Name cannot be empty\" })\n      .max(50, { message: \"Name cannot be more than 50 characters\" })\n      .optional(),\n    endpoint: z\n      .string()\n      .url({ message: \"Please enter a valid URL\" })\n      .refine(\n        (raw) => {\n          try {\n            return new URL(raw).protocol === \"https:\";\n          } catch {\n            return false;\n          }\n        },\n        { message: \"Webhook URL must use HTTPS\" }\n      )\n      .refine((raw) => isSafeWebhookUrl(raw), {\n        message: \"Webhook URL cannot target private or internal addresses\",\n      })\n      .optional(),\n    events: z\n      .array(webhookEventEnum)\n      .min(1, { message: \"Please select at least one event\" })\n      .optional(),\n    format: payloadFormatEnum.optional(),\n    enabled: z.boolean().optional(),\n  })\n  .superRefine((data, ctx) => {\n    // Only validate endpoint/format relationship if both are provided\n    if (data.endpoint && data.format) {\n      try {\n        const hostname = new URL(data.endpoint).hostname;\n\n        switch (data.format) {\n          case \"discord\":\n            if (!VALID_DISCORD_DOMAINS.includes(hostname)) {\n              ctx.addIssue({\n                code: z.ZodIssueCode.custom,\n                message: `Discord webhook URL must be from one of: ${VALID_DISCORD_DOMAINS.join(\", \")}`,\n                path: [\"endpoint\"],\n              });\n            }\n            break;\n          case \"slack\":\n            if (!VALID_SLACK_DOMAINS.includes(hostname)) {\n              ctx.addIssue({\n                code: z.ZodIssueCode.custom,\n                message: `Slack webhook URL must be from: ${VALID_SLACK_DOMAINS.join(\", \")}`,\n                path: [\"endpoint\"],\n              });\n            }\n            break;\n          default:\n            break;\n        }\n      } catch {\n        // URL parsing error already handled by endpoint validation\n      }\n    }\n  });\n\nexport type WebhookUpdateValues = z.infer<typeof webhookUpdateSchema>;\n\nexport type WebhookEvent = z.infer<typeof webhookEventEnum>;\nexport type PayloadFormat = z.infer<typeof payloadFormatEnum>;\n\nexport const webhookEvents: Array<{\n  id: WebhookEvent;\n  label: string;\n  description: string;\n}> = [\n  {\n    id: \"post_published\",\n    label: \"post.published\",\n    description: \"When a post is published\",\n  },\n  {\n    id: \"post_unpublished\",\n    label: \"post.unpublished\",\n    description: \"When a post is unpublished\",\n  },\n  {\n    id: \"post_updated\",\n    label: \"post.updated\",\n    description: \"When a post is updated\",\n  },\n  {\n    id: \"post_deleted\",\n    label: \"post.deleted\",\n    description: \"When a post is deleted\",\n  },\n  {\n    id: \"category_created\",\n    label: \"category.created\",\n    description: \"When a category is created\",\n  },\n  {\n    id: \"category_updated\",\n    label: \"category.updated\",\n    description: \"When a category is updated\",\n  },\n  {\n    id: \"category_deleted\",\n    label: \"category.deleted\",\n    description: \"When a category is deleted\",\n  },\n  {\n    id: \"tag_created\",\n    label: \"tag.created\",\n    description: \"When a tag is created\",\n  },\n  {\n    id: \"tag_updated\",\n    label: \"tag.updated\",\n    description: \"When a tag is updated\",\n  },\n  {\n    id: \"tag_deleted\",\n    label: \"tag.deleted\",\n    description: \"When a tag is deleted\",\n  },\n  {\n    id: \"media_uploaded\",\n    label: \"media.uploaded\",\n    description: \"When media is uploaded\",\n  },\n  {\n    id: \"media_updated\",\n    label: \"media.updated\",\n    description: \"When media is updated\",\n  },\n  {\n    id: \"media_deleted\",\n    label: \"media.deleted\",\n    description: \"When media is deleted\",\n  },\n  {\n    id: \"author_created\",\n    label: \"author.created\",\n    description: \"When an author is created\",\n  },\n  {\n    id: \"author_updated\",\n    label: \"author.updated\",\n    description: \"When an author is updated\",\n  },\n  {\n    id: \"author_deleted\",\n    label: \"author.deleted\",\n    description: \"When an author is deleted\",\n  },\n];\n"
  },
  {
    "path": "apps/cms/src/lib/validations/workspace.ts",
    "content": "import * as z from \"zod\";\nimport { RESERVED_WORKSPACE_SLUGS, timezones } from \"@/lib/constants\";\n\nconst workspaceSlugField = z\n  .string()\n  .min(4, { message: \"Slug must be at least 4 characters\" })\n  .max(32, { message: \"Slug cannot be more than 32 characters\" })\n  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {\n    message:\n      \"Slug can only contain lowercase letters, numbers, and single hyphens\",\n  })\n  .refine(\n    (slug) => !(RESERVED_WORKSPACE_SLUGS as readonly string[]).includes(slug),\n    {\n      message: \"This slug is not available\",\n    }\n  );\n\n// Tag Schema\nexport const tagSchema = z.object({\n  name: z.string().trim().min(1, { message: \"Name cannot be empty\" }),\n  slug: z.string().slugify().min(1, { message: \"Slug cannot be empty\" }),\n  description: z.string().trim().optional(),\n});\nexport type CreateTagValues = z.infer<typeof tagSchema>;\n\n// Category Schema\nexport const categorySchema = z.object({\n  name: z.string().trim().min(1, { message: \"Name cannot be empty\" }),\n  slug: z.string().slugify().min(1, { message: \"Slug cannot be empty\" }),\n  description: z.string().trim().optional(),\n});\nexport type CreateCategoryValues = z.infer<typeof categorySchema>;\n\n// Workspace Creation Schema\nexport const workspaceSchema = z.object({\n  name: z\n    .string()\n    .min(1, { message: \"Name cannot be empty\" })\n    .max(32, { message: \"Name cannot be more than 32 characters\" }),\n  slug: workspaceSlugField,\n  timezone: z\n    .enum(timezones as [string, ...string[]], {\n      message: \"Please select a valid timezone\",\n    })\n    .optional(),\n});\nexport type CreateWorkspaceValues = z.infer<typeof workspaceSchema>;\n\n// Workspace Name Update Schema\nexport const nameSchema = z.object({\n  name: z\n    .string()\n    .trim()\n    .min(1)\n    .max(32, { message: \"Name cannot be more than 32 characters\" }),\n});\nexport type NameValues = z.infer<typeof nameSchema>;\n\n// Workspace Slug Update Schema\nexport const slugSchema = z.object({\n  slug: workspaceSlugField,\n});\nexport type SlugValues = z.infer<typeof slugSchema>;\n\n// Workspace Timezone Update Schema\nexport const timezoneSchema = z.object({\n  timezone: z.enum(timezones as [string, ...string[]]),\n});\nexport type TimezoneValues = z.infer<typeof timezoneSchema>;\n"
  },
  {
    "path": "apps/cms/src/providers/user.tsx",
    "content": "\"use client\";\n\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport { useRouter } from \"next/navigation\";\nimport type React from \"react\";\nimport { createContext, useContext, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { authClient, useSession } from \"@/lib/auth/client\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type { UserContextType, UserProfile } from \"@/types/user\";\nimport { request } from \"@/utils/fetch/client\";\n\ninterface UserProviderProps {\n  children: React.ReactNode;\n  initialUser: UserProfile | null;\n}\n\nconst UserContext = createContext<UserContextType | null>(null);\n\nexport function UserProvider({ children, initialUser }: UserProviderProps) {\n  const queryClient = useQueryClient();\n  const router = useRouter();\n  const [user, setUser] = useState<UserProfile | null>(initialUser);\n  const [isSigningOut, setIsSigningOut] = useState(false);\n  const { data: session, isPending: isSessionPending } = useSession();\n  const isAuthenticated = !!session;\n\n  const fetchCurrentUser = async (): Promise<UserProfile> => {\n    try {\n      const response = await request<UserProfile>(\"user\");\n      setUser(response.data);\n      return response.data;\n    } catch (error) {\n      console.error(\"Failed to fetch user:\", error);\n      throw error;\n    }\n  };\n\n  const { isLoading: isFetchingUser } = useQuery({\n    queryKey: QUERY_KEYS.USER,\n    queryFn: fetchCurrentUser,\n    enabled: isAuthenticated && !isSessionPending,\n    staleTime: 60 * 60 * 1000, // 1 hour\n    gcTime: 10 * 60 * 1000,\n    refetchOnWindowFocus: false,\n    retry: false,\n  });\n\n  const { mutateAsync: updateUserMutation, isPending: isUpdatingUser } =\n    useMutation({\n      mutationFn: async (\n        updates: Partial<Pick<UserProfile, \"name\" | \"image\">>\n      ) => {\n        const response = await request<UserProfile>(\"user\", \"PATCH\", updates);\n        return response.data;\n      },\n      onSuccess: (data) => {\n        setUser(data);\n        toast.success(\"Profile updated\");\n        queryClient.setQueryData(QUERY_KEYS.USER, data);\n        queryClient.invalidateQueries({ queryKey: QUERY_KEYS.USER });\n      },\n      onError: (_error) => {\n        toast.error(\"Failed to update profile\");\n      },\n    });\n\n  const updateUser = async (\n    updates: Partial<Pick<UserProfile, \"name\" | \"image\">>\n  ) => {\n    await updateUserMutation(updates);\n  };\n\n  const signOut = async () => {\n    setIsSigningOut(true);\n    try {\n      await authClient.signOut();\n      queryClient.removeQueries({ queryKey: QUERY_KEYS.USER });\n      router.push(\"/login\");\n    } catch (error) {\n      console.error(\"Failed to sign out:\", error);\n      toast.error(\"Failed to sign out\");\n    }\n    setIsSigningOut(false);\n  };\n\n  return (\n    <UserContext.Provider\n      value={{\n        user,\n        isAuthenticated,\n        isFetchingUser,\n        updateUser,\n        isUpdatingUser,\n        signOut,\n        isSigningOut,\n      }}\n    >\n      {children}\n    </UserContext.Provider>\n  );\n}\n\nexport const useUser = () => {\n  const context = useContext(UserContext);\n  if (!context) {\n    throw new Error(\"useUser must be used within a UserProvider\");\n  }\n  return context;\n};\n"
  },
  {
    "path": "apps/cms/src/providers/workspace.tsx",
    "content": "\"use client\";\n\nimport { useMutation, useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport type { AxiosError } from \"axios\";\nimport { usePathname, useRouter } from \"next/navigation\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useState,\n} from \"react\";\nimport { toast } from \"sonner\";\nimport { organization } from \"@/lib/auth/client\";\nimport {\n  WORKSPACE_SCOPED_PREFIXES,\n  type WorkspaceScopedPrefix,\n} from \"@/lib/constants\";\nimport { QUERY_KEYS } from \"@/lib/queries/keys\";\nimport type {\n  Workspace,\n  WorkspaceContextType,\n  WorkspaceProviderProps,\n} from \"@/types/workspace\";\nimport { request } from \"@/utils/fetch/client\";\nimport { setLastVisitedWorkspace } from \"@/utils/workspace/client\";\n\nconst WorkspaceContext = createContext<WorkspaceContextType | undefined>(\n  undefined\n);\n\nexport function WorkspaceProvider({\n  children,\n  initialWorkspace,\n  workspaceSlug,\n}: WorkspaceProviderProps) {\n  const router = useRouter();\n  const pathname = usePathname();\n  const queryClient = useQueryClient();\n  const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(\n    initialWorkspace\n  );\n  const [isSwitchingWorkspace, setIsSwitchingWorkspace] = useState(false);\n\n  const { data: usersWorkspaces } = useQuery({\n    queryKey: QUERY_KEYS.WORKSPACE_LIST,\n    queryFn: async () => {\n      const response = await request<Workspace[]>(\"workspaces\");\n      return response.data;\n    },\n  });\n\n  const { mutateAsync: updateActiveWorkspaceMutation } = useMutation({\n    mutationFn: async (workspace: Partial<Workspace>) => {\n      setIsSwitchingWorkspace(true);\n\n      if (workspace.slug) {\n        setLastVisitedWorkspace(workspace.slug);\n      }\n\n      if (!workspace.id) {\n        throw new Error(\"Workspace ID is required for switching\");\n      }\n\n      const { data, error } = await organization.setActive({\n        organizationId: workspace.id,\n      });\n\n      if (error) {\n        toast.error(error.message);\n        throw new Error(error.message);\n      }\n\n      return data;\n    },\n    onSuccess: (_data, workspace) => {\n      if (workspace.slug) {\n        const isWorkspaceScopedQuery = (queryKey: readonly unknown[]) =>\n          Array.isArray(queryKey) &&\n          queryKey.length > 0 &&\n          typeof queryKey[0] === \"string\" &&\n          WORKSPACE_SCOPED_PREFIXES.includes(\n            queryKey[0] as WorkspaceScopedPrefix\n          );\n\n        queryClient.cancelQueries({\n          predicate: (query) => isWorkspaceScopedQuery(query.queryKey),\n        });\n        queryClient.removeQueries({\n          predicate: (query) => isWorkspaceScopedQuery(query.queryKey),\n        });\n\n        // Preserve the path after the workspace slug\n        // e.g., /oldworkspace/posts/123 → /newworkspace/posts/123\n        const pathSegments = pathname.split(\"/\").filter(Boolean);\n        const pathAfterWorkspace = pathSegments.slice(1).join(\"/\");\n        const newPath = pathAfterWorkspace\n          ? `/${workspace.slug}/${pathAfterWorkspace}`\n          : `/${workspace.slug}`;\n\n        router.push(newPath);\n      }\n    },\n    onError: (error: AxiosError) => {\n      console.error(\"Failed to switch workspace:\", error);\n      setIsSwitchingWorkspace(false);\n    },\n  });\n\n  const updateActiveWorkspace = useCallback(\n    async (workspace: Partial<Workspace>) => {\n      await updateActiveWorkspaceMutation(workspace);\n    },\n    [updateActiveWorkspaceMutation]\n  );\n\n  const refreshActiveWorkspace = useCallback(async () => {\n    const response = await request<Workspace | null>(\n      `workspaces/${workspaceSlug}`\n    );\n    setActiveWorkspace(response.data);\n  }, [workspaceSlug]);\n\n  useEffect(() => {\n    setActiveWorkspace(initialWorkspace);\n    setIsSwitchingWorkspace(false);\n  }, [initialWorkspace]);\n\n  const isFetchingWorkspace = isSwitchingWorkspace;\n  const isOwner = activeWorkspace?.currentUserRole === \"owner\";\n  const isAdmin = activeWorkspace?.currentUserRole === \"admin\";\n  const isMember = activeWorkspace?.currentUserRole === \"member\";\n  const currentUserRole = activeWorkspace?.currentUserRole || null;\n\n  return (\n    <WorkspaceContext.Provider\n      value={{\n        activeWorkspace,\n        updateActiveWorkspace,\n        refreshActiveWorkspace,\n        isFetchingWorkspace,\n        workspaceList: usersWorkspaces ?? null,\n        isOwner,\n        isAdmin,\n        isMember,\n        currentUserRole,\n      }}\n    >\n      {children}\n    </WorkspaceContext.Provider>\n  );\n}\n\nexport function useWorkspace() {\n  const context = useContext(WorkspaceContext);\n  if (context === undefined) {\n    throw new Error(\"useWorkspace must be used within a WorkspaceProvider\");\n  }\n  return context;\n}\n"
  },
  {
    "path": "apps/cms/src/proxy.ts",
    "content": "import { betterFetch } from \"@better-fetch/fetch\";\nimport type { NextRequest } from \"next/server\";\nimport { NextResponse } from \"next/server\";\nimport type { Session } from \"./lib/auth/types\";\nimport { getLastActiveWorkspaceOrNewOneToSetAsActive } from \"./lib/queries/workspace\";\n\nexport async function proxy(request: NextRequest) {\n  const { data: session } = await betterFetch<Session>(\n    \"/api/auth/get-session\",\n    {\n      baseURL: request.nextUrl.origin,\n      headers: {\n        cookie: request.headers.get(\"cookie\") || \"\",\n      },\n    }\n  );\n\n  const isVerified = session?.user?.emailVerified;\n  const path = request.nextUrl.pathname;\n  const isRootPage = path === \"/\";\n  const isInvitePage = path.startsWith(\"/invite\");\n  const isOnboardingPage = path.startsWith(\"/new\");\n  const isVerifyPage = path.startsWith(\"/verify\");\n  const isSharePage = path.startsWith(\"/share\");\n  const isAuthPage =\n    path.startsWith(\"/login\") ||\n    path.startsWith(\"/register\") ||\n    path.startsWith(\"/reset\");\n\n  // Allow invite flows to proceed normally\n  if (isInvitePage) {\n    return NextResponse.next();\n  }\n\n  if (isSharePage) {\n    return NextResponse.next();\n  }\n\n  // If not logged in at all\n  if (!session) {\n    // Allow auth pages\n    if (isAuthPage) {\n      return NextResponse.next();\n    }\n\n    // Redirect to login for protected routes\n    const callbackUrl = encodeURIComponent(request.nextUrl.pathname);\n    return NextResponse.redirect(\n      new URL(`/login?from=${callbackUrl}`, request.url)\n    );\n  }\n\n  // User is logged in but not verified\n  if (session && !isVerified) {\n    // Allow only verify page for unverified users\n    if (isVerifyPage) {\n      return NextResponse.next();\n    }\n\n    const callbackUrl = encodeURIComponent(request.nextUrl.pathname);\n\n    const email = session.user.email;\n\n    // Redirect unverified users to verify page\n    return NextResponse.redirect(\n      new URL(\n        `/verify?email=${encodeURIComponent(email)}&from=${callbackUrl}`,\n        request.url\n      )\n    );\n  }\n\n  // User is logged in and verified\n  if (session && isVerified) {\n    // Don't redirect if already on onboarding\n    if (isOnboardingPage) {\n      return NextResponse.next();\n    }\n\n    // Redirect auth pages or root to workspace or onboarding\n    if (isAuthPage || isRootPage || isVerifyPage) {\n      const workspace = await getLastActiveWorkspaceOrNewOneToSetAsActive(\n        session.user.id,\n        request.cookies\n      );\n      if (workspace) {\n        return NextResponse.redirect(\n          new URL(`/${workspace.slug}`, request.url)\n        );\n      }\n      return NextResponse.redirect(new URL(\"/new\", request.url));\n    }\n  }\n\n  return NextResponse.next();\n}\n\nexport const config = {\n  matcher: [\"/((?!api|static|.*\\\\..*|_next/static|_next/image|favicon.ico).*)\"],\n};\n"
  },
  {
    "path": "apps/cms/src/styles/editor.css",
    "content": "div[data-youtube-video] {\n  position: relative;\n  width: 100%;\n  padding-bottom: 56.25%;\n  height: 0;\n  overflow: hidden;\n  border-radius: 0.375rem;\n}\n\ndiv[data-youtube-video] > iframe {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  border: 0;\n}\n\n/* Video node styles */\nfigure[data-type=\"video\"] video {\n  width: 100%;\n  height: auto;\n  max-width: 100%;\n  border-radius: 0.375rem;\n}\n\n@media (width <= 720px) {\n  figure[data-type=\"video\"] video {\n    max-height: 360px;\n  }\n}\n\n@media (width <= 480px) {\n  figure[data-type=\"video\"] video {\n    max-height: 200px;\n  }\n}\n\n/* CSS for bold coloring and highlighting issue*/\nspan[style] > strong {\n  color: inherit;\n}\n\nmark[style] > strong {\n  color: inherit;\n}\n\n/* Ensure bg-sidebar works within prose context */\n.prose .bg-sidebar {\n  background-color: var(--color-sidebar) !important;\n}\n"
  },
  {
    "path": "apps/cms/src/styles/globals.css",
    "content": "@import \"tailwindcss\";\n\n/* UI component utilities */\n@source \"../../../../packages/ui/src/components/**/*.{ts,tsx}\";\n\n/* Editor package utilities */\n@source \"../../../../packages/editor/src/**/*.{ts,tsx}\";\n\n/* Custom variant for dark mode */\n@custom-variant dark (&:is(.dark *));\n\n/* Plugins */\n@plugin \"@tailwindcss/typography\";\n@plugin \"tailwindcss-animate\";\n\n:root {\n  --background: hsl(0 0% 100%);\n  --foreground: hsl(0 0% 9%);\n  --card: hsl(0 0% 100%);\n  --card-foreground: hsl(0 0% 9%);\n  --popover: hsl(0 0% 100%);\n  --popover-foreground: hsl(0 0% 9%);\n  --primary: hsl(244 100% 65%);\n  --primary-foreground: hsl(0 0% 98%);\n  --secondary: hsl(0 0% 96.1%);\n  --secondary-foreground: hsl(0 0% 9%);\n  --muted: hsl(0 0% 96.1%);\n  --muted-foreground: hsl(0 0% 45.1%);\n  --accent: hsl(0 0% 96.1%);\n  --accent-foreground: hsl(0 0% 9%);\n  --destructive: hsl(0 84.2% 60.2%);\n  --destructive-foreground: hsl(0 0% 98%);\n  --border: hsl(0 0% 89.8%);\n  --input: hsl(0 0% 89.8%);\n  --ring: hsl(244, 91%, 70%);\n\n  /* chart */\n  --chart-1: hsl(12 76% 61%);\n  --chart-2: hsl(173 58% 39%);\n  --chart-3: hsl(197 37% 24%);\n  --chart-4: hsl(43 74% 66%);\n  --chart-5: hsl(27 87% 67%);\n\n  /* radius */\n  --radius: 0.65rem;\n\n  /* surface - muted background for cards, modals, panels */\n  --surface: hsl(233 10% 96.5%);\n  --surface-foreground: hsl(240 5.3% 26.1%);\n\n  /* sidebar */\n  --sidebar-background: hsl(233 10% 96.5%);\n  --sidebar-foreground: hsl(240 5.3% 26.1%);\n  --sidebar-primary: hsl(240 5.9% 10%);\n  --sidebar-primary-foreground: hsl(0 0% 98%);\n  --sidebar-accent: hsl(0 0% 100%);\n  --sidebar-accent-foreground: hsl(240 5.9% 10%);\n  --sidebar-border: hsl(220 13% 91%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n\n  /* editor */\n  --editor-background: hsl(233 10% 94%);\n  --editor-content-background: hsl(0 0% 100%);\n  --editor-sidebar-background: hsl(0 0% 100%);\n  --editor-field-background: hsl(0 0% 97%);\n  --editor-field-border: hsl(0 0% 91%);\n\n  /* scrollbar */\n  --scrollbar-thumb: hsl(0 0% 37%);\n  --scrollbar-thumb-hover: hsl(0 0% 40%);\n\n  /* breakpoints */\n  --breakpoint-sm: 40rem;\n  --breakpoint-md: 48rem;\n  --breakpoint-lg: 64rem;\n  --breakpoint-xl: 80rem;\n  --breakpoint-2xl: 96rem;\n\n  /* container sizes */\n  --container-xs: 20rem;\n  --container-sm: 24rem;\n  --container-md: 28rem;\n  --container-lg: 32rem;\n  --container-xl: 36rem;\n  --container-2xl: 42rem;\n  --container-3xl: 48rem;\n  --container-4xl: 56rem;\n  --container-5xl: 64rem;\n  --container-6xl: 72rem;\n  --container-7xl: 80rem;\n\n  /* workspace container max-widths */\n  --workspace-container-sm: 40rem;\n  --workspace-container-md: 48rem;\n  --workspace-container-lg: 56rem;\n  --workspace-container-xl: 72rem;\n  --workspace-container-2xl: 80rem;\n}\n.dark {\n  --background: hsl(233 7% 8%);\n  --foreground: hsl(0 0% 98%);\n  --card: hsl(240 6% 10%);\n  --card-foreground: hsl(0 0% 98%);\n  --popover: hsl(233 7% 8%);\n  --popover-foreground: hsl(0 0% 98%);\n  --primary: hsl(244, 97%, 65%);\n  --primary-foreground: hsl(0 0% 98%);\n  --secondary: hsl(0 0% 14.9%);\n  --secondary-foreground: hsl(0 0% 98%);\n  --muted: hsl(0 0% 14.9%);\n  --muted-foreground: hsl(0 0% 63.9%);\n  --accent: hsl(0 0% 14.9%);\n  --accent-foreground: hsl(0 0% 98%);\n  --destructive: hsl(0 84% 56%);\n  --destructive-foreground: hsl(0 0% 98%);\n  --border: hsl(0, 1%, 17%);\n  --input: hsl(0 0% 14.9%);\n  --ring: hsl(244 100% 65%);\n\n  /* chart */\n  --chart-1: hsl(220 70% 50%);\n  --chart-2: hsl(160 60% 45%);\n  --chart-3: hsl(30 80% 55%);\n  --chart-4: hsl(280 65% 60%);\n  --chart-5: hsl(340 75% 55%);\n\n  /* sidebar */\n  --sidebar-background: hsl(240 6% 10%);\n  --sidebar-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-primary: hsl(224.3 76.3% 48%);\n  --sidebar-primary-foreground: hsl(0 0% 100%);\n  --sidebar-accent: hsl(240 4% 16%);\n  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-border: hsl(240 3.7% 15.9%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n\n  /* surface - muted background for cards, modals, panels */\n  --surface: hsl(240 6% 10%);\n  --surface-foreground: hsl(240 4.8% 95.9%);\n\n  /* editor */\n  --editor-background: hsl(240 6% 10%);\n  --editor-content-background: hsl(233 7% 8%);\n  --editor-sidebar-background: hsl(233 7% 8%);\n  --editor-field-background: hsl(0 0% 14.9% / 0.3);\n  --editor-field-border: hsl(0 0% 14.9%);\n\n  /* scrollbar */\n  --scrollbar-thumb: hsl(0, 0%, 60%);\n  --scrollbar-thumb-hover: hsl(0, 0%, 53%);\n}\n\n@theme inline {\n  /* Typography */\n  --font-sans:\n    var(--font-sans), ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\",\n    \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n  --font-mono:\n    var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,\n    \"Liberation Mono\", \"Courier New\", monospace;\n\n  /* Border radius */\n  --radius-lg: var(--radius);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-sm: calc(var(--radius) - 4px);\n\n  /* Palette mapped to root design tokens */\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n\n  /* Chart colors */\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n\n  /* Sidebar colors */\n  --color-sidebar: var(--sidebar-background);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n\n  /* Surface colors - muted background for cards, modals, panels */\n  --color-surface: var(--surface);\n  --color-surface-foreground: var(--surface-foreground);\n\n  /* Editor colors */\n  --color-editor-background: var(--editor-background);\n  --color-editor-content-background: var(--editor-content-background);\n  --color-editor-sidebar-background: var(--editor-sidebar-background);\n  --color-editor-field-background: var(--editor-field-background);\n  --color-editor-field-border: var(--editor-field-border);\n}\n\n/*\n  The default border color has changed to `currentcolor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentcolor);\n  }\n}\n\n@utility scrollbar-hide {\n  -ms-overflow-style: none;\n  scrollbar-width: none;\n  &::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n@utility scrollbar-custom {\n  scrollbar-width: thin;\n  scrollbar-color: var(--scrollbar-thumb) transparent;\n\n  &::-webkit-scrollbar {\n    width: 8px;\n  }\n\n  &::-webkit-scrollbar-track {\n    background: transparent;\n  }\n\n  &::-webkit-scrollbar-thumb {\n    background-color: var(--scrollbar-thumb);\n    border-radius: 4px;\n    border: 1px solid transparent;\n    background-clip: content-box;\n  }\n\n  &::-webkit-scrollbar-thumb:hover {\n    background-color: var(--scrollbar-thumb-hover);\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n  html {\n    scrollbar-gutter: stable;\n  }\n  .darkbackground {\n    background-color: hsl(150, 4%, 9%);\n  }\n  .lightbackground {\n    background-color: hsl(165, 100%, 97%);\n  }\n}\n\n@layer utilities {\n  .bg-editor-background {\n    background-color: var(--color-editor-background);\n  }\n  .bg-editor-content-background {\n    background-color: var(--color-editor-content-background);\n  }\n  .bg-editor-sidebar-background {\n    background-color: var(--color-editor-sidebar-background);\n  }\n  .bg-editor-field {\n    background-color: var(--color-editor-field-background);\n    border-color: var(--color-editor-field-border);\n  }\n  .bg-editor-field:hover {\n    background-color: hsl(0 0% 94%);\n  }\n  .dark .bg-editor-field:hover {\n    background-color: hsl(0 0% 14.9% / 0.5);\n  }\n  .workspace-container {\n    width: 100%;\n  }\n  @media (width >= 40rem) {\n    .workspace-container {\n      max-width: var(--workspace-container-sm, 48rem);\n    }\n  }\n  @media (width >= 48rem) {\n    .workspace-container {\n      max-width: var(--workspace-container-md, 56rem);\n    }\n  }\n  @media (width >= 64rem) {\n    .workspace-container {\n      max-width: var(--workspace-container-lg, 72rem);\n    }\n  }\n  @media (width >= 80rem) {\n    .workspace-container {\n      max-width: var(--workspace-container-xl, 80rem);\n    }\n  }\n\n  /* Compact variant - narrower for settings pages */\n  .workspace-container-compact {\n    width: 100%;\n  }\n  @media (width >= 40rem) {\n    .workspace-container-compact {\n      max-width: 40rem;\n    }\n  }\n  @media (width >= 48rem) {\n    .workspace-container-compact {\n      max-width: 42rem;\n    }\n  }\n  @media (width >= 64rem) {\n    .workspace-container-compact {\n      max-width: 48rem;\n    }\n  }\n  @media (width >= 80rem) {\n    .workspace-container-compact {\n      max-width: 56rem;\n    }\n  }\n  @media (width >= 96rem) {\n    .workspace-container-compact {\n      max-width: 60rem;\n    }\n  }\n\n  .workspace-container-wide {\n    width: 100%;\n  }\n  @media (width >= 64rem) {\n    .workspace-container-wide {\n      max-width: var(--workspace-container-xl, 72rem);\n    }\n  }\n  @media (width >= 80rem) {\n    .workspace-container-wide {\n      max-width: var(--workspace-container-2xl, 80rem);\n    }\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/types/author.ts",
    "content": "import type { SocialPlatform } from \"@/lib/constants\";\n\nexport interface AuthorSocial {\n  id: string;\n  url: string;\n  platform: SocialPlatform;\n}\n\nexport interface Author {\n  id: string;\n  name: string;\n  image: string | null;\n  role: string | null;\n  bio: string | null;\n  email: string | null;\n  slug: string;\n  userId: string | null;\n  isActive: boolean;\n  createdAt: Date | string;\n  updatedAt: Date | string;\n  socials?: AuthorSocial[];\n}\n"
  },
  {
    "path": "apps/cms/src/types/dashboard.ts",
    "content": "import type { Media, MediaType } from \"./media\";\n\ntype DashboardRecentUpload = Pick<\n  Media,\n  | \"alt\"\n  | \"blurHash\"\n  | \"duration\"\n  | \"height\"\n  | \"id\"\n  | \"mimeType\"\n  | \"name\"\n  | \"size\"\n  | \"type\"\n  | \"url\"\n  | \"width\"\n> & {\n  createdAt: string;\n  type: MediaType;\n};\n\nexport interface UsageDashboardData {\n  api: {\n    totals: {\n      total: number;\n      lastPeriod: number;\n      changePercentage: number;\n    };\n    chart: Array<{\n      date: string;\n      label: string;\n      value: number;\n    }>;\n  };\n  webhooks: {\n    total: number;\n    last7Days: number;\n    last24Hours: number;\n    topEndpoint: string | null;\n    topEndpointCount: number;\n    chart: Array<{\n      date: string;\n      label: string;\n      value: number;\n    }>;\n  };\n  media: {\n    total: number;\n    last30Days: number;\n    totalSize: number;\n    lastUploadAt: string | null;\n    recentUploads: DashboardRecentUpload[];\n  };\n}\n\nexport interface PublishingMetricsData {\n  graph: {\n    activity: Array<{\n      date: string;\n      count: number;\n      level: number;\n    }>;\n  };\n}\n"
  },
  {
    "path": "apps/cms/src/types/fields.ts",
    "content": "export interface FieldOption {\n  id: string;\n  fieldId: string;\n  workspaceId: string;\n  value: string;\n  label: string;\n  position: number;\n  createdAt: string;\n  updatedAt: string;\n}\n\nexport interface CustomField {\n  id: string;\n  name: string;\n  description: string | null;\n  key: string;\n  type:\n    | \"text\"\n    | \"number\"\n    | \"boolean\"\n    | \"date\"\n    | \"richtext\"\n    | \"select\"\n    | \"multiselect\";\n  required: boolean;\n  position: number;\n  hasValues?: boolean;\n  options: FieldOption[];\n  createdAt: string;\n  updatedAt: string;\n}\n"
  },
  {
    "path": "apps/cms/src/types/icons.ts",
    "content": "import type { RefAttributes, SVGProps } from \"react\";\n\ntype SVGAttributes = Partial<SVGProps<SVGSVGElement>>;\ntype ElementAttributes = RefAttributes<SVGSVGElement> & SVGAttributes;\n\nexport interface IconProps extends ElementAttributes {\n  size?: string | number;\n  absoluteStrokeWidth?: boolean;\n}\n"
  },
  {
    "path": "apps/cms/src/types/media.ts",
    "content": "import type {\n  MEDIA_FILTER_TYPES,\n  MEDIA_SORTS,\n  MEDIA_TYPES,\n} from \"@/lib/constants\";\n\nimport type { QUERY_KEYS } from \"@/lib/queries/keys\";\n\nexport type MediaType = (typeof MEDIA_TYPES)[number];\n\nexport type MediaFilterType = (typeof MEDIA_FILTER_TYPES)[number];\n\nexport type UploadType = \"avatar\" | \"logo\" | \"media\";\n\nexport interface Media {\n  id: string;\n  name: string;\n  url: string;\n  type: MediaType;\n  size: number;\n  alt: string | null;\n  mimeType: string | null;\n  width: number | null;\n  height: number | null;\n  duration: number | null;\n  blurHash: string | null;\n  createdAt: string;\n}\n\nexport type MediaSort = (typeof MEDIA_SORTS)[number];\n\nexport type MediaQueryKey = [\n  ...ReturnType<typeof QUERY_KEYS.MEDIA>,\n  {\n    page?: number;\n    perPage?: number;\n    search?: string;\n    sort: MediaSort;\n    type?: string;\n  },\n];\n\nexport interface MediaPaginatedListResponse {\n  media: Media[];\n  pageCount: number;\n  totalCount: number;\n  hasAnyMedia: boolean;\n}\n\nexport interface MediaCursorListResponse {\n  media: Media[];\n  nextCursor?: string;\n  hasAnyMedia: boolean;\n}\n\nexport type MediaListResponse =\n  | MediaCursorListResponse\n  | MediaPaginatedListResponse;\n\n/** Response from POST /api/upload — returns a presigned URL and storage key */\nexport interface PresignedUrlResponse {\n  url: string;\n  key: string;\n}\n\n/** Response from POST /api/upload/complete for non-media types (avatar, logo) */\nexport interface UploadResponse {\n  url: string;\n}\n\n/**\n * Maps each UploadType to its corresponding response from POST /api/upload/complete.\n * - avatar / logo → UploadResponse (just a public URL)\n * - media → Media (includes id, name, size, type, etc.)\n */\nexport interface UploadResponseMap {\n  avatar: UploadResponse;\n  logo: UploadResponse;\n  media: Media;\n}\n"
  },
  {
    "path": "apps/cms/src/types/misc.ts",
    "content": "export type AuthMethod = \"google\" | \"github\" | \"email\";\n"
  },
  {
    "path": "apps/cms/src/types/share.ts",
    "content": "// Share page types\n\nexport interface ShareAuthor {\n  id: string;\n  name: string;\n  image: string | null;\n  bio: string | null;\n}\n\nexport interface ShareCategory {\n  id: string;\n  name: string;\n  slug: string;\n}\n\nexport interface ShareTag {\n  id: string;\n  name: string;\n  slug: string;\n}\n\nexport interface ShareWorkspace {\n  id: string;\n  name: string;\n  logo: string | null;\n  slug: string;\n}\n\nexport interface SharePost {\n  id: string;\n  title: string;\n  content: string;\n  contentJson: unknown;\n  description: string;\n  coverImage: string | null;\n  status: string;\n  createdAt: string;\n  updatedAt: string;\n  publishedAt: string;\n  authors: ShareAuthor[];\n  category: ShareCategory;\n  tags: ShareTag[];\n  workspace: ShareWorkspace;\n}\n\nexport interface ShareData {\n  post: SharePost;\n  expiresAt: string;\n}\n\nexport interface SharePageClientProps {\n  token?: string;\n  data?: ShareData;\n  status?: \"expired\" | \"not-found\";\n}\n\nexport type ShareStatus = \"expired\" | \"not-found\";\n\n// API response types\nexport interface ShareLinkResponse {\n  shareLink: string;\n  expiresAt: string;\n}\n\nexport interface ShareErrorResponse {\n  error: string;\n}\n"
  },
  {
    "path": "apps/cms/src/types/user.ts",
    "content": "import type { User } from \"better-auth\";\n\nexport interface UserProfile extends Omit<User, \"emailVerified\"> {\n  id: string;\n  name: string;\n  email: string;\n  image?: string | null;\n  emailVerified: boolean;\n  createdAt: Date;\n  updatedAt: Date;\n  workspaceRole: string | null;\n  // accountId: string | null;\n  activeWorkspace: {\n    id: string;\n    name: string;\n    slug: string;\n  } | null;\n}\n\nexport interface UserContextType {\n  user: UserProfile | null;\n  isAuthenticated: boolean;\n  isFetchingUser: boolean;\n  updateUser: (\n    updates: Partial<Pick<UserProfile, \"name\" | \"image\">>\n  ) => Promise<void>;\n  isUpdatingUser: boolean;\n  signOut: () => Promise<void>;\n  isSigningOut: boolean;\n}\n"
  },
  {
    "path": "apps/cms/src/types/webhook.ts",
    "content": "export interface Webhook {\n  id: string;\n  name: string;\n  url: string;\n  secret: string;\n  events: string[];\n  enabled: boolean;\n  format: string;\n  createdAt: Date;\n  updatedAt: Date;\n}\n"
  },
  {
    "path": "apps/cms/src/types/workspace.ts",
    "content": "import type { PlanType } from \"@/lib/plans\";\n\nexport interface Workspace {\n  id: string;\n  name: string;\n  slug: string;\n  logo: string | null;\n  timezone: string | null;\n  createdAt: Date | string;\n  currentUserRole: string | null;\n  members: Array<{\n    id: string;\n    role: string;\n    organizationId: string;\n    createdAt: Date | string;\n    userId: string;\n    user: {\n      id: string;\n      name: string | null;\n      email: string;\n      image: string | null;\n    };\n  }>;\n  invitations?: Array<{\n    id: string;\n    email: string;\n    role: string | null;\n    status: string;\n    organizationId: string;\n    inviterId: string;\n    expiresAt: Date | string;\n  }>;\n  subscription: {\n    id: string;\n    status: string;\n    plan: PlanType;\n    activePlan: PlanType;\n    currentPeriodStart?: string | Date;\n    currentPeriodEnd?: string | Date;\n    cancelAtPeriodEnd?: boolean;\n    canceledAt?: string | Date | null;\n  } | null;\n}\n\nexport interface WorkspaceContextType {\n  activeWorkspace: Workspace | null;\n  updateActiveWorkspace: (workspace: Partial<Workspace>) => Promise<void>;\n  refreshActiveWorkspace: () => Promise<void>;\n  workspaceList: Workspace[] | null;\n  isFetchingWorkspace: boolean;\n  isOwner: boolean;\n  isAdmin: boolean;\n  isMember: boolean;\n  currentUserRole: string | null;\n}\n\nexport interface WorkspaceProviderProps {\n  children: React.ReactNode;\n  initialWorkspace: Workspace | null;\n  workspaceSlug: string;\n}\n"
  },
  {
    "path": "apps/cms/src/utils/author.tsx",
    "content": "import {\n  DiscordLogoIcon,\n  FacebookLogoIcon,\n  FanIcon,\n  GithubLogoIcon,\n  GlobeHemisphereEastIcon,\n  InstagramLogoIcon,\n  LinkedinLogoIcon,\n  XLogoIcon,\n  YoutubeLogoIcon,\n} from \"@phosphor-icons/react\";\nimport { Bluesky as BlueskyIcon } from \"@/components/icons/social\";\nimport { PLATFORM_DOMAINS, type SocialPlatform } from \"@/lib/constants\";\n\n/**\n * Detects the social platform from a URL\n */\nexport function detectPlatform(url: string): SocialPlatform {\n  try {\n    const normalized = /^(https?:)?\\/\\//i.test(url) ? url : `https://${url}`;\n    const { hostname } = new URL(normalized);\n    const host = hostname.toLowerCase().replace(/^www\\./, \"\");\n\n    for (const [platform, domains] of Object.entries(PLATFORM_DOMAINS)) {\n      if (\n        domains.some((domain) => host === domain || host.endsWith(`.${domain}`))\n      ) {\n        return platform as SocialPlatform;\n      }\n    }\n\n    return \"website\";\n  } catch {\n    return \"website\";\n  }\n}\n\n/**\n * Gets the display name for a social platform\n */\nexport function getPlatformDisplayName(platform: SocialPlatform): string {\n  const displayNames: Record<SocialPlatform, string> = {\n    x: \"X (Twitter)\",\n    github: \"GitHub\",\n    facebook: \"Facebook\",\n    instagram: \"Instagram\",\n    youtube: \"YouTube\",\n    tiktok: \"TikTok\",\n    linkedin: \"LinkedIn\",\n    onlyfans: \"OnlyFans\",\n    discord: \"Discord\",\n    website: \"Website\",\n    bluesky: \"Bluesky\",\n  };\n\n  return displayNames[platform] || platform;\n}\n\nexport const getPlatformIcon = (platform: SocialPlatform) => {\n  const iconProps = { className: \"size-6 text-muted-foreground\" };\n\n  switch (platform) {\n    case \"x\":\n      return <XLogoIcon {...iconProps} />;\n    case \"github\":\n      return <GithubLogoIcon {...iconProps} />;\n    case \"linkedin\":\n      return <LinkedinLogoIcon {...iconProps} />;\n    case \"facebook\":\n      return <FacebookLogoIcon {...iconProps} />;\n    case \"instagram\":\n      return <InstagramLogoIcon {...iconProps} />;\n    case \"youtube\":\n      return <YoutubeLogoIcon {...iconProps} />;\n    case \"tiktok\":\n      return <GlobeHemisphereEastIcon {...iconProps} />;\n    case \"website\":\n      return <GlobeHemisphereEastIcon {...iconProps} />;\n    case \"onlyfans\":\n      return <FanIcon {...iconProps} />;\n    case \"discord\":\n      return <DiscordLogoIcon {...iconProps} />;\n    case \"bluesky\":\n      return <BlueskyIcon {...iconProps} />;\n    default:\n      return <GlobeHemisphereEastIcon {...iconProps} />;\n  }\n};\n"
  },
  {
    "path": "apps/cms/src/utils/editor.ts",
    "content": "import sanitize, { defaults } from \"sanitize-html\";\n\nexport const sanitizeHtml = (content: string) => {\n  const sanitizedContent = sanitize(content, {\n    allowedTags: [\n      \"b\",\n      \"i\",\n      \"em\",\n      \"strong\",\n      \"a\",\n      \"img\",\n      \"video\",\n      \"track\",\n      \"h1\",\n      \"h2\",\n      \"h3\",\n      \"h4\",\n      \"h5\",\n      \"h6\",\n      \"code\",\n      \"pre\",\n      \"p\",\n      \"li\",\n      \"ul\",\n      \"ol\",\n      \"blockquote\",\n      \"td\",\n      \"th\",\n      \"table\",\n      \"tr\",\n      \"tbody\",\n      \"thead\",\n      \"tfoot\",\n      \"small\",\n      \"div\",\n      \"iframe\",\n      \"input\",\n      \"label\",\n      \"figure\",\n      \"figcaption\",\n      \"span\",\n      \"mark\",\n      \"s\",\n      \"u\",\n      \"sub\",\n      \"sup\",\n      \"hr\",\n    ],\n    allowedAttributes: {\n      ...defaults.allowedAttributes,\n      \"*\": [\"style\"],\n      code: [\"class\"],\n      a: [\"href\", \"target\"],\n      iframe: [\"src\", \"allowfullscreen\", \"style\", \"width\", \"height\"],\n      input: [\"type\", \"checked\"],\n      figure: [\n        \"src\",\n        \"alt\",\n        \"data-width\",\n        \"caption\",\n        \"data-align\",\n        \"data-type\",\n      ],\n      video: [\"src\", \"controls\", \"preload\", \"muted\", \"loop\", \"playsinline\"],\n      track: [\"kind\", \"src\", \"srclang\", \"label\"],\n      div: [\"data-twitter\", \"data-src\", \"data-youtube-video\"],\n      span: [\"style\", \"data-color\"],\n      mark: [\"style\", \"data-color\"],\n    },\n    allowedStyles: {\n      \"*\": {\n        color: [\n          /^#[\\da-fA-F]{3,6}$/,\n          /^rgb\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*\\)$/,\n          /^rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*[\\d.]+\\s*\\)$/,\n          /^[a-zA-Z]+$/,\n        ],\n        \"background-color\": [\n          /^#[\\da-fA-F]{3,6}$/,\n          /^rgb\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*\\)$/,\n          /^rgba\\(\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*[\\d.]+\\s*\\)$/,\n          /^[a-zA-Z]+$/,\n        ],\n        \"text-decoration\": [/^line-through$/, /^underline$/, /^none$/],\n      },\n    },\n    allowedSchemes: [\"http\", \"https\", \"ftp\", \"mailto\"],\n    allowedSchemesByTag: {\n      img: [\"http\", \"https\", \"data\"],\n      video: [\"http\", \"https\"],\n      a: [\"http\", \"https\", \"ftp\", \"mailto\"],\n      iframe: [\"https\"],\n    },\n    allowedIframeHostnames: [\"www.youtube.com\", \"www.youtube-nocookie.com\"],\n    exclusiveFilter: (frame) => {\n      if (frame.tag === \"script\") {\n        return true;\n      }\n      if (frame.attribs) {\n        for (const attr in frame.attribs) {\n          if (/^on/i.test(attr)) {\n            return true;\n          }\n        }\n      }\n      return false;\n    },\n  });\n\n  return sanitizedContent;\n};\n\nexport const sanitizeRichTextHtml = (content: string) => {\n  return sanitize(content, {\n    allowedTags: [\n      \"a\",\n      \"b\",\n      \"br\",\n      \"em\",\n      \"i\",\n      \"li\",\n      \"ol\",\n      \"p\",\n      \"strong\",\n      \"u\",\n      \"ul\",\n    ],\n    allowedAttributes: {\n      a: [\"href\", \"target\", \"rel\"],\n    },\n    allowedSchemes: [\"http\", \"https\", \"mailto\"],\n    exclusiveFilter: (frame) => {\n      if (frame.tag === \"script\") {\n        return true;\n      }\n      if (frame.attribs) {\n        for (const attr in frame.attribs) {\n          if (/^on/i.test(attr)) {\n            return true;\n          }\n        }\n      }\n      return false;\n    },\n  });\n};\n"
  },
  {
    "path": "apps/cms/src/utils/fetch/client.ts",
    "content": "import axios from \"axios\";\n\ntype RequestMethod = \"GET\" | \"POST\" | \"PUT\" | \"DELETE\" | \"PATCH\";\n\nexport async function request<T>(\n  endpoint: string,\n  method: RequestMethod = \"GET\",\n  body?: unknown\n) {\n  const response = await axios<T>({\n    method,\n    url: `${process.env.NEXT_PUBLIC_APP_URL}/api/${endpoint}`,\n    data: body,\n  });\n  return response;\n}\n"
  },
  {
    "path": "apps/cms/src/utils/keys.ts",
    "content": "import { API_KEY_PREFIXES as PREFIXES } from \"@marble/utils\";\n\n// biome-ignore lint/performance/noBarrelFile: <>\nexport { API_KEY_PREFIXES } from \"@marble/utils\";\n\nexport type ApiKeyPrefix = (typeof PREFIXES)[keyof typeof PREFIXES];\n\n/**\n * Default scopes for public API keys (read-only access)\n */\nexport const DEFAULT_PUBLIC_SCOPES = [\n  \"posts_read\",\n  \"authors_read\",\n  \"categories_read\",\n  \"tags_read\",\n  \"media_read\",\n] as const;\n\n/**\n * Default scopes for private API keys (full access)\n */\nexport const DEFAULT_PRIVATE_SCOPES = [\n  \"posts_read\",\n  \"posts_write\",\n  \"authors_read\",\n  \"authors_write\",\n  \"categories_read\",\n  \"categories_write\",\n  \"tags_read\",\n  \"tags_write\",\n  \"media_read\",\n  \"media_write\",\n] as const;\n\n/**\n * Validates if an API key has a valid prefix\n * @param key - The API key to validate\n * @returns The key type if valid, null otherwise\n */\nexport function getApiKeyType(key: string): \"public\" | \"private\" | null {\n  if (key.startsWith(PREFIXES.public)) {\n    return \"public\";\n  }\n  if (key.startsWith(PREFIXES.private)) {\n    return \"private\";\n  }\n  return null;\n}\n\n/**\n * Valid scope values matching the ApiScope enum\n */\nexport const VALID_SCOPES = [\n  \"posts_read\",\n  \"posts_write\",\n  \"authors_read\",\n  \"authors_write\",\n  \"categories_read\",\n  \"categories_write\",\n  \"tags_read\",\n  \"tags_write\",\n  \"media_read\",\n  \"media_write\",\n] as const;\n\nexport type ApiScope = (typeof VALID_SCOPES)[number];\n\n/**\n * Parse permissions string (comma-separated) into scopes array\n * @param permissions - Comma-separated permissions string (should be underscore format)\n * @returns Array of valid scope strings\n */\nexport function parseScopes(permissions: string | null): ApiScope[] {\n  if (!permissions) {\n    return [];\n  }\n\n  return permissions\n    .split(\",\")\n    .map((p) => p.trim())\n    .filter((scope): scope is ApiScope =>\n      VALID_SCOPES.includes(scope as ApiScope)\n    );\n}\n\n/**\n * Check if a scope exists in the scopes array\n * @param scopes - Array of scopes to check\n * @param scope - Scope to check for\n * @returns True if scope exists\n */\nexport function hasScope(scopes: ApiScope[], scope: ApiScope): boolean {\n  return scopes.includes(scope);\n}\n\n/**\n * Validate that all scope strings are valid\n * @param scopes - Array of scope strings to validate\n * @returns True if all scopes are valid\n */\nexport function validateScopes(scopes: string[]): boolean {\n  return scopes.every((scope) => VALID_SCOPES.includes(scope as ApiScope));\n}\n"
  },
  {
    "path": "apps/cms/src/utils/media.ts",
    "content": "import type { MEDIA_SORT_BY, SORT_DIRECTIONS } from \"@/lib/constants\";\nimport type {\n  Media,\n  MediaFilterType,\n  MediaSort,\n  MediaType,\n} from \"@/types/media\";\n\nexport function getMediaType(mimeType: string): MediaType {\n  if (mimeType.startsWith(\"image/\")) {\n    return \"image\";\n  }\n  if (mimeType.startsWith(\"video/\")) {\n    return \"video\";\n  }\n  if (mimeType.startsWith(\"audio/\")) {\n    return \"audio\";\n  }\n  return \"document\";\n}\n\nexport function getEmptyStateMessage(type?: MediaType, hasAnyMedia?: boolean) {\n  if (!hasAnyMedia) {\n    return \"Media you upload in this workspace will appear here.\";\n  }\n  switch (type) {\n    case \"image\":\n      return \"No images found. Try uploading some images or adjusting your filters.\";\n    case \"video\":\n      return \"No videos found. Try uploading some videos or adjusting your filters.\";\n    case \"audio\":\n      return \"No audio files found. Try uploading some audio files or adjusting your filters.\";\n    case \"document\":\n      return \"No documents found. Try uploading some documents or adjusting your filters.\";\n    default:\n      return \"No media found. Try adjusting your filters or upload some media.\";\n  }\n}\n\nexport function isMediaSort(value: string): value is MediaSort {\n  // defer to constants list at call sites where needed; this keeps util generic\n  return [\"createdAt_desc\", \"createdAt_asc\", \"name_asc\", \"name_desc\"].includes(\n    value\n  );\n}\n\nexport function splitMediaSort(sort: MediaSort) {\n  const [field, direction] = sort.split(\"_\") as [\n    (typeof MEDIA_SORT_BY)[number],\n    (typeof SORT_DIRECTIONS)[number],\n  ];\n  return { field, direction };\n}\n\nexport function isMediaFilterType(\n  value: MediaFilterType\n): value is MediaFilterType {\n  return [\"all\", \"image\", \"video\", \"audio\", \"document\"].includes(\n    value as string\n  );\n}\n\nexport function toMediaType(value: MediaFilterType): MediaType | undefined {\n  return value === \"all\" ? undefined : value;\n}\n\nexport async function downloadMedia(media: Media) {\n  const response = await fetch(media.url);\n  if (!response.ok) {\n    throw new Error(\"Failed to download media\");\n  }\n\n  const blob = await response.blob();\n  const objectUrl = URL.createObjectURL(blob);\n  const link = document.createElement(\"a\");\n  try {\n    link.href = objectUrl;\n    link.download = media.name;\n    document.body.appendChild(link);\n    link.click();\n  } finally {\n    link.remove();\n    URL.revokeObjectURL(objectUrl);\n  }\n}\n\nexport function formatMediaType(media: Media) {\n  return `${media.type.charAt(0).toUpperCase()}${media.type.slice(1)}`;\n}\n\nexport function formatMediaDimensions(media: Media) {\n  if (media.width && media.height) {\n    return `${media.width} x ${media.height}`;\n  }\n  return \"-\";\n}\n\nexport function formatMediaDuration(duration: number | null | undefined) {\n  if (duration == null) {\n    return \"-\";\n  }\n\n  const totalSeconds = Math.round(duration / 1000);\n  const hours = Math.floor(totalSeconds / 3600);\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  const seconds = totalSeconds % 60;\n  const paddedMinutes = String(minutes).padStart(hours > 0 ? 2 : 1, \"0\");\n  const paddedSeconds = String(seconds).padStart(2, \"0\");\n\n  if (hours > 0) {\n    return `${hours}:${paddedMinutes}:${paddedSeconds}`;\n  }\n\n  return `${paddedMinutes}:${paddedSeconds}`;\n}\n"
  },
  {
    "path": "apps/cms/src/utils/readability.ts",
    "content": "import type { Editor } from \"@marble/editor\";\n\nexport function calculateReadabilityScore(editor: Editor): number {\n  const text = editor?.getText();\n  if (!text || text.trim().length === 0) {\n    return 0;\n  }\n\n  const wordCountResult = editor.storage.characterCount?.words\n    ? editor.storage.characterCount.words()\n    : 0;\n  const sentences = text\n    .split(/[.!?]+/)\n    .filter((sentence) => sentence.trim().length > 0);\n\n  const words = text\n    .trim()\n    .split(/\\s+/)\n    .filter((word) => word.length > 0);\n  const syllables = words.reduce((acc, word) => acc + countSyllables(word), 0);\n\n  if (sentences.length === 0 || wordCountResult === 0) {\n    return 0;\n  }\n\n  const avgSentenceLength = wordCountResult / sentences.length;\n  const avgSyllablesPerWord = syllables / wordCountResult;\n\n  const score =\n    206.835 - 1.015 * avgSentenceLength - 84.6 * avgSyllablesPerWord;\n\n  return Math.max(0, Math.min(100, Math.round(score)));\n}\n\nfunction countSyllables(word: string): number {\n  const lowerCaseWord = word.toLowerCase();\n  if (lowerCaseWord.length <= 3) {\n    return 1;\n  }\n\n  const lowerCaseWordWithoutEs = lowerCaseWord.replace(\n    /(?:[^laeiouy]es|ed|[^laeiouy]e)$/,\n    \"\"\n  );\n  const lowerCaseWordWithoutY = lowerCaseWordWithoutEs.replace(/^y/, \"\");\n\n  const syllableMatches = lowerCaseWordWithoutY.match(/[aeiouy]{1,2}/g);\n  return syllableMatches ? syllableMatches.length : 1;\n}\n\nexport function getReadabilityLevel(score: number): {\n  level: string;\n  description: string;\n} {\n  if (score >= 90) {\n    return {\n      level: \"Very Easy\",\n      description: \"Easily understood by an average 11-year-old student\",\n    };\n  }\n  if (score >= 80) {\n    return {\n      level: \"Easy\",\n      description: \"Conversational English for consumers\",\n    };\n  }\n  if (score >= 70) {\n    return {\n      level: \"Fairly Easy\",\n      description: \"Easily understood by 13- to 15-year-old students\",\n    };\n  }\n  if (score >= 60) {\n    return {\n      level: \"Standard\",\n      description: \"Easily understood by 15- to 17-year-old students\",\n    };\n  }\n  if (score >= 50) {\n    return {\n      level: \"Fairly Difficult\",\n      description: \"Understood by 13- to 15-year-old students\",\n    };\n  }\n  if (score >= 30) {\n    return {\n      level: \"Difficult\",\n      description: \"Best understood by university graduates\",\n    };\n  }\n  return {\n    level: \"Very Difficult\",\n    description: \"Best understood by university graduates\",\n  };\n}\n\nexport function generateSuggestions(metrics: {\n  wordCount: number;\n  sentenceCount: number;\n  wordsPerSentence: number;\n  readabilityScore: number;\n}): string[] {\n  const { wordCount, sentenceCount, wordsPerSentence, readabilityScore } =\n    metrics;\n  const suggestions: string[] = [];\n\n  if (wordCount === 0) {\n    suggestions.push(\n      \"Start writing your content to get SEO insights and readability analysis\"\n    );\n    suggestions.push(\"Aim for at least 300 words for good SEO performance\");\n    return suggestions;\n  }\n\n  if (wordCount <= 50) {\n    suggestions.push(\n      \"Add more content - articles with 300+ words tend to perform better in search results\"\n    );\n    suggestions.push(\n      \"Consider expanding your ideas with examples, details, or explanations\"\n    );\n    return suggestions;\n  }\n\n  if (wordCount <= 150) {\n    suggestions.push(\n      \"Your content is quite short. Consider adding more details for better SEO\"\n    );\n    suggestions.push(\n      \"Aim for 300-600 words for optimal search engine visibility\"\n    );\n    if (sentenceCount < 3) {\n      suggestions.push(\n        \"Break your content into more sentences for better readability\"\n      );\n    }\n    return suggestions;\n  }\n\n  if (wordCount <= 300) {\n    suggestions.push(\n      \"Good start! Consider expanding to 300-600 words for better SEO performance\"\n    );\n    if (wordsPerSentence > 25) {\n      suggestions.push(\n        `Your sentences are quite long (avg: ${wordsPerSentence} words). Try shorter sentences for better readability`\n      );\n    }\n    if (readabilityScore < 50) {\n      suggestions.push(\"Consider using simpler words to improve readability\");\n    }\n    return suggestions;\n  }\n  if (wordsPerSentence > 25) {\n    suggestions.push(\n      `Consider shorter sentences (avg: ${wordsPerSentence} words) to improve readability`\n    );\n  }\n\n  if (readabilityScore < 30) {\n    suggestions.push(\n      \"Your content is quite complex. Consider using simpler vocabulary for broader accessibility\"\n    );\n  } else if (readabilityScore < 50) {\n    suggestions.push(\n      \"Consider simplifying some sentences to improve readability\"\n    );\n  }\n\n  if (sentenceCount < wordCount / 20) {\n    suggestions.push(\n      \"Consider breaking up some longer sentences into shorter ones\"\n    );\n  }\n\n  if (wordCount > 1000 && wordsPerSentence < 15) {\n    suggestions.push(\n      \"Your content is comprehensive! Consider varying sentence length for better flow\"\n    );\n  }\n\n  if (suggestions.length === 0) {\n    if (readabilityScore >= 60) {\n      suggestions.push(\n        \"Great! Your content has good readability and length for SEO\"\n      );\n    } else {\n      suggestions.push(\n        \"Your content length is good. Focus on improving readability for better engagement\"\n      );\n    }\n  }\n\n  return suggestions;\n}\n"
  },
  {
    "path": "apps/cms/src/utils/site.ts",
    "content": "export const SITE_CONFIG = {\n  title: \"Marble\",\n  description:\n    \"A simple, collaborative CMS for publishing articles, changelogs, and product updates.\",\n  url: \"https://app.marblecms.com\",\n  ogImage: \"https://app.marblecms.com/og.webp\",\n};\n"
  },
  {
    "path": "apps/cms/src/utils/string.ts",
    "content": "import { format } from \"date-fns\";\n\n/**\n * Formats a UTC-midnight date as a calendar date string, ignoring the\n * browser's local timezone so \"March 18 00:00 UTC\" always renders as\n * \"Mar 18, 2026\" regardless of where the viewer is.\n */\nexport function formatCalendarDate(date: Date, formatStr: string) {\n  const local = new Date(\n    date.getUTCFullYear(),\n    date.getUTCMonth(),\n    date.getUTCDate()\n  );\n  return format(local, formatStr);\n}\n\nexport function generateSlug(\n  text: string,\n  { trimEdges = true }: { trimEdges?: boolean } = {}\n) {\n  const slug = (trimEdges ? text.trim() : text)\n    .toLowerCase()\n    .replace(/\\s+/g, \"-\")\n    .replace(/[^a-z0-9-]/g, \"\") // Allow lowercase letters, digits, and hyphens\n    .replace(/-+/g, \"-\") // Replace multiple hyphens with a single hyphen\n    .replace(trimEdges ? /^-+|-+$/g : /$^/g, \"\");\n\n  return slug;\n}\n\nexport function formatBytes(\n  bytes: number,\n  opts: {\n    decimals?: number;\n    sizeType?: \"accurate\" | \"normal\";\n  } = {}\n) {\n  const { decimals = 2, sizeType = \"normal\" } = opts;\n\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const accurateSizes = [\"Bytes\", \"KiB\", \"MiB\", \"GiB\", \"TiB\"];\n\n  if (bytes === 0) {\n    return \"0 Byte\";\n  }\n\n  const i = Math.floor(Math.log(bytes) / Math.log(1024));\n\n  return `${Number.parseFloat((bytes / 1024 ** i).toFixed(decimals))} ${\n    sizeType === \"accurate\" ? accurateSizes[i] : sizes[i]\n  }`;\n}\n"
  },
  {
    "path": "apps/cms/src/utils/usage/media.ts",
    "content": "import { db } from \"@marble/db\";\nimport { createPolarClient } from \"@/lib/polar/client\";\n\n/**\n * Gets the customer ID for a workspace by finding the owner's user ID.\n * Falls back to workspaceId if lookup fails or no owner is found.\n */\nexport async function getCustomerIdForWorkspace(\n  workspaceId: string\n): Promise<string> {\n  try {\n    const organization = await db.organization.findFirst({\n      where: { id: workspaceId },\n      select: {\n        members: {\n          where: { role: \"owner\" },\n          select: { userId: true },\n          take: 1,\n        },\n      },\n    });\n\n    const ownerUserId =\n      organization?.members && organization.members.length > 0\n        ? organization.members[0]?.userId\n        : undefined;\n\n    return ownerUserId ?? workspaceId;\n  } catch (error) {\n    console.error(\"[Media Upload] Failed to get customer ID:\", error);\n    return workspaceId;\n  }\n}\n\n/**\n * Tracks media upload in the database by creating a usage event.\n */\nexport async function trackMediaUploadInDB(\n  workspaceId: string,\n  fileSize: number\n): Promise<void> {\n  await db.usageEvent.create({\n    data: {\n      type: \"media_upload\",\n      workspaceId,\n      size: fileSize,\n    },\n  });\n}\n\n/**\n * Tracks media upload in Polar analytics.\n */\nexport async function trackMediaUploadInPolar(\n  customerId: string,\n  fileSize: number,\n  mediaType: string\n): Promise<void> {\n  const polarClient = createPolarClient();\n  if (!polarClient) {\n    return;\n  }\n\n  await polarClient.events.ingest({\n    events: [\n      {\n        name: \"media_upload\",\n        externalCustomerId: customerId,\n        metadata: {\n          size: fileSize,\n          type: mediaType,\n        },\n      },\n    ],\n  });\n}\n\n/**\n * Orchestrates all media upload tracking operations.\n * Each operation is independent and failures don't block others.\n */\nexport async function trackMediaUpload(\n  workspaceId: string,\n  fileSize: number,\n  mediaType: string\n): Promise<void> {\n  try {\n    await trackMediaUploadInDB(workspaceId, fileSize);\n  } catch (error) {\n    console.error(\"[Media Upload] Failed to track in DB:\", error);\n  }\n\n  try {\n    const customerId = await getCustomerIdForWorkspace(workspaceId);\n    await trackMediaUploadInPolar(customerId, fileSize, mediaType);\n  } catch (error) {\n    console.error(\n      \"[Media Upload] Polar ingestion error (events may still be processed):\",\n      error instanceof Error ? error.message : error\n    );\n  }\n}\n"
  },
  {
    "path": "apps/cms/src/utils/workspace/client.ts",
    "content": "import type { RequestCookies } from \"next/dist/compiled/@edge-runtime/cookies\";\nimport type { ReadonlyRequestCookies } from \"next/dist/server/web/spec-extension/adapters/request-cookies\";\nimport { lastVisitedWorkspace } from \"./constants\";\n\n/**\n * Sets the last visited workspace in a cookie.\n * @param workspace The slug of the workspace to set as last visited.\n * @param maxAge The maximum age of the cookie in seconds.\n */\nexport const setLastVisitedWorkspace = (\n  workspace: string,\n  maxAge: number = 30 * 86_400\n) => {\n  // biome-ignore lint/suspicious/noDocumentCookie: <>\n  document.cookie = `${lastVisitedWorkspace}=${workspace}; max-age=${maxAge}; path=/`;\n};\n\n/**\n * Retrieves the last visited workspace from cookies.\n * @param cookies The RequestCookies or ReadonlyRequestCookies object to read from.\n * @returns The slug of the last visited workspace, or undefined if not set.\n */\nexport const getLastVisitedWorkspace = (\n  cookies: RequestCookies | ReadonlyRequestCookies\n): string | undefined => cookies.get(lastVisitedWorkspace)?.value;\n"
  },
  {
    "path": "apps/cms/src/utils/workspace/constants.ts",
    "content": "export const lastVisitedWorkspace = \"last-visited-workspace\";\n"
  },
  {
    "path": "apps/cms/src/utils/workspace/server.ts",
    "content": "\"use server\";\n\nimport { cookies } from \"next/headers\";\nimport { lastVisitedWorkspace } from \"./constants\";\n\n/**\n * Sets the last visited workspace cookie on the server.\n *\n * Use inside API routes, server actions, or other server-side code.\n *\n * @param workspace - The workspace slug to store in the cookie.\n * @param maxAge - Cookie lifetime in seconds (defaults to 30 days).\n */\nexport const setServerLastVisitedWorkspace = async (\n  workspace: string,\n  maxAge: number = 30 * 86_400\n) => {\n  if (\n    !workspace ||\n    typeof workspace !== \"string\" ||\n    workspace.trim().length === 0\n  ) {\n    throw new Error(\"Invalid workspace: must be a non-empty string\");\n  }\n  if (workspace.length > 255) {\n    throw new Error(\"Invalid workspace: exceeds maximum length\");\n  }\n\n  const cookieStore = await cookies();\n  cookieStore.set({\n    name: lastVisitedWorkspace,\n    value: workspace,\n    path: \"/\",\n    maxAge,\n    sameSite: \"lax\",\n    httpOnly: true,\n    secure: process.env.NODE_ENV === \"production\",\n  });\n};\n\n/**\n * Reads the last visited workspace cookie on the server.\n *\n * @returns The stored workspace slug, or undefined if not present.\n */\nexport const getServerLastVisitedWorkspace = async (): Promise<\n  string | undefined\n> => {\n  const cookieStore = await cookies();\n  return cookieStore.get(lastVisitedWorkspace)?.value;\n};\n"
  },
  {
    "path": "apps/cms/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/nextjs.json\",\n  \"compilerOptions\": {\n    \"strict\": true,\n    \"declaration\": false,\n    \"declarationMap\": false,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@marble/ui/*\": [\"../../packages/ui/src/*\"]\n    }\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"next.config.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\"\n  ],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "apps/jobs/.gitignore",
    "content": "# prod\ndist/\n\n# dev\n.yarn/\n!.yarn/releases\n.vscode/*\n!.vscode/launch.json\n!.vscode/*.code-snippets\n.idea/workspace.xml\n.idea/usage.statistics.xml\n.idea/shelf\n\n# deps\nnode_modules/\n.wrangler\n\n# env\n.env\n.env.production\n.dev.vars\n\n# logs\nlogs/\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\n# misc\n.DS_Store\n"
  },
  {
    "path": "apps/jobs/README.md",
    "content": "# Marble Jobs\n\nCloudflare Worker for background jobs that should not run in the API or CMS\nrequest path. It currently owns webhook event fan-out, webhook delivery\nattempts, dead-letter queue handling, and scheduled cleanup.\n\n## Queues\n\nThe worker consumes two primary queues:\n\n- `marble-events`: receives persisted `workspaceEvent` IDs and fans them out to\n  matching webhook endpoints.\n- `marble-webhook-deliveries`: receives `webhookDelivery` IDs and performs the\n  outbound HTTP POST.\n\nBoth queues are configured with a matching dead-letter queue in\n`wrangler.jsonc`:\n\n- `marble-events-dlq`\n- `marble-webhook-deliveries-dlq`\n\n## Event Flow\n\n1. API or CMS creates a `workspaceEvent`.\n2. The event ID is sent to `marble-events`.\n3. The jobs worker loads the event and finds matching webhook endpoints.\n4. For each endpoint, the worker upserts a `webhookDelivery` row and enqueues\n   the delivery ID to `marble-webhook-deliveries`.\n5. The delivery consumer atomically claims pending/retrying deliveries before\n   sending, signs the payload, stores a `webhookDeliveryAttempt`, and marks the\n   delivery as `success`, `retrying`, or `failed`.\n\nFan-out is idempotent through the unique `(eventId, webhookEndpointId)` delivery\nconstraint. If an event message is retried after a partial fan-out, existing\ndelivery rows are reused instead of duplicated.\n\n## Webhook Delivery Behavior\n\nOutbound webhook requests include:\n\n- `x-marble-event`\n- `x-marble-event-id`\n- `x-marble-delivery-id`\n- `x-marble-timestamp`\n- `x-marble-signature`\n\nThe signature is the customer-facing verification primitive. The event,\ndelivery, and timestamp headers are included for receiver routing, replay\nprotection, and debugging.\n\nDeliveries use a 15 second fetch timeout. Network errors, timeouts, and non-2xx\nresponses are recorded as attempts. Failed deliveries are retried by the queue\nuntil the delivery reaches `maxAttempts`.\n\n## Usage Alerts\n\nSuccessful non-test webhook deliveries record `usageEvent` rows. The worker\nchecks usage before sending and can emit one usage email per billing period for\neach semantic alert kind:\n\n- `warning`\n- `critical`\n- `exhausted`\n\nThe current percentage mapping lives in `src/lib/usage.ts`, not in the database\nschema. That lets us move the warning threshold later without sending a second\nalert for the same billing period.\n\n## Local Development\n\n```txt\npnpm --filter jobs dev\n```\n\nGenerate Cloudflare binding types after changing `wrangler.jsonc`:\n\n```txt\npnpm --filter jobs cf-typegen\n```\n\nType-check the worker:\n\n```txt\npnpm exec tsc -p apps/jobs/tsconfig.json --noEmit\n```\n\nDeploy:\n\n```txt\npnpm --filter jobs deploy\n```\n\n## Configuration\n\nRequired bindings and secrets:\n\n- `HYPERDRIVE`: database connection through Cloudflare Hyperdrive.\n- `EVENT_QUEUE`: producer binding for `marble-events`.\n- `WEBHOOK_DELIVERY_QUEUE`: producer binding for `marble-webhook-deliveries`.\n- `RESEND_API_KEY`: used for usage alert emails.\n"
  },
  {
    "path": "apps/jobs/package.json",
    "content": "{\n  \"name\": \"jobs\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"wrangler dev\",\n    \"deploy\": \"wrangler deploy --minify\",\n    \"cf-typegen\": \"wrangler types --env-interface CloudflareBindings\"\n  },\n  \"dependencies\": {\n    \"@marble/db\": \"workspace:*\",\n    \"@marble/email\": \"workspace:*\",\n    \"@marble/events\": \"workspace:*\",\n    \"@marble/utils\": \"workspace:*\",\n    \"@upstash/redis\": \"^1.36.4\",\n    \"hono\": \"^4.12.18\",\n    \"resend\": \"^6.12.3\"\n  },\n  \"devDependencies\": {\n    \"@cloudflare/workers-types\": \"^4.20260508.1\",\n    \"wrangler\": \"^4.90.0\"\n  }\n}\n"
  },
  {
    "path": "apps/jobs/src/consumers/deliveries.ts",
    "content": "import { buildWebhookPayload, serializeEventType } from \"@marble/events\";\nimport { createDbClient } from \"../lib/db\";\nimport { buildWebhookRequestBody } from \"../lib/formats\";\nimport { signPayload } from \"../lib/signing\";\nimport {\n  checkWebhookUsage,\n  recordWebhookUsage,\n  sendWebhookUsageAlert,\n} from \"../lib/usage\";\nimport type { Env } from \"../types/env\";\n\nconst WEBHOOK_DELIVERY_TIMEOUT_MS = 15_000;\n\ninterface DeliveryMessage {\n  deliveryId: string;\n}\n\nexport async function handleWebhookDeliveryQueue(\n  batch: MessageBatch<DeliveryMessage>,\n  env: Env\n) {\n  const db = createDbClient(env);\n\n  for (const message of batch.messages) {\n    const { deliveryId } = message.body;\n\n    try {\n      await processDelivery(db, env, deliveryId);\n      message.ack();\n    } catch (error) {\n      console.error(\n        `[Delivery] Failed to deliver ${deliveryId}:`,\n        error instanceof Error ? error.message : error\n      );\n      message.retry();\n    }\n  }\n}\n\nasync function processDelivery(\n  db: ReturnType<typeof createDbClient>,\n  env: Env,\n  deliveryId: string\n) {\n  const claim = await db.webhookDelivery.updateMany({\n    where: {\n      id: deliveryId,\n      status: { in: [\"pending\", \"retrying\"] },\n    },\n    data: {\n      status: \"sending\",\n      attemptCount: { increment: 1 },\n      lastAttemptAt: new Date(),\n    },\n  });\n\n  if (claim.count === 0) {\n    return;\n  }\n\n  const delivery = await db.webhookDelivery.findUnique({\n    where: { id: deliveryId },\n    include: {\n      event: true,\n      webhookEndpoint: {\n        select: {\n          format: true,\n          secret: true,\n        },\n      },\n    },\n  });\n\n  if (!delivery) {\n    console.error(`[Delivery] Delivery not found: ${deliveryId}`);\n    return;\n  }\n\n  const usage = delivery.isTest\n    ? null\n    : await checkWebhookUsage(db, delivery.workspaceId);\n\n  if (usage && !usage.allowed) {\n    await sendWebhookUsageAlert(db, {\n      resendApiKey: env.RESEND_API_KEY,\n      workspaceId: delivery.workspaceId,\n      kind: \"exhausted\",\n      usageAmount: usage.currentUsage,\n      limitAmount: usage.limit,\n      period: usage.period,\n    });\n\n    await db.webhookDelivery.update({\n      where: { id: delivery.id },\n      data: {\n        status: \"failed\",\n        failedAt: new Date(),\n      },\n    });\n    return;\n  }\n\n  const attemptNumber = delivery.attemptCount;\n\n  const payload = buildWebhookPayload(delivery.event);\n  const requestBody = buildWebhookRequestBody(\n    payload,\n    delivery.webhookEndpoint.format\n  );\n\n  const body = JSON.stringify(requestBody);\n  const signature = await signPayload(body, delivery.webhookEndpoint.secret);\n  const timestamp = Math.floor(Date.now() / 1000).toString();\n  const eventType = serializeEventType(delivery.event.type);\n  const startedAt = Date.now();\n\n  let response: Response;\n\n  try {\n    response = await fetch(delivery.url, {\n      method: \"POST\",\n      signal: AbortSignal.timeout(WEBHOOK_DELIVERY_TIMEOUT_MS),\n      headers: {\n        \"content-type\": \"application/json\",\n        \"user-agent\": \"Marble-Webhooks/1.0\",\n        \"x-marble-event\": eventType,\n        \"x-marble-event-id\": delivery.event.id,\n        \"x-marble-delivery-id\": delivery.id,\n        \"x-marble-timestamp\": timestamp,\n        \"x-marble-signature\": `sha256=${signature}`,\n      },\n      body,\n    });\n  } catch (error) {\n    const durationMs = Date.now() - startedAt;\n\n    await db.webhookDeliveryAttempt.create({\n      data: {\n        deliveryId: delivery.id,\n        attemptNumber,\n        success: false,\n        errorMessage: error instanceof Error ? error.message : String(error),\n        durationMs,\n      },\n    });\n\n    if (attemptNumber >= delivery.maxAttempts) {\n      await db.webhookDelivery.update({\n        where: { id: delivery.id },\n        data: {\n          status: \"failed\",\n          failedAt: new Date(),\n        },\n      });\n      return;\n    }\n\n    await db.webhookDelivery.update({\n      where: { id: delivery.id },\n      data: { status: \"retrying\" },\n    });\n\n    throw error;\n  }\n\n  const responseBody = await response.text();\n  const durationMs = Date.now() - startedAt;\n\n  await db.webhookDeliveryAttempt.create({\n    data: {\n      deliveryId: delivery.id,\n      attemptNumber,\n      success: response.ok,\n      statusCode: response.status,\n      responseBody: responseBody.slice(0, 5000),\n      durationMs,\n    },\n  });\n\n  if (response.ok && !delivery.isTest) {\n    try {\n      await recordWebhookUsage(db, delivery.workspaceId, delivery.url);\n\n      if (usage?.alertKind) {\n        await sendWebhookUsageAlert(db, {\n          resendApiKey: env.RESEND_API_KEY,\n          workspaceId: delivery.workspaceId,\n          kind: usage.alertKind,\n          usageAmount: usage.currentUsage + 1,\n          limitAmount: usage.limit,\n          period: usage.period,\n        });\n      }\n    } catch (error) {\n      console.error(\"[Delivery] Failed to track webhook usage:\", error);\n    }\n  }\n\n  if (response.ok) {\n    await db.webhookDelivery.update({\n      where: { id: delivery.id },\n      data: {\n        status: \"success\",\n        deliveredAt: new Date(),\n      },\n    });\n    return;\n  }\n\n  if (attemptNumber >= delivery.maxAttempts) {\n    await db.webhookDelivery.update({\n      where: { id: delivery.id },\n      data: {\n        status: \"failed\",\n        failedAt: new Date(),\n      },\n    });\n    return;\n  }\n\n  await db.webhookDelivery.update({\n    where: { id: delivery.id },\n    data: { status: \"retrying\" },\n  });\n\n  throw new Error(`Webhook returned ${response.status}`);\n}\n"
  },
  {
    "path": "apps/jobs/src/consumers/dlq.ts",
    "content": "import { createDbClient } from \"../lib/db\";\nimport type { Env } from \"../types/env\";\n\ninterface DlqMessage {\n  eventId?: string;\n  deliveryId?: string;\n}\n\nexport async function handleDeadLetterQueue(\n  batch: MessageBatch<DlqMessage>,\n  env: Env\n) {\n  const db = createDbClient(env);\n\n  for (const message of batch.messages) {\n    const { deliveryId } = message.body;\n\n    try {\n      if (deliveryId) {\n        await db.webhookDelivery.update({\n          where: { id: deliveryId },\n          data: {\n            status: \"failed\",\n            failedAt: new Date(),\n          },\n        });\n        console.error(\n          `[DLQ] Marked delivery as permanently failed: ${deliveryId}`\n        );\n      } else {\n        console.error(\n          \"[DLQ] Message permanently failed (no deliveryId):\",\n          JSON.stringify(message.body)\n        );\n      }\n\n      message.ack();\n    } catch (error) {\n      console.error(\"[DLQ] Failed to process DLQ message:\", error);\n      message.ack();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/jobs/src/consumers/events.ts",
    "content": "import { createDbClient } from \"../lib/db\";\nimport type { Env } from \"../types/env\";\n\ninterface EventMessage {\n  eventId: string;\n  targetWebhookEndpointId?: string;\n  isTest?: boolean;\n}\n\nexport async function handleEventQueue(\n  batch: MessageBatch<EventMessage>,\n  env: Env\n) {\n  const db = createDbClient(env);\n\n  for (const message of batch.messages) {\n    const { eventId, targetWebhookEndpointId, isTest = false } = message.body;\n\n    try {\n      const event = await db.workspaceEvent.findUnique({\n        where: { id: eventId },\n      });\n\n      if (!event) {\n        console.error(`[Events] Event not found: ${eventId}`);\n        message.retry();\n        continue;\n      }\n\n      if (event.processedAt) {\n        message.ack();\n        continue;\n      }\n\n      const webhooks = await db.webhookEndpoint.findMany({\n        where: {\n          ...(targetWebhookEndpointId && { id: targetWebhookEndpointId }),\n          workspaceId: event.workspaceId,\n          ...(targetWebhookEndpointId ? {} : { enabled: true }),\n          ...(targetWebhookEndpointId ? {} : { events: { has: event.type } }),\n        },\n      });\n\n      if (webhooks.length === 0) {\n        await db.workspaceEvent.update({\n          where: { id: event.id },\n          data: { processedAt: new Date() },\n        });\n        message.ack();\n        continue;\n      }\n\n      for (const webhook of webhooks) {\n        const delivery = await db.webhookDelivery.upsert({\n          where: {\n            eventId_webhookEndpointId: {\n              eventId: event.id,\n              webhookEndpointId: webhook.id,\n            },\n          },\n          create: {\n            eventId: event.id,\n            workspaceId: event.workspaceId,\n            webhookEndpointId: webhook.id,\n            url: webhook.url,\n            status: \"pending\",\n            isTest,\n          },\n          update: {},\n        });\n\n        await env.WEBHOOK_DELIVERY_QUEUE.send({\n          deliveryId: delivery.id,\n        });\n      }\n\n      await db.workspaceEvent.update({\n        where: { id: event.id },\n        data: { processedAt: new Date() },\n      });\n\n      message.ack();\n    } catch (error) {\n      console.error(`[Events] Failed to process event ${eventId}:`, error);\n      message.retry();\n    }\n  }\n}\n"
  },
  {
    "path": "apps/jobs/src/index.ts",
    "content": "import { handleWebhookDeliveryQueue } from \"./consumers/deliveries\";\nimport { handleDeadLetterQueue } from \"./consumers/dlq\";\nimport { handleEventQueue } from \"./consumers/events\";\nimport { handleCleanup } from \"./scheduled/cleanup\";\nimport type { Env } from \"./types/env\";\n\nexport default {\n  async fetch() {\n    return new Response(\"Error\", { status: 404 });\n  },\n\n  async queue(batch: MessageBatch, env: Env, _ctx: ExecutionContext) {\n    switch (batch.queue) {\n      case \"marble-events\":\n        await handleEventQueue(batch as MessageBatch<{ eventId: string }>, env);\n        break;\n      case \"marble-webhook-deliveries\":\n        await handleWebhookDeliveryQueue(\n          batch as MessageBatch<{ deliveryId: string }>,\n          env\n        );\n        break;\n      case \"marble-events-dlq\":\n      case \"marble-webhook-deliveries-dlq\":\n        await handleDeadLetterQueue(\n          batch as MessageBatch<{ eventId?: string; deliveryId?: string }>,\n          env\n        );\n        break;\n      default:\n        console.error(`[Jobs] Unknown queue: ${batch.queue}`);\n    }\n  },\n\n  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {\n    ctx.waitUntil(handleCleanup(event, env, ctx));\n  },\n};\n"
  },
  {
    "path": "apps/jobs/src/lib/db.ts",
    "content": "import { createClient } from \"@marble/db/hyperdrive\";\nimport type { Env } from \"../types/env\";\n\nexport type DbClient = ReturnType<typeof createDbClient>;\n\nexport function createDbClient(env: Env) {\n  if (!env.HYPERDRIVE?.connectionString) {\n    throw new Error(\n      \"Database configuration error: no connection string available\"\n    );\n  }\n  return createClient(env.HYPERDRIVE.connectionString);\n}\n"
  },
  {
    "path": "apps/jobs/src/lib/formats.ts",
    "content": "import type { buildWebhookPayload } from \"@marble/events\";\n\ntype WebhookPayload = ReturnType<typeof buildWebhookPayload>;\n\ntype PayloadFormat = \"json\" | \"discord\" | \"slack\";\n\ntype WebhookData = Record<string, unknown>;\n\ninterface DiscordEmbedField {\n  name: string;\n  value: string;\n  inline?: boolean;\n}\n\nexport interface DiscordWebhookBody {\n  content?: string;\n  username: string;\n  avatar_url: string;\n  embeds: Array<{\n    title: string;\n    description: string;\n    color: number;\n    author: {\n      name: string;\n      icon_url: string;\n    };\n    fields: DiscordEmbedField[];\n    footer: {\n      text: string;\n    };\n  }>;\n  allowed_mentions: {\n    parse: string[];\n  };\n}\n\nexport interface SlackWebhookBody {\n  text: string;\n  blocks: Record<string, unknown>[];\n}\n\nconst MARBLE_COLOR = 5_786_879;\nconst MARBLE_AVATAR_URL = \"https://marblecms.com/logo.svg\";\n\nfunction formatEvent(input: string) {\n  return (\n    input.replace(/\\./g, \" \").replace(/\\b\\w/g, (char) => char.toUpperCase()) +\n    \"!\"\n  );\n}\n\nfunction getData(payload: WebhookPayload): WebhookData {\n  return payload.data &&\n    typeof payload.data === \"object\" &&\n    !Array.isArray(payload.data)\n    ? (payload.data as WebhookData)\n    : {};\n}\n\nfunction stringify(value: unknown) {\n  if (value === null || value === undefined) {\n    return \"None\";\n  }\n  if (typeof value === \"string\") {\n    return value;\n  }\n  if (typeof value === \"number\" || typeof value === \"boolean\") {\n    return String(value);\n  }\n  return JSON.stringify(value);\n}\n\nfunction getDisplayFields(payload: WebhookPayload) {\n  const data = getData(payload);\n  const fields: DiscordEmbedField[] = [\n    { name: \"Event ID\", value: payload.id },\n    { name: \"Workspace ID\", value: payload.workspaceId },\n  ];\n\n  if (\"id\" in data) {\n    fields.splice(1, 0, { name: \"Resource ID\", value: stringify(data.id) });\n  }\n\n  if (\"slug\" in data) {\n    fields.push({ name: \"Slug\", value: stringify(data.slug) });\n  } else if (\"name\" in data) {\n    fields.push({ name: \"Name\", value: stringify(data.name) });\n  }\n\n  if (payload.actor?.id) {\n    fields.push({\n      name: \"Actor\",\n      value: `${payload.actor.type} (${payload.actor.id})`,\n    });\n  }\n\n  return fields;\n}\n\n/**\n * Converts Marble's canonical webhook envelope into a Discord incoming webhook\n * message body.\n */\nexport function buildDiscordWebhookBody(\n  payload: WebhookPayload\n): DiscordWebhookBody {\n  const data = getData(payload);\n  const formattedEvent = formatEvent(payload.type);\n\n  return {\n    content: \"title\" in data ? stringify(data.title) : undefined,\n    username: \"Marble\",\n    avatar_url: MARBLE_AVATAR_URL,\n    embeds: [\n      {\n        title: formattedEvent,\n        description: payload.type,\n        color: MARBLE_COLOR,\n        author: {\n          name: \"Marble\",\n          icon_url: MARBLE_AVATAR_URL,\n        },\n        fields: getDisplayFields(payload),\n        footer: {\n          text: \"Powered by marblecms.com\",\n        },\n      },\n    ],\n    allowed_mentions: { parse: [] },\n  };\n}\n\n/**\n * Converts Marble's canonical webhook envelope into a Slack incoming webhook\n * message body.\n */\nexport function buildSlackWebhookBody(\n  payload: WebhookPayload\n): SlackWebhookBody {\n  const data = getData(payload);\n  const formattedEvent = formatEvent(payload.type);\n  const fields = getDisplayFields(payload).map(\n    (field) => `*${field.name}:* ${field.value}`\n  );\n\n  return {\n    text: \"title\" in data ? stringify(data.title) : formattedEvent,\n    blocks: [\n      {\n        type: \"header\",\n        text: {\n          type: \"plain_text\",\n          text: formattedEvent,\n        },\n      },\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: fields.join(\"\\n\"),\n        },\n        accessory: {\n          type: \"image\",\n          image_url: MARBLE_AVATAR_URL,\n          alt_text: \"Marble\",\n        },\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: \"Powered by marblecms.com\",\n          },\n        ],\n      },\n    ],\n  };\n}\n\n/**\n * Builds the exact JSON-serializable request body sent to a webhook endpoint.\n * JSON endpoints receive the canonical envelope, while chat destinations receive\n * platform-specific message payloads.\n */\nexport function buildWebhookRequestBody(\n  payload: WebhookPayload,\n  format: PayloadFormat\n) {\n  switch (format) {\n    case \"discord\":\n      return buildDiscordWebhookBody(payload);\n    case \"slack\":\n      return buildSlackWebhookBody(payload);\n    default:\n      return payload;\n  }\n}\n"
  },
  {
    "path": "apps/jobs/src/lib/signing.ts",
    "content": "const encoder = new TextEncoder();\n\nexport async function signPayload(\n  payload: string,\n  secret: string\n): Promise<string> {\n  const key = await crypto.subtle.importKey(\n    \"raw\",\n    encoder.encode(secret),\n    { name: \"HMAC\", hash: \"SHA-256\" },\n    false,\n    [\"sign\"]\n  );\n\n  const signature = await crypto.subtle.sign(\n    \"HMAC\",\n    key,\n    encoder.encode(payload)\n  );\n\n  return Array.from(new Uint8Array(signature))\n    .map((b) => b.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n}\n"
  },
  {
    "path": "apps/jobs/src/lib/usage.ts",
    "content": "import { sendUsageLimitEmail } from \"@marble/email\";\nimport { getWorkspacePlan, PLAN_LIMITS } from \"@marble/utils\";\nimport { Resend } from \"resend\";\nimport type { DbClient } from \"./db\";\n\ntype UsageThreshold = 75 | 90 | 100;\ntype UsageAlertKind = \"warning\" | \"critical\" | \"exhausted\";\n\nconst USAGE_ALERT_THRESHOLDS = {\n  warning: 75,\n  critical: 90,\n  exhausted: 100,\n} as const satisfies Record<UsageAlertKind, UsageThreshold>;\n\ninterface UsagePeriod {\n  start: Date;\n  end: Date;\n}\n\ninterface WebhookUsageCheck {\n  allowed: boolean;\n  currentUsage: number;\n  limit: number;\n  period: UsagePeriod;\n  alertKind?: UsageAlertKind;\n}\n\n/**\n * Detects Prisma unique constraint errors without importing Prisma runtime types\n * into the worker bundle.\n */\nfunction isUniqueConstraintError(error: unknown) {\n  return (\n    typeof error === \"object\" &&\n    error !== null &&\n    \"code\" in error &&\n    error.code === \"P2002\"\n  );\n}\n\n/**\n * Returns a calendar-safe date for monthly fallback billing cycles, clamping\n * days like the 31st to the last day of shorter months.\n */\nfunction getValidDate(year: number, month: number, day: number) {\n  const lastDay = new Date(year, month + 1, 0).getDate();\n  return new Date(year, month, Math.min(day, lastDay));\n}\n\n/**\n * Resolves the billing window used for monthly usage enforcement.\n *\n * Active paid subscriptions use the provider's current period. Workspaces\n * without an active subscription fall back to a monthly cycle anchored to their\n * creation day.\n */\nasync function getBillingPeriod(\n  db: DbClient,\n  workspaceId: string\n): Promise<UsagePeriod> {\n  const workspace = await db.organization.findUnique({\n    where: { id: workspaceId },\n    select: {\n      createdAt: true,\n      subscriptions: {\n        where: { status: { in: [\"active\", \"trialing\", \"canceled\"] } },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          status: true,\n          cancelAtPeriodEnd: true,\n          currentPeriodStart: true,\n          currentPeriodEnd: true,\n        },\n      },\n    },\n  });\n\n  if (!workspace) {\n    const now = new Date();\n    return {\n      start: new Date(now.getFullYear(), now.getMonth(), 1),\n      end: new Date(now.getFullYear(), now.getMonth() + 1, 1),\n    };\n  }\n\n  const subscription = workspace.subscriptions[0];\n  const isActive =\n    subscription?.status === \"active\" ||\n    subscription?.status === \"trialing\" ||\n    (subscription?.status === \"canceled\" &&\n      subscription.cancelAtPeriodEnd &&\n      subscription.currentPeriodEnd &&\n      subscription.currentPeriodEnd > new Date());\n\n  if (\n    isActive &&\n    subscription.currentPeriodStart &&\n    subscription.currentPeriodEnd\n  ) {\n    return {\n      start: subscription.currentPeriodStart,\n      end: subscription.currentPeriodEnd,\n    };\n  }\n\n  const now = new Date();\n  const dayOfMonth = workspace.createdAt.getDate();\n  let start = getValidDate(now.getFullYear(), now.getMonth(), dayOfMonth);\n\n  if (start > now) {\n    start = getValidDate(now.getFullYear(), now.getMonth() - 1, dayOfMonth);\n  }\n\n  return {\n    start,\n    end: getValidDate(start.getFullYear(), start.getMonth() + 1, dayOfMonth),\n  };\n}\n\n/**\n * Returns the first configured threshold crossed by moving from current usage\n * to the next counted usage value.\n */\nfunction getCrossedAlertKind(\n  currentUsage: number,\n  nextUsage: number,\n  limit: number\n): UsageAlertKind | undefined {\n  if (limit <= 0) {\n    return;\n  }\n\n  const currentPercentage = (currentUsage / limit) * 100;\n  const nextPercentage = (nextUsage / limit) * 100;\n\n  if (\n    nextPercentage >= USAGE_ALERT_THRESHOLDS.exhausted &&\n    currentPercentage < USAGE_ALERT_THRESHOLDS.exhausted\n  ) {\n    return \"exhausted\";\n  }\n\n  if (\n    nextPercentage >= USAGE_ALERT_THRESHOLDS.critical &&\n    currentPercentage < USAGE_ALERT_THRESHOLDS.critical\n  ) {\n    return \"critical\";\n  }\n\n  if (\n    nextPercentage >= USAGE_ALERT_THRESHOLDS.warning &&\n    currentPercentage < USAGE_ALERT_THRESHOLDS.warning\n  ) {\n    return \"warning\";\n  }\n}\n\n/**\n * Checks the current billing-period webhook delivery count, plan limit, and\n * whether sending one more delivery would cross an alert threshold.\n */\nexport async function checkWebhookUsage(\n  db: DbClient,\n  workspaceId: string\n): Promise<WebhookUsageCheck> {\n  const workspace = await db.organization.findUnique({\n    where: { id: workspaceId },\n    select: {\n      subscriptions: {\n        where: { status: { in: [\"active\", \"trialing\", \"canceled\"] } },\n        orderBy: { createdAt: \"desc\" },\n        take: 1,\n        select: {\n          plan: true,\n          status: true,\n          cancelAtPeriodEnd: true,\n          currentPeriodEnd: true,\n        },\n      },\n    },\n  });\n\n  const plan = getWorkspacePlan(workspace?.subscriptions[0]);\n  const limit = PLAN_LIMITS[plan].maxWebhookEvents;\n  const period = await getBillingPeriod(db, workspaceId);\n  const currentUsage = await db.usageEvent.count({\n    where: {\n      workspaceId,\n      type: \"webhook_delivery\",\n      createdAt: { gte: period.start, lt: period.end },\n    },\n  });\n\n  return {\n    allowed: currentUsage < limit,\n    currentUsage,\n    limit,\n    period,\n    alertKind: getCrossedAlertKind(currentUsage, currentUsage + 1, limit),\n  };\n}\n\n/**\n * Records a successful webhook delivery against the workspace's monthly usage.\n * Test deliveries intentionally skip this path in the consumer.\n */\nexport async function recordWebhookUsage(\n  db: DbClient,\n  workspaceId: string,\n  endpoint: string\n) {\n  await db.usageEvent.create({\n    data: {\n      type: \"webhook_delivery\",\n      workspaceId,\n      endpoint,\n    },\n  });\n}\n\n/**\n * Reserves and sends a webhook usage email once per alert kind per billing\n * period. The unique `usage_alert` row is created before sending so concurrent\n * deliveries cannot send duplicate emails. If the email send fails, the\n * reservation is removed so a later delivery can retry the alert.\n */\nexport async function sendWebhookUsageAlert(\n  db: DbClient,\n  {\n    resendApiKey,\n    workspaceId,\n    kind,\n    usageAmount,\n    limitAmount,\n    period,\n  }: {\n    resendApiKey?: string;\n    workspaceId: string;\n    kind: UsageAlertKind;\n    usageAmount: number;\n    limitAmount: number;\n    period: UsagePeriod;\n  }\n) {\n  if (!resendApiKey) {\n    console.warn(\n      \"[WebhookUsage] RESEND_API_KEY not configured, skipping alert\"\n    );\n    return;\n  }\n\n  const owner = await db.member.findFirst({\n    where: {\n      organizationId: workspaceId,\n      role: \"owner\",\n      OR: [\n        { notificationPreferences: null },\n        { notificationPreferences: { usageAlerts: true } },\n      ],\n    },\n    select: {\n      user: { select: { email: true, name: true } },\n    },\n  });\n\n  if (!owner?.user.email) {\n    console.warn(\n      `[WebhookUsage] No alertable owner found for workspace ${workspaceId}`\n    );\n    return;\n  }\n\n  let alert: { id: string };\n\n  try {\n    alert = await db.usageAlert.create({\n      data: {\n        workspaceId,\n        type: \"webhook_delivery\",\n        kind,\n        periodStart: period.start,\n        periodEnd: period.end,\n        emailSentTo: owner.user.email,\n      },\n      select: {\n        id: true,\n      },\n    });\n  } catch (error) {\n    if (!isUniqueConstraintError(error)) {\n      console.error(\"[WebhookUsage] Failed to reserve usage alert:\", error);\n      return;\n    }\n    return;\n  }\n\n  try {\n    const resend = new Resend(resendApiKey);\n    await sendUsageLimitEmail(resend, {\n      userEmail: owner.user.email,\n      userName: owner.user.name,\n      featureName: \"Webhook Events\",\n      usageAmount,\n      limitAmount,\n      workspaceId,\n    });\n    console.log(\n      `[WebhookUsage] Sent ${kind} usage email for workspace ${workspaceId}`\n    );\n  } catch (error) {\n    await db.usageAlert\n      .delete({\n        where: { id: alert.id },\n      })\n      .catch((deleteError) => {\n        console.error(\n          \"[WebhookUsage] Failed to clear usage alert reservation:\",\n          deleteError\n        );\n      });\n\n    console.error(\"[WebhookUsage] Failed to send threshold email:\", error);\n  }\n}\n"
  },
  {
    "path": "apps/jobs/src/scheduled/cleanup.ts",
    "content": "import type { Env } from \"../types/env\";\n\nexport async function handleCleanup(\n  _event: ScheduledEvent,\n  _env: Env,\n  _ctx: ExecutionContext\n) {\n  console.log(\n    `[Cleanup] Running scheduled cleanup at ${new Date().toISOString()}`\n  );\n\n  // TODO: Delete old usage events, aggregate analytics, etc.\n}\n"
  },
  {
    "path": "apps/jobs/src/types/env.ts",
    "content": "export interface Env {\n  HYPERDRIVE: { connectionString: string };\n  EVENT_QUEUE: Queue;\n  WEBHOOK_DELIVERY_QUEUE: Queue;\n  RESEND_API_KEY: string;\n}\n"
  },
  {
    "path": "apps/jobs/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"lib\": [\"ESNext\"],\n    \"types\": [\"@cloudflare/workers-types\"],\n    \"jsx\": \"react-jsx\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/jobs/wrangler.jsonc",
    "content": "{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"name\": \"marble-jobs\",\n  \"main\": \"src/index.ts\",\n  \"compatibility_date\": \"2026-05-11\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"keep_vars\": true,\n  \"placement\": { \"mode\": \"smart\" },\n  \"observability\": {\n    \"enabled\": true,\n    \"head_sampling_rate\": 1\n  },\n  \"hyperdrive\": [\n    { \"binding\": \"HYPERDRIVE\", \"id\": \"c0eea431cc454c9b96589cd52f70a009\" }\n  ],\n  \"queues\": {\n    \"producers\": [\n      {\n        \"binding\": \"EVENT_QUEUE\",\n        \"queue\": \"marble-events\"\n      },\n      {\n        \"binding\": \"WEBHOOK_DELIVERY_QUEUE\",\n        \"queue\": \"marble-webhook-deliveries\"\n      }\n    ],\n    \"consumers\": [\n      {\n        \"queue\": \"marble-events\",\n        \"max_batch_size\": 10,\n        \"max_retries\": 3,\n        \"retry_delay\": 60,\n        \"dead_letter_queue\": \"marble-events-dlq\"\n      },\n      {\n        \"queue\": \"marble-webhook-deliveries\",\n        \"max_batch_size\": 10,\n        \"max_retries\": 3,\n        \"retry_delay\": 60,\n        \"dead_letter_queue\": \"marble-webhook-deliveries-dlq\"\n      }\n    ]\n  },\n  \"env\": {\n    \"development\": {\n      \"hyperdrive\": [\n        {\n          \"binding\": \"HYPERDRIVE\",\n          \"id\": \"d20494d3ce894268b67505bf684a2395\"\n        }\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mcp/.gitignore",
    "content": "# prod\ndist/\n\n# dev\n.yarn/\n!.yarn/releases\n.vscode/*\n!.vscode/launch.json\n!.vscode/*.code-snippets\n.idea/workspace.xml\n.idea/usage.statistics.xml\n.idea/shelf\n\n# deps\nnode_modules/\n.wrangler\n\n# env\n.env\n.env.production\n.dev.vars\n\n# logs\nlogs/\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\n# misc\n.DS_Store\n"
  },
  {
    "path": "apps/mcp/README.md",
    "content": "# Marble MCP\n\nRemote Model Context Protocol server for Marble.\n\nThe Worker exposes Marble API operations as MCP tools for agents and editors\nsuch as Cursor, Claude Code, Codex, and other MCP clients.\n\n### Routes\n\n- `GET /` - basic service metadata\n- `GET /health` - health check\n- `GET|POST /mcp` - MCP Streamable HTTP endpoint\n\n### Local development\n\nFrom the repository root:\n\n```sh\npnpm --filter mcp dev\n```\n\nWrangler will start the Worker locally, usually at:\n\n```txt\nhttp://localhost:8787\n```\n\nIf that port is already in use, Wrangler may choose another port.\n\n### Authentication\n\nMCP clients must send a Marble API key using one of these headers:\n\n```txt\nMcp-Marble-Api-Key: <key>\nX-Marble-Api-Key: <key>\nAuthorization: Bearer <key>\n```\n\nThe MCP server forwards the key to the Marble API.\n\n### Client configuration\n\nFor clients that support remote Streamable HTTP MCP servers directly, use:\n\n```txt\nhttp://localhost:8787/mcp\n```\n\nFor clients that require a local stdio command, use `mcp-remote`:\n\n```json\n{\n  \"command\": \"npx\",\n  \"args\": [\n    \"mcp-remote\",\n    \"http://localhost:8787/mcp\",\n    \"--header\",\n    \"Mcp-Marble-Api-Key:${MCP_MARBLE_API_KEY}\"\n  ],\n  \"env\": {\n    \"MCP_MARBLE_API_KEY\": \"<your-api-key>\"\n  }\n}\n```\n\n### Tools\n\nThe server currently exposes tools for:\n\n- Posts: list, search, get, create, update, delete\n- Categories: list, get, create, update, delete\n- Tags: list, get, create, update, delete\n- Authors: list, get, create, update, delete\n- Media: list, get, upload from URL, update, delete\n\nWrite operations require a private Marble API key.\n\n### Verification\n\nTypecheck the app:\n\n```sh\npnpm --filter mcp exec tsc --noEmit\n```\n\nDeploy the Worker:\n\n```sh\npnpm --filter mcp deploy\n```\n"
  },
  {
    "path": "apps/mcp/package.json",
    "content": "{\n  \"name\": \"mcp\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"wrangler dev\",\n    \"deploy\": \"wrangler deploy --minify\",\n    \"cf-typegen\": \"wrangler types --env-interface CloudflareBindings\"\n  },\n  \"dependencies\": {\n    \"@modelcontextprotocol/sdk\": \"^1.29.0\",\n    \"agents\": \"^0.12.3\",\n    \"hono\": \"^4.12.16\",\n    \"zod\": \"^4.4.2\"\n  },\n  \"devDependencies\": {\n    \"@cloudflare/workers-types\": \"^4.20260508.1\",\n    \"wrangler\": \"^4.90.0\"\n  }\n}\n"
  },
  {
    "path": "apps/mcp/public/home.js",
    "content": "function initCopyButtons() {\n  const buttons = document.querySelectorAll(\"[data-copy]\");\n\n  for (const button of buttons) {\n    if (!(button instanceof HTMLButtonElement)) {\n      continue;\n    }\n\n    const label = button.textContent ?? \"Copy\";\n    button.addEventListener(\"click\", async () => {\n      const value = button.dataset.copy;\n      if (!value) {\n        return;\n      }\n\n      await navigator.clipboard.writeText(value);\n      button.textContent = \"Copied\";\n      window.setTimeout(() => {\n        button.textContent = label;\n      }, 1600);\n    });\n  }\n}\n\ninitCopyButtons();\n"
  },
  {
    "path": "apps/mcp/public/styles.css",
    "content": ":root {\n  color-scheme: light dark;\n  font-family:\n    system-ui, -apple-system, \"Segoe UI\", Roboto, Ubuntu, Cantarell, \"Noto Sans\",\n    sans-serif;\n  line-height: 1.5;\n  font-size: 1rem;\n\n  --background: hsl(0 0% 100%);\n  --foreground: hsl(0 0% 9%);\n  --primary: hsl(244 100% 65%);\n  --primary-foreground: hsl(0 0% 98%);\n  --muted: hsl(0 0% 96%);\n  --muted-foreground: hsl(0 0% 42%);\n  --surface: hsl(0 0% 98%);\n  --surface-foreground: hsl(0 0% 18%);\n  --border: hsl(0 0% 89%);\n  --ring: hsl(244 91% 70%);\n  --radius: 0.5rem;\n  --pill: 999px;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --background: hsl(240 7% 8%);\n    --foreground: hsl(0 0% 98%);\n    --muted: hsl(240 5% 15%);\n    --muted-foreground: hsl(0 0% 64%);\n    --surface: hsl(240 6% 10%);\n    --surface-foreground: hsl(0 0% 94%);\n    --border: hsl(240 5% 18%);\n    --ring: hsl(244 100% 65%);\n  }\n}\n\n*,\n*::before,\n*::after {\n  box-sizing: border-box;\n}\n\nbody {\n  margin: 0;\n  min-height: 100dvh;\n  background: var(--background);\n  color: var(--foreground);\n}\n\nmain {\n  width: min(100%, 50rem);\n  margin: 0 auto;\n  padding: clamp(1.5rem, 4vw, 3rem) clamp(1rem, 4vw, 1.75rem);\n}\n\n.site-footer {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1rem;\n  width: min(100%, 50rem);\n  margin: 0 auto;\n  padding: clamp(1.5rem, 4vw, 3rem) clamp(1rem, 4vw, 1.75rem)\n    clamp(1.5rem, 4vw, 3rem);\n}\n\nh1,\nh2,\np {\n  margin: 0;\n}\n\nh1 {\n  margin-bottom: 0.5rem;\n  font-size: clamp(1.25rem, 3vw, 1.625rem);\n  font-weight: 500;\n  line-height: 1.12;\n}\n\np {\n  max-width: 36rem;\n  color: var(--muted-foreground);\n  font-size: 0.875rem;\n}\n\n.site-footer p {\n  font-size: 0.875rem;\n}\n\n.site-footer nav {\n  display: flex;\n  align-items: center;\n  gap: 1rem;\n}\n\n.site-footer a {\n  color: var(--muted-foreground);\n  font-size: 0.875rem;\n  text-underline-offset: 0.1875rem;\n}\n\n.site-footer a:hover {\n  color: var(--foreground);\n}\n\ncode {\n  font-family:\n    \"SFMono-Regular\", Consolas, \"Liberation Mono\", Menlo, ui-monospace,\n    monospace;\n}\n\np code {\n  display: inline-block;\n  padding: 0.0625rem 0.3125rem;\n  border: 1px solid var(--border);\n  border-radius: 0.3125rem;\n  background: var(--muted);\n  color: var(--foreground);\n  font-size: 0.875rem;\n  line-height: 1.35;\n}\n\nbutton,\n.button {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 2rem;\n  padding: 0 0.875rem;\n  border: none;\n  border-radius: var(--pill);\n  background: var(--primary);\n  color: var(--primary-foreground);\n  font: inherit;\n  font-size: 0.875rem;\n  font-weight: 500;\n  text-decoration: none;\n  cursor: pointer;\n  box-shadow: none;\n}\n\nbutton::-moz-focus-inner {\n  border: 0;\n}\n\nbutton:hover,\n.button:hover {\n  opacity: 0.86;\n}\n\nbutton:focus-visible,\n.button:focus-visible,\nsummary:focus-visible {\n  outline: 2px solid var(--ring);\n  outline-offset: 2px;\n}\n\n.heading {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 1rem;\n  padding-bottom: clamp(2rem, 5vw, 3.25rem);\n}\n\n.section-heading {\n  margin-bottom: 0.75rem;\n  font-size: 1rem;\n  font-weight: 500;\n  line-height: 1.2;\n}\n\nsection:not(:last-child) {\n  margin-bottom: 2rem;\n}\n\n.server-section p {\n  margin-top: 0.75rem;\n}\n\n.client-list {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));\n  gap: 0.625rem;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.client-card {\n  display: flex;\n  align-items: center;\n  gap: 0.625rem;\n  min-height: 3.25rem;\n  padding: 0.75rem;\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n  background: var(--surface);\n  color: var(--surface-foreground);\n  font-size: 0.875rem;\n  font-weight: 500;\n}\n\n.client-icon-svg {\n  width: 1.375rem;\n  height: 1.375rem;\n  flex: 0 0 auto;\n}\n\n.server-url {\n  display: flex;\n  align-items: stretch;\n  gap: 0.5rem;\n  width: 100%;\n  padding: 0.5rem;\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n  background: var(--surface);\n}\n\n.server-url button {\n  min-width: 4.75rem;\n}\n\n.server-url code {\n  display: flex;\n  flex: 1;\n  align-items: center;\n  min-width: 0;\n  overflow-x: auto;\n  padding: 0 0.25rem;\n  color: var(--surface-foreground);\n  font-size: 0.875rem;\n  white-space: nowrap;\n}\n\n.tool-groups {\n  overflow: hidden;\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n}\n\n.tool-group {\n  background: var(--background);\n}\n\n.tool-group:not(:last-child) {\n  border-bottom: 1px solid var(--border);\n}\n\n.tool-group summary {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 0.75rem;\n  padding: 0.75rem 1rem;\n  cursor: pointer;\n  list-style: none;\n}\n\n.tool-group summary::-webkit-details-marker {\n  display: none;\n}\n\n.tool-group summary span:first-child {\n  display: grid;\n  gap: 0.0625rem;\n}\n\n.tool-group-name {\n  font-size: 1rem;\n  font-weight: 500;\n}\n\n.tool-group small,\n.tool-count,\n.tool-item span {\n  color: var(--muted-foreground);\n  font-size: 0.875rem;\n}\n\n.tool-count {\n  flex: 0 0 auto;\n  font-size: 0.875rem;\n}\n\n.tool-list {\n  display: grid;\n  gap: 0.375rem;\n  margin: 0;\n  padding: 0 1rem 1rem;\n  list-style: none;\n}\n\n.tool-item {\n  display: grid;\n  grid-template-columns: minmax(8.5rem, 12rem) 1fr;\n  gap: 0.75rem;\n  padding: 0.625rem 0.75rem;\n  border-radius: var(--radius);\n  background: var(--surface);\n}\n\n.tool-item code {\n  color: var(--surface-foreground);\n  font-size: 0.875rem;\n  font-weight: 600;\n}\n\n@media (max-width: 720px) {\n  .heading,\n  .server-url,\n  .tool-item,\n  .site-footer {\n    display: grid;\n  }\n\n  .button,\n  .server-url button {\n    width: 100%;\n  }\n\n  .tool-group summary {\n    align-items: flex-start;\n  }\n}\n"
  },
  {
    "path": "apps/mcp/src/components/icons.tsx",
    "content": "export const CodexIcon = (props: { class?: string }) => (\n  <svg\n    aria-hidden=\"true\"\n    class={props.class}\n    fill=\"none\"\n    focusable=\"false\"\n    viewBox=\"0 0 24 24\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <title>Codex</title>\n    <path\n      d=\"M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z\"\n      fill=\"#fff\"\n    />\n    <path\n      d=\"M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z\"\n      fill=\"url(#codexMarkGradient)\"\n    />\n    <defs>\n      <linearGradient\n        gradientUnits=\"userSpaceOnUse\"\n        id=\"codexMarkGradient\"\n        x1=\"12\"\n        x2=\"12\"\n        y1=\"3\"\n        y2=\"21\"\n      >\n        <stop stopColor=\"#B1A7FF\" />\n        <stop offset=\"0.5\" stopColor=\"#7A9DFF\" />\n        <stop offset=\"1\" stopColor=\"#3941FF\" />\n      </linearGradient>\n    </defs>\n  </svg>\n);\n\nexport const ClaudeIcon = (props: { class?: string }) => (\n  <svg\n    aria-hidden=\"true\"\n    class={props.class}\n    focusable=\"false\"\n    preserveAspectRatio=\"xMidYMid meet\"\n    viewBox=\"0 0 256 257\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <title>Claude</title>\n    <path\n      d=\"m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z\"\n      fill=\"#D97757\"\n    />\n  </svg>\n);\n\nexport const CursorIcon = (props: { class?: string }) => (\n  <svg\n    aria-hidden=\"true\"\n    class={props.class}\n    fill=\"currentColor\"\n    focusable=\"false\"\n    viewBox=\"0 0 466.73 532.09\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <title>Cursor</title>\n    <path d=\"M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z\" />\n  </svg>\n);\n\nexport const VsCodeIcon = (props: { class?: string }) => (\n  <svg\n    aria-hidden=\"true\"\n    class={props.class}\n    fill=\"none\"\n    focusable=\"false\"\n    viewBox=\"0 0 100 100\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <title>Visual Studio Code</title>\n    <path\n      d=\"M96.4 10.8 75.8.9a6.2 6.2 0 0 0-7.1 1.2L1.3 63.6a4.2 4.2 0 0 0 0 6.1l5.5 5a4.2 4.2 0 0 0 5.3.3l81.2-61.6c2.8-2.1 6.7-.1 6.7 3.3v-.3a6.3 6.3 0 0 0-3.6-5.6Z\"\n      fill=\"#0065A9\"\n    />\n    <path\n      d=\"m96.4 89.2-20.6 9.9a6.2 6.2 0 0 1-7.1-1.2L1.3 36.4a4.2 4.2 0 0 1 0-6.1l5.5-5a4.2 4.2 0 0 1 5.3-.3l81.2 61.6c2.8 2.1 6.7.1 6.7-3.3v.3a6.3 6.3 0 0 1-3.6 5.6Z\"\n      fill=\"#007ACC\"\n    />\n    <path\n      d=\"M75.8 99.1a6.2 6.2 0 0 1-7.1-1.2c2.3 2.3 6.3.7 6.3-2.6V4.7c0-3.3-4-4.9-6.3-2.6A6.2 6.2 0 0 1 75.8.9l20.6 9.9a6.3 6.3 0 0 1 3.6 5.6v67.2a6.3 6.3 0 0 1-3.6 5.6l-20.6 9.9Z\"\n      fill=\"#1F9CF0\"\n    />\n  </svg>\n);\n\nexport const GeminiIcon = (props: { class?: string }) => (\n  <svg\n    aria-hidden=\"true\"\n    class={props.class}\n    fill=\"none\"\n    focusable=\"false\"\n    viewBox=\"0 0 296 298\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <title>Gemini</title>\n    <mask\n      height=\"298\"\n      id=\"gemini-mask\"\n      maskUnits=\"userSpaceOnUse\"\n      style={{ maskType: \"alpha\" }}\n      width=\"296\"\n      x=\"0\"\n      y=\"0\"\n    >\n      <path\n        d=\"M141.201 4.886c2.282-6.17 11.042-6.071 13.184.148l5.985 17.37a184.004 184.004 0 0 0 111.257 113.049l19.304 6.997c6.143 2.227 6.156 10.91.02 13.155l-19.35 7.082a184.001 184.001 0 0 0-109.495 109.385l-7.573 20.629c-2.241 6.105-10.869 6.121-13.133.025l-7.908-21.296a184 184 0 0 0-109.02-108.658l-19.698-7.239c-6.102-2.243-6.118-10.867-.025-13.132l20.083-7.467A183.998 183.998 0 0 0 133.291 26.28l7.91-21.394Z\"\n        fill=\"#3186FF\"\n      />\n    </mask>\n    <g mask=\"url(#gemini-mask)\">\n      <g filter=\"url(#gemini-blue-blur)\">\n        <ellipse cx=\"163\" cy=\"149\" fill=\"#3689FF\" rx=\"196\" ry=\"159\" />\n      </g>\n      <g filter=\"url(#gemini-yellow-blur)\">\n        <ellipse cx=\"33.5\" cy=\"142.5\" fill=\"#F6C013\" rx=\"68.5\" ry=\"72.5\" />\n      </g>\n      <g filter=\"url(#gemini-yellow-blur-2)\">\n        <ellipse cx=\"19.5\" cy=\"148.5\" fill=\"#F6C013\" rx=\"68.5\" ry=\"72.5\" />\n      </g>\n      <g filter=\"url(#gemini-red-blur)\">\n        <path\n          d=\"M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z\"\n          fill=\"#FA4340\"\n        />\n      </g>\n      <g filter=\"url(#gemini-red-blur-2)\">\n        <path\n          d=\"M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z\"\n          fill=\"#FA4340\"\n        />\n      </g>\n      <g filter=\"url(#gemini-green-blur)\">\n        <path\n          d=\"M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z\"\n          fill=\"#14BB69\"\n        />\n      </g>\n      <g filter=\"url(#gemini-green-blur-2)\">\n        <path\n          d=\"M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z\"\n          fill=\"#14BB69\"\n        />\n      </g>\n    </g>\n    <defs>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"390\"\n        id=\"gemini-blue-blur\"\n        width=\"464\"\n        x=\"-69\"\n        y=\"-46\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"18\"\n        />\n      </filter>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"273\"\n        id=\"gemini-yellow-blur\"\n        width=\"265\"\n        x=\"-99\"\n        y=\"6\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"32\"\n        />\n      </filter>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"273\"\n        id=\"gemini-yellow-blur-2\"\n        width=\"265\"\n        x=\"-113\"\n        y=\"12\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"32\"\n        />\n      </filter>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"329\"\n        id=\"gemini-red-blur\"\n        width=\"299.5\"\n        x=\"-41.5\"\n        y=\"-130\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"32\"\n        />\n      </filter>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"329\"\n        id=\"gemini-red-blur-2\"\n        width=\"299.5\"\n        x=\"-45\"\n        y=\"-153\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"32\"\n        />\n      </filter>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"329\"\n        id=\"gemini-green-blur\"\n        width=\"299.5\"\n        x=\"-41\"\n        y=\"91\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"32\"\n        />\n      </filter>\n      <filter\n        colorInterpolationFilters=\"sRGB\"\n        filterUnits=\"userSpaceOnUse\"\n        height=\"329\"\n        id=\"gemini-green-blur-2\"\n        width=\"299.5\"\n        x=\"-39\"\n        y=\"132\"\n      >\n        <feFlood floodOpacity=\"0\" result=\"BackgroundImageFix\" />\n        <feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />\n        <feGaussianBlur\n          result=\"effect1_foregroundBlur_69_17998\"\n          stdDeviation=\"32\"\n        />\n      </filter>\n    </defs>\n  </svg>\n);\n"
  },
  {
    "path": "apps/mcp/src/components/mcp-clients.tsx",
    "content": "import type { Child } from \"hono/jsx\";\nimport {\n  ClaudeIcon,\n  CodexIcon,\n  CursorIcon,\n  GeminiIcon,\n} from \"@/components/icons\";\n\nexport interface McpClient {\n  Icon: (props: { class?: string }) => Child;\n  id: string;\n  name: string;\n}\n\nexport const MCP_CLIENTS: McpClient[] = [\n  {\n    id: \"cursor\",\n    name: \"Cursor\",\n    Icon: CursorIcon,\n  },\n  {\n    id: \"claude-code\",\n    name: \"Claude Code\",\n    Icon: ClaudeIcon,\n  },\n  {\n    id: \"codex\",\n    name: \"Codex\",\n    Icon: CodexIcon,\n  },\n  {\n    id: \"gemini\",\n    name: \"Gemini CLI\",\n    Icon: GeminiIcon,\n  },\n];\n"
  },
  {
    "path": "apps/mcp/src/index.ts",
    "content": "import { Hono } from \"hono\";\nimport { trimTrailingSlash } from \"hono/trailing-slash\";\nimport { homeRoute } from \"./routes/home\";\nimport { mcpRoute } from \"./routes/mcp\";\nimport type { Env } from \"./types\";\n\nconst app = new Hono<{ Bindings: Env }>();\n\napp.use(trimTrailingSlash());\n\napp.route(\"/\", homeRoute);\napp.get(\"/health\", (c) => c.json({ status: \"ok\" }));\napp.route(\"/mcp\", mcpRoute);\n\nexport default app;\n"
  },
  {
    "path": "apps/mcp/src/lib/api.ts",
    "content": "import type { QueryParams } from \"@/types\";\nimport { authHeaderValue } from \"./auth\";\n\ntype ApiBody = Record<string, unknown> | null;\n\n/**\n * Builds a Marble API URL and serializes array query values using the comma\n * format expected by the existing API filters.\n */\nfunction buildUrl(apiBaseUrl: string, path: string, query?: QueryParams) {\n  const url = new URL(path, apiBaseUrl);\n\n  for (const [key, value] of Object.entries(query ?? {})) {\n    if (value === undefined) {\n      continue;\n    }\n\n    url.searchParams.set(\n      key,\n      Array.isArray(value) ? value.join(\",\") : String(value)\n    );\n  }\n\n  return url;\n}\n\n/**\n * Calls the Marble API with the caller's API key and returns the parsed JSON\n * body. API failures are converted into MCP-friendly errors so agents can\n * recover or ask the user for the right fix.\n */\nexport async function readJsonApi(\n  apiBaseUrl: string,\n  apiKey: string,\n  path: string,\n  query?: QueryParams\n) {\n  return requestJsonApi(apiBaseUrl, apiKey, \"GET\", path, { query });\n}\n\nexport async function validateApiKey(apiBaseUrl: string, apiKey: string) {\n  await readJsonApi(apiBaseUrl, apiKey, \"/v1/media\", { limit: 1 });\n}\n\n/**\n * Sends JSON to the Marble API with the caller's API key and returns the parsed\n * JSON body. Used by create/update tools that require private Marble API keys.\n */\nexport async function writeJsonApi(\n  apiBaseUrl: string,\n  apiKey: string,\n  method: \"PATCH\" | \"POST\",\n  path: string,\n  body: Record<string, unknown>\n) {\n  return requestJsonApi(apiBaseUrl, apiKey, method, path, { body });\n}\n\n/**\n * Uploads a binary body to the Marble API as multipart/form-data. The MCP tool\n * uses this for URL-based media ingestion, where the server fetches the remote\n * asset first and forwards it to Marble's media upload endpoint.\n */\nexport async function uploadMediaApi(\n  apiBaseUrl: string,\n  apiKey: string,\n  file: Blob,\n  filename: string\n) {\n  const formData = new FormData();\n  formData.set(\"file\", file, filename);\n\n  const response = await fetch(buildUrl(apiBaseUrl, \"/v1/media/upload\"), {\n    method: \"POST\",\n    headers: {\n      Authorization: authHeaderValue(apiKey),\n      Accept: \"application/json\",\n    },\n    body: formData,\n  });\n\n  const text = await response.text();\n  const body = parseApiBody(text);\n\n  if (!response.ok) {\n    const message =\n      typeof body?.message === \"string\"\n        ? body.message\n        : typeof body?.error === \"string\"\n          ? body.error\n          : text || \"The Marble API returned an error.\";\n\n    if (response.status === 401) {\n      throw new Error(\n        \"The Marble API key is missing or invalid. Ask the user to check their MCP Marble API key.\"\n      );\n    }\n\n    throw new Error(`Marble API ${response.status}: ${message}`);\n  }\n\n  if (text && !body) {\n    throw new Error(\"Marble API returned a non-JSON response.\");\n  }\n\n  return body ?? {};\n}\n\n/**\n * Deletes a Marble API resource with the caller's API key and returns the\n * parsed JSON response.\n */\nexport async function deleteJsonApi(\n  apiBaseUrl: string,\n  apiKey: string,\n  path: string\n) {\n  return requestJsonApi(apiBaseUrl, apiKey, \"DELETE\", path);\n}\n\nasync function requestJsonApi(\n  apiBaseUrl: string,\n  apiKey: string,\n  method: \"DELETE\" | \"GET\" | \"PATCH\" | \"POST\",\n  path: string,\n  options: {\n    body?: Record<string, unknown>;\n    query?: QueryParams;\n  } = {}\n) {\n  const response = await fetch(buildUrl(apiBaseUrl, path, options.query), {\n    method,\n    headers: {\n      Authorization: authHeaderValue(apiKey),\n      Accept: \"application/json\",\n      ...(options.body ? { \"Content-Type\": \"application/json\" } : {}),\n    },\n    body: options.body ? JSON.stringify(options.body) : undefined,\n  });\n\n  const text = await response.text();\n  const body = parseApiBody(text);\n\n  if (!response.ok) {\n    const message =\n      typeof body?.message === \"string\"\n        ? body.message\n        : typeof body?.error === \"string\"\n          ? body.error\n          : text || \"The Marble API returned an error.\";\n\n    if (response.status === 401) {\n      throw new Error(\n        \"The Marble API key is missing or invalid. Ask the user to check their MCP Marble API key.\"\n      );\n    }\n\n    throw new Error(`Marble API ${response.status}: ${message}`);\n  }\n\n  if (text && !body) {\n    throw new Error(\"Marble API returned a non-JSON response.\");\n  }\n\n  return body ?? {};\n}\n\nfunction parseApiBody(text: string): ApiBody {\n  if (!text) {\n    return null;\n  }\n\n  try {\n    const value = JSON.parse(text) as unknown;\n    return value && typeof value === \"object\" && !Array.isArray(value)\n      ? (value as Record<string, unknown>)\n      : { value };\n  } catch {\n    return null;\n  }\n}\n"
  },
  {
    "path": "apps/mcp/src/lib/auth.ts",
    "content": "/**\n * Reads the Marble API key from the headers MCP clients can realistically send.\n * Cursor and mcp-remote support custom headers, while some clients prefer the\n * standard Authorization header.\n */\nexport function getApiKey(request: Request) {\n  const authorization = request.headers.get(\"authorization\");\n  const apiKey =\n    request.headers.get(\"mcp-marble-api-key\") ??\n    request.headers.get(\"x-marble-api-key\") ??\n    parseAuthorizationHeader(authorization);\n\n  if (!apiKey) {\n    throw new Error(\n      authorization\n        ? \"Unsupported Authorization header. Use Authorization: Bearer <key>.\"\n        : \"Missing Marble API key.\"\n    );\n  }\n\n  return apiKey;\n}\n\n/**\n * Normalizes a raw Marble API key into the Authorization header format expected\n * by the Marble API.\n */\nexport function authHeaderValue(apiKey: string) {\n  const bearerMatch = /^Bearer\\s+(.+)$/i.exec(apiKey.trim());\n  if (bearerMatch) {\n    return `Bearer ${bearerMatch[1]}`;\n  }\n\n  if (/^[a-z]+ /i.test(apiKey)) {\n    throw new Error(\n      \"Unsupported API key header value. Use a raw key or Bearer token.\"\n    );\n  }\n\n  return `Bearer ${apiKey}`;\n}\n\nfunction parseAuthorizationHeader(header: string | null) {\n  if (!header) {\n    return null;\n  }\n\n  const match = /^Bearer\\s+(.+)$/i.exec(header.trim());\n  return match?.[1] ?? null;\n}\n"
  },
  {
    "path": "apps/mcp/src/lib/constants.ts",
    "content": "export const DEFAULT_API_BASE_URL = \"https://api.marblecms.com\";\n\nexport const MCP_TOOL_GROUPS = [\n  {\n    name: \"Posts\",\n    description: \"Read, search, create, update, and delete Marble posts.\",\n    tools: [\n      {\n        name: \"get_posts\",\n        description:\n          \"Get a paginated list of Marble posts with optional filters.\",\n      },\n      {\n        name: \"search_posts\",\n        description: \"Search posts by title and content.\",\n      },\n      { name: \"get_post\", description: \"Get a single post by ID or slug.\" },\n      { name: \"create_post\", description: \"Create a new post.\" },\n      {\n        name: \"update_post\",\n        description: \"Update an existing post by ID or slug.\",\n      },\n      { name: \"delete_post\", description: \"Delete a post by ID or slug.\" },\n    ],\n  },\n  {\n    name: \"Categories\",\n    description: \"Manage categories in your Marble workspace.\",\n    tools: [\n      {\n        name: \"get_categories\",\n        description: \"Get a paginated list of categories.\",\n      },\n      {\n        name: \"get_category\",\n        description: \"Get a single category by ID or slug.\",\n      },\n      { name: \"create_category\", description: \"Create a new category.\" },\n      {\n        name: \"update_category\",\n        description: \"Update an existing category by ID or slug.\",\n      },\n      {\n        name: \"delete_category\",\n        description: \"Delete a category by ID or slug.\",\n      },\n    ],\n  },\n  {\n    name: \"Tags\",\n    description: \"Manage tags in your Marble workspace.\",\n    tools: [\n      { name: \"get_tags\", description: \"Get a paginated list of tags.\" },\n      { name: \"get_tag\", description: \"Get a single tag by ID or slug.\" },\n      { name: \"create_tag\", description: \"Create a new tag.\" },\n      {\n        name: \"update_tag\",\n        description: \"Update an existing tag by ID or slug.\",\n      },\n      { name: \"delete_tag\", description: \"Delete a tag by ID or slug.\" },\n    ],\n  },\n  {\n    name: \"Authors\",\n    description: \"Manage authors in your Marble workspace.\",\n    tools: [\n      { name: \"get_authors\", description: \"Get a paginated list of authors.\" },\n      { name: \"get_author\", description: \"Get a single author by ID or slug.\" },\n      { name: \"create_author\", description: \"Create a new author.\" },\n      {\n        name: \"update_author\",\n        description: \"Update an existing author by ID or slug.\",\n      },\n      { name: \"delete_author\", description: \"Delete an author by ID or slug.\" },\n    ],\n  },\n  {\n    name: \"Media\",\n    description: \"Read, upload, update, and delete Marble media assets.\",\n    tools: [\n      { name: \"get_media\", description: \"Get a paginated list of media.\" },\n      {\n        name: \"get_media_asset\",\n        description: \"Get a single media asset by ID.\",\n      },\n      {\n        name: \"upload_media_from_url\",\n        description: \"Upload a remote file URL to Marble media.\",\n      },\n      { name: \"update_media\", description: \"Update media asset metadata.\" },\n      { name: \"delete_media\", description: \"Delete a media asset by ID.\" },\n    ],\n  },\n] as const;\n"
  },
  {
    "path": "apps/mcp/src/lib/instructions.ts",
    "content": "/**\n * MCP server instructions: Marble workflows plus HTML sanitization rules for posts.\n */\nexport function getServerInstructions() {\n  return `\n## Marble / scope\n\nMarble is a headless CMS. These tools manage posts, taxonomy (categories, tags), authors, and media in the workspace tied to the caller's Marble API key. Use HTTP tool responses as JSON: structured fields from the Marble API plus error details when requests fail — read the payload before assuming success.\n\nFor **datetimes**, use ISO 8601 strings (RFC 3339). **publishedAt** and similar fields are absolute instants. This MCP server does not expose a workspace timezone; if calendar-day boundaries matter, follow what the user states in conversation rather than inventing offsets.\n\n## Authentication and mutating actions\n\nRead-only listing and fetch tools work with keys that permit reads. **create_**, **update_**, **delete_**, **upload_** tools expect a **private** Marble API key with write access — if a call fails with authorization or permission errors, say so clearly and avoid retry loops with the same key.\n\nTools marked destructive in MCP metadata can remove or overwrite data — confirm destructive intent with the user when context is ambiguous.\n\n## Tool families\n\nOrient by prefix; schemas carry per-tool specifics:\n- **get_posts**, **search_posts**, **get_post**, **create_post**, **update_post**, **delete_post** — articles; filter by status, categories, tags, featured; HTML or markdown bodies where supported.\n- **get_categories**, **get_category**, **create_category**, **update_category**, **delete_category** — taxonomy (required category on **create_post**).\n- **get_tags**, **get_tag**, **create_tag**, **update_tag**, **delete_tag** — labels; pass tag IDs/slugs on posts when attaching.\n- **get_authors**, **get_author**, **create_author**, **update_author**, **delete_author** — contributors; omit **authors** on **create_post** to fall back to the first workspace author.\n- **get_media**, **get_media_asset**, **upload_media_from_url**, **update_media**, **delete_media** — library assets; reuse stable URLs inside post HTML when applicable.\n\n## Key patterns before editing content\n\nPrefer **search_posts** or **get_posts** with **query** for discovery; **get_post** for one record by ID/slug — **identifiers** throughout are **UUID or slug**, depending on the API shape returned in list responses.\n\n**Pagination**: list tools accept **limit** (1–100) and **page** (defaults if omitted); increase **page** when meta indicates more rows.\n\nPosts support **published** vs **draft** vs **all** filters — include **draft** or **all** when the user needs drafts (**search_posts** description calls this out).\n\nFor new posts you need **categoryId** — resolve IDs with **get_categories** first when the user has not supplied one. **tags** and **authors** are optional arrays of IDs already present in Marble.\n\nPublished times default when omitted (**create_post** uses “now”) — supply **publishedAt** when backdating or scheduling needs to be explicit.\n\n## Post HTML and Marble's editor\n\nWhen creating or updating posts, send clean HTML in the **content** field. Prefer semantic, editor-friendly HTML that survives Marble's sanitizer — avoid relying on markup Marble will strip before storage.\n\n## Allowed tags and URLs\n\nAllowed content tags include: p, h1, h2, h3, h4, h5, h6, strong, b, em, i, u, s, sub, sup, a, ul, ol, li, blockquote, pre, code, table, thead, tbody, tfoot, tr, th, td, img, figure, figcaption, video, track, iframe, div, span, mark, small, input, label, and hr.\n\nAllowed URL schemes are **http**, **https**, **ftp**, and **mailto**. Images may use **http**, **https**, or **data** URLs. Videos may use **http** or **https** URLs. Iframes must use **https** and are restricted to YouTube hosts: **www.youtube.com** and **www.youtube-nocookie.com**.\n\nAvoid class names, ids, JavaScript event handlers, scripts, arbitrary data attributes, and unknown inline styles — Marble strips unsupported markup before storing.\n\n## Allowed attributes (high-signal subset)\n\nUseful allowed attributes include:\n- **a**: href, target\n- **img**: safe sanitizer defaults such as **src**, **alt**\n- **iframe**: src, allowfullscreen, style, width, height\n- **figure**: src, alt, data-width, caption, data-align, data-type\n- **video**: src, controls, preload, muted, loop, playsinline\n- **track**: kind, src, srclang, label\n- **code**: class\n- **div**: data-twitter, data-src, data-youtube-video\n- **span** and **mark**: style, data-color\n- **input**: type, checked — only checkbox inputs are kept\n\n## Allowed inline styles\n\nLimited to **color**, **background-color**, and **text-decoration** with safe values. Other style declarations may be removed.\n\n## Figures, images, and video\n\nStandalone images fit best wrapped for the editor:\n\n\\`<figure data-width=\"100\" data-align=\"center\"><img src=\"https://example.com/image.jpg\" alt=\"Descriptive alt text\"><figcaption>Optional caption</figcaption></figure>\\`\n\nVideo:\n\n\\`<figure data-type=\"video\" data-width=\"100\" data-align=\"center\"><video src=\"https://example.com/video.mp4\" controls></video><figcaption>Optional caption</figcaption></figure>\\`\n\nUse **data-align** sparingly (**left**, **center**, **right**). Use **data-width** as a percentage-like string such as \"100\".\n\n## Embeds\n\nYouTube (Marble stores a wrapper **div** with an embed iframe):\n\n\\`<div data-youtube-video><iframe width=\"640\" height=\"480\" allowfullscreen=\"true\" src=\"https://www.youtube.com/embed/VIDEO_ID\"></iframe></div>\\`\n\nX/Twitter markers for Marble-aware rendering:\n\n\\`<div data-twitter data-src=\"https://x.com/username/status/1234567890\"></div>\\`\n\nConsumers that dump raw HTML may need separate handling for twitter markers — fallback to linking when unsure.\n\n## Disallowed / unsafe markup\n\nNever include script tags, onClick/onLoad-style handlers, javascript: URLs, non-YouTube iframes, unsupported attributes, or layout that depends entirely on stripped attributes. Prefer semantic HTML and let the site's theme handle styling.\n`.trim();\n}\n"
  },
  {
    "path": "apps/mcp/src/lib/mcp.ts",
    "content": "/**\n * Returns both human-readable text and structured data for MCP clients.\n * Structured content gives capable clients a stable object to inspect, while\n * the text fallback keeps the result useful everywhere.\n */\nexport function toolResult(data: Record<string, unknown>) {\n  return {\n    content: [{ type: \"text\" as const, text: JSON.stringify(data, null, 2) }],\n    structuredContent: data,\n  };\n}\n"
  },
  {
    "path": "apps/mcp/src/lib/media.ts",
    "content": "const MAX_REMOTE_MEDIA_BYTES = 5 * 1024 * 1024;\nconst REMOTE_MEDIA_FETCH_TIMEOUT_MS = 10_000;\n\nexport function filenameFromUrl(url: string) {\n  try {\n    const pathname = new URL(url).pathname;\n    const filename = pathname.split(\"/\").filter(Boolean).at(-1);\n    return filename || \"media-upload\";\n  } catch {\n    return \"media-upload\";\n  }\n}\n\nexport function assertPrivateApiKey(apiKey: string) {\n  if (!rawApiKey(apiKey).startsWith(\"msk\")) {\n    throw new Error(\n      \"upload_media_from_url requires a private Marble API key (msk_...).\"\n    );\n  }\n}\n\nexport async function fetchRemoteMedia(url: string) {\n  const remoteUrl = new URL(url);\n  assertAllowedRemoteUrl(remoteUrl);\n\n  const response = await fetch(remoteUrl, {\n    redirect: \"error\",\n    signal: AbortSignal.timeout(REMOTE_MEDIA_FETCH_TIMEOUT_MS),\n  });\n\n  if (!response.ok) {\n    throw new Error(`Failed to fetch media URL: ${response.status}`);\n  }\n\n  const contentLength = Number(response.headers.get(\"content-length\"));\n  if (contentLength > MAX_REMOTE_MEDIA_BYTES) {\n    throw new Error(\"Remote media files are limited to 5 MiB.\");\n  }\n\n  const reader = response.body?.getReader();\n  if (!reader) {\n    return response.blob();\n  }\n\n  let receivedBytes = 0;\n  const chunks: ArrayBuffer[] = [];\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) {\n      break;\n    }\n\n    receivedBytes += value.byteLength;\n    if (receivedBytes > MAX_REMOTE_MEDIA_BYTES) {\n      await reader.cancel();\n      throw new Error(\"Remote media files are limited to 5 MiB.\");\n    }\n\n    chunks.push(\n      value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)\n    );\n  }\n\n  return new Blob(chunks, {\n    type: response.headers.get(\"content-type\") ?? undefined,\n  });\n}\n\nfunction rawApiKey(apiKey: string) {\n  return apiKey.replace(/^Bearer\\s+/i, \"\").trim();\n}\n\nfunction assertAllowedRemoteUrl(url: URL) {\n  if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n    throw new Error(\"Remote media URL must use HTTP or HTTPS.\");\n  }\n\n  const hostname = url.hostname.toLowerCase();\n  if (\n    hostname === \"localhost\" ||\n    hostname === \"0.0.0.0\" ||\n    hostname === \"::1\" ||\n    hostname.startsWith(\"127.\") ||\n    hostname.startsWith(\"10.\") ||\n    hostname.startsWith(\"192.168.\")\n  ) {\n    throw new Error(\"Remote media URL cannot target a private host.\");\n  }\n\n  const ipv4Match = /^172\\.(\\d{1,3})\\./.exec(hostname);\n  if (ipv4Match) {\n    const secondOctet = Number(ipv4Match[1]);\n    if (secondOctet >= 16 && secondOctet <= 31) {\n      throw new Error(\"Remote media URL cannot target a private host.\");\n    }\n  }\n}\n"
  },
  {
    "path": "apps/mcp/src/routes/home.tsx",
    "content": "import { Hono } from \"hono\";\nimport { MCP_CLIENTS } from \"@/components/mcp-clients\";\nimport { MCP_TOOL_GROUPS } from \"@/lib/constants\";\nimport type { Env } from \"@/types\";\n\nexport const homeRoute = new Hono<{ Bindings: Env }>();\n\nhomeRoute.get(\"/\", (c) => {\n  const requestUrl = new URL(c.req.url);\n  const mcpHttpUrl = `${requestUrl.origin}/mcp`;\n\n  return c.html(\n    <html lang=\"en\">\n      <head>\n        <meta charset=\"utf-8\" />\n        <meta content=\"width=device-width, initial-scale=1\" name=\"viewport\" />\n        <title>Marble MCP</title>\n        <meta\n          content=\"Connect AI agents and MCP clients to your Marble workspace.\"\n          name=\"description\"\n        />\n        <meta content=\"Marble MCP\" property=\"og:title\" />\n        <meta\n          content=\"Connect AI agents and MCP clients to your Marble workspace.\"\n          property=\"og:description\"\n        />\n        <meta content=\"website\" property=\"og:type\" />\n        <meta content={requestUrl.origin} property=\"og:url\" />\n        <meta content=\"/opengraph.png\" property=\"og:image\" />\n        <meta content=\"summary_large_image\" name=\"twitter:card\" />\n        <meta content=\"Marble MCP\" name=\"twitter:title\" />\n        <meta\n          content=\"Connect AI agents and MCP clients to your Marble workspace.\"\n          name=\"twitter:description\"\n        />\n        <meta content=\"/opengraph.png\" name=\"twitter:image\" />\n        <link href=\"/favicon.svg\" rel=\"icon\" type=\"image/svg+xml\" />\n        <link href=\"/styles.css\" rel=\"stylesheet\" />\n      </head>\n      <body>\n        <main>\n          <section class=\"heading\">\n            <div>\n              <h1>Marble MCP</h1>\n              <p>Connect AI agents and MCP clients to your Marble workspace.</p>\n            </div>\n            <a class=\"button\" href=\"https://docs.marblecms.com/guides/mcp\">\n              Documentation\n            </a>\n          </section>\n          <section class=\"server-section\">\n            <h2 class=\"section-heading\">Server URL</h2>\n            <div class=\"server-url\">\n              <code>{mcpHttpUrl}</code>\n              <button data-copy={mcpHttpUrl} type=\"button\">\n                Copy\n              </button>\n            </div>\n            <p>\n              Configure your MCP client with Streamable HTTP and pass your\n              Marble API key using the <code>Mcp-Marble-Api-Key</code> header.\n            </p>\n          </section>\n          <section>\n            <h2 class=\"section-heading\">Works with</h2>\n            <ul class=\"client-list\">\n              {MCP_CLIENTS.map((client) => (\n                <li class=\"client-card\" key={client.id}>\n                  {client.Icon({\n                    class:\n                      client.id === \"codex\"\n                        ? \"client-icon-svg client-icon-codex\"\n                        : \"client-icon-svg\",\n                  })}\n                  <span>{client.name}</span>\n                </li>\n              ))}\n            </ul>\n          </section>\n          <section>\n            <h2 class=\"section-heading\">Available tools</h2>\n            <div class=\"tool-groups\">\n              {MCP_TOOL_GROUPS.map((group) => (\n                <details class=\"tool-group\" key={group.name}>\n                  <summary>\n                    <span>\n                      <span class=\"tool-group-name\">{group.name}</span>\n                      <small>{group.description}</small>\n                    </span>\n                    <span class=\"tool-count\">{group.tools.length} tools</span>\n                  </summary>\n                  <ul class=\"tool-list\">\n                    {group.tools.map((tool) => (\n                      <li class=\"tool-item\" key={tool.name}>\n                        <code>{tool.name}</code>\n                        <span>{tool.description}</span>\n                      </li>\n                    ))}\n                  </ul>\n                </details>\n              ))}\n            </div>\n          </section>\n        </main>\n        <footer class=\"site-footer\">\n          <p>© 2026 all rights reserved</p>\n          <nav aria-label=\"Footer links\">\n            <a href=\"https://github.com/usemarble/marble\">GitHub</a>\n            <a href=\"https://marblecms.com?utm_source=mcp_home\">Website</a>\n          </nav>\n        </footer>\n        <script src=\"/home.js\" type=\"module\" />\n      </body>\n    </html>\n  );\n});\n"
  },
  {
    "path": "apps/mcp/src/routes/mcp.ts",
    "content": "import { createMcpHandler } from \"agents/mcp\";\nimport { Hono } from \"hono\";\nimport { getApiKey } from \"@/lib/auth\";\nimport { DEFAULT_API_BASE_URL } from \"@/lib/constants\";\nimport { createServer } from \"@/server\";\nimport type { Env } from \"@/types\";\n\nexport const mcpRoute = new Hono<{ Bindings: Env }>();\n\nmcpRoute.all(\"/\", async (c) => {\n  let apiKey: string;\n  try {\n    apiKey = getApiKey(c.req.raw);\n  } catch (error) {\n    return c.json(\n      {\n        error: \"Unauthorized\",\n        message:\n          error instanceof Error\n            ? error.message\n            : \"Missing or invalid Marble API key.\",\n      },\n      401\n    );\n  }\n\n  const apiBaseUrl = c.env.MARBLE_API_BASE_URL ?? DEFAULT_API_BASE_URL;\n  const server = createServer(apiBaseUrl, apiKey);\n  const handler = createMcpHandler(server, { route: \"/mcp\" });\n\n  return handler(c.req.raw, c.env, c.executionCtx);\n});\n"
  },
  {
    "path": "apps/mcp/src/server.ts",
    "content": "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { getServerInstructions } from \"@/lib/instructions\";\nimport { registerAuthorTools } from \"@/tools/authors\";\nimport { registerCategoryTools } from \"@/tools/categories\";\nimport { registerMediaTools } from \"@/tools/media\";\nimport { registerPostTools } from \"@/tools/posts\";\nimport { registerTagTools } from \"@/tools/tags\";\n\nexport function createServer(apiBaseUrl: string, apiKey: string) {\n  const server = new McpServer(\n    {\n      name: \"Marble\",\n      version: \"1.0.0\",\n    },\n    {\n      instructions: getServerInstructions(),\n    }\n  );\n\n  registerPostTools(server, apiBaseUrl, apiKey);\n  registerCategoryTools(server, apiBaseUrl, apiKey);\n  registerTagTools(server, apiBaseUrl, apiKey);\n  registerAuthorTools(server, apiBaseUrl, apiKey);\n  registerMediaTools(server, apiBaseUrl, apiKey);\n\n  return server;\n}\n"
  },
  {
    "path": "apps/mcp/src/tools/authors.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport { deleteJsonApi, readJsonApi, writeJsonApi } from \"@/lib/api\";\nimport { toolResult } from \"@/lib/mcp\";\nimport {\n  destructiveAnnotations,\n  identifierInput,\n  paginationInput,\n  readOnlyAnnotations,\n} from \"./shared\";\n\nconst socialInput = z.object({\n  platform: z\n    .enum([\n      \"x\",\n      \"twitter\",\n      \"github\",\n      \"facebook\",\n      \"instagram\",\n      \"youtube\",\n      \"tiktok\",\n      \"linkedin\",\n      \"website\",\n      \"onlyfans\",\n      \"discord\",\n      \"bluesky\",\n    ])\n    .describe(\"Social media platform.\"),\n  url: z.string().url().describe(\"Social profile URL.\"),\n});\n\nconst authorBody = {\n  name: z.string().min(1).describe(\"Author display name.\"),\n  slug: z.string().min(1).describe(\"URL-friendly author slug.\"),\n  bio: z.string().nullable().optional().describe(\"Author bio.\"),\n  role: z.string().nullable().optional().describe(\"Author role or title.\"),\n  email: z.email().nullable().optional().describe(\"Author email address.\"),\n  image: z.url().nullable().optional().describe(\"Author image URL.\"),\n  socials: z\n    .array(socialInput)\n    .optional()\n    .describe(\"Social media links for this author.\"),\n};\n\nconst updateAuthorBody = {\n  name: z.string().min(1).optional().describe(\"Updated author display name.\"),\n  slug: z\n    .string()\n    .min(1)\n    .optional()\n    .describe(\"Updated URL-friendly author slug.\"),\n  bio: z.string().nullable().optional().describe(\"Updated author bio.\"),\n  role: z\n    .string()\n    .nullable()\n    .optional()\n    .describe(\"Updated author role or title.\"),\n  email: z\n    .email()\n    .nullable()\n    .optional()\n    .describe(\"Updated author email address.\"),\n  image: z.url().nullable().optional().describe(\"Updated author image URL.\"),\n  socials: z\n    .array(socialInput)\n    .optional()\n    .describe(\n      \"Social media links. Replaces all existing socials when provided.\"\n    ),\n};\n\nexport function registerAuthorTools(\n  server: McpServer,\n  apiBaseUrl: string,\n  apiKey: string\n) {\n  server.registerTool(\n    \"get_authors\",\n    {\n      title: \"Get Authors\",\n      description: \"Get a paginated list of authors who have published posts.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: paginationInput,\n    },\n    async ({ limit, page }) =>\n      toolResult(\n        await readJsonApi(apiBaseUrl, apiKey, \"/v1/authors\", { limit, page })\n      )\n  );\n\n  server.registerTool(\n    \"get_author\",\n    {\n      title: \"Get Author\",\n      description: \"Get a single author by ID or slug.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await readJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/authors/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n\n  server.registerTool(\n    \"create_author\",\n    {\n      title: \"Create Author\",\n      description:\n        \"Create a new author. Requires a private Marble API key. Hobby plan is limited to 1 author.\",\n      inputSchema: {\n        body: z.object(authorBody),\n      },\n    },\n    async ({ body }) =>\n      toolResult(\n        await writeJsonApi(apiBaseUrl, apiKey, \"POST\", \"/v1/authors\", body)\n      )\n  );\n\n  server.registerTool(\n    \"update_author\",\n    {\n      title: \"Update Author\",\n      description:\n        \"Update an existing author by ID or slug. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: {\n        ...identifierInput,\n        body: z.object(updateAuthorBody),\n      },\n    },\n    async ({ identifier, body }) =>\n      toolResult(\n        await writeJsonApi(\n          apiBaseUrl,\n          apiKey,\n          \"PATCH\",\n          `/v1/authors/${encodeURIComponent(identifier)}`,\n          body\n        )\n      )\n  );\n\n  server.registerTool(\n    \"delete_author\",\n    {\n      title: \"Delete Author\",\n      description:\n        \"Delete an author by ID or slug. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await deleteJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/authors/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n}\n"
  },
  {
    "path": "apps/mcp/src/tools/categories.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport { deleteJsonApi, readJsonApi, writeJsonApi } from \"@/lib/api\";\nimport { toolResult } from \"@/lib/mcp\";\nimport {\n  destructiveAnnotations,\n  identifierInput,\n  namedResourceBody,\n  paginationInput,\n  readOnlyAnnotations,\n  updateNamedResourceBody,\n} from \"./shared\";\n\nexport function registerCategoryTools(\n  server: McpServer,\n  apiBaseUrl: string,\n  apiKey: string\n) {\n  server.registerTool(\n    \"get_categories\",\n    {\n      title: \"Get Categories\",\n      description: \"Get a paginated list of categories.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: paginationInput,\n    },\n    async ({ limit, page }) =>\n      toolResult(\n        await readJsonApi(apiBaseUrl, apiKey, \"/v1/categories\", { limit, page })\n      )\n  );\n\n  server.registerTool(\n    \"get_category\",\n    {\n      title: \"Get Category\",\n      description: \"Get a single category by ID or slug.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await readJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/categories/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n\n  server.registerTool(\n    \"create_category\",\n    {\n      title: \"Create Category\",\n      description: \"Create a new category. Requires a private Marble API key.\",\n      inputSchema: {\n        body: z.object(namedResourceBody),\n      },\n    },\n    async ({ body }) =>\n      toolResult(\n        await writeJsonApi(apiBaseUrl, apiKey, \"POST\", \"/v1/categories\", body)\n      )\n  );\n\n  server.registerTool(\n    \"update_category\",\n    {\n      title: \"Update Category\",\n      description:\n        \"Update an existing category by ID or slug. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: {\n        ...identifierInput,\n        body: z.object(updateNamedResourceBody),\n      },\n    },\n    async ({ identifier, body }) =>\n      toolResult(\n        await writeJsonApi(\n          apiBaseUrl,\n          apiKey,\n          \"PATCH\",\n          `/v1/categories/${encodeURIComponent(identifier)}`,\n          body\n        )\n      )\n  );\n\n  server.registerTool(\n    \"delete_category\",\n    {\n      title: \"Delete Category\",\n      description:\n        \"Delete a category by ID or slug. Requires a private Marble API key. Cannot delete a category that has posts assigned to it.\",\n      annotations: destructiveAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await deleteJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/categories/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n}\n"
  },
  {
    "path": "apps/mcp/src/tools/media.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n  deleteJsonApi,\n  readJsonApi,\n  uploadMediaApi,\n  validateApiKey,\n  writeJsonApi,\n} from \"@/lib/api\";\nimport { toolResult } from \"@/lib/mcp\";\nimport {\n  assertPrivateApiKey,\n  fetchRemoteMedia,\n  filenameFromUrl,\n} from \"@/lib/media\";\nimport {\n  destructiveAnnotations,\n  paginationInput,\n  readOnlyAnnotations,\n} from \"./shared\";\n\nconst mediaType = z.enum([\"image\", \"video\", \"audio\", \"document\"]);\n\nconst mediaIdentifierInput = {\n  id: z.string().min(1).describe(\"Media asset ID.\"),\n};\n\nconst updateMediaBody = {\n  name: z.string().min(1).optional().describe(\"Updated media display name.\"),\n  alt: z\n    .string()\n    .nullable()\n    .optional()\n    .describe(\"Updated image alt text. Use null to clear it.\"),\n};\n\nexport function registerMediaTools(\n  server: McpServer,\n  apiBaseUrl: string,\n  apiKey: string\n) {\n  server.registerTool(\n    \"get_media\",\n    {\n      title: \"Get Media\",\n      description: \"Get a paginated list of media assets.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: {\n        ...paginationInput,\n        order: z.enum([\"asc\", \"desc\"]).optional().describe(\"Sort order.\"),\n        type: mediaType.optional().describe(\"Filter by media type.\"),\n        query: z\n          .string()\n          .optional()\n          .describe(\"Search by name, alt text, URL, or MIME type.\"),\n      },\n    },\n    async (params) =>\n      toolResult(await readJsonApi(apiBaseUrl, apiKey, \"/v1/media\", params))\n  );\n\n  server.registerTool(\n    \"get_media_asset\",\n    {\n      title: \"Get Media Asset\",\n      description: \"Get a single media asset by ID.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: mediaIdentifierInput,\n    },\n    async ({ id }) =>\n      toolResult(\n        await readJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/media/${encodeURIComponent(id)}`\n        )\n      )\n  );\n\n  server.registerTool(\n    \"upload_media_from_url\",\n    {\n      title: \"Upload Media From URL\",\n      description:\n        \"Fetch a remote file URL and upload it to Marble. Requires a private Marble API key. The Marble API currently accepts files up to 5 MiB.\",\n      inputSchema: {\n        url: z.url().describe(\"Remote file URL to fetch and upload.\"),\n        filename: z\n          .string()\n          .min(1)\n          .optional()\n          .describe(\"Optional filename to use for the uploaded asset.\"),\n      },\n    },\n    async ({ url, filename }) => {\n      assertPrivateApiKey(apiKey);\n      await validateApiKey(apiBaseUrl, apiKey);\n\n      const blob = await fetchRemoteMedia(url);\n      return toolResult(\n        await uploadMediaApi(\n          apiBaseUrl,\n          apiKey,\n          blob,\n          filename ?? filenameFromUrl(url)\n        )\n      );\n    }\n  );\n\n  server.registerTool(\n    \"update_media\",\n    {\n      title: \"Update Media\",\n      description:\n        \"Update media asset metadata. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: {\n        ...mediaIdentifierInput,\n        body: z.object(updateMediaBody),\n      },\n    },\n    async ({ id, body }) =>\n      toolResult(\n        await writeJsonApi(\n          apiBaseUrl,\n          apiKey,\n          \"PATCH\",\n          `/v1/media/${encodeURIComponent(id)}`,\n          body\n        )\n      )\n  );\n\n  server.registerTool(\n    \"delete_media\",\n    {\n      title: \"Delete Media\",\n      description:\n        \"Delete a media asset and its stored file. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: mediaIdentifierInput,\n    },\n    async ({ id }) =>\n      toolResult(\n        await deleteJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/media/${encodeURIComponent(id)}`\n        )\n      )\n  );\n}\n"
  },
  {
    "path": "apps/mcp/src/tools/posts.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport { deleteJsonApi, readJsonApi, writeJsonApi } from \"@/lib/api\";\nimport { toolResult } from \"@/lib/mcp\";\nimport {\n  destructiveAnnotations,\n  identifierInput,\n  paginationInput,\n  readOnlyAnnotations,\n} from \"./shared\";\n\nconst postFilters = {\n  order: z.enum([\"asc\", \"desc\"]).optional().describe(\"Sort order.\"),\n  status: z\n    .enum([\"published\", \"draft\", \"all\"])\n    .optional()\n    .describe(\"Filter by post status. Defaults to published posts.\"),\n  format: z\n    .enum([\"html\", \"markdown\"])\n    .optional()\n    .describe(\"Content format to return.\"),\n  featured: z\n    .enum([\"true\", \"false\"])\n    .optional()\n    .describe(\"Filter by featured posts.\"),\n  categories: z\n    .array(z.string())\n    .optional()\n    .describe(\"Category IDs or slugs to include.\"),\n  excludeCategories: z\n    .array(z.string())\n    .optional()\n    .describe(\"Category IDs or slugs to exclude.\"),\n  tags: z.array(z.string()).optional().describe(\"Tag IDs or slugs to include.\"),\n  excludeTags: z\n    .array(z.string())\n    .optional()\n    .describe(\"Tag IDs or slugs to exclude.\"),\n};\n\nconst postBody = {\n  title: z.string().min(1).describe(\"Post title.\"),\n  content: z\n    .string()\n    .min(1)\n    .describe(\n      \"Post body content as HTML. Use clean semantic HTML. Marble sanitizes content before storing it, so avoid scripts, event handlers, unsupported attributes, unsafe URL schemes, and non-YouTube iframes. For captioned images or videos, prefer editor-compatible figure markup.\"\n    ),\n  description: z.string().min(1).describe(\"Short post description or excerpt.\"),\n  slug: z.string().min(1).describe(\"URL-friendly post slug.\"),\n  categoryId: z.string().min(1).describe(\"Required category ID for the post.\"),\n  status: z\n    .enum([\"published\", \"draft\"])\n    .describe(\"Initial post status: published or draft.\"),\n  tags: z\n    .array(z.string())\n    .optional()\n    .describe(\"Array of tag IDs to attach to the post.\"),\n  authors: z\n    .array(z.string())\n    .optional()\n    .describe(\n      \"Array of author IDs. If omitted, the first workspace author is used.\"\n    ),\n  featured: z\n    .boolean()\n    .optional()\n    .describe(\"Whether the post should be marked as featured.\"),\n  coverImage: z\n    .url()\n    .nullable()\n    .optional()\n    .describe(\"Cover image URL. Use null to clear it.\"),\n  publishedAt: z.iso\n    .datetime()\n    .optional()\n    .describe(\"ISO 8601 datetime. Defaults to current time if omitted.\"),\n};\n\nconst updatePostBody = {\n  title: z.string().min(1).optional().describe(\"Updated post title.\"),\n  content: z\n    .string()\n    .min(1)\n    .optional()\n    .describe(\n      \"Updated post body content as HTML. Use clean semantic HTML. Marble sanitizes content before storing it, so avoid scripts, event handlers, unsupported attributes, unsafe URL schemes, and non-YouTube iframes. For captioned images or videos, prefer editor-compatible figure markup.\"\n    ),\n  description: z\n    .string()\n    .min(1)\n    .optional()\n    .describe(\"Updated short post description or excerpt.\"),\n  slug: z\n    .string()\n    .min(1)\n    .optional()\n    .describe(\"Updated URL-friendly post slug.\"),\n  categoryId: z.string().min(1).optional().describe(\"Updated category ID.\"),\n  status: z\n    .enum([\"published\", \"draft\"])\n    .optional()\n    .describe(\"Updated post status.\"),\n  tags: z\n    .array(z.string())\n    .optional()\n    .describe(\"Array of tag IDs. Replaces all existing tags when provided.\"),\n  authors: z\n    .array(z.string())\n    .optional()\n    .describe(\n      \"Array of author IDs. Replaces all existing authors when provided.\"\n    ),\n  featured: z.boolean().optional().describe(\"Updated featured status.\"),\n  coverImage: z\n    .url()\n    .nullable()\n    .optional()\n    .describe(\"Updated cover image URL. Use null to clear it.\"),\n  publishedAt: z.iso\n    .datetime()\n    .optional()\n    .describe(\"Updated ISO 8601 publication datetime.\"),\n};\n\nexport function registerPostTools(\n  server: McpServer,\n  apiBaseUrl: string,\n  apiKey: string\n) {\n  server.registerTool(\n    \"get_posts\",\n    {\n      title: \"Get Posts\",\n      description:\n        \"Get a paginated list of published posts with optional filtering.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: {\n        ...paginationInput,\n        ...postFilters,\n        query: z\n          .string()\n          .optional()\n          .describe(\"Search query for title and content.\"),\n      },\n    },\n    async (params) =>\n      toolResult(await readJsonApi(apiBaseUrl, apiKey, \"/v1/posts\", params))\n  );\n\n  server.registerTool(\n    \"search_posts\",\n    {\n      title: \"Search Posts\",\n      description:\n        \"Search Marble posts by title and content. Use status 'all' when the user wants drafts included.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: {\n        ...paginationInput,\n        query: z.string().min(1),\n        status: z.enum([\"published\", \"draft\", \"all\"]).optional(),\n        format: z.enum([\"html\", \"markdown\"]).optional(),\n      },\n    },\n    async (params) =>\n      toolResult(await readJsonApi(apiBaseUrl, apiKey, \"/v1/posts\", params))\n  );\n\n  server.registerTool(\n    \"get_post\",\n    {\n      title: \"Get Post\",\n      description:\n        \"Get a single post by ID or slug, with optional status filtering.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: {\n        ...identifierInput,\n        status: z.enum([\"published\", \"draft\", \"all\"]).optional(),\n        format: z.enum([\"html\", \"markdown\"]).optional(),\n      },\n    },\n    async ({ identifier, status, format }) =>\n      toolResult(\n        await readJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/posts/${encodeURIComponent(identifier)}`,\n          { status, format }\n        )\n      )\n  );\n\n  server.registerTool(\n    \"create_post\",\n    {\n      title: \"Create Post\",\n      description:\n        \"Create a new post. Requires a private Marble API key. Category is required. If authors are omitted, the first workspace author is used.\",\n      inputSchema: {\n        body: z.object(postBody),\n      },\n    },\n    async ({ body }) =>\n      toolResult(\n        await writeJsonApi(apiBaseUrl, apiKey, \"POST\", \"/v1/posts\", body)\n      )\n  );\n\n  server.registerTool(\n    \"update_post\",\n    {\n      title: \"Update Post\",\n      description:\n        \"Update an existing post by ID or slug. All fields are optional - only provided fields are updated. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: {\n        ...identifierInput,\n        body: z.object(updatePostBody),\n      },\n    },\n    async ({ identifier, body }) =>\n      toolResult(\n        await writeJsonApi(\n          apiBaseUrl,\n          apiKey,\n          \"PATCH\",\n          `/v1/posts/${encodeURIComponent(identifier)}`,\n          body\n        )\n      )\n  );\n\n  server.registerTool(\n    \"delete_post\",\n    {\n      title: \"Delete Post\",\n      description:\n        \"Delete a post by ID or slug. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await deleteJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/posts/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n}\n"
  },
  {
    "path": "apps/mcp/src/tools/shared.ts",
    "content": "import { z } from \"zod\";\n\nexport const readOnlyAnnotations = {\n  readOnlyHint: true,\n} as const;\n\nexport const destructiveAnnotations = {\n  readOnlyHint: false,\n  destructiveHint: true,\n} as const;\n\nexport const paginationInput = {\n  limit: z\n    .number()\n    .int()\n    .min(1)\n    .max(100)\n    .optional()\n    .describe(\"Number of items per page. Defaults to the Marble API default.\"),\n  page: z\n    .number()\n    .int()\n    .min(1)\n    .optional()\n    .describe(\"Page number. Defaults to page 1.\"),\n};\n\nexport const identifierInput = {\n  identifier: z.string().min(1).describe(\"Resource ID or slug.\"),\n};\n\nexport const namedResourceBody = {\n  name: z.string().min(1).describe(\"Display name.\"),\n  slug: z.string().min(1).describe(\"URL-friendly slug.\"),\n  description: z.string().optional().describe(\"Optional description.\"),\n};\n\nexport const updateNamedResourceBody = {\n  name: z.string().min(1).optional().describe(\"Updated display name.\"),\n  slug: z.string().min(1).optional().describe(\"Updated URL-friendly slug.\"),\n  description: z\n    .string()\n    .nullable()\n    .optional()\n    .describe(\"Updated description. Use null to clear it.\"),\n};\n"
  },
  {
    "path": "apps/mcp/src/tools/tags.ts",
    "content": "import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport { deleteJsonApi, readJsonApi, writeJsonApi } from \"@/lib/api\";\nimport { toolResult } from \"@/lib/mcp\";\nimport {\n  destructiveAnnotations,\n  identifierInput,\n  namedResourceBody,\n  paginationInput,\n  readOnlyAnnotations,\n  updateNamedResourceBody,\n} from \"./shared\";\n\nexport function registerTagTools(\n  server: McpServer,\n  apiBaseUrl: string,\n  apiKey: string\n) {\n  server.registerTool(\n    \"get_tags\",\n    {\n      title: \"Get Tags\",\n      description: \"Get a paginated list of tags.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: paginationInput,\n    },\n    async ({ limit, page }) =>\n      toolResult(\n        await readJsonApi(apiBaseUrl, apiKey, \"/v1/tags\", { limit, page })\n      )\n  );\n\n  server.registerTool(\n    \"get_tag\",\n    {\n      title: \"Get Tag\",\n      description: \"Get a single tag by ID or slug.\",\n      annotations: readOnlyAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await readJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/tags/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n\n  server.registerTool(\n    \"create_tag\",\n    {\n      title: \"Create Tag\",\n      description: \"Create a new tag. Requires a private Marble API key.\",\n      inputSchema: {\n        body: z.object(namedResourceBody),\n      },\n    },\n    async ({ body }) =>\n      toolResult(\n        await writeJsonApi(apiBaseUrl, apiKey, \"POST\", \"/v1/tags\", body)\n      )\n  );\n\n  server.registerTool(\n    \"update_tag\",\n    {\n      title: \"Update Tag\",\n      description:\n        \"Update an existing tag by ID or slug. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: {\n        ...identifierInput,\n        body: z.object(updateNamedResourceBody),\n      },\n    },\n    async ({ identifier, body }) =>\n      toolResult(\n        await writeJsonApi(\n          apiBaseUrl,\n          apiKey,\n          \"PATCH\",\n          `/v1/tags/${encodeURIComponent(identifier)}`,\n          body\n        )\n      )\n  );\n\n  server.registerTool(\n    \"delete_tag\",\n    {\n      title: \"Delete Tag\",\n      description:\n        \"Delete a tag by ID or slug. Requires a private Marble API key.\",\n      annotations: destructiveAnnotations,\n      inputSchema: identifierInput,\n    },\n    async ({ identifier }) =>\n      toolResult(\n        await deleteJsonApi(\n          apiBaseUrl,\n          apiKey,\n          `/v1/tags/${encodeURIComponent(identifier)}`\n        )\n      )\n  );\n}\n"
  },
  {
    "path": "apps/mcp/src/types.ts",
    "content": "export interface Env {\n  MARBLE_API_BASE_URL?: string;\n}\n\nexport type QueryParams = Record<\n  string,\n  boolean | number | string | string[] | undefined\n>;\n"
  },
  {
    "path": "apps/mcp/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"lib\": [\"ESNext\", \"WebWorker\"],\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    },\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"hono/jsx\"\n  }\n}\n"
  },
  {
    "path": "apps/mcp/wrangler.jsonc",
    "content": "{\n  \"$schema\": \"node_modules/wrangler/config-schema.json\",\n  \"name\": \"marble-mcp\",\n  \"main\": \"src/index.ts\",\n  \"assets\": {\n    \"directory\": \"./public/\"\n  },\n  \"compatibility_date\": \"2026-05-02\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"keep_vars\": true,\n  \"placement\": { \"mode\": \"smart\" },\n  \"observability\": {\n    \"enabled\": true,\n    \"head_sampling_rate\": 1\n  }\n}\n"
  },
  {
    "path": "apps/web/.gitignore",
    "content": "# build output\ndist/\n\n# generated types\n.astro/\n\n# dependencies\nnode_modules/\n\n# logs\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n# environment variables\n.env\n.env.production\n\n# macOS-specific files\n.DS_Store\n\n# jetbrains setting folder\n.idea/\n"
  },
  {
    "path": "apps/web/.vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"command\": \"./node_modules/.bin/astro dev\",\n      \"name\": \"Development server\",\n      \"request\": \"launch\",\n      \"type\": \"node-terminal\"\n    }\n  ]\n}\n"
  },
  {
    "path": "apps/web/README.md",
    "content": "# WEB\n\nMarketing site for marble.\n"
  },
  {
    "path": "apps/web/astro.config.mjs",
    "content": "// @ts-check\n\nimport mdx from \"@astrojs/mdx\";\nimport sitemap from \"@astrojs/sitemap\";\nimport vercel from \"@astrojs/vercel\";\nimport tailwind from \"@tailwindcss/vite\";\nimport { defineConfig, fontProviders } from \"astro/config\";\n\n// https://astro.build/config\nexport default defineConfig({\n  integrations: [mdx(), sitemap()],\n  vite: {\n    plugins: [tailwind()],\n  },\n  site: \"https://marblecms.com\",\n  trailingSlash: \"never\",\n  image: {\n    domains: [\"images.marblecms.com\", \"media.marblecms.com\"],\n  },\n  adapter: vercel({\n    webAnalytics: {\n      enabled: true,\n    },\n    isr: {\n      expiration: 3600,\n      exclude: [/^\\/(?!contributors\\/?$).*/],\n    },\n  }),\n  fonts: [\n    {\n      name: \"Literata\",\n      cssVariable: \"--font-literata\",\n      provider: fontProviders.fontsource(),\n      weights: [400, 500, 600, 700],\n      styles: [\"normal\"],\n      subsets: [\"latin\"],\n    },\n    {\n      name: \"Geist\",\n      cssVariable: \"--font-geist\",\n      provider: fontProviders.google(),\n      weights: [400, 500, 600, 700],\n      styles: [\"normal\"],\n      subsets: [\"latin\"],\n    },\n  ],\n  experimental: {\n    svgo: true,\n  },\n});\n"
  },
  {
    "path": "apps/web/package.json",
    "content": "{\n  \"name\": \"web\",\n  \"type\": \"module\",\n  \"version\": \"0.0.1\",\n  \"scripts\": {\n    \"dev\": \"astro dev\",\n    \"build\": \"astro build\",\n    \"preview\": \"astro preview\",\n    \"astro\": \"astro\"\n  },\n  \"dependencies\": {\n    \"@astrojs/mdx\": \"^5.0.3\",\n    \"@astrojs/rss\": \"^4.0.18\",\n    \"@astrojs/sitemap\": \"^3.7.2\",\n    \"@astrojs/vercel\": \"^10.0.4\",\n    \"@marble/utils\": \"workspace:*\",\n    \"@tailwindcss/vite\": \"^4.2.1\",\n    \"@usemarble/sdk\": \"^1.1.0\",\n    \"@vercel/speed-insights\": \"^1.3.1\",\n    \"astro\": \"^6.1.10\",\n    \"clsx\": \"^2.1.1\",\n    \"free-astro-components\": \"^1.2.1\",\n    \"sharp\": \"^0.34.5\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tailwindcss\": \"^4.2.1\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/typography\": \"^0.5.19\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "apps/web/public/robots.txt",
    "content": "User-agent: *\nDisallow: /terms\nDisallow: /privacy\n\nSitemap: https://marblecms.com/sitemap-index.xml"
  },
  {
    "path": "apps/web/public/site.webmanifest",
    "content": "{\n  \"name\": \"Marble\",\n  \"short_name\": \"Marble\",\n  \"icons\": [\n    {\n      \"src\": \"/web-app-manifest-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/web-app-manifest-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"theme_color\": \"#ffffff\",\n  \"background_color\": \"#ffffff\",\n  \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "apps/web/src/components/BlogHeader.astro",
    "content": "---\n  import { SITE } from \"@/lib/constants/site\";\n  import { REGISTER_URL, TRACKING_EVENTS } from \"@/lib/constants/tracking\";\n  import Container from \"./Container.astro\";\n  import WordMark from \"./icons/WordMark.astro\";\n---\n<header\n  id=\"header\"\n  class=\"bg-background/90 z-50 relative max-lg:py-4 backdrop-blur-md\"\n>\n  <div class=\"lg:border-x border-dashed sm:w-[calc(100%-140px)] mx-auto\">\n    <Container class=\"lg:py-4\">\n      <nav\n        id=\"nav\"\n        class=\"group grid grid-cols-2 transition items-center sticky top-96\"\n      >\n        <!-- mobile nav -->\n        <div\n          id=\"mobile-nav\"\n          class=\"hidden data-expanded:block data-expanded:fixed data-expanded:inset-0 z-50 pt-20 px-6 h-screen bg-background/90 backdrop-blur-[5px] md:hidden\"\n        >\n          <ul class=\"flex flex-col gap-6\">\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a href=\"https://docs.marblecms.com\">\n                <span>Docs</span>\n              </a>\n            </li>\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a href=\"/pricing\">\n                <span>Pricing</span>\n              </a>\n            </li>\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a href=\"/blog\">\n                <span>Blog</span>\n              </a>\n            </li>\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a\n                href={REGISTER_URL}\n                data-track={TRACKING_EVENTS.signupClicked}\n                data-context=\"blog_header_mobile\"\n                data-label=\"Sign up\"\n              >\n                <span>Sign up</span>\n              </a>\n            </li>\n          </ul>\n        </div>\n        <!-- desktop nav -->\n        <div class=\"flex items-center gap-4 justify-start z-100\">\n          <a\n            href=\"/blog\"\n            class=\"text-lg tracking-wider flex items-center gap-2\"\n          >\n            <WordMark/>\n            <span class=\"sr-only\">marblecms</span>\n          </a>\n        </div>\n\n        <div class=\"hidden md:flex items-center justify-end\">\n          <a\n            href={`${SITE.APP_URL}/login`}\n            class=\"font-medium hover:bg-primary rounded hover:text-white p-2 w-24 flex items-center justify-center transition duration-300 text-sm border border-primary bg-white\"\n          >\n            <span>Sign in</span>\n          </a>\n        </div>\n        <!-- mobile nav toggle -->\n        <div class=\"flex items-center justify-end md:hidden z-100\">\n          <button\n            id=\"toggle\"\n            aria-label=\"Toggle mobile navigation\"\n            class=\"flex flex-col justify-center gap-1.5 size-6 focus-visible:outline-dashed focus-visible:outline-offset-1\"\n          >\n            <span\n              id=\"bar\"\n              class=\"h-0.5 self-start bg-foreground w-1/2 data-expanded:w-[calc(50%-3px)] data-expanded:rotate-45 data-expanded:translate-y-[2px] data-expanded:translate-x-[2px] transition duration-300\"\n            ></span>\n            <span\n              id=\"bar\"\n              class=\"h-0.5 w-full bg-foreground data-expanded:-rotate-45 transition duration-300\"\n            ></span>\n            <span\n              id=\"bar\"\n              class=\"h-0.5 self-end bg-foreground w-1/2 data-expanded:w-[calc(50%-3px)] data-expanded:rotate-45 data-expanded:-translate-y-[3px] data-expanded:-translate-x-0.5 transition duration-300\"\n            ></span>\n          </button>\n        </div>\n      </nav>\n    </Container>\n  </div>\n</header>\n\n<script>\n  function initMobileNav() {\n    const toggle = document.getElementById(\"toggle\");\n    const nav = document.getElementById(\"mobile-nav\");\n\n    const toggleNav = () => {\n      nav?.toggleAttribute(\"data-expanded\");\n      document.body.classList.toggle(\"overflow-y-hidden\");\n      const barElements = document.querySelectorAll(\"#bar\");\n      for (const el of barElements) {\n        el.toggleAttribute(\"data-expanded\");\n      }\n    };\n\n    if (toggle) {\n      toggle.addEventListener(\"click\", toggleNav);\n    }\n  }\n\n  // Initialize on first load\n  initMobileNav();\n\n  // Re-initialize after page transitions\n  document.addEventListener(\"astro:after-swap\", initMobileNav);\n</script>\n"
  },
  {
    "path": "apps/web/src/components/CategoryCard.astro",
    "content": "---\n  import { Image } from \"astro:assets\";\n  import type { Post } from \"@/lib/schemas\";\n\n  interface Props {\n    entry: Post;\n  }\n\n  const { entry } = Astro.props as Props;\n  const { title, description, coverImage, publishedAt, authors, slug } = entry;\n\n  const formattedDate = new Date(publishedAt).toLocaleDateString(\"en-US\", {\n    day: \"2-digit\",\n    month: \"2-digit\",\n    year: \"numeric\",\n    timeZone: \"UTC\",\n  });\n---\n<li class=\"border-b border-r border-dashed bg-white h-full group\">\n  <a\n    href={`/blog/${slug}`}\n    class=\"flex flex-col h-full hover:bg-gray-50 transition-colors duration-300\"\n  >\n    <div class=\"w-full aspect-video overflow-hidden border-b border-dashed\">\n      <Image\n        src={coverImage || \"/og-blog.jpg\"}\n        alt={title}\n        loading=\"eager\"\n        inferSize\n        class=\"w-full h-full object-cover group-hover:scale-105 transition-transform duration-500\"\n      />\n    </div>\n    <div class=\"flex flex-col gap-4 p-6 flex-1\">\n      <div class=\"space-y-3 flex-1\">\n        <div\n          class=\"flex items-center gap-2 text-xs text-muted-foreground uppercase tracking-wider\"\n        >\n          <time datetime={new Date(publishedAt).toISOString()}>\n            {formattedDate}\n          </time>\n          <span>•</span>\n          <span>{authors[0].name}</span>\n        </div>\n        <h2 class=\"text-xl font-medium leading-snug\">{title}</h2>\n        <p class=\"text-muted-foreground text-sm leading-relaxed line-clamp-2\">\n          {description}\n        </p>\n      </div>\n      <div class=\"text-sm font-medium text-accent flex items-center gap-1\">\n        Read article\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 20 20\"\n          fill=\"currentColor\"\n          class=\"w-4 h-4 transition-transform group-hover:translate-x-1\"\n        >\n          <path\n            fill-rule=\"evenodd\"\n            d=\"M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z\"\n            clip-rule=\"evenodd\"\n          />\n        </svg>\n      </div>\n    </div>\n  </a>\n</li>\n"
  },
  {
    "path": "apps/web/src/components/CategoryFilter.astro",
    "content": "---\n  import type { CollectionEntry } from \"astro:content\";\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import { cn } from \"@/lib/utils\";\n\n  interface Props {\n    categories: CollectionEntry<\"categories\">[];\n  }\n\n  const { categories } = Astro.props as Props;\n  const categoriesToShow = categories.filter(\n    (category) =>\n      category.data.slug !== \"legal\" && category.data.slug !== \"changelog\"\n  );\n\n  const currentPath = Astro.url.pathname.replace(/\\/$/, \"\");\n---\n<ul class=\"flex flex-wrap items-center gap-2 justify-start mt-4\">\n  <li>\n    <ButtonComponent\n      href=\"/blog\"\n      variant={currentPath === \"/blog\" ? \"primary\" : \"outline\"}\n      size=\"sm\"\n      class={cn(\"px-5 py-1.5\", currentPath !== \"/blog\" && \"hover:bg-muted/50 hover:text-foreground\")}\n    >\n      All Posts\n    </ButtonComponent>\n  </li>\n  {categoriesToShow.map((category) => {\n        const link = `/blog/category/${category.data.slug}`;\n        const isActive = currentPath === link;\n        return (\n          <li>\n            <ButtonComponent\n              href={link}\n              variant={isActive ? \"primary\" : \"outline\"}\n              size=\"sm\"\n              class={cn(\"px-4 py-1.5\", !isActive && \"hover:bg-muted/50 hover:text-foreground\")}\n            >\n              {category.data.name}\n            </ButtonComponent>\n          </li>\n        )\n      })}\n</ul>\n"
  },
  {
    "path": "apps/web/src/components/ChangelogCard.astro",
    "content": "---\n  import { Image } from \"astro:assets\";\n  import type { CollectionEntry } from \"astro:content\";\n\n  interface Props {\n    entry: CollectionEntry<\"changelog\">;\n  }\n\n  const { entry } = Astro.props as Props;\n  const { title, coverImage, publishedAt, authors, slug, tags } = entry.data;\n\n  const formattedDate = new Date(publishedAt).toLocaleDateString(\"en-US\", {\n    day: \"2-digit\",\n    month: \"long\",\n    year: \"numeric\",\n    timeZone: \"UTC\",\n  });\n---\n<li>\n  <a\n    href={`/changelog/${slug}`}\n    class=\"flex flex-col h-full bg-white border border-dashed rounded-2xl p-1.5 hover:shadow-xs transition-all duration-300 group\"\n  >\n    <div class=\"w-full aspect-video overflow-hidden rounded-xl bg-gray-50\">\n      {coverImage && (\n        <Image\n          src={coverImage}\n           alt={title}\n           loading=\"eager\"\n          inferSize\n          class='w-full h-full object-cover group-hover:scale-105 transition-transform duration-500'\n        />\n      )}\n    </div>\n    <div class=\"flex flex-col gap-4 px-3 py-4 flex-1\">\n      <div class=\"space-y-2\">\n        <h2 class=\"text-xl font-medium leading-snug\">{title}</h2>\n        <!-- <p class='text-muted-foreground text-sm line-clamp-2'>{description}</p> -->\n      </div>\n      <div class=\"flex items-center flex-wrap gap-3 justify-between mt-auto\">\n        <div class=\"flex items-center gap-2\">\n          <div class=\"flex items-center gap-2\">\n            <Image\n              src={authors[0].image || \"/og-blog.jpg\"}\n              alt={authors[0].name}\n              class=\"size-6 rounded-full ring-1 ring-border\"\n              inferSize\n            />\n            <p class=\"text-muted-foreground sr-only text-xs font-medium\">\n              {authors[0].name}\n            </p>\n          </div>\n          <span class=\"h-3 w-px bg-border shrink-0\"></span>\n          <time\n            datetime={publishedAt.toString()}\n            class=\"text-muted-foreground text-xs font-medium\"\n          >\n            {formattedDate}\n          </time>\n        </div>\n\n        <div class=\"flex items-center gap-2\">\n          {tags.map((tag) => (\n            <span class=\"text-muted-foreground text-xs bg-secondary px-2 py-0.5 rounded-md border border-transparent group-hover:border-border transition-colors\">\n              #\n              {tag.name}\n            </span>\n          ))}\n        </div>\n      </div>\n    </div>\n  </a>\n</li>\n"
  },
  {
    "path": "apps/web/src/components/Container.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n  import { cn } from \"@/lib/utils\";\n\n  interface Props extends HTMLAttributes<\"div\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<div\n  {...attrs}\n  class={cn(\"h-full mx-auto w-full max-w-6xl px-6 sm:px-8\", classNames)}\n>\n  <slot/>\n</div>\n"
  },
  {
    "path": "apps/web/src/components/Footer.astro",
    "content": "---\n  import {\n    FOOTER_SECTIONS,\n    FOOTER_SOCIAL_LINKS,\n  } from \"@/lib/constants/navigation\";\n  import Container from \"./Container.astro\";\n  import WordMark from \"./icons/WordMark.astro\";\n\n  const year = new Date().getFullYear();\n  const copyright = `© ${year} all rights reserved`;\n---\n<footer>\n  <div\n    class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] bg-white mx-auto\"\n  >\n    <Container class=\"py-12 sm:py-20 md:py-36\">\n      <section class=\"md:min-h-[420px] flex flex-col justify-between gap-5\">\n        <div class=\"flex flex-col justify-between gap-10 md:flex-row\">\n          <div class=\"sm:col-span-3\">\n            <a\n              href=\"/\"\n              class=\"mb-2 flex items-center gap-2 tracking-widest w-fit font-medium\"\n            >\n              <WordMark/>\n              <span class=\"sr-only\">marblecms</span>\n            </a>\n            <!-- <p class=\"mb-1 text-sm text-muted-foreground tracking-wider\">\n              Content simplified.\n            </p> -->\n          </div>\n\n          <div class=\"grid grid-cols-1 gap-8 md:grid-cols-4 lg:gap-14\">\n            {FOOTER_SECTIONS.map((section) => (\n              <div class=\"flex flex-col gap-4 text-sm\">\n                <p class=\"font-medium mb-2\">{section.title}</p>\n                {section.links.map((link) => (\n                  <a\n                    href={link.href}\n                    target={link.target}\n                    rel={link.rel}\n                    class=\"text-muted-foreground underline-offset-2 hover:underline\"\n                  >\n                    {link.label}\n                  </a>\n                ))}\n              </div>\n            ))}\n          </div>\n        </div>\n        <div\n          class=\"flex gap-6 flex-col items-center justify-center md:flex-row lg:items-end lg:justify-between mt-10 lg:mt-20\"\n        >\n          <ul class=\"flex gap-6\">\n            {FOOTER_SOCIAL_LINKS.map((link) => {\n              const IconComponent = link.icon;\n              return (\n                <li>\n                  <a\n                    href={link.href}\n                    target={link.target}\n                    rel={link.rel}\n                    aria-label={`${link.label} link to Marble`}\n                    class=\"text-muted-foreground hover:text-primary transition duration-300\"\n                  >\n                    <span class=\"sr-only\">{link.label}</span>\n                    {IconComponent && <IconComponent />}\n                  </a>\n                </li>\n              );\n            })}\n          </ul>\n          <p class=\"text-xs text-muted-foreground\">{copyright}</p>\n        </div>\n      </section>\n    </Container>\n  </div>\n  <div aria-hidden={true} class=\"h-16 border-t border-dashed\">\n    <div\n      class=\"mx-auto sm:w-[calc(100%-140px)] px-8 md:px-12 lg:px-16 lg:border-x border-dashed h-full\"\n    ></div>\n  </div>\n</footer>\n"
  },
  {
    "path": "apps/web/src/components/Head.astro",
    "content": "---\n  import { SITE } from \"@/lib/constants/site\";\n  import { buildSiteJsonLd, jsonLd } from \"@/lib/seo\";\n  import \"@/styles/globals.css\";\n  import { Font } from \"astro:assets\";\n  import { getSecret } from \"astro:env/server\";\n  import { ClientRouter } from \"astro:transitions\";\n\n  interface Props {\n    title: string;\n    description: string;\n    image?: string;\n    canonical?: string;\n    structuredData?: unknown;\n  }\n\n  const {\n    title,\n    description,\n    image = \"/og.webp\",\n    canonical,\n    structuredData,\n  } = Astro.props;\n\n  const canonicalURL = canonical\n    ? new URL(canonical, Astro.site)\n    : new URL(Astro.url.pathname, Astro.site);\n\n  const databuddyClientId = getSecret(\"DATABUDDY_CLIENT_ID\");\n  const siteStructuredData = buildSiteJsonLd({\n    title: SITE.TITLE,\n    description: SITE.DESCRIPTION,\n    url: SITE.URL,\n    twitterUrl: SITE.TWITTER_URL,\n  });\n---\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<meta name=\"generator\" content={Astro.generator}>\n\n<link rel=\"icon\" type=\"image/png\" href=\"/favicon-96x96.png\" sizes=\"96x96\">\n<link rel=\"shortcut icon\" href=\"/favicon.ico\">\n<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\">\n<meta name=\"apple-mobile-web-app-title\" content=\"MyWebSite\">\n<link rel=\"manifest\" href=\"/site.webmanifest\">\n\n<link rel=\"canonical\" href={canonicalURL}>\n\n<title>{title}</title>\n<meta name=\"title\" content={title}>\n<meta name=\"description\" content={description}>\n\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content={Astro.url}>\n<meta property=\"og:title\" content={title}>\n<meta property=\"og:site_name\" content={SITE.TITLE}>\n<meta property=\"og:description\" content={description}>\n<meta property=\"og:image\" content={new URL(image, Astro.url)}>\n\n<meta property=\"twitter:card\" content=\"summary_large_image\">\n<meta property=\"twitter:url\" content={Astro.url}>\n<meta property=\"twitter:title\" content={title}>\n<meta property=\"twitter:description\" content={description}>\n<meta property=\"twitter:image\" content={new URL(image, Astro.url)}>\n\n<script type=\"application/ld+json\" set:html={jsonLd(siteStructuredData)}/>\n\n{structuredData && (\n  <script type=\"application/ld+json\" set:html={jsonLd(structuredData)} />\n)}\n\n<link rel=\"sitemap\" href=\"/sitemap-index.xml\">\n<link\n  rel=\"alternate\"\n  type=\"application/rss+xml\"\n  title={title}\n  href={new URL('rss.xml', Astro.site)}\n>\n\n{databuddyClientId && (\n  <script\n    is:inline\n    src=\"https://cdn.databuddy.cc/databuddy.js\"\n    data-client-id={databuddyClientId}\n    data-track-attributes=\"true\"\n    data-track-web-vitals=\"true\"\n    data-track-errors=\"true\"\n    data-enable-batching=\"true\"\n    crossorigin=\"anonymous\"\n    async\n  />\n)}\n\n<Font cssVariable=\"--font-geist\" preload/>\n<Font cssVariable=\"--font-literata\"/>\n\n<ClientRouter/>\n"
  },
  {
    "path": "apps/web/src/components/Header.astro",
    "content": "---\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { REGISTER_URL, TRACKING_EVENTS } from \"@/lib/constants/tracking\";\n  import Container from \"./Container.astro\";\n  import Logo from \"./icons/Logo.astro\";\n---\n<header\n  id=\"header\"\n  class=\"bg-background/90 z-50 relative max-lg:py-4 backdrop-blur-md\"\n>\n  <div class=\"lg:border-x border-dashed sm:w-[calc(100%-140px)] mx-auto\">\n    <Container class=\"lg:py-4\">\n      <nav\n        id=\"nav\"\n        class=\"group grid grid-cols-2 md:grid-cols-3 transition items-center sticky top-96\"\n      >\n        <!-- mobile nav -->\n        <div\n          id=\"mobile-nav\"\n          class=\"hidden data-expanded:block data-expanded:fixed data-expanded:inset-0 z-50 pt-20 px-6 h-screen bg-white/95 backdrop-blur-xl md:hidden\"\n        >\n          <ul class=\"flex flex-col gap-6\">\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a href=\"https://docs.marblecms.com\" target=\"_blank\">\n                <span>Docs</span>\n              </a>\n            </li>\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a href=\"/pricing\">\n                <span>Pricing</span>\n              </a>\n            </li>\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a href=\"/blog\">\n                <span>Blog</span>\n              </a>\n            </li>\n            <li\n              class=\"relative after:absolute after:bg-primary after:h-full after:w-0.5 after:top-auto after:bottom-0 after:left-0 after:scale-y-0 after:origin-top hover:after:scale-y-100 hover:after:origin-bottom transition after:transition after:duration-500 w-fit px-2\"\n            >\n              <a\n                href={REGISTER_URL}\n                data-track={TRACKING_EVENTS.signupClicked}\n                data-context=\"header_mobile\"\n                data-label=\"Sign up\"\n              >\n                <span>Sign up</span>\n              </a>\n            </li>\n          </ul>\n        </div>\n        <!-- desktop nav -->\n        <div class=\"flex items-center gap-4 justify-start z-100\">\n          <a href=\"/\" class=\"text-lg tracking-wider flex items-center gap-2\">\n            <Logo/>\n            <span class=\"sr-only\">Marble</span>\n          </a>\n        </div>\n        <ul class=\"items-center gap-4 text-sm justify-center hidden md:flex\">\n          <li>\n            <ButtonComponent\n              href=\"https://docs.marblecms.com\"\n              target=\"_blank\"\n              variant=\"ghost\"\n              size=\"sm\"\n            >\n              Docs\n            </ButtonComponent>\n          </li>\n          <li>\n            <ButtonComponent href=\"/pricing\" variant=\"ghost\" size=\"sm\">\n              Pricing\n            </ButtonComponent>\n          </li>\n          <li>\n            <ButtonComponent href=\"/blog\" variant=\"ghost\" size=\"sm\">\n              Blog\n            </ButtonComponent>\n          </li>\n        </ul>\n        <div class=\"hidden md:flex items-center justify-end\">\n          <ButtonComponent\n            href={`${SITE.APP_URL}/login`}\n            variant=\"secondary\"\n            size=\"sm\"\n            class=\"w-24\"\n          >\n            Sign in\n          </ButtonComponent>\n        </div>\n        <!-- mobile nav toggle -->\n        <div class=\"flex items-center justify-end md:hidden z-100\">\n          <button\n            id=\"toggle\"\n            aria-label=\"Toggle mobile navigation\"\n            class=\"flex flex-col justify-center gap-1.5 size-6 focus-visible:outline-dashed focus-visible:outline-offset-1\"\n          >\n            <span\n              id=\"bar\"\n              class=\"h-0.5 self-start bg-foreground w-1/2 data-expanded:w-[calc(50%-3px)] data-expanded:rotate-45 data-expanded:translate-y-[2px] data-expanded:translate-x-[2px] transition duration-300\"\n            ></span>\n            <span\n              id=\"bar\"\n              class=\"h-0.5 w-full bg-foreground data-expanded:-rotate-45 transition duration-300\"\n            ></span>\n            <span\n              id=\"bar\"\n              class=\"h-0.5 self-end bg-foreground w-1/2 data-expanded:w-[calc(50%-3px)] data-expanded:rotate-45 data-expanded:-translate-y-[3px] data-expanded:-translate-x-0.5 transition duration-300\"\n            ></span>\n          </button>\n        </div>\n      </nav>\n    </Container>\n  </div>\n</header>\n\n<script>\n  function initMobileNav() {\n    const toggle = document.getElementById(\"toggle\");\n    const nav = document.getElementById(\"mobile-nav\");\n\n    const toggleNav = () => {\n      nav?.toggleAttribute(\"data-expanded\");\n      document.body.classList.toggle(\"overflow-hidden\");\n      document.body.classList.toggle(\"fixed\");\n      document.body.classList.toggle(\"w-full\");\n      const barElements = document.querySelectorAll(\"#bar\");\n      for (const el of barElements) {\n        el.toggleAttribute(\"data-expanded\");\n      }\n    };\n\n    if (toggle) {\n      toggle.addEventListener(\"click\", toggleNav);\n    }\n  }\n\n  // Initialize on first load\n  initMobileNav();\n\n  // Re-initialize after page transitions\n  document.addEventListener(\"astro:after-swap\", initMobileNav);\n</script>\n"
  },
  {
    "path": "apps/web/src/components/PostCard.astro",
    "content": "---\n  import { Image } from \"astro:assets\";\n  import type { CollectionEntry } from \"astro:content\";\n\n  interface Props {\n    entry: CollectionEntry<\"posts\">;\n  }\n\n  const { entry } = Astro.props as Props;\n  const { title, description, coverImage, publishedAt, authors, slug } =\n    entry.data;\n\n  const formattedDate = new Date(publishedAt).toLocaleDateString(\"en-US\", {\n    day: \"2-digit\",\n    month: \"2-digit\",\n    year: \"numeric\",\n    timeZone: \"UTC\",\n  });\n---\n<li class=\"border-b border-r border-dashed bg-white h-full group\">\n  <a\n    href={`/blog/${slug}`}\n    class=\"flex flex-col h-full hover:bg-gray-50 transition-colors duration-300\"\n  >\n    <div class=\"w-full aspect-video overflow-hidden p-2 border-dashed\">\n      <Image\n        src={coverImage || \"/og-blog.jpg\"}\n        alt={title}\n        loading=\"eager\"\n        inferSize\n        class=\"w-full h-full object-cover [transition:filter_0.3s] duration-300 grayscale group-hover:grayscale-0\"\n      />\n    </div>\n    <div class=\"flex flex-col gap-4 px-6 py-4 flex-1\">\n      <div class=\"space-y-3 flex-1\">\n        <h2 class=\"lg:text-lg font-medium leading-snug\">{title}</h2>\n        <p class=\"text-muted-foreground text-sm leading-relaxed line-clamp-2\">\n          {description}\n        </p>\n      </div>\n      <div class=\"flex items-center justify-between\">\n        <time\n          datetime={publishedAt.toISOString()}\n          class=\"text-xs text-muted-foreground\"\n        >\n          {formattedDate}\n        </time>\n        <div class=\"text-sm font-medium text-accent flex items-center gap-1\">\n          Read article\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            viewBox=\"0 0 20 20\"\n            fill=\"currentColor\"\n            class=\"w-4 h-4 transition-transform group-hover:translate-x-1\"\n          >\n            <path\n              fill-rule=\"evenodd\"\n              d=\"M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z\"\n              clip-rule=\"evenodd\"\n            />\n          </svg>\n        </div>\n      </div>\n    </div>\n  </a>\n</li>\n"
  },
  {
    "path": "apps/web/src/components/PricingCard.astro",
    "content": "---\n  interface Props {\n    title: string;\n    description: string;\n    price: string;\n    features: string[];\n    button: {\n      href: string;\n      label: string;\n    };\n  }\n---\n<div></div>\n"
  },
  {
    "path": "apps/web/src/components/Prose.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n  import { cn } from \"@/lib/utils\";\n\n  interface Props extends HTMLAttributes<\"article\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<article\n  {...attrs}\n  class={cn(\"prose prose-marble max-w-3xl prose-headings:font-sans prose-headings:font-medium prose-headings:text-balance prose-figcaption:text-center prose-code:before:content-none prose-code:after:content-none my-6 mx-auto\", classNames)}\n>\n  <slot/>\n</article>\n"
  },
  {
    "path": "apps/web/src/components/ScrollToTop.astro",
    "content": "---\n  import { cn } from \"@/lib/utils\";\n\n  const { class: classNames } = Astro.props;\n---\n<button\n  id=\"scroll-to-top\"\n  class={cn(\"group fixed bottom-16 right-8 lg:bottom-20 lg:right-20 z-50 hidden w-fit hover:text-primary rounded-md border hover:bg-muted p-2 bg-white transition duration-300 text-muted-foreground\", classNames)}\n>\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n    stroke-width=\"1.5\"\n    stroke=\"currentColor\"\n    class=\"size-4\"\n  >\n    <path\n      stroke-linecap=\"round\"\n      stroke-linejoin=\"round\"\n      d=\"M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18\"\n    />\n  </svg>\n  <span class=\"sr-only\">Scroll to top</span>\n</button>\n\n<script>\n  document.addEventListener(\"astro:page-load\", () => {\n    const scrollToTopButton = document.getElementById(\"scroll-to-top\");\n    const footer = document.querySelector(\"footer\");\n\n    if (scrollToTopButton && footer) {\n      scrollToTopButton.addEventListener(\"click\", () => {\n        window.scrollTo({ top: 0, behavior: \"smooth\" });\n      });\n\n      window.addEventListener(\"scroll\", () => {\n        const footerRect = footer.getBoundingClientRect();\n        const isFooterVisible = footerRect.top <= window.innerHeight;\n\n        scrollToTopButton.classList.toggle(\n          \"hidden\",\n          window.scrollY <= 300 || isFooterVisible\n        );\n      });\n    }\n  });\n</script>\n"
  },
  {
    "path": "apps/web/src/components/Welcome.astro",
    "content": "---\n  import astroLogo from \"../assets/astro.svg\";\n  import background from \"../assets/background.svg\";\n---\n<div id=\"container\">\n  <img id=\"background\" src={background.src} alt=\"\" fetchpriority=\"high\">\n  <main>\n    <section id=\"hero\">\n      <a href=\"https://astro.build\"\n        ><img\n          src={astroLogo.src}\n          width=\"115\"\n          height=\"48\"\n          alt=\"Astro Homepage\"\n        ></a\n      >\n      <h1>\n        To get started, open the\n        <code>\n          <pre>src/pages</pre>\n        </code>\n        directory in your project.\n      </h1>\n      <section id=\"links\">\n        <a class=\"button\" href=\"https://docs.astro.build\">Read our docs</a>\n        <a href=\"https://astro.build/chat\"\n          >Join our Discord\n          <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 127.14 96.36\">\n            <path\n              fill=\"currentColor\"\n              d=\"M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z\"\n            ></path>\n          </svg>\n        </a>\n      </section>\n    </section>\n  </main>\n\n  <a href=\"https://astro.build/blog/astro-5/\" id=\"news\" class=\"box\">\n    <svg width=\"32\" height=\"32\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n      <path\n        d=\"M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z\"\n        fill=\"#111827\"\n      ></path>\n    </svg>\n    <h2>What's New in Astro 5.0?</h2>\n    <p>\n      From content layers to server islands, click to learn more about the new\n      features and improvements in Astro 5.0\n    </p>\n  </a>\n</div>\n\n<style>\n  #background {\n    position: fixed;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    z-index: -1;\n    filter: blur(100px);\n  }\n\n  #container {\n    font-family:\n      Inter, Roboto, \"Helvetica Neue\", \"Arial Nova\", \"Nimbus Sans\", Arial,\n      sans-serif;\n    height: 100%;\n  }\n\n  main {\n    height: 100%;\n    display: flex;\n    justify-content: center;\n  }\n\n  #hero {\n    display: flex;\n    align-items: start;\n    flex-direction: column;\n    justify-content: center;\n    padding: 16px;\n  }\n\n  h1 {\n    font-size: 22px;\n    margin-top: 0.25em;\n  }\n\n  #links {\n    display: flex;\n    gap: 16px;\n  }\n\n  #links a {\n    display: flex;\n    align-items: center;\n    padding: 10px 12px;\n    color: #111827;\n    text-decoration: none;\n    transition: color 0.2s;\n  }\n\n  #links a:hover {\n    color: rgb(78, 80, 86);\n  }\n\n  #links a svg {\n    height: 1em;\n    margin-left: 8px;\n  }\n\n  #links a.button {\n    color: white;\n    background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);\n    box-shadow:\n      inset 0 0 0 1px rgba(255, 255, 255, 0.12),\n      inset 0 -2px 0 rgba(0, 0, 0, 0.24);\n    border-radius: 10px;\n  }\n\n  #links a.button:hover {\n    color: rgb(230, 230, 230);\n    box-shadow: none;\n  }\n\n  pre {\n    font-family:\n      ui-monospace, \"Cascadia Code\", \"Source Code Pro\", Menlo, Consolas,\n      \"DejaVu Sans Mono\", monospace;\n    font-weight: normal;\n    background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);\n    -webkit-background-clip: text;\n    -webkit-text-fill-color: transparent;\n    background-clip: text;\n    margin: 0;\n  }\n\n  h2 {\n    margin: 0 0 1em;\n    font-weight: normal;\n    color: #111827;\n    font-size: 20px;\n  }\n\n  p {\n    color: #4b5563;\n    font-size: 16px;\n    line-height: 24px;\n    letter-spacing: -0.006em;\n    margin: 0;\n  }\n\n  code {\n    display: inline-block;\n    background:\n      linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,\n      linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;\n    border-radius: 8px;\n    border: 1px solid transparent;\n    padding: 6px 8px;\n  }\n\n  .box {\n    padding: 16px;\n    background: rgba(255, 255, 255, 1);\n    border-radius: 16px;\n    border: 1px solid white;\n  }\n\n  #news {\n    position: absolute;\n    bottom: 16px;\n    right: 16px;\n    max-width: 300px;\n    text-decoration: none;\n    transition: background 0.2s;\n    backdrop-filter: blur(50px);\n  }\n\n  #news:hover {\n    background: rgba(255, 255, 255, 0.55);\n  }\n\n  @media screen and (max-height: 368px) {\n    #news {\n      display: none;\n    }\n  }\n\n  @media screen and (max-width: 768px) {\n    #container {\n      display: flex;\n      flex-direction: column;\n    }\n\n    #hero {\n      display: block;\n      padding-top: 10%;\n    }\n\n    #links {\n      flex-wrap: wrap;\n    }\n\n    #links a.button {\n      padding: 14px 18px;\n    }\n\n    #news {\n      right: 16px;\n      left: 16px;\n      bottom: 2.5rem;\n      max-width: 100%;\n    }\n\n    h1 {\n      line-height: 1.5;\n    }\n  }\n</style>\n"
  },
  {
    "path": "apps/web/src/components/icons/Collab.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  viewBox=\"0 0 257 210\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <path\n    d=\"M24.1824 12.8858C23.2595 12.8858 22.4646 12.6848 21.7976 12.2827C21.1397 11.8716 20.6326 11.2868 20.2763 10.5284C19.9199 9.77004 19.7418 8.86546 19.7418 7.8147C19.7418 6.76393 19.9199 5.85935 20.2763 5.10097C20.6326 4.33346 21.1397 3.74411 21.7976 3.33294C22.4646 2.92177 23.2595 2.71619 24.1824 2.71619C25.1144 2.71619 25.9093 2.92177 26.5672 3.33294C27.2342 3.74411 27.7459 4.33346 28.1022 5.10097C28.4586 5.85935 28.6367 6.76393 28.6367 7.8147C28.6367 8.86546 28.4586 9.77004 28.1022 10.5284C27.7459 11.2868 27.2342 11.8716 26.5672 12.2827C25.9093 12.6848 25.1144 12.8858 24.1824 12.8858ZM24.1824 11.7345C25.1875 11.7345 25.9733 11.3919 26.5398 10.7066C27.1154 10.0122 27.4032 9.04821 27.4032 7.8147C27.4032 6.58119 27.1154 5.61722 26.5398 4.9228C25.9733 4.21924 25.1875 3.86746 24.1824 3.86746C23.1864 3.86746 22.4007 4.21924 21.825 4.9228C21.2585 5.61722 20.9753 6.58119 20.9753 7.8147C20.9753 9.04821 21.2585 10.0122 21.825 10.7066C22.4007 11.3919 23.1864 11.7345 24.1824 11.7345ZM30.3535 12.6665V5.4025H31.4088L31.4362 6.70454C31.6281 6.202 31.9205 5.83194 32.3134 5.59438C32.7154 5.35681 33.1677 5.23803 33.6703 5.23803C34.2276 5.23803 34.6845 5.36138 35.0408 5.60808C35.4063 5.85478 35.6758 6.18829 35.8495 6.6086C36.0322 7.01977 36.1236 7.48119 36.1236 7.99287V12.6665H34.9723V8.32181C34.9723 7.64566 34.8489 7.13398 34.6022 6.78677C34.3647 6.43042 33.9718 6.25225 33.4235 6.25225C32.8662 6.25225 32.4048 6.43042 32.0393 6.78677C31.6829 7.13398 31.5048 7.64566 31.5048 8.32181V12.6665H30.3535ZM40.9937 12.831C40.3175 12.831 39.7282 12.6756 39.2257 12.365C38.7322 12.0543 38.3485 11.6157 38.0744 11.0492C37.8003 10.4736 37.6632 9.80202 37.6632 9.0345C37.6632 8.26698 37.8003 7.59997 38.0744 7.03347C38.3485 6.46697 38.7322 6.02839 39.2257 5.71773C39.7282 5.39793 40.3175 5.23803 40.9937 5.23803C41.8343 5.23803 42.5287 5.45732 43.0769 5.8959C43.6252 6.32535 43.9587 6.94667 44.0775 7.75987L42.8714 7.84211C42.7891 7.35784 42.579 6.98779 42.2409 6.73195C41.9028 6.46697 41.4871 6.33448 40.9937 6.33448C40.3267 6.33448 39.8059 6.57662 39.4312 7.06088C39.0566 7.53601 38.8693 8.19389 38.8693 9.0345C38.8693 9.87512 39.0566 10.5376 39.4312 11.0218C39.8059 11.497 40.3267 11.7345 40.9937 11.7345C41.4871 11.7345 41.9028 11.5975 42.2409 11.3233C42.579 11.0492 42.7891 10.6381 42.8714 10.0898L44.0775 10.1721C43.9587 10.9761 43.6252 11.6203 43.0769 12.1046C42.5287 12.5888 41.8343 12.831 40.9937 12.831ZM48.3417 12.831C47.6565 12.831 47.0625 12.6756 46.56 12.365C46.0666 12.0543 45.6828 11.6157 45.4087 11.0492C45.1438 10.4736 45.0113 9.80202 45.0113 9.0345C45.0113 8.26698 45.1438 7.59997 45.4087 7.03347C45.6828 6.46697 46.062 6.02839 46.5463 5.71773C47.0397 5.39793 47.6199 5.23803 48.2869 5.23803C48.9174 5.23803 49.4747 5.38879 49.959 5.69032C50.4433 5.9827 50.8179 6.41215 51.0829 6.97865C51.357 7.54515 51.494 8.235 51.494 9.04821V9.39085H46.2174C46.2631 10.1675 46.4686 10.7523 46.8341 11.1452C47.2087 11.5381 47.7113 11.7345 48.3417 11.7345C48.8169 11.7345 49.2052 11.6249 49.5067 11.4056C49.8174 11.1772 50.0321 10.8802 50.1509 10.5147L51.3844 10.6107C51.1925 11.2594 50.827 11.7939 50.2879 12.2142C49.758 12.6254 49.1093 12.831 48.3417 12.831ZM46.2174 8.37663H50.2331C50.1783 7.67307 49.9727 7.15682 49.6164 6.82789C49.2692 6.49895 48.826 6.33448 48.2869 6.33448C47.7296 6.33448 47.2681 6.50809 46.9026 6.8553C46.5463 7.19337 46.3179 7.70048 46.2174 8.37663ZM58.9985 12.831C58.2767 12.831 57.6965 12.5888 57.2579 12.1046C56.8285 11.6112 56.6138 10.935 56.6138 10.0761V5.4025H57.765V9.7609C57.765 10.4645 57.8884 10.9853 58.1351 11.3233C58.3909 11.6523 58.7793 11.8168 59.3001 11.8168C59.8757 11.8168 60.328 11.634 60.6569 11.2685C60.9859 10.8939 61.1503 10.3822 61.1503 9.73349V5.4025H62.3016V12.6665H61.2052V11.4878C60.8031 12.3833 60.0676 12.831 58.9985 12.831ZM64.4972 14.7224V5.4025H65.5662L65.5799 6.51266C65.7992 6.09235 66.1008 5.77712 66.4845 5.56697C66.8774 5.34767 67.3206 5.23803 67.814 5.23803C68.5358 5.23803 69.1251 5.4162 69.582 5.77255C70.048 6.1289 70.3906 6.59489 70.6099 7.17053C70.8384 7.74617 70.9526 8.36749 70.9526 9.0345C70.9526 9.70151 70.8384 10.3228 70.6099 10.8985C70.3906 11.4741 70.048 11.9401 69.582 12.2965C69.1251 12.6528 68.5358 12.831 67.814 12.831C67.3388 12.831 66.9048 12.7305 66.5119 12.5294C66.1282 12.3284 65.8404 12.0543 65.6485 11.7071V14.7224H64.4972ZM67.7043 11.7345C68.3348 11.7345 68.8328 11.497 69.1982 11.0218C69.5637 10.5467 69.7465 9.88425 69.7465 9.0345C69.7465 8.18475 69.5637 7.52231 69.1982 7.04718C68.8328 6.57205 68.3348 6.33448 67.7043 6.33448C67.0739 6.33448 66.5713 6.56291 66.1967 7.01977C65.8312 7.46749 65.6485 8.13906 65.6485 9.0345C65.6485 9.9208 65.8312 10.5924 66.1967 11.0492C66.5622 11.5061 67.0647 11.7345 67.7043 11.7345ZM75.3516 12.831C74.6845 12.831 74.0998 12.6756 73.5972 12.365C73.0947 12.0543 72.7064 11.6157 72.4322 11.0492C72.1581 10.4736 72.0211 9.80202 72.0211 9.0345C72.0211 8.25785 72.1581 7.58627 72.4322 7.01977C72.7064 6.45327 73.0947 6.01468 73.5972 5.70402C74.0998 5.39336 74.6845 5.23803 75.3516 5.23803C76.0186 5.23803 76.5988 5.39336 77.0922 5.70402C77.5947 6.01468 77.983 6.45327 78.2572 7.01977C78.5313 7.58627 78.6683 8.25785 78.6683 9.0345C78.6683 9.80202 78.5313 10.4736 78.2572 11.0492C77.983 11.6157 77.5947 12.0543 77.0922 12.365C76.5988 12.6756 76.0186 12.831 75.3516 12.831ZM75.3516 11.7345C76.0186 11.7345 76.5348 11.497 76.9003 11.0218C77.2749 10.5376 77.4622 9.87512 77.4622 9.0345C77.4622 8.19389 77.2749 7.53601 76.9003 7.06088C76.5348 6.57662 76.0186 6.33448 75.3516 6.33448C74.6845 6.33448 74.1637 6.57662 73.7891 7.06088C73.4145 7.53601 73.2272 8.19389 73.2272 9.0345C73.2272 9.87512 73.4145 10.5376 73.7891 11.0218C74.1637 11.497 74.6845 11.7345 75.3516 11.7345ZM80.2373 12.6665V5.4025H81.2926L81.3201 6.70454C81.5119 6.202 81.8043 5.83194 82.1972 5.59438C82.5992 5.35681 83.0515 5.23803 83.5541 5.23803C84.1114 5.23803 84.5683 5.36138 84.9246 5.60808C85.2901 5.85478 85.5597 6.18829 85.7333 6.6086C85.916 7.01977 86.0074 7.48119 86.0074 7.99287V12.6665H84.8561V8.32181C84.8561 7.64566 84.7328 7.13398 84.4861 6.78677C84.2485 6.43042 83.8556 6.25225 83.3074 6.25225C82.75 6.25225 82.2886 6.43042 81.9231 6.78677C81.5668 7.13398 81.3886 7.64566 81.3886 8.32181V12.6665H80.2373ZM93.5874 12.831C92.8381 12.831 92.2397 12.6574 91.7919 12.3102C91.3534 11.9629 91.1341 11.4787 91.1341 10.8574C91.1341 10.236 91.3168 9.7472 91.6823 9.39085C92.0478 9.0345 92.628 8.78323 93.4229 8.63704L95.9996 8.15734C95.9996 6.9421 95.4239 6.33448 94.2727 6.33448C93.7701 6.33448 93.3727 6.4487 93.0803 6.67713C92.7879 6.89642 92.5869 7.21622 92.4772 7.63652L91.2574 7.54058C91.3945 6.8553 91.7234 6.3025 92.2442 5.8822C92.7742 5.45275 93.4503 5.23803 94.2727 5.23803C95.2046 5.23803 95.9173 5.50301 96.4107 6.03296C96.9042 6.55377 97.1509 7.27561 97.1509 8.19845V11.2C97.1509 11.5015 97.2788 11.6523 97.5346 11.6523H97.8224V12.6665C97.7128 12.6848 97.5666 12.6939 97.3838 12.6939C96.9453 12.6939 96.6163 12.6071 96.397 12.4335C96.1869 12.2508 96.059 11.9538 96.0133 11.5426C95.8305 11.9173 95.5153 12.2279 95.0676 12.4746C94.6199 12.7122 94.1265 12.831 93.5874 12.831ZM93.697 11.8168C94.428 11.8168 94.9945 11.6112 95.3965 11.2C95.7986 10.7888 95.9996 10.2817 95.9996 9.67867V9.15785L93.6422 9.59643C93.1488 9.6878 92.8062 9.82943 92.6143 10.0213C92.4315 10.2041 92.3402 10.4462 92.3402 10.7477C92.3402 11.0858 92.4589 11.3508 92.6965 11.5426C92.9432 11.7254 93.2767 11.8168 93.697 11.8168ZM105.262 12.6665C104.604 12.6665 104.115 12.5157 103.795 12.2142C103.484 11.9127 103.329 11.4421 103.329 10.8025V6.41672H102.26V5.4025H103.329V3.70299H104.48V5.4025H106.413V6.41672H104.48V10.7751C104.48 11.1041 104.553 11.3325 104.7 11.4604C104.846 11.5883 105.07 11.6523 105.371 11.6523H106.413V12.6665H105.262ZM107.89 12.6665V5.4025H109.041V12.6665H107.89ZM107.862 4.26493V2.92177H109.068V4.26493H107.862ZM111.37 12.6665V5.4025H112.425L112.452 6.67713C112.635 6.22941 112.905 5.87763 113.261 5.62179C113.617 5.36595 114.028 5.23803 114.494 5.23803C115.043 5.23803 115.504 5.37052 115.879 5.63549C116.253 5.90047 116.518 6.27509 116.674 6.75936C116.829 6.27509 117.089 5.90047 117.455 5.63549C117.82 5.37052 118.268 5.23803 118.798 5.23803C119.575 5.23803 120.169 5.48016 120.58 5.96443C120.991 6.43956 121.196 7.11571 121.196 7.99287V12.6665H120.045V8.23957C120.045 6.91469 119.538 6.25225 118.524 6.25225C118.021 6.25225 117.615 6.43499 117.304 6.80048C117.003 7.16596 116.852 7.67307 116.852 8.32181V12.6665H115.701V8.32181C115.701 7.67307 115.586 7.16596 115.358 6.80048C115.129 6.43499 114.746 6.25225 114.207 6.25225C113.704 6.25225 113.297 6.43499 112.987 6.80048C112.676 7.16596 112.521 7.67307 112.521 8.32181V12.6665H111.37ZM126.065 12.831C125.38 12.831 124.786 12.6756 124.283 12.365C123.79 12.0543 123.406 11.6157 123.132 11.0492C122.867 10.4736 122.735 9.80202 122.735 9.0345C122.735 8.26698 122.867 7.59997 123.132 7.03347C123.406 6.46697 123.786 6.02839 124.27 5.71773C124.763 5.39793 125.343 5.23803 126.01 5.23803C126.641 5.23803 127.198 5.38879 127.682 5.69032C128.167 5.9827 128.541 6.41215 128.806 6.97865C129.08 7.54515 129.218 8.235 129.218 9.04821V9.39085H123.941C123.987 10.1675 124.192 10.7523 124.558 11.1452C124.932 11.5381 125.435 11.7345 126.065 11.7345C126.54 11.7345 126.929 11.6249 127.23 11.4056C127.541 11.1772 127.756 10.8802 127.874 10.5147L129.108 10.6107C128.916 11.2594 128.551 11.7939 128.011 12.2142C127.481 12.6254 126.833 12.831 126.065 12.831ZM123.941 8.37663H127.957C127.902 7.67307 127.696 7.15682 127.34 6.82789C126.993 6.49895 126.549 6.33448 126.01 6.33448C125.453 6.33448 124.992 6.50809 124.626 6.8553C124.27 7.19337 124.041 7.70048 123.941 8.37663ZM173.406 12.6665V5.4025H174.421L174.462 6.74565C174.708 5.85022 175.284 5.4025 176.189 5.4025H176.901V6.49895H176.202C175.106 6.49895 174.558 7.09286 174.558 8.28069V12.6665H173.406ZM180.888 12.831C180.221 12.831 179.636 12.6756 179.134 12.365C178.631 12.0543 178.243 11.6157 177.969 11.0492C177.694 10.4736 177.557 9.80202 177.557 9.0345C177.557 8.25785 177.694 7.58627 177.969 7.01977C178.243 6.45327 178.631 6.01468 179.134 5.70402C179.636 5.39336 180.221 5.23803 180.888 5.23803C181.555 5.23803 182.135 5.39336 182.629 5.70402C183.131 6.01468 183.519 6.45327 183.794 7.01977C184.068 7.58627 184.205 8.25785 184.205 9.0345C184.205 9.80202 184.068 10.4736 183.794 11.0492C183.519 11.6157 183.131 12.0543 182.629 12.365C182.135 12.6756 181.555 12.831 180.888 12.831ZM180.888 11.7345C181.555 11.7345 182.071 11.497 182.437 11.0218C182.811 10.5376 182.999 9.87512 182.999 9.0345C182.999 8.19389 182.811 7.53601 182.437 7.06088C182.071 6.57662 181.555 6.33448 180.888 6.33448C180.221 6.33448 179.7 6.57662 179.325 7.06088C178.951 7.53601 178.764 8.19389 178.764 9.0345C178.764 9.87512 178.951 10.5376 179.325 11.0218C179.7 11.497 180.221 11.7345 180.888 11.7345ZM187.266 12.6665C186.846 12.6665 186.503 12.5569 186.238 12.3376C185.973 12.1183 185.841 11.7711 185.841 11.2959V2.93548H186.992V11.2C186.992 11.5015 187.143 11.6523 187.444 11.6523H188.075V12.6665H187.266ZM190.786 12.6665C190.366 12.6665 190.023 12.5569 189.758 12.3376C189.493 12.1183 189.361 11.7711 189.361 11.2959V2.93548H190.512V11.2C190.512 11.5015 190.663 11.6523 190.964 11.6523H191.595V12.6665H190.786ZM192.908 12.6665V5.4025H194.059V12.6665H192.908ZM192.88 4.26493V2.92177H194.086V4.26493H192.88ZM196.388 12.6665V5.4025H197.443L197.47 6.70454C197.662 6.202 197.955 5.83194 198.347 5.59438C198.749 5.35681 199.202 5.23803 199.704 5.23803C200.262 5.23803 200.719 5.36138 201.075 5.60808C201.44 5.85478 201.71 6.18829 201.883 6.6086C202.066 7.01977 202.158 7.48119 202.158 7.99287V12.6665H201.006V8.32181C201.006 7.64566 200.883 7.13398 200.636 6.78677C200.399 6.43042 200.006 6.25225 199.458 6.25225C198.9 6.25225 198.439 6.43042 198.073 6.78677C197.717 7.13398 197.539 7.64566 197.539 8.32181V12.6665H196.388ZM206.945 14.8868C206.169 14.8868 205.506 14.6949 204.958 14.3112C204.41 13.9274 204.058 13.4157 203.903 12.7761L205.109 12.6939C205.219 13.032 205.41 13.297 205.685 13.4888C205.968 13.6899 206.388 13.7904 206.945 13.7904C207.603 13.7904 208.106 13.6579 208.453 13.3929C208.809 13.1279 208.988 12.7305 208.988 12.2005V11.0218C208.814 11.4056 208.535 11.7117 208.152 11.9401C207.768 12.1685 207.338 12.2827 206.863 12.2827C206.251 12.2827 205.703 12.132 205.219 11.8305C204.743 11.5289 204.369 11.1132 204.095 10.5832C203.83 10.0533 203.697 9.44567 203.697 8.76039C203.697 8.06597 203.83 7.45835 204.095 6.93753C204.36 6.40758 204.725 5.99184 205.191 5.69032C205.666 5.38879 206.205 5.23803 206.808 5.23803C207.32 5.23803 207.777 5.35681 208.179 5.59438C208.59 5.83194 208.878 6.15174 209.042 6.55377V5.4025H210.139V12.1731C210.139 13.0229 209.856 13.6853 209.289 14.1604C208.732 14.6447 207.951 14.8868 206.945 14.8868ZM206.918 11.1863C207.539 11.1863 208.037 10.9716 208.412 10.5421C208.787 10.1035 208.978 9.50963 208.988 8.76039C209.006 8.01114 208.823 7.4218 208.439 6.99236C208.056 6.55377 207.549 6.33448 206.918 6.33448C206.278 6.33448 205.781 6.55377 205.424 6.99236C205.077 7.4218 204.903 8.01114 204.903 8.76039C204.903 9.50963 205.082 10.1035 205.438 10.5421C205.803 10.9716 206.297 11.1863 206.918 11.1863ZM215.755 12.6665V2.93548H216.906V6.54007C217.098 6.09235 217.386 5.76341 217.77 5.55326C218.153 5.34311 218.596 5.23803 219.099 5.23803C219.885 5.23803 220.483 5.49387 220.894 6.00555C221.315 6.51723 221.525 7.17967 221.525 7.99287V12.6665H220.374V8.32181C220.374 6.9421 219.867 6.25225 218.852 6.25225C218.268 6.25225 217.797 6.43042 217.441 6.78677C217.084 7.14312 216.906 7.65937 216.906 8.33551V12.6665H215.755ZM223.585 12.6665V5.4025H224.736V12.6665H223.585ZM223.557 4.26493V2.92177H224.763V4.26493H223.557ZM228.222 12.6665C227.802 12.6665 227.459 12.5569 227.194 12.3376C226.929 12.1183 226.797 11.7711 226.797 11.2959V2.93548H227.948V11.2C227.948 11.5015 228.099 11.6523 228.401 11.6523H229.031V12.6665H228.222ZM231.742 12.6665C231.322 12.6665 230.98 12.5569 230.715 12.3376C230.45 12.1183 230.317 11.7711 230.317 11.2959V2.93548H231.468V11.2C231.468 11.5015 231.619 11.6523 231.921 11.6523H232.551V12.6665H231.742ZM236.536 12.831C235.567 12.831 234.814 12.6025 234.274 12.1457C233.745 11.6797 233.452 11.0904 233.397 10.3777L234.603 10.2954C234.677 10.734 234.864 11.0858 235.165 11.3508C235.467 11.6066 235.924 11.7345 236.536 11.7345C237.029 11.7345 237.422 11.6569 237.715 11.5015C238.016 11.3371 238.167 11.0766 238.167 10.7203C238.167 10.5284 238.121 10.3685 238.03 10.2406C237.938 10.1127 237.76 10.003 237.495 9.91166C237.23 9.81116 236.837 9.71522 236.317 9.62384C235.622 9.49593 235.074 9.34059 234.672 9.15785C234.279 8.96597 233.996 8.73298 233.822 8.45886C233.658 8.17561 233.576 7.84211 233.576 7.45835C233.576 6.80961 233.813 6.27966 234.288 5.86849C234.763 5.44818 235.439 5.23803 236.317 5.23803C236.911 5.23803 237.409 5.34767 237.811 5.56697C238.222 5.77712 238.542 6.06037 238.77 6.41672C239.008 6.76393 239.163 7.14769 239.236 7.56799L238.03 7.65023C237.948 7.26647 237.76 6.95124 237.468 6.70454C237.185 6.45783 236.796 6.33448 236.303 6.33448C235.782 6.33448 235.398 6.43499 235.152 6.63601C234.905 6.83702 234.782 7.09286 234.782 7.40353C234.782 7.75987 234.914 8.02485 235.179 8.19845C235.444 8.36292 235.896 8.49998 236.536 8.60963C237.276 8.73754 237.852 8.89288 238.263 9.07562C238.674 9.25836 238.962 9.48222 239.126 9.7472C239.291 10.0122 239.373 10.3365 239.373 10.7203C239.373 11.168 239.245 11.5518 238.989 11.8716C238.743 12.1822 238.404 12.4198 237.975 12.5843C237.555 12.7487 237.075 12.831 236.536 12.831ZM23.0585 38.831C22.3915 38.831 21.8067 38.6756 21.3042 38.365C20.8017 38.0543 20.4133 37.6157 20.1392 37.0492C19.8651 36.4736 19.728 35.802 19.728 35.0345C19.728 34.2578 19.8651 33.5863 20.1392 33.0198C20.4133 32.4533 20.8017 32.0147 21.3042 31.704C21.8067 31.3934 22.3915 31.238 23.0585 31.238C23.7255 31.238 24.3057 31.3934 24.7991 31.704C25.3017 32.0147 25.69 32.4533 25.9641 33.0198C26.2382 33.5863 26.3753 34.2578 26.3753 35.0345C26.3753 35.802 26.2382 36.4736 25.9641 37.0492C25.69 37.6157 25.3017 38.0543 24.7991 38.365C24.3057 38.6756 23.7255 38.831 23.0585 38.831ZM23.0585 37.7345C23.7255 37.7345 24.2418 37.497 24.6073 37.0218C24.9819 36.5376 25.1692 35.8751 25.1692 35.0345C25.1692 34.1939 24.9819 33.536 24.6073 33.0609C24.2418 32.5766 23.7255 32.3345 23.0585 32.3345C22.3915 32.3345 21.8707 32.5766 21.4961 33.0609C21.1215 33.536 20.9341 34.1939 20.9341 35.0345C20.9341 35.8751 21.1215 36.5376 21.4961 37.0218C21.8707 37.497 22.3915 37.7345 23.0585 37.7345ZM28.3759 38.6665V32.4167H27.3891V31.4025H28.3759V30.7446C28.3759 30.2055 28.5312 29.7715 28.8419 29.4426C29.1526 29.1045 29.6368 28.9355 30.2947 28.9355H31.446V29.9497H30.3769C30.0937 29.9497 29.879 30.0274 29.7328 30.1827C29.5957 30.338 29.5272 30.5527 29.5272 30.8269V31.4025H31.35V32.4167H29.5272V38.6665H28.3759ZM40.3619 38.8858C39.4482 38.8858 38.667 38.6665 38.0183 38.2279C37.3695 37.7893 36.8716 37.1909 36.5243 36.4325C36.1863 35.665 36.0172 34.7924 36.0172 33.8147C36.0172 32.837 36.1908 31.9644 36.5381 31.1969C36.8853 30.4294 37.3832 29.8263 38.032 29.3878C38.6898 28.94 39.4756 28.7162 40.3893 28.7162C41.166 28.7162 41.8193 28.8578 42.3493 29.1411C42.8883 29.4243 43.3132 29.8081 43.6239 30.2923C43.9437 30.7766 44.163 31.3203 44.2818 31.9233L43.0482 32.0055C42.9295 31.3751 42.6645 30.8634 42.2533 30.4705C41.8421 30.0685 41.2208 29.8675 40.3893 29.8675C39.6492 29.8675 39.0462 30.0502 38.5802 30.4157C38.1233 30.7812 37.7853 31.2654 37.566 31.8685C37.3558 32.4624 37.2507 33.1111 37.2507 33.8147C37.2507 34.5548 37.3604 35.2218 37.5797 35.8157C37.799 36.4005 38.1416 36.8665 38.6076 37.2137C39.0736 37.5609 39.6675 37.7345 40.3893 37.7345C40.965 37.7345 41.463 37.6066 41.8833 37.3508C42.3036 37.0858 42.6279 36.734 42.8564 36.2954C43.0848 35.8568 43.2036 35.3817 43.2127 34.87H40.4168V33.7462H44.3092V38.6665H43.432L43.3772 37.0355C43.1305 37.5838 42.733 38.0315 42.1848 38.3787C41.6366 38.7168 41.0289 38.8858 40.3619 38.8858ZM46.0936 38.6665V31.4025H47.1078L47.1489 32.7457C47.3956 31.8502 47.9713 31.4025 48.8758 31.4025H49.5885V32.499H48.8895C47.7931 32.499 47.2449 33.0929 47.2449 34.2807V38.6665H46.0936ZM53.5751 38.831C52.8898 38.831 52.2959 38.6756 51.7933 38.365C51.2999 38.0543 50.9162 37.6157 50.642 37.0492C50.3771 36.4736 50.2446 35.802 50.2446 35.0345C50.2446 34.267 50.3771 33.6 50.642 33.0335C50.9162 32.467 51.2954 32.0284 51.7796 31.7177C52.273 31.3979 52.8532 31.238 53.5202 31.238C54.1507 31.238 54.7081 31.3888 55.1923 31.6903C55.6766 31.9827 56.0512 32.4121 56.3162 32.9787C56.5903 33.5452 56.7274 34.235 56.7274 35.0482V35.3908H51.4507C51.4964 36.1675 51.702 36.7523 52.0674 37.1452C52.4421 37.5381 52.9446 37.7345 53.5751 37.7345C54.0502 37.7345 54.4385 37.6249 54.74 37.4056C55.0507 37.1772 55.2654 36.8802 55.3842 36.5147L56.6177 36.6107C56.4258 37.2594 56.0604 37.7939 55.5213 38.2142C54.9913 38.6254 54.3426 38.831 53.5751 38.831ZM51.4507 34.3766H55.4664C55.4116 33.6731 55.206 33.1568 54.8497 32.8279C54.5025 32.499 54.0593 32.3345 53.5202 32.3345C52.9629 32.3345 52.5015 32.5081 52.136 32.8553C51.7796 33.1934 51.5512 33.7005 51.4507 34.3766ZM61.1239 38.831C60.4386 38.831 59.8447 38.6756 59.3422 38.365C58.8487 38.0543 58.465 37.6157 58.1909 37.0492C57.9259 36.4736 57.7934 35.802 57.7934 35.0345C57.7934 34.267 57.9259 33.6 58.1909 33.0335C58.465 32.467 58.8442 32.0284 59.3284 31.7177C59.8219 31.3979 60.4021 31.238 61.0691 31.238C61.6995 31.238 62.2569 31.3888 62.7412 31.6903C63.2254 31.9827 63.6 32.4121 63.865 32.9787C64.1391 33.5452 64.2762 34.235 64.2762 35.0482V35.3908H58.9995C59.0452 36.1675 59.2508 36.7523 59.6163 37.1452C59.9909 37.5381 60.4934 37.7345 61.1239 37.7345C61.599 37.7345 61.9873 37.6249 62.2889 37.4056C62.5995 37.1772 62.8143 36.8802 62.933 36.5147L64.1665 36.6107C63.9747 37.2594 63.6092 37.7939 63.0701 38.2142C62.5401 38.6254 61.8914 38.831 61.1239 38.831ZM58.9995 34.3766H63.0153C62.9604 33.6731 62.7549 33.1568 62.3985 32.8279C62.0513 32.499 61.6082 32.3345 61.0691 32.3345C60.5117 32.3345 60.0503 32.5081 59.6848 32.8553C59.3284 33.1934 59.1 33.7005 58.9995 34.3766ZM65.8356 38.6665V31.4025H66.891L66.9184 32.7045C67.1103 32.202 67.4027 31.8319 67.7956 31.5944C68.1976 31.3568 68.6499 31.238 69.1524 31.238C69.7098 31.238 70.1666 31.3614 70.523 31.6081C70.8885 31.8548 71.158 32.1883 71.3316 32.6086C71.5144 33.0198 71.6057 33.4812 71.6057 33.9929V38.6665H70.4545V34.3218C70.4545 33.6457 70.3311 33.134 70.0844 32.7868C69.8468 32.4304 69.4539 32.2522 68.9057 32.2522C68.3483 32.2522 67.8869 32.4304 67.5214 32.7868C67.1651 33.134 66.9869 33.6457 66.9869 34.3218V38.6665H65.8356ZM75.1812 38.6665L72.536 31.4025H73.7969L75.9076 37.4878L78.0183 31.4025H79.2792L76.634 38.6665H75.1812ZM82.3311 38.831C81.5818 38.831 80.9833 38.6574 80.5356 38.3102C80.097 37.9629 79.8777 37.4787 79.8777 36.8574C79.8777 36.236 80.0605 35.7472 80.426 35.3908C80.7915 35.0345 81.3717 34.7832 82.1666 34.637L84.7433 34.1573C84.7433 32.9421 84.1676 32.3345 83.0163 32.3345C82.5138 32.3345 82.1163 32.4487 81.824 32.6771C81.5316 32.8964 81.3305 33.2162 81.2209 33.6365L80.0011 33.5406C80.1382 32.8553 80.4671 32.3025 80.9879 31.8822C81.5179 31.4528 82.194 31.238 83.0163 31.238C83.9483 31.238 84.661 31.503 85.1544 32.033C85.6478 32.5538 85.8945 33.2756 85.8945 34.1985V37.2C85.8945 37.5015 86.0225 37.6523 86.2783 37.6523H86.5661V38.6665C86.4565 38.6848 86.3103 38.6939 86.1275 38.6939C85.689 38.6939 85.36 38.6071 85.1407 38.4335C84.9306 38.2508 84.8027 37.9538 84.757 37.5426C84.5742 37.9173 84.259 38.2279 83.8113 38.4746C83.3636 38.7122 82.8702 38.831 82.3311 38.831ZM82.4407 37.8168C83.1717 37.8168 83.7382 37.6112 84.1402 37.2C84.5422 36.7888 84.7433 36.2817 84.7433 35.6787V35.1579L82.3859 35.5964C81.8925 35.6878 81.5498 35.8294 81.358 36.0213C81.1752 36.2041 81.0838 36.4462 81.0838 36.7477C81.0838 37.0858 81.2026 37.3508 81.4402 37.5426C81.6869 37.7254 82.0204 37.8168 82.4407 37.8168ZM89.2115 38.6665C88.7912 38.6665 88.4486 38.5569 88.1836 38.3376C87.9186 38.1183 87.7861 37.7711 87.7861 37.2959V28.9355H88.9374V37.2C88.9374 37.5015 89.0882 37.6523 89.3897 37.6523H90.0202V38.6665H89.2115ZM94.1968 38.831C93.5116 38.831 92.9177 38.6756 92.4151 38.365C91.9217 38.0543 91.5379 37.6157 91.2638 37.0492C90.9989 36.4736 90.8664 35.802 90.8664 35.0345C90.8664 34.267 90.9989 33.6 91.2638 33.0335C91.5379 32.467 91.9171 32.0284 92.4014 31.7177C92.8948 31.3979 93.475 31.238 94.142 31.238C94.7725 31.238 95.3299 31.3888 95.8141 31.6903C96.2984 31.9827 96.673 32.4121 96.938 32.9787C97.2121 33.5452 97.3492 34.235 97.3492 35.0482V35.3908H92.0725C92.1182 36.1675 92.3237 36.7523 92.6892 37.1452C93.0638 37.5381 93.5664 37.7345 94.1968 37.7345C94.672 37.7345 95.0603 37.6249 95.3618 37.4056C95.6725 37.1772 95.8872 36.8802 96.006 36.5147L97.2395 36.6107C97.0476 37.2594 96.6821 37.7939 96.1431 38.2142C95.6131 38.6254 94.9644 38.831 94.1968 38.831ZM92.0725 34.3766H96.0882C96.0334 33.6731 95.8278 33.1568 95.4715 32.8279C95.1243 32.499 94.6811 32.3345 94.142 32.3345C93.5847 32.3345 93.1232 32.5081 92.7578 32.8553C92.4014 33.1934 92.173 33.7005 92.0725 34.3766ZM98.5902 40.8046L99.248 38.6665H98.549V37.1178H100.098V38.4746L99.2754 40.8046H98.5902ZM107.885 38.6665C107.227 38.6665 106.738 38.5157 106.418 38.2142C106.108 37.9127 105.952 37.4421 105.952 36.8025V32.4167H104.883V31.4025H105.952V29.703H107.104V31.4025H109.036V32.4167H107.104V36.7751C107.104 37.1041 107.177 37.3325 107.323 37.4604C107.469 37.5883 107.693 37.6523 107.995 37.6523H109.036V38.6665H107.885ZM110.513 38.6665V28.9355H111.664V32.5401C111.856 32.0923 112.144 31.7634 112.528 31.5533C112.911 31.3431 113.355 31.238 113.857 31.238C114.643 31.238 115.241 31.4939 115.653 32.0055C116.073 32.5172 116.283 33.1797 116.283 33.9929V38.6665H115.132V34.3218C115.132 32.9421 114.625 32.2522 113.61 32.2522C113.026 32.2522 112.555 32.4304 112.199 32.7868C111.842 33.1431 111.664 33.6594 111.664 34.3355V38.6665H110.513ZM121.153 38.831C120.468 38.831 119.874 38.6756 119.371 38.365C118.878 38.0543 118.494 37.6157 118.22 37.0492C117.955 36.4736 117.823 35.802 117.823 35.0345C117.823 34.267 117.955 33.6 118.22 33.0335C118.494 32.467 118.873 32.0284 119.358 31.7177C119.851 31.3979 120.431 31.238 121.098 31.238C121.729 31.238 122.286 31.3888 122.77 31.6903C123.255 31.9827 123.629 32.4121 123.894 32.9787C124.168 33.5452 124.305 34.235 124.305 35.0482V35.3908H119.029C119.074 36.1675 119.28 36.7523 119.646 37.1452C120.02 37.5381 120.523 37.7345 121.153 37.7345C121.628 37.7345 122.017 37.6249 122.318 37.4056C122.629 37.1772 122.843 36.8802 122.962 36.5147L124.196 36.6107C124.004 37.2594 123.638 37.7939 123.099 38.2142C122.569 38.6254 121.921 38.831 121.153 38.831ZM119.029 34.3766H123.045C122.99 33.6731 122.784 33.1568 122.428 32.8279C122.081 32.499 121.637 32.3345 121.098 32.3345C120.541 32.3345 120.08 32.5081 119.714 32.8553C119.358 33.1934 119.129 33.7005 119.029 34.3766ZM125.865 38.6665V31.4025H126.879L126.92 32.7457C127.167 31.8502 127.743 31.4025 128.647 31.4025H129.36V32.499H128.661C127.564 32.499 127.016 33.0929 127.016 34.2807V38.6665H125.865ZM133.346 38.831C132.661 38.831 132.067 38.6756 131.565 38.365C131.071 38.0543 130.687 37.6157 130.413 37.0492C130.148 36.4736 130.016 35.802 130.016 35.0345C130.016 34.267 130.148 33.6 130.413 33.0335C130.687 32.467 131.067 32.0284 131.551 31.7177C132.044 31.3979 132.625 31.238 133.292 31.238C133.922 31.238 134.479 31.3888 134.964 31.6903C135.448 31.9827 135.823 32.4121 136.087 32.9787C136.362 33.5452 136.499 34.235 136.499 35.0482V35.3908H131.222C131.268 36.1675 131.473 36.7523 131.839 37.1452C132.213 37.5381 132.716 37.7345 133.346 37.7345C133.821 37.7345 134.21 37.6249 134.511 37.4056C134.822 37.1772 135.037 36.8802 135.156 36.5147L136.389 36.6107C136.197 37.2594 135.832 37.7939 135.293 38.2142C134.763 38.6254 134.114 38.831 133.346 38.831ZM131.222 34.3766H135.238C135.183 33.6731 134.977 33.1568 134.621 32.8279C134.274 32.499 133.831 32.3345 133.292 32.3345C132.734 32.3345 132.273 32.5081 131.907 32.8553C131.551 33.1934 131.322 33.7005 131.222 34.3766ZM143.044 38.6665C142.623 38.6665 142.281 38.5569 142.016 38.3376C141.751 38.1183 141.618 37.7711 141.618 37.2959V28.9355H142.77V37.2C142.77 37.5015 142.92 37.6523 143.222 37.6523H143.852V38.6665H143.044ZM145.165 38.6665V31.4025H146.317V38.6665H145.165ZM145.138 30.2649V28.9218H146.344V30.2649H145.138ZM150.228 38.6665L147.583 31.4025H148.843L150.954 37.4878L153.065 31.4025H154.326L151.681 38.6665H150.228ZM158.201 38.831C157.516 38.831 156.922 38.6756 156.42 38.365C155.926 38.0543 155.542 37.6157 155.268 37.0492C155.003 36.4736 154.871 35.802 154.871 35.0345C154.871 34.267 155.003 33.6 155.268 33.0335C155.542 32.467 155.922 32.0284 156.406 31.7177C156.899 31.3979 157.479 31.238 158.146 31.238C158.777 31.238 159.334 31.3888 159.819 31.6903C160.303 31.9827 160.677 32.4121 160.942 32.9787C161.217 33.5452 161.354 34.235 161.354 35.0482V35.3908H156.077C156.123 36.1675 156.328 36.7523 156.694 37.1452C157.068 37.5381 157.571 37.7345 158.201 37.7345C158.676 37.7345 159.065 37.6249 159.366 37.4056C159.677 37.1772 159.892 36.8802 160.01 36.5147L161.244 36.6107C161.052 37.2594 160.687 37.7939 160.147 38.2142C159.618 38.6254 158.969 38.831 158.201 38.831ZM156.077 34.3766H160.093C160.038 33.6731 159.832 33.1568 159.476 32.8279C159.129 32.499 158.686 32.3345 158.146 32.3345C157.589 32.3345 157.128 32.5081 156.762 32.8553C156.406 33.1934 156.177 33.7005 156.077 34.3766ZM165.49 38.831C164.859 38.831 164.311 38.6756 163.845 38.365C163.388 38.0543 163.036 37.6157 162.79 37.0492C162.543 36.4827 162.42 35.8112 162.42 35.0345C162.42 34.2578 162.543 33.5863 162.79 33.0198C163.036 32.4533 163.388 32.0147 163.845 31.704C164.311 31.3934 164.859 31.238 165.49 31.238C165.974 31.238 166.417 31.3431 166.819 31.5533C167.221 31.7543 167.523 32.0421 167.724 32.4167V28.9355H168.875V38.6665H167.833L167.792 37.5701C167.591 37.9629 167.285 38.2736 166.874 38.502C166.463 38.7213 166.001 38.831 165.49 38.831ZM165.723 37.7345C166.371 37.7345 166.865 37.4924 167.203 37.0081C167.55 36.5239 167.724 35.866 167.724 35.0345C167.724 34.1939 167.55 33.536 167.203 33.0609C166.865 32.5766 166.371 32.3345 165.723 32.3345C165.083 32.3345 164.571 32.5766 164.188 33.0609C163.813 33.536 163.626 34.1939 163.626 35.0345C163.626 35.866 163.813 36.5239 164.188 37.0081C164.571 37.4924 165.083 37.7345 165.723 37.7345ZM176.45 38.831C175.701 38.831 175.103 38.6574 174.655 38.3102C174.216 37.9629 173.997 37.4787 173.997 36.8574C173.997 36.236 174.18 35.7472 174.545 35.3908C174.911 35.0345 175.491 34.7832 176.286 34.637L178.863 34.1573C178.863 32.9421 178.287 32.3345 177.136 32.3345C176.633 32.3345 176.236 32.4487 175.943 32.6771C175.651 32.8964 175.45 33.2162 175.34 33.6365L174.121 33.5406C174.258 32.8553 174.586 32.3025 175.107 31.8822C175.637 31.4528 176.313 31.238 177.136 31.238C178.068 31.238 178.78 31.503 179.274 32.033C179.767 32.5538 180.014 33.2756 180.014 34.1985V37.2C180.014 37.5015 180.142 37.6523 180.398 37.6523H180.686V38.6665C180.576 38.6848 180.43 38.6939 180.247 38.6939C179.808 38.6939 179.479 38.6071 179.26 38.4335C179.05 38.2508 178.922 37.9538 178.876 37.5426C178.694 37.9173 178.378 38.2279 177.931 38.4746C177.483 38.7122 176.99 38.831 176.45 38.831ZM176.56 37.8168C177.291 37.8168 177.858 37.6112 178.26 37.2C178.662 36.7888 178.863 36.2817 178.863 35.6787V35.1579L176.505 35.5964C176.012 35.6878 175.669 35.8294 175.477 36.0213C175.295 36.2041 175.203 36.4462 175.203 36.7477C175.203 37.0858 175.322 37.3508 175.56 37.5426C175.806 37.7254 176.14 37.8168 176.56 37.8168ZM188.303 38.831C187.627 38.831 187.037 38.6756 186.535 38.365C186.041 38.0543 185.658 37.6157 185.384 37.0492C185.109 36.4736 184.972 35.802 184.972 35.0345C184.972 34.267 185.109 33.6 185.384 33.0335C185.658 32.467 186.041 32.0284 186.535 31.7177C187.037 31.3979 187.627 31.238 188.303 31.238C189.143 31.238 189.838 31.4573 190.386 31.8959C190.934 32.3253 191.268 32.9467 191.387 33.7599L190.181 33.8421C190.098 33.3578 189.888 32.9878 189.55 32.7319C189.212 32.467 188.796 32.3345 188.303 32.3345C187.636 32.3345 187.115 32.5766 186.74 33.0609C186.366 33.536 186.179 34.1939 186.179 35.0345C186.179 35.8751 186.366 36.5376 186.74 37.0218C187.115 37.497 187.636 37.7345 188.303 37.7345C188.796 37.7345 189.212 37.5975 189.55 37.3233C189.888 37.0492 190.098 36.6381 190.181 36.0898L191.387 36.1721C191.268 36.9761 190.934 37.6203 190.386 38.1046C189.838 38.5888 189.143 38.831 188.303 38.831ZM195.199 38.831C194.477 38.831 193.897 38.5888 193.458 38.1046C193.029 37.6112 192.814 36.935 192.814 36.0761V31.4025H193.965V35.7609C193.965 36.4645 194.088 36.9853 194.335 37.3233C194.591 37.6523 194.979 37.8168 195.5 37.8168C196.076 37.8168 196.528 37.634 196.857 37.2685C197.186 36.8939 197.35 36.3822 197.35 35.7335V31.4025H198.502V38.6665H197.405V37.4878C197.003 38.3833 196.268 38.831 195.199 38.831ZM200.697 38.6665V31.4025H201.712L201.753 32.7457C201.999 31.8502 202.575 31.4025 203.48 31.4025H204.192V32.499H203.493C202.397 32.499 201.849 33.0929 201.849 34.2807V38.6665H200.697ZM205.476 38.6665V31.4025H206.627V38.6665H205.476ZM205.448 30.2649V28.9218H206.654V30.2649H205.448ZM211.525 38.831C210.858 38.831 210.273 38.6756 209.771 38.365C209.268 38.0543 208.88 37.6157 208.606 37.0492C208.331 36.4736 208.194 35.802 208.194 35.0345C208.194 34.2578 208.331 33.5863 208.606 33.0198C208.88 32.4533 209.268 32.0147 209.771 31.704C210.273 31.3934 210.858 31.238 211.525 31.238C212.192 31.238 212.772 31.3934 213.266 31.704C213.768 32.0147 214.156 32.4533 214.43 33.0198C214.705 33.5863 214.842 34.2578 214.842 35.0345C214.842 35.802 214.705 36.4736 214.43 37.0492C214.156 37.6157 213.768 38.0543 213.266 38.365C212.772 38.6756 212.192 38.831 211.525 38.831ZM211.525 37.7345C212.192 37.7345 212.708 37.497 213.074 37.0218C213.448 36.5376 213.636 35.8751 213.636 35.0345C213.636 34.1939 213.448 33.536 213.074 33.0609C212.708 32.5766 212.192 32.3345 211.525 32.3345C210.858 32.3345 210.337 32.5766 209.962 33.0609C209.588 33.536 209.401 34.1939 209.401 35.0345C209.401 35.8751 209.588 36.5376 209.962 37.0218C210.337 37.497 210.858 37.7345 211.525 37.7345ZM218.795 38.831C218.074 38.831 217.493 38.5888 217.055 38.1046C216.625 37.6112 216.411 36.935 216.411 36.0761V31.4025H217.562V35.7609C217.562 36.4645 217.685 36.9853 217.932 37.3233C218.188 37.6523 218.576 37.8168 219.097 37.8168C219.673 37.8168 220.125 37.634 220.454 37.2685C220.783 36.8939 220.947 36.3822 220.947 35.7335V31.4025H222.099V38.6665H221.002V37.4878C220.6 38.3833 219.864 38.831 218.795 38.831ZM226.805 38.831C225.837 38.831 225.083 38.6025 224.544 38.1457C224.014 37.6797 223.722 37.0904 223.667 36.3777L224.873 36.2954C224.946 36.734 225.133 37.0858 225.435 37.3508C225.736 37.6066 226.193 37.7345 226.805 37.7345C227.299 37.7345 227.692 37.6569 227.984 37.5015C228.286 37.3371 228.436 37.0766 228.436 36.7203C228.436 36.5284 228.391 36.3685 228.299 36.2406C228.208 36.1127 228.03 36.003 227.765 35.9117C227.5 35.8112 227.107 35.7152 226.586 35.6238C225.892 35.4959 225.343 35.3406 224.941 35.1579C224.549 34.966 224.265 34.733 224.092 34.4589C223.927 34.1756 223.845 33.8421 223.845 33.4583C223.845 32.8096 224.083 32.2797 224.558 31.8685C225.033 31.4482 225.709 31.238 226.586 31.238C227.18 31.238 227.678 31.3477 228.08 31.567C228.491 31.7771 228.811 32.0604 229.039 32.4167C229.277 32.7639 229.432 33.1477 229.505 33.568L228.299 33.6502C228.217 33.2665 228.03 32.9512 227.737 32.7045C227.454 32.4578 227.066 32.3345 226.572 32.3345C226.052 32.3345 225.668 32.435 225.421 32.636C225.174 32.837 225.051 33.0929 225.051 33.4035C225.051 33.7599 225.184 34.0248 225.449 34.1985C225.714 34.3629 226.166 34.5 226.805 34.6096C227.546 34.7375 228.121 34.8929 228.532 35.0756C228.944 35.2584 229.231 35.4822 229.396 35.7472C229.56 36.0122 229.643 36.3365 229.643 36.7203C229.643 37.168 229.515 37.5518 229.259 37.8716C229.012 38.1822 228.674 38.4198 228.245 38.5843C227.824 38.7487 227.345 38.831 226.805 38.831ZM22.8666 64.831C21.8981 64.831 21.1443 64.6025 20.6052 64.1457C20.0753 63.6797 19.7829 63.0904 19.728 62.3777L20.9341 62.2954C21.0072 62.734 21.1946 63.0858 21.4961 63.3508C21.7976 63.6066 22.2545 63.7345 22.8666 63.7345C23.3601 63.7345 23.7529 63.6569 24.0453 63.5015C24.3469 63.3371 24.4976 63.0766 24.4976 62.7203C24.4976 62.5284 24.4519 62.3685 24.3606 62.2406C24.2692 62.1127 24.091 62.003 23.826 61.9117C23.5611 61.8112 23.1682 61.7152 22.6474 61.6238C21.9529 61.4959 21.4047 61.3406 21.0027 61.1579C20.6098 60.966 20.3265 60.733 20.1529 60.4589C19.9885 60.1756 19.9062 59.8421 19.9062 59.4583C19.9062 58.8096 20.1438 58.2797 20.6189 57.8685C21.094 57.4482 21.7702 57.238 22.6474 57.238C23.2413 57.238 23.7392 57.3477 24.1413 57.567C24.5524 57.7771 24.8722 58.0604 25.1007 58.4167C25.3382 58.7639 25.4936 59.1477 25.5667 59.568L24.3606 59.6502C24.2783 59.2665 24.091 58.9512 23.7986 58.7045C23.5154 58.4578 23.1271 58.3345 22.6337 58.3345C22.1128 58.3345 21.7291 58.435 21.4824 58.636C21.2357 58.837 21.1123 59.0929 21.1123 59.4035C21.1123 59.7599 21.2448 60.0248 21.5098 60.1985C21.7748 60.3629 22.2271 60.5 22.8666 60.6096C23.6068 60.7375 24.1824 60.8929 24.5936 61.0756C25.0047 61.2584 25.2926 61.4822 25.457 61.7472C25.6215 62.0122 25.7037 62.3365 25.7037 62.7203C25.7037 63.168 25.5758 63.5518 25.32 63.8716C25.0733 64.1822 24.7352 64.4198 24.3057 64.5843C23.8854 64.7487 23.4057 64.831 22.8666 64.831ZM27.2751 64.6665V54.9355H28.4263V58.5401C28.6182 58.0923 28.906 57.7634 29.2898 57.5533C29.6735 57.3431 30.1167 57.238 30.6192 57.238C31.405 57.238 32.0035 57.4939 32.4147 58.0055C32.835 58.5172 33.0451 59.1797 33.0451 59.9929V64.6665H31.8939V60.3218C31.8939 58.9421 31.3868 58.2522 30.3725 58.2522C29.7878 58.2522 29.3172 58.4304 28.9609 58.7868C28.6045 59.1431 28.4263 59.6594 28.4263 60.3355V64.6665H27.2751ZM37.9153 64.831C37.23 64.831 36.6361 64.6756 36.1335 64.365C35.6401 64.0543 35.2564 63.6157 34.9822 63.0492C34.7173 62.4736 34.5848 61.802 34.5848 61.0345C34.5848 60.267 34.7173 59.6 34.9822 59.0335C35.2564 58.467 35.6356 58.0284 36.1198 57.7177C36.6132 57.3979 37.1934 57.238 37.8604 57.238C38.4909 57.238 39.0483 57.3888 39.5325 57.6903C40.0168 57.9827 40.3914 58.4121 40.6564 58.9787C40.9305 59.5452 41.0676 60.235 41.0676 61.0482V61.3908H35.7909C35.8366 62.1675 36.0422 62.7523 36.4076 63.1452C36.7823 63.5381 37.2848 63.7345 37.9153 63.7345C38.3904 63.7345 38.7787 63.6249 39.0802 63.4056C39.3909 63.1772 39.6056 62.8802 39.7244 62.5147L40.9579 62.6107C40.766 63.2594 40.4006 63.7939 39.8615 64.2142C39.3315 64.6254 38.6828 64.831 37.9153 64.831ZM35.7909 60.3766H39.8066C39.7518 59.6731 39.5462 59.1568 39.1899 58.8279C38.8427 58.499 38.3995 58.3345 37.8604 58.3345C37.3031 58.3345 36.8417 58.5081 36.4762 58.8553C36.1198 59.1934 35.8914 59.7005 35.7909 60.3766ZM45.4641 64.831C44.7788 64.831 44.1849 64.6756 43.6824 64.365C43.1889 64.0543 42.8052 63.6157 42.5311 63.0492C42.2661 62.4736 42.1336 61.802 42.1336 61.0345C42.1336 60.267 42.2661 59.6 42.5311 59.0335C42.8052 58.467 43.1844 58.0284 43.6686 57.7177C44.162 57.3979 44.7423 57.238 45.4093 57.238C46.0397 57.238 46.5971 57.3888 47.0814 57.6903C47.5656 57.9827 47.9402 58.4121 48.2052 58.9787C48.4793 59.5452 48.6164 60.235 48.6164 61.0482V61.3908H43.3397C43.3854 62.1675 43.591 62.7523 43.9565 63.1452C44.3311 63.5381 44.8336 63.7345 45.4641 63.7345C45.9392 63.7345 46.3275 63.6249 46.6291 63.4056C46.9397 63.1772 47.1545 62.8802 47.2732 62.5147L48.5067 62.6107C48.3149 63.2594 47.9494 63.7939 47.4103 64.2142C46.8803 64.6254 46.2316 64.831 45.4641 64.831ZM43.3397 60.3766H47.3555C47.3006 59.6731 47.0951 59.1568 46.7387 58.8279C46.3915 58.499 45.9484 58.3345 45.4093 58.3345C44.8519 58.3345 44.3905 58.5081 44.025 58.8553C43.6686 59.1934 43.4402 59.7005 43.3397 60.3766ZM50.1758 66.7224V57.4025H51.2449L51.2586 58.5127C51.4779 58.0923 51.7794 57.7771 52.1632 57.567C52.5561 57.3477 52.9992 57.238 53.4926 57.238C54.2144 57.238 54.8038 57.4162 55.2606 57.7726C55.7266 58.1289 56.0693 58.5949 56.2886 59.1705C56.517 59.7462 56.6312 60.3675 56.6312 61.0345C56.6312 61.7015 56.517 62.3228 56.2886 62.8985C56.0693 63.4741 55.7266 63.9401 55.2606 64.2965C54.8038 64.6528 54.2144 64.831 53.4926 64.831C53.0175 64.831 52.5835 64.7305 52.1906 64.5294C51.8068 64.3284 51.519 64.0543 51.3271 63.7071V66.7224H50.1758ZM53.383 63.7345C54.0134 63.7345 54.5114 63.497 54.8769 63.0218C55.2424 62.5467 55.4251 61.8843 55.4251 61.0345C55.4251 60.1847 55.2424 59.5223 54.8769 59.0472C54.5114 58.572 54.0134 58.3345 53.383 58.3345C52.7525 58.3345 52.25 58.5629 51.8753 59.0198C51.5099 59.4675 51.3271 60.1391 51.3271 61.0345C51.3271 61.9208 51.5099 62.5924 51.8753 63.0492C52.2408 63.5061 52.7434 63.7345 53.383 63.7345ZM61.7534 64.6665V57.4025H62.8087L62.8361 58.7045C63.028 58.202 63.3204 57.8319 63.7133 57.5944C64.1153 57.3568 64.5676 57.238 65.0702 57.238C65.6275 57.238 66.0844 57.3614 66.4407 57.6081C66.8062 57.8548 67.0758 58.1883 67.2494 58.6086C67.4321 59.0198 67.5235 59.4812 67.5235 59.9929V64.6665H66.3722V60.3218C66.3722 59.6457 66.2488 59.134 66.0021 58.7868C65.7646 58.4304 65.3717 58.2522 64.8235 58.2522C64.2661 58.2522 63.8047 58.4304 63.4392 58.7868C63.0828 59.134 62.9047 59.6457 62.9047 60.3218V64.6665H61.7534ZM71.5432 64.831C70.794 64.831 70.1955 64.6574 69.7478 64.3102C69.3092 63.9629 69.0899 63.4787 69.0899 62.8574C69.0899 62.236 69.2726 61.7472 69.6381 61.3908C70.0036 61.0345 70.5838 60.7832 71.3787 60.637L73.9554 60.1573C73.9554 58.9421 73.3798 58.3345 72.2285 58.3345C71.7259 58.3345 71.3285 58.4487 71.0361 58.6771C70.7437 58.8964 70.5427 59.2162 70.433 59.6365L69.2132 59.5406C69.3503 58.8553 69.6792 58.3025 70.2 57.8822C70.73 57.4528 71.4061 57.238 72.2285 57.238C73.1605 57.238 73.8732 57.503 74.3666 58.033C74.86 58.5538 75.1067 59.2756 75.1067 60.1985V63.2C75.1067 63.5015 75.2346 63.6523 75.4904 63.6523H75.7782V64.6665C75.6686 64.6848 75.5224 64.6939 75.3397 64.6939C74.9011 64.6939 74.5722 64.6071 74.3529 64.4335C74.1427 64.2508 74.0148 63.9538 73.9691 63.5426C73.7864 63.9173 73.4711 64.2279 73.0234 64.4746C72.5757 64.7122 72.0823 64.831 71.5432 64.831ZM71.6528 63.8168C72.3838 63.8168 72.9503 63.6112 73.3523 63.2C73.7544 62.7888 73.9554 62.2817 73.9554 61.6787V61.1579L71.598 61.5964C71.1046 61.6878 70.762 61.8294 70.5701 62.0213C70.3874 62.2041 70.296 62.4462 70.296 62.7477C70.296 63.0858 70.4148 63.3508 70.6523 63.5426C70.899 63.7254 71.2325 63.8168 71.6528 63.8168ZM77.0786 64.6665V57.4025H78.1339L78.1613 58.6771C78.3441 58.2294 78.6136 57.8776 78.97 57.6218C79.3263 57.3659 79.7375 57.238 80.2035 57.238C80.7517 57.238 81.2131 57.3705 81.5877 57.6355C81.9624 57.9005 82.2273 58.2751 82.3827 58.7594C82.538 58.2751 82.7984 57.9005 83.1639 57.6355C83.5294 57.3705 83.9771 57.238 84.507 57.238C85.2837 57.238 85.8776 57.4802 86.2888 57.9644C86.7 58.4396 86.9055 59.1157 86.9055 59.9929V64.6665H85.7543V60.2396C85.7543 58.9147 85.2472 58.2522 84.2329 58.2522C83.7304 58.2522 83.3238 58.435 83.0131 58.8005C82.7116 59.166 82.5608 59.6731 82.5608 60.3218V64.6665H81.4096V60.3218C81.4096 59.6731 81.2954 59.166 81.0669 58.8005C80.8385 58.435 80.4547 58.2522 79.9156 58.2522C79.4131 58.2522 79.0065 58.435 78.6958 58.8005C78.3852 59.166 78.2299 59.6731 78.2299 60.3218V64.6665H77.0786ZM91.7743 64.831C91.089 64.831 90.4951 64.6756 89.9925 64.365C89.4991 64.0543 89.1154 63.6157 88.8413 63.0492C88.5763 62.4736 88.4438 61.802 88.4438 61.0345C88.4438 60.267 88.5763 59.6 88.8413 59.0335C89.1154 58.467 89.4946 58.0284 89.9788 57.7177C90.4722 57.3979 91.0524 57.238 91.7194 57.238C92.3499 57.238 92.9073 57.3888 93.3915 57.6903C93.8758 57.9827 94.2504 58.4121 94.5154 58.9787C94.7895 59.5452 94.9266 60.235 94.9266 61.0482V61.3908H89.6499C89.6956 62.1675 89.9012 62.7523 90.2666 63.1452C90.6413 63.5381 91.1438 63.7345 91.7743 63.7345C92.2494 63.7345 92.6377 63.6249 92.9392 63.4056C93.2499 63.1772 93.4646 62.8802 93.5834 62.5147L94.8169 62.6107C94.625 63.2594 94.2596 63.7939 93.7205 64.2142C93.1905 64.6254 92.5418 64.831 91.7743 64.831ZM89.6499 60.3766H93.6656C93.6108 59.6731 93.4052 59.1568 93.0489 58.8279C92.7017 58.499 92.2585 58.3345 91.7194 58.3345C91.1621 58.3345 90.7007 58.5081 90.3352 58.8553C89.9788 59.1934 89.7504 59.7005 89.6499 60.3766ZM99.0627 64.831C98.4322 64.831 97.884 64.6756 97.418 64.365C96.9611 64.0543 96.6094 63.6157 96.3627 63.0492C96.116 62.4827 95.9926 61.8112 95.9926 61.0345C95.9926 60.2578 96.116 59.5863 96.3627 59.0198C96.6094 58.4533 96.9611 58.0147 97.418 57.704C97.884 57.3934 98.4322 57.238 99.0627 57.238C99.547 57.238 99.9901 57.3431 100.392 57.5533C100.794 57.7543 101.096 58.0421 101.297 58.4167V54.9355H102.448V64.6665H101.406L101.365 63.5701C101.164 63.9629 100.858 64.2736 100.447 64.502C100.036 64.7213 99.5744 64.831 99.0627 64.831ZM99.2957 63.7345C99.9444 63.7345 100.438 63.4924 100.776 63.0081C101.123 62.5239 101.297 61.866 101.297 61.0345C101.297 60.1939 101.123 59.536 100.776 59.0609C100.438 58.5766 99.9444 58.3345 99.2957 58.3345C98.6561 58.3345 98.1444 58.5766 97.7606 59.0609C97.386 59.536 97.1987 60.1939 97.1987 61.0345C97.1987 61.866 97.386 62.5239 97.7606 63.0081C98.1444 63.4924 98.6561 63.7345 99.2957 63.7345ZM110.051 64.6665L107.488 54.9355H108.776L110.777 63.0492L112.765 54.9355H114.108L116.109 63.0492L118.096 54.9355H119.398L116.835 64.6665H115.355L113.436 57.1284L111.517 64.6665H110.051ZM123.308 64.831C122.641 64.831 122.056 64.6756 121.554 64.365C121.051 64.0543 120.663 63.6157 120.389 63.0492C120.115 62.4736 119.978 61.802 119.978 61.0345C119.978 60.2578 120.115 59.5863 120.389 59.0198C120.663 58.4533 121.051 58.0147 121.554 57.704C122.056 57.3934 122.641 57.238 123.308 57.238C123.975 57.238 124.555 57.3934 125.049 57.704C125.551 58.0147 125.94 58.4533 126.214 59.0198C126.488 59.5863 126.625 60.2578 126.625 61.0345C126.625 61.802 126.488 62.4736 126.214 63.0492C125.94 63.6157 125.551 64.0543 125.049 64.365C124.555 64.6756 123.975 64.831 123.308 64.831ZM123.308 63.7345C123.975 63.7345 124.491 63.497 124.857 63.0218C125.231 62.5376 125.419 61.8751 125.419 61.0345C125.419 60.1939 125.231 59.536 124.857 59.0609C124.491 58.5766 123.975 58.3345 123.308 58.3345C122.641 58.3345 122.12 58.5766 121.746 59.0609C121.371 59.536 121.184 60.1939 121.184 61.0345C121.184 61.8751 121.371 62.5376 121.746 63.0218C122.12 63.497 122.641 63.7345 123.308 63.7345ZM131.031 64.831C130.364 64.831 129.779 64.6756 129.277 64.365C128.774 64.0543 128.386 63.6157 128.112 63.0492C127.837 62.4736 127.7 61.802 127.7 61.0345C127.7 60.2578 127.837 59.5863 128.112 59.0198C128.386 58.4533 128.774 58.0147 129.277 57.704C129.779 57.3934 130.364 57.238 131.031 57.238C131.698 57.238 132.278 57.3934 132.771 57.704C133.274 58.0147 133.662 58.4533 133.936 59.0198C134.211 59.5863 134.348 60.2578 134.348 61.0345C134.348 61.802 134.211 62.4736 133.936 63.0492C133.662 63.6157 133.274 64.0543 132.771 64.365C132.278 64.6756 131.698 64.831 131.031 64.831ZM131.031 63.7345C131.698 63.7345 132.214 63.497 132.58 63.0218C132.954 62.5376 133.142 61.8751 133.142 61.0345C133.142 60.1939 132.954 59.536 132.58 59.0609C132.214 58.5766 131.698 58.3345 131.031 58.3345C130.364 58.3345 129.843 58.5766 129.468 59.0609C129.094 59.536 128.906 60.1939 128.906 61.0345C128.906 61.8751 129.094 62.5376 129.468 63.0218C129.843 63.497 130.364 63.7345 131.031 63.7345ZM137.409 64.6665C136.989 64.6665 136.646 64.5569 136.381 64.3376C136.116 64.1183 135.984 63.7711 135.984 63.2959V54.9355H137.135V63.2C137.135 63.5015 137.286 63.6523 137.587 63.6523H138.218V64.6665H137.409ZM140.929 64.6665C140.509 64.6665 140.166 64.5569 139.901 64.3376C139.636 64.1183 139.504 63.7711 139.504 63.2959V54.9355H140.655V63.2C140.655 63.5015 140.806 63.6523 141.107 63.6523H141.738V64.6665H140.929ZM143.138 66.7224V65.7081H143.974C144.23 65.7081 144.417 65.667 144.536 65.5848C144.664 65.5117 144.76 65.3883 144.824 65.2147L145.084 64.5294H144.687L142.028 57.4025H143.289L145.454 63.4604L147.524 57.4025H148.785L145.838 65.5848C145.701 65.9777 145.496 66.2655 145.221 66.4482C144.947 66.631 144.568 66.7224 144.084 66.7224H143.138ZM148.875 64.6665V63.1178H150.423V64.6665H148.875ZM159.184 64.8858C158.434 64.8858 157.781 64.7396 157.224 64.4472C156.675 64.1548 156.251 63.7391 155.949 63.2C155.657 62.6518 155.51 62.0076 155.51 61.2675V54.9218H156.689V61.2675C156.689 62.0533 156.904 62.6609 157.333 63.0904C157.772 63.5198 158.389 63.7345 159.184 63.7345C159.969 63.7345 160.577 63.5198 161.006 63.0904C161.445 62.6609 161.664 62.0533 161.664 61.2675V54.9218H162.843V61.2675C162.843 62.0076 162.692 62.6518 162.391 63.2C162.098 63.7391 161.678 64.1548 161.13 64.4472C160.582 64.7396 159.933 64.8858 159.184 64.8858ZM165.001 64.6665V57.4025H166.056L166.084 58.7045C166.276 58.202 166.568 57.8319 166.961 57.5944C167.363 57.3568 167.815 57.238 168.318 57.238C168.875 57.238 169.332 57.3614 169.688 57.6081C170.054 57.8548 170.323 58.1883 170.497 58.6086C170.68 59.0198 170.771 59.4812 170.771 59.9929V64.6665H169.62V60.3218C169.62 59.6457 169.496 59.134 169.25 58.7868C169.012 58.4304 168.619 58.2522 168.071 58.2522C167.514 58.2522 167.052 58.4304 166.687 58.7868C166.33 59.134 166.152 59.6457 166.152 60.3218V64.6665H165.001ZM174.189 64.6665C173.769 64.6665 173.426 64.5569 173.161 64.3376C172.896 64.1183 172.764 63.7711 172.764 63.2959V54.9355H173.915V63.2C173.915 63.5015 174.066 63.6523 174.368 63.6523H174.998V64.6665H174.189ZM176.311 64.6665V57.4025H177.462V64.6665H176.311ZM176.283 56.2649V54.9218H177.49V56.2649H176.283ZM179.389 64.6665V54.9355H180.541V61.3086L184.077 57.4025H185.612L182.747 60.4863L185.735 64.6665H184.337L181.966 61.2675L180.541 62.7751V64.6665H179.389ZM189.628 64.831C188.943 64.831 188.349 64.6756 187.846 64.365C187.353 64.0543 186.969 63.6157 186.695 63.0492C186.43 62.4736 186.297 61.802 186.297 61.0345C186.297 60.267 186.43 59.6 186.695 59.0335C186.969 58.467 187.348 58.0284 187.832 57.7177C188.326 57.3979 188.906 57.238 189.573 57.238C190.204 57.238 190.761 57.3888 191.245 57.6903C191.729 57.9827 192.104 58.4121 192.369 58.9787C192.643 59.5452 192.78 60.235 192.78 61.0482V61.3908H187.504C187.549 62.1675 187.755 62.7523 188.12 63.1452C188.495 63.5381 188.997 63.7345 189.628 63.7345C190.103 63.7345 190.491 63.6249 190.793 63.4056C191.104 63.1772 191.318 62.8802 191.437 62.5147L192.671 62.6107C192.479 63.2594 192.113 63.7939 191.574 64.2142C191.044 64.6254 190.395 64.831 189.628 64.831ZM187.504 60.3766H191.519C191.465 59.6731 191.259 59.1568 190.903 58.8279C190.555 58.499 190.112 58.3345 189.573 58.3345C189.016 58.3345 188.554 58.5081 188.189 58.8553C187.832 59.1934 187.604 59.7005 187.504 60.3766ZM200.559 64.6665C199.901 64.6665 199.412 64.5157 199.092 64.2142C198.782 63.9127 198.626 63.4421 198.626 62.8025V58.4167H197.557V57.4025H198.626V55.703H199.778V57.4025H201.71V58.4167H199.778V62.7751C199.778 63.1041 199.851 63.3325 199.997 63.4604C200.143 63.5883 200.367 63.6523 200.668 63.6523H201.71V64.6665H200.559ZM203.187 64.6665V54.9355H204.338V58.5401C204.53 58.0923 204.818 57.7634 205.202 57.5533C205.585 57.3431 206.028 57.238 206.531 57.238C207.317 57.238 207.915 57.4939 208.326 58.0055C208.747 58.5172 208.957 59.1797 208.957 59.9929V64.6665H207.806V60.3218C207.806 58.9421 207.299 58.2522 206.284 58.2522C205.7 58.2522 205.229 58.4304 204.873 58.7868C204.516 59.1431 204.338 59.6594 204.338 60.3355V64.6665H203.187ZM213.827 64.831C213.142 64.831 212.548 64.6756 212.045 64.365C211.552 64.0543 211.168 63.6157 210.894 63.0492C210.629 62.4736 210.497 61.802 210.497 61.0345C210.497 60.267 210.629 59.6 210.894 59.0335C211.168 58.467 211.547 58.0284 212.032 57.7177C212.525 57.3979 213.105 57.238 213.772 57.238C214.403 57.238 214.96 57.3888 215.444 57.6903C215.929 57.9827 216.303 58.4121 216.568 58.9787C216.842 59.5452 216.979 60.235 216.979 61.0482V61.3908H211.703C211.748 62.1675 211.954 62.7523 212.319 63.1452C212.694 63.5381 213.197 63.7345 213.827 63.7345C214.302 63.7345 214.69 63.6249 214.992 63.4056C215.303 63.1772 215.517 62.8802 215.636 62.5147L216.87 62.6107C216.678 63.2594 216.312 63.7939 215.773 64.2142C215.243 64.6254 214.595 64.831 213.827 64.831ZM211.703 60.3766H215.718C215.664 59.6731 215.458 59.1568 215.102 58.8279C214.754 58.499 214.311 58.3345 213.772 58.3345C213.215 58.3345 212.753 58.5081 212.388 58.8553C212.032 59.1934 211.803 59.7005 211.703 60.3766ZM23.0585 90.831C22.3915 90.831 21.8067 90.6756 21.3042 90.365C20.8017 90.0543 20.4133 89.6157 20.1392 89.0492C19.8651 88.4736 19.728 87.802 19.728 87.0345C19.728 86.2578 19.8651 85.5863 20.1392 85.0198C20.4133 84.4533 20.8017 84.0147 21.3042 83.704C21.8067 83.3934 22.3915 83.238 23.0585 83.238C23.7255 83.238 24.3057 83.3934 24.7991 83.704C25.3017 84.0147 25.69 84.4533 25.9641 85.0198C26.2382 85.5863 26.3753 86.2578 26.3753 87.0345C26.3753 87.802 26.2382 88.4736 25.9641 89.0492C25.69 89.6157 25.3017 90.0543 24.7991 90.365C24.3057 90.6756 23.7255 90.831 23.0585 90.831ZM23.0585 89.7345C23.7255 89.7345 24.2418 89.497 24.6073 89.0218C24.9819 88.5376 25.1692 87.8751 25.1692 87.0345C25.1692 86.1939 24.9819 85.536 24.6073 85.0609C24.2418 84.5766 23.7255 84.3345 23.0585 84.3345C22.3915 84.3345 21.8707 84.5766 21.4961 85.0609C21.1215 85.536 20.9341 86.1939 20.9341 87.0345C20.9341 87.8751 21.1215 88.5376 21.4961 89.0218C21.8707 89.497 22.3915 89.7345 23.0585 89.7345ZM30.3221 90.6665C29.6642 90.6665 29.1754 90.5157 28.8556 90.2142C28.5449 89.9127 28.3896 89.4421 28.3896 88.8025V84.4167H27.3206V83.4025H28.3896V81.703H29.5409V83.4025H31.4734V84.4167H29.5409V88.7751C29.5409 89.1041 29.614 89.3325 29.7602 89.4604C29.9064 89.5883 30.1302 89.6523 30.4318 89.6523H31.4734V90.6665H30.3221ZM32.9501 90.6665V80.9355H34.1013V84.5401C34.2932 84.0923 34.581 83.7634 34.9648 83.5533C35.3486 83.3431 35.7917 83.238 36.2942 83.238C37.08 83.238 37.6785 83.4939 38.0897 84.0055C38.51 84.5172 38.7201 85.1797 38.7201 85.9929V90.6665H37.5689V86.3218C37.5689 84.9421 37.0618 84.2522 36.0475 84.2522C35.4628 84.2522 34.9922 84.4304 34.6359 84.7868C34.2795 85.1431 34.1013 85.6594 34.1013 86.3355V90.6665H32.9501ZM43.5903 90.831C42.905 90.831 42.3111 90.6756 41.8085 90.365C41.3151 90.0543 40.9314 89.6157 40.6573 89.0492C40.3923 88.4736 40.2598 87.802 40.2598 87.0345C40.2598 86.267 40.3923 85.6 40.6573 85.0335C40.9314 84.467 41.3106 84.0284 41.7948 83.7177C42.2882 83.3979 42.8684 83.238 43.5354 83.238C44.1659 83.238 44.7233 83.3888 45.2075 83.6903C45.6918 83.9827 46.0664 84.4121 46.3314 84.9787C46.6055 85.5452 46.7426 86.235 46.7426 87.0482V87.3908H41.4659C41.5116 88.1675 41.7172 88.7523 42.0826 89.1452C42.4573 89.5381 42.9598 89.7345 43.5903 89.7345C44.0654 89.7345 44.4537 89.6249 44.7552 89.4056C45.0659 89.1772 45.2806 88.8802 45.3994 88.5147L46.6329 88.6107C46.441 89.2594 46.0756 89.7939 45.5365 90.2142C45.0065 90.6254 44.3578 90.831 43.5903 90.831ZM41.4659 86.3766H45.4816C45.4268 85.6731 45.2212 85.1568 44.8649 84.8279C44.5177 84.499 44.0745 84.3345 43.5354 84.3345C42.9781 84.3345 42.5167 84.5081 42.1512 84.8553C41.7948 85.1934 41.5664 85.7005 41.4659 86.3766ZM48.302 90.6665V83.4025H49.3162L49.3574 84.7457C49.6041 83.8502 50.1797 83.4025 51.0843 83.4025H51.797V84.499H51.098C50.0015 84.499 49.4533 85.0929 49.4533 86.2807V90.6665H48.302ZM59.5668 90.831C58.5983 90.831 57.8444 90.6025 57.3054 90.1457C56.7754 89.6797 56.483 89.0904 56.4282 88.3777L57.6343 88.2954C57.7074 88.734 57.8947 89.0858 58.1962 89.3508C58.4978 89.6066 58.9546 89.7345 59.5668 89.7345C60.0602 89.7345 60.4531 89.6569 60.7455 89.5015C61.047 89.3371 61.1978 89.0766 61.1978 88.7203C61.1978 88.5284 61.1521 88.3685 61.0607 88.2406C60.9693 88.1127 60.7912 88.003 60.5262 87.9117C60.2612 87.8112 59.8683 87.7152 59.3475 87.6238C58.6531 87.4959 58.1049 87.3406 57.7028 87.1579C57.3099 86.966 57.0267 86.733 56.8531 86.4589C56.6886 86.1756 56.6064 85.8421 56.6064 85.4583C56.6064 84.8096 56.8439 84.2797 57.3191 83.8685C57.7942 83.4482 58.4703 83.238 59.3475 83.238C59.9414 83.238 60.4394 83.3477 60.8414 83.567C61.2526 83.7771 61.5724 84.0604 61.8008 84.4167C62.0384 84.7639 62.1937 85.1477 62.2668 85.568L61.0607 85.6502C60.9785 85.2665 60.7912 84.9512 60.4988 84.7045C60.2155 84.4578 59.8272 84.3345 59.3338 84.3345C58.813 84.3345 58.4292 84.435 58.1825 84.636C57.9358 84.837 57.8125 85.0929 57.8125 85.4035C57.8125 85.7599 57.945 86.0248 58.2099 86.1985C58.4749 86.3629 58.9272 86.5 59.5668 86.6096C60.3069 86.7375 60.8825 86.8929 61.2937 87.0756C61.7049 87.2584 61.9927 87.4822 62.1572 87.7472C62.3216 88.0122 62.4039 88.3365 62.4039 88.7203C62.4039 89.168 62.2759 89.5518 62.0201 89.8716C61.7734 90.1822 61.4353 90.4198 61.0059 90.5843C60.5856 90.7487 60.1059 90.831 59.5668 90.831ZM63.9752 90.6665V80.9355H65.1265V84.5401C65.3184 84.0923 65.6062 83.7634 65.9899 83.5533C66.3737 83.3431 66.8168 83.238 67.3194 83.238C68.1052 83.238 68.7037 83.4939 69.1148 84.0055C69.5351 84.5172 69.7453 85.1797 69.7453 85.9929V90.6665H68.594V86.3218C68.594 84.9421 68.0869 84.2522 67.0727 84.2522C66.4879 84.2522 66.0173 84.4304 65.661 84.7868C65.3047 85.1431 65.1265 85.6594 65.1265 86.3355V90.6665H63.9752ZM74.6154 90.831C73.9301 90.831 73.3362 90.6756 72.8337 90.365C72.3403 90.0543 71.9565 89.6157 71.6824 89.0492C71.4174 88.4736 71.2849 87.802 71.2849 87.0345C71.2849 86.267 71.4174 85.6 71.6824 85.0335C71.9565 84.467 72.3357 84.0284 72.82 83.7177C73.3134 83.3979 73.8936 83.238 74.5606 83.238C75.191 83.238 75.7484 83.3888 76.2327 83.6903C76.7169 83.9827 77.0916 84.4121 77.3565 84.9787C77.6307 85.5452 77.7677 86.235 77.7677 87.0482V87.3908H72.491C72.5367 88.1675 72.7423 88.7523 73.1078 89.1452C73.4824 89.5381 73.9849 89.7345 74.6154 89.7345C75.0905 89.7345 75.4789 89.6249 75.7804 89.4056C76.0911 89.1772 76.3058 88.8802 76.4246 88.5147L77.6581 88.6107C77.4662 89.2594 77.1007 89.7939 76.5616 90.2142C76.0317 90.6254 75.3829 90.831 74.6154 90.831ZM72.491 86.3766H76.5068C76.452 85.6731 76.2464 85.1568 75.89 84.8279C75.5428 84.499 75.0997 84.3345 74.5606 84.3345C74.0032 84.3345 73.5418 84.5081 73.1763 84.8553C72.82 85.1934 72.5915 85.7005 72.491 86.3766ZM82.1642 90.831C81.4789 90.831 80.885 90.6756 80.3825 90.365C79.8891 90.0543 79.5053 89.6157 79.2312 89.0492C78.9662 88.4736 78.8338 87.802 78.8338 87.0345C78.8338 86.267 78.9662 85.6 79.2312 85.0335C79.5053 84.467 79.8845 84.0284 80.3688 83.7177C80.8622 83.3979 81.4424 83.238 82.1094 83.238C82.7399 83.238 83.2972 83.3888 83.7815 83.6903C84.2658 83.9827 84.6404 84.4121 84.9054 84.9787C85.1795 85.5452 85.3165 86.235 85.3165 87.0482V87.3908H80.0399C80.0855 88.1675 80.2911 88.7523 80.6566 89.1452C81.0312 89.5381 81.5338 89.7345 82.1642 89.7345C82.6394 89.7345 83.0277 89.6249 83.3292 89.4056C83.6399 89.1772 83.8546 88.8802 83.9734 88.5147L85.2069 88.6107C85.015 89.2594 84.6495 89.7939 84.1104 90.2142C83.5805 90.6254 82.9318 90.831 82.1642 90.831ZM80.0399 86.3766H84.0556C84.0008 85.6731 83.7952 85.1568 83.4389 84.8279C83.0917 84.499 82.6485 84.3345 82.1094 84.3345C81.552 84.3345 81.0906 84.5081 80.7251 84.8553C80.3688 85.1934 80.1404 85.7005 80.0399 86.3766ZM86.876 92.7224V83.4025H87.945L87.9587 84.5127C88.178 84.0923 88.4796 83.7771 88.8633 83.567C89.2562 83.3477 89.6994 83.238 90.1928 83.238C90.9146 83.238 91.5039 83.4162 91.9608 83.7726C92.4268 84.1289 92.7694 84.5949 92.9887 85.1705C93.2171 85.7462 93.3314 86.3675 93.3314 87.0345C93.3314 87.7015 93.2171 88.3228 92.9887 88.8985C92.7694 89.4741 92.4268 89.9401 91.9608 90.2965C91.5039 90.6528 90.9146 90.831 90.1928 90.831C89.7176 90.831 89.2836 90.7305 88.8907 90.5294C88.507 90.3284 88.2191 90.0543 88.0273 89.7071V92.7224H86.876ZM90.0831 89.7345C90.7136 89.7345 91.2115 89.497 91.577 89.0218C91.9425 88.5467 92.1253 87.8843 92.1253 87.0345C92.1253 86.1847 91.9425 85.5223 91.577 85.0472C91.2115 84.572 90.7136 84.3345 90.0831 84.3345C89.4527 84.3345 88.9501 84.5629 88.5755 85.0198C88.21 85.4675 88.0273 86.1391 88.0273 87.0345C88.0273 87.9208 88.21 88.5924 88.5755 89.0492C88.941 89.5061 89.4435 89.7345 90.0831 89.7345ZM94.5748 92.8046L95.2327 90.6665H94.5337V89.1178H96.0825V90.4746L95.2601 92.8046H94.5748ZM102.664 90.6665L100.416 83.4025H101.677L103.376 89.4604L105.103 83.4025H106.337L108.064 89.4604L109.777 83.4025H111.038L108.79 90.6665H107.337L105.72 85.1431L104.103 90.6665H102.664ZM112.306 90.6665V80.9355H113.458V84.5401C113.65 84.0923 113.937 83.7634 114.321 83.5533C114.705 83.3431 115.148 83.238 115.651 83.238C116.436 83.238 117.035 83.4939 117.446 84.0055C117.866 84.5172 118.077 85.1797 118.077 85.9929V90.6665H116.925V86.3218C116.925 84.9421 116.418 84.2522 115.404 84.2522C114.819 84.2522 114.349 84.4304 113.992 84.7868C113.636 85.1431 113.458 85.6594 113.458 86.3355V90.6665H112.306ZM122.947 90.831C122.28 90.831 121.695 90.6756 121.192 90.365C120.69 90.0543 120.301 89.6157 120.027 89.0492C119.753 88.4736 119.616 87.802 119.616 87.0345C119.616 86.2578 119.753 85.5863 120.027 85.0198C120.301 84.4533 120.69 84.0147 121.192 83.704C121.695 83.3934 122.28 83.238 122.947 83.238C123.614 83.238 124.194 83.3934 124.687 83.704C125.19 84.0147 125.578 84.4533 125.852 85.0198C126.126 85.5863 126.263 86.2578 126.263 87.0345C126.263 87.802 126.126 88.4736 125.852 89.0492C125.578 89.6157 125.19 90.0543 124.687 90.365C124.194 90.6756 123.614 90.831 122.947 90.831ZM122.947 89.7345C123.614 89.7345 124.13 89.497 124.495 89.0218C124.87 88.5376 125.057 87.8751 125.057 87.0345C125.057 86.1939 124.87 85.536 124.495 85.0609C124.13 84.5766 123.614 84.3345 122.947 84.3345C122.28 84.3345 121.759 84.5766 121.384 85.0609C121.01 85.536 120.822 86.1939 120.822 87.0345C120.822 87.8751 121.01 88.5376 121.384 89.0218C121.759 89.497 122.28 89.7345 122.947 89.7345ZM132.845 90.6665L130.598 83.4025H131.859L133.558 89.4604L135.285 83.4025H136.519L138.245 89.4604L139.959 83.4025H141.22L138.972 90.6665H137.519L135.902 85.1431L134.285 90.6665H132.845ZM145.098 90.831C144.413 90.831 143.819 90.6756 143.316 90.365C142.823 90.0543 142.439 89.6157 142.165 89.0492C141.9 88.4736 141.767 87.802 141.767 87.0345C141.767 86.267 141.9 85.6 142.165 85.0335C142.439 84.467 142.818 84.0284 143.302 83.7177C143.796 83.3979 144.376 83.238 145.043 83.238C145.674 83.238 146.231 83.3888 146.715 83.6903C147.199 83.9827 147.574 84.4121 147.839 84.9787C148.113 85.5452 148.25 86.235 148.25 87.0482V87.3908H142.974C143.019 88.1675 143.225 88.7523 143.59 89.1452C143.965 89.5381 144.467 89.7345 145.098 89.7345C145.573 89.7345 145.961 89.6249 146.263 89.4056C146.574 89.1772 146.788 88.8802 146.907 88.5147L148.141 88.6107C147.949 89.2594 147.583 89.7939 147.044 90.2142C146.514 90.6254 145.865 90.831 145.098 90.831ZM142.974 86.3766H146.989C146.934 85.6731 146.729 85.1568 146.373 84.8279C146.025 84.499 145.582 84.3345 145.043 84.3345C144.486 84.3345 144.024 84.5081 143.659 84.8553C143.302 85.1934 143.074 85.7005 142.974 86.3766ZM149.81 90.6665V83.4025H150.824L150.865 84.7457C151.112 83.8502 151.687 83.4025 152.592 83.4025H153.305V84.499H152.606C151.509 84.499 150.961 85.0929 150.961 86.2807V90.6665H149.81ZM157.291 90.831C156.606 90.831 156.012 90.6756 155.509 90.365C155.016 90.0543 154.632 89.6157 154.358 89.0492C154.093 88.4736 153.961 87.802 153.961 87.0345C153.961 86.267 154.093 85.6 154.358 85.0335C154.632 84.467 155.011 84.0284 155.496 83.7177C155.989 83.3979 156.569 83.238 157.236 83.238C157.867 83.238 158.424 83.3888 158.908 83.6903C159.393 83.9827 159.767 84.4121 160.032 84.9787C160.306 85.5452 160.443 86.235 160.443 87.0482V87.3908H155.167C155.212 88.1675 155.418 88.7523 155.783 89.1452C156.158 89.5381 156.661 89.7345 157.291 89.7345C157.766 89.7345 158.155 89.6249 158.456 89.4056C158.767 89.1772 158.981 88.8802 159.1 88.5147L160.334 88.6107C160.142 89.2594 159.776 89.7939 159.237 90.2142C158.707 90.6254 158.059 90.831 157.291 90.831ZM155.167 86.3766H159.183C159.128 85.6731 158.922 85.1568 158.566 84.8279C158.219 84.499 157.775 84.3345 157.236 84.3345C156.679 84.3345 156.218 84.5081 155.852 84.8553C155.496 85.1934 155.267 85.7005 155.167 86.3766ZM168.4 90.831C167.724 90.831 167.135 90.6756 166.632 90.365C166.139 90.0543 165.755 89.6157 165.481 89.0492C165.207 88.4736 165.07 87.802 165.07 87.0345C165.07 86.267 165.207 85.6 165.481 85.0335C165.755 84.467 166.139 84.0284 166.632 83.7177C167.135 83.3979 167.724 83.238 168.4 83.238C169.241 83.238 169.935 83.4573 170.483 83.8959C171.032 84.3253 171.365 84.9467 171.484 85.7599L170.278 85.8421C170.196 85.3578 169.986 84.9878 169.647 84.7319C169.309 84.467 168.894 84.3345 168.4 84.3345C167.733 84.3345 167.212 84.5766 166.838 85.0609C166.463 85.536 166.276 86.1939 166.276 87.0345C166.276 87.8751 166.463 88.5376 166.838 89.0218C167.212 89.497 167.733 89.7345 168.4 89.7345C168.894 89.7345 169.309 89.5975 169.647 89.3233C169.986 89.0492 170.196 88.6381 170.278 88.0898L171.484 88.1721C171.365 88.9761 171.032 89.6203 170.483 90.1046C169.935 90.5888 169.241 90.831 168.4 90.831ZM175.748 90.831C175.081 90.831 174.496 90.6756 173.994 90.365C173.491 90.0543 173.103 89.6157 172.829 89.0492C172.555 88.4736 172.418 87.802 172.418 87.0345C172.418 86.2578 172.555 85.5863 172.829 85.0198C173.103 84.4533 173.491 84.0147 173.994 83.704C174.496 83.3934 175.081 83.238 175.748 83.238C176.415 83.238 176.995 83.3934 177.489 83.704C177.991 84.0147 178.38 84.4533 178.654 85.0198C178.928 85.5863 179.065 86.2578 179.065 87.0345C179.065 87.802 178.928 88.4736 178.654 89.0492C178.38 89.6157 177.991 90.0543 177.489 90.365C176.995 90.6756 176.415 90.831 175.748 90.831ZM175.748 89.7345C176.415 89.7345 176.932 89.497 177.297 89.0218C177.672 88.5376 177.859 87.8751 177.859 87.0345C177.859 86.1939 177.672 85.536 177.297 85.0609C176.932 84.5766 176.415 84.3345 175.748 84.3345C175.081 84.3345 174.56 84.5766 174.186 85.0609C173.811 85.536 173.624 86.1939 173.624 87.0345C173.624 87.8751 173.811 88.5376 174.186 89.0218C174.56 89.497 175.081 89.7345 175.748 89.7345ZM180.634 90.6665V83.4025H181.689L181.717 84.7045C181.909 84.202 182.201 83.8319 182.594 83.5944C182.996 83.3568 183.448 83.238 183.951 83.238C184.508 83.238 184.965 83.3614 185.321 83.6081C185.687 83.8548 185.956 84.1883 186.13 84.6086C186.313 85.0198 186.404 85.4812 186.404 85.9929V90.6665H185.253V86.3218C185.253 85.6457 185.129 85.134 184.883 84.7868C184.645 84.4304 184.252 84.2522 183.704 84.2522C183.147 84.2522 182.685 84.4304 182.32 84.7868C181.963 85.134 181.785 85.6457 181.785 86.3218V90.6665H180.634ZM190.708 90.6665C190.05 90.6665 189.561 90.5157 189.241 90.2142C188.931 89.9127 188.775 89.4421 188.775 88.8025V84.4167H187.706V83.4025H188.775V81.703H189.927V83.4025H191.859V84.4167H189.927V88.7751C189.927 89.1041 190 89.3325 190.146 89.4604C190.292 89.5883 190.516 89.6523 190.818 89.6523H191.859V90.6665H190.708ZM196.039 90.831C195.354 90.831 194.76 90.6756 194.257 90.365C193.764 90.0543 193.38 89.6157 193.106 89.0492C192.841 88.4736 192.709 87.802 192.709 87.0345C192.709 86.267 192.841 85.6 193.106 85.0335C193.38 84.467 193.759 84.0284 194.244 83.7177C194.737 83.3979 195.317 83.238 195.984 83.238C196.615 83.238 197.172 83.3888 197.656 83.6903C198.141 83.9827 198.515 84.4121 198.78 84.9787C199.054 85.5452 199.191 86.235 199.191 87.0482V87.3908H193.915C193.96 88.1675 194.166 88.7523 194.531 89.1452C194.906 89.5381 195.409 89.7345 196.039 89.7345C196.514 89.7345 196.903 89.6249 197.204 89.4056C197.515 89.1772 197.729 88.8802 197.848 88.5147L199.082 88.6107C198.89 89.2594 198.524 89.7939 197.985 90.2142C197.455 90.6254 196.807 90.831 196.039 90.831ZM193.915 86.3766H197.93C197.876 85.6731 197.67 85.1568 197.314 84.8279C196.967 84.499 196.523 84.3345 195.984 84.3345C195.427 84.3345 194.965 84.5081 194.6 84.8553C194.244 85.1934 194.015 85.7005 193.915 86.3766ZM200.751 90.6665V83.4025H201.806L201.834 84.7045C202.025 84.202 202.318 83.8319 202.711 83.5944C203.113 83.3568 203.565 83.238 204.068 83.238C204.625 83.238 205.082 83.3614 205.438 83.6081C205.804 83.8548 206.073 84.1883 206.247 84.6086C206.43 85.0198 206.521 85.4812 206.521 85.9929V90.6665H205.37V86.3218C205.37 85.6457 205.246 85.134 205 84.7868C204.762 84.4304 204.369 84.2522 203.821 84.2522C203.264 84.2522 202.802 84.4304 202.437 84.7868C202.08 85.134 201.902 85.6457 201.902 86.3218V90.6665H200.751ZM210.825 90.6665C210.167 90.6665 209.678 90.5157 209.358 90.2142C209.048 89.9127 208.892 89.4421 208.892 88.8025V84.4167H207.823V83.4025H208.892V81.703H210.043V83.4025H211.976V84.4167H210.043V88.7751C210.043 89.1041 210.117 89.3325 210.263 89.4604C210.409 89.5883 210.633 89.6523 210.934 89.6523H211.976V90.6665H210.825ZM219.618 90.6665C218.96 90.6665 218.472 90.5157 218.152 90.2142C217.841 89.9127 217.686 89.4421 217.686 88.8025V84.4167H216.617V83.4025H217.686V81.703H218.837V83.4025H220.77V84.4167H218.837V88.7751C218.837 89.1041 218.91 89.3325 219.056 89.4604C219.203 89.5883 219.426 89.6523 219.728 89.6523H220.77V90.6665H219.618ZM224.949 90.831C224.282 90.831 223.698 90.6756 223.195 90.365C222.693 90.0543 222.304 89.6157 222.03 89.0492C221.756 88.4736 221.619 87.802 221.619 87.0345C221.619 86.2578 221.756 85.5863 222.03 85.0198C222.304 84.4533 222.693 84.0147 223.195 83.704C223.698 83.3934 224.282 83.238 224.949 83.238C225.616 83.238 226.197 83.3934 226.69 83.704C227.193 84.0147 227.581 84.4533 227.855 85.0198C228.129 85.5863 228.266 86.2578 228.266 87.0345C228.266 87.802 228.129 88.4736 227.855 89.0492C227.581 89.6157 227.193 90.0543 226.69 90.365C226.197 90.6756 225.616 90.831 224.949 90.831ZM224.949 89.7345C225.616 89.7345 226.133 89.497 226.498 89.0218C226.873 88.5376 227.06 87.8751 227.06 87.0345C227.06 86.1939 226.873 85.536 226.498 85.0609C226.133 84.5766 225.616 84.3345 224.949 84.3345C224.282 84.3345 223.762 84.5766 223.387 85.0609C223.012 85.536 222.825 86.1939 222.825 87.0345C222.825 87.8751 223.012 88.5376 223.387 89.0218C223.762 89.497 224.282 89.7345 224.949 89.7345ZM22.9763 118.887C22.1996 118.887 21.5372 118.695 20.989 118.311C20.4407 117.927 20.089 117.416 19.9336 116.776L21.1397 116.694C21.2494 117.032 21.4413 117.297 21.7154 117.489C21.9986 117.69 22.4189 117.79 22.9763 117.79C23.6342 117.79 24.1367 117.658 24.4839 117.393C24.8403 117.128 25.0184 116.73 25.0184 116.201V115.022C24.8448 115.406 24.5662 115.712 24.1824 115.94C23.7986 116.169 23.3692 116.283 22.8941 116.283C22.2819 116.283 21.7336 116.132 21.2494 115.83C20.7742 115.529 20.3996 115.113 20.1255 114.583C19.8605 114.053 19.728 113.446 19.728 112.76C19.728 112.066 19.8605 111.458 20.1255 110.938C20.3905 110.408 20.756 109.992 21.222 109.69C21.6971 109.389 22.2362 109.238 22.8392 109.238C23.3509 109.238 23.8078 109.357 24.2098 109.594C24.621 109.832 24.9088 110.152 25.0733 110.554V109.402H26.1697V116.173C26.1697 117.023 25.8865 117.685 25.32 118.16C24.7626 118.645 23.9814 118.887 22.9763 118.887ZM22.9489 115.186C23.5702 115.186 24.0682 114.972 24.4428 114.542C24.8174 114.104 25.0093 113.51 25.0184 112.76C25.0367 112.011 24.854 111.422 24.4702 110.992C24.0865 110.554 23.5793 110.334 22.9489 110.334C22.3093 110.334 21.8113 110.554 21.455 110.992C21.1078 111.422 20.9341 112.011 20.9341 112.76C20.9341 113.51 21.1123 114.104 21.4687 114.542C21.8342 114.972 22.3276 115.186 22.9489 115.186ZM28.3592 116.667V109.402H29.3734L29.4145 110.746C29.6612 109.85 30.2369 109.402 31.1414 109.402H31.8541V110.499H31.1552C30.0587 110.499 29.5105 111.093 29.5105 112.281V116.667H28.3592ZM34.9635 116.831C34.2143 116.831 33.6158 116.657 33.1681 116.31C32.7295 115.963 32.5102 115.479 32.5102 114.857C32.5102 114.236 32.6929 113.747 33.0584 113.391C33.4239 113.035 34.0041 112.783 34.799 112.637L37.3757 112.157C37.3757 110.942 36.8001 110.334 35.6488 110.334C35.1463 110.334 34.7488 110.449 34.4564 110.677C34.164 110.896 33.963 111.216 33.8533 111.637L32.6335 111.541C32.7706 110.855 33.0995 110.303 33.6204 109.882C34.1503 109.453 34.8265 109.238 35.6488 109.238C36.5808 109.238 37.2935 109.503 37.7869 110.033C38.2803 110.554 38.527 111.276 38.527 112.198V115.2C38.527 115.502 38.6549 115.652 38.9107 115.652H39.1986V116.667C39.0889 116.685 38.9427 116.694 38.76 116.694C38.3214 116.694 37.9925 116.607 37.7732 116.434C37.563 116.251 37.4351 115.954 37.3894 115.543C37.2067 115.917 36.8914 116.228 36.4437 116.475C35.996 116.712 35.5026 116.831 34.9635 116.831ZM35.0732 115.817C35.8041 115.817 36.3706 115.611 36.7727 115.2C37.1747 114.789 37.3757 114.282 37.3757 113.679V113.158L35.0183 113.596C34.5249 113.688 34.1823 113.829 33.9904 114.021C33.8077 114.204 33.7163 114.446 33.7163 114.748C33.7163 115.086 33.8351 115.351 34.0726 115.543C34.3193 115.725 34.6528 115.817 35.0732 115.817ZM40.1033 116.667V115.515L44.6947 110.417H40.2404V109.402H45.9557V110.554L41.3643 115.652H46.0653V116.667H40.1033ZM50.3628 116.831C49.6775 116.831 49.0836 116.676 48.5811 116.365C48.0877 116.054 47.7039 115.616 47.4298 115.049C47.1648 114.474 47.0323 113.802 47.0323 113.035C47.0323 112.267 47.1648 111.6 47.4298 111.033C47.7039 110.467 48.0831 110.028 48.5674 109.718C49.0608 109.398 49.641 109.238 50.308 109.238C50.9384 109.238 51.4958 109.389 51.9801 109.69C52.4643 109.983 52.839 110.412 53.1039 110.979C53.378 111.545 53.5151 112.235 53.5151 113.048V113.391H48.2384C48.2841 114.168 48.4897 114.752 48.8552 115.145C49.2298 115.538 49.7323 115.735 50.3628 115.735C50.8379 115.735 51.2263 115.625 51.5278 115.406C51.8384 115.177 52.0532 114.88 52.1719 114.515L53.4055 114.611C53.2136 115.259 52.8481 115.794 52.309 116.214C51.779 116.625 51.1303 116.831 50.3628 116.831ZM48.2384 112.377H52.2542C52.1994 111.673 51.9938 111.157 51.6374 110.828C51.2902 110.499 50.8471 110.334 50.308 110.334C49.7506 110.334 49.2892 110.508 48.9237 110.855C48.5674 111.193 48.3389 111.7 48.2384 112.377ZM60.5947 116.831C59.8455 116.831 59.247 116.657 58.7993 116.31C58.3607 115.963 58.1414 115.479 58.1414 114.857C58.1414 114.236 58.3241 113.747 58.6896 113.391C59.0551 113.035 59.6353 112.783 60.4303 112.637L63.0069 112.157C63.0069 110.942 62.4313 110.334 61.28 110.334C60.7775 110.334 60.38 110.449 60.0876 110.677C59.7952 110.896 59.5942 111.216 59.4846 111.637L58.2648 111.541C58.4018 110.855 58.7308 110.303 59.2516 109.882C59.7815 109.453 60.4577 109.238 61.28 109.238C62.212 109.238 62.9247 109.503 63.4181 110.033C63.9115 110.554 64.1582 111.276 64.1582 112.198V115.2C64.1582 115.502 64.2861 115.652 64.542 115.652H64.8298V116.667C64.7201 116.685 64.5739 116.694 64.3912 116.694C63.9526 116.694 63.6237 116.607 63.4044 116.434C63.1942 116.251 63.0663 115.954 63.0206 115.543C62.8379 115.917 62.5227 116.228 62.0749 116.475C61.6272 116.712 61.1338 116.831 60.5947 116.831ZM60.7044 115.817C61.4353 115.817 62.0018 115.611 62.4039 115.2C62.8059 114.789 63.0069 114.282 63.0069 113.679V113.158L60.6495 113.596C60.1561 113.688 59.8135 113.829 59.6216 114.021C59.4389 114.204 59.3475 114.446 59.3475 114.748C59.3475 115.086 59.4663 115.351 59.7039 115.543C59.9506 115.725 60.2841 115.817 60.7044 115.817ZM67.4752 116.667C67.0549 116.667 66.7122 116.557 66.4473 116.338C66.1823 116.118 66.0498 115.771 66.0498 115.296V106.935H67.2011V115.2C67.2011 115.502 67.3518 115.652 67.6534 115.652H68.2838V116.667H67.4752ZM70.9953 116.667C70.575 116.667 70.2323 116.557 69.9674 116.338C69.7024 116.118 69.5699 115.771 69.5699 115.296V106.935H70.7212V115.2C70.7212 115.502 70.8719 115.652 71.1735 115.652H71.8039V116.667H70.9953ZM79.2269 116.831C78.5965 116.831 78.0483 116.676 77.5823 116.365C77.1254 116.054 76.7736 115.616 76.5269 115.049C76.2802 114.483 76.1569 113.811 76.1569 113.035C76.1569 112.258 76.2802 111.586 76.5269 111.02C76.7736 110.453 77.1254 110.015 77.5823 109.704C78.0483 109.393 78.5965 109.238 79.2269 109.238C79.7112 109.238 80.1544 109.343 80.5564 109.553C80.9584 109.754 81.2599 110.042 81.461 110.417V106.935H82.6122V116.667H81.5706L81.5295 115.57C81.3285 115.963 81.0224 116.274 80.6112 116.502C80.2 116.721 79.7386 116.831 79.2269 116.831ZM79.4599 115.735C80.1087 115.735 80.6021 115.492 80.9401 115.008C81.2874 114.524 81.461 113.866 81.461 113.035C81.461 112.194 81.2874 111.536 80.9401 111.061C80.6021 110.577 80.1087 110.334 79.4599 110.334C78.8203 110.334 78.3087 110.577 77.9249 111.061C77.5503 111.536 77.363 112.194 77.363 113.035C77.363 113.866 77.5503 114.524 77.9249 115.008C78.3087 115.492 78.8203 115.735 79.4599 115.735ZM86.6275 116.831C85.8782 116.831 85.2797 116.657 84.832 116.31C84.3934 115.963 84.1741 115.479 84.1741 114.857C84.1741 114.236 84.3569 113.747 84.7224 113.391C85.0879 113.035 85.6681 112.783 86.463 112.637L89.0397 112.157C89.0397 110.942 88.464 110.334 87.3127 110.334C86.8102 110.334 86.4127 110.449 86.1204 110.677C85.828 110.896 85.6269 111.216 85.5173 111.637L84.2975 111.541C84.4346 110.855 84.7635 110.303 85.2843 109.882C85.8143 109.453 86.4904 109.238 87.3127 109.238C88.2447 109.238 88.9574 109.503 89.4508 110.033C89.9442 110.554 90.1909 111.276 90.1909 112.198V115.2C90.1909 115.502 90.3189 115.652 90.5747 115.652H90.8625V116.667C90.7529 116.685 90.6067 116.694 90.4239 116.694C89.9854 116.694 89.6564 116.607 89.4371 116.434C89.227 116.251 89.0991 115.954 89.0534 115.543C88.8706 115.917 88.5554 116.228 88.1077 116.475C87.66 116.712 87.1666 116.831 86.6275 116.831ZM86.7371 115.817C87.4681 115.817 88.0346 115.611 88.4366 115.2C88.8386 114.789 89.0397 114.282 89.0397 113.679V113.158L86.6823 113.596C86.1889 113.688 85.8462 113.829 85.6544 114.021C85.4716 114.204 85.3802 114.446 85.3802 114.748C85.3802 115.086 85.499 115.351 85.7366 115.543C85.9833 115.725 86.3168 115.817 86.7371 115.817ZM92.197 118.722V117.708H93.033C93.2889 117.708 93.4762 117.667 93.595 117.585C93.7229 117.512 93.8188 117.388 93.8828 117.215L94.1432 116.529H93.7457L91.0868 109.402H92.3478L94.5133 115.46L96.5828 109.402H97.8437L94.897 117.585C94.76 117.978 94.5544 118.265 94.2803 118.448C94.0061 118.631 93.627 118.722 93.1427 118.722H92.197ZM97.9745 118.805L98.6323 116.667H97.9334V115.118H99.4821V116.475L98.6598 118.805H97.9745ZM106.598 116.667L104.035 106.935H105.323L107.324 115.049L109.311 106.935H110.655L112.656 115.049L114.643 106.935H115.945L113.382 116.667H111.902L109.983 109.128L108.064 116.667H106.598ZM119.855 116.831C119.188 116.831 118.603 116.676 118.101 116.365C117.598 116.054 117.21 115.616 116.936 115.049C116.661 114.474 116.524 113.802 116.524 113.035C116.524 112.258 116.661 111.586 116.936 111.02C117.21 110.453 117.598 110.015 118.101 109.704C118.603 109.393 119.188 109.238 119.855 109.238C120.522 109.238 121.102 109.393 121.595 109.704C122.098 110.015 122.486 110.453 122.76 111.02C123.035 111.586 123.172 112.258 123.172 113.035C123.172 113.802 123.035 114.474 122.76 115.049C122.486 115.616 122.098 116.054 121.595 116.365C121.102 116.676 120.522 116.831 119.855 116.831ZM119.855 115.735C120.522 115.735 121.038 115.497 121.404 115.022C121.778 114.538 121.966 113.875 121.966 113.035C121.966 112.194 121.778 111.536 121.404 111.061C121.038 110.577 120.522 110.334 119.855 110.334C119.188 110.334 118.667 110.577 118.292 111.061C117.918 111.536 117.73 112.194 117.73 113.035C117.73 113.875 117.918 114.538 118.292 115.022C118.667 115.497 119.188 115.735 119.855 115.735ZM127.578 116.831C126.911 116.831 126.326 116.676 125.823 116.365C125.321 116.054 124.932 115.616 124.658 115.049C124.384 114.474 124.247 113.802 124.247 113.035C124.247 112.258 124.384 111.586 124.658 111.02C124.932 110.453 125.321 110.015 125.823 109.704C126.326 109.393 126.911 109.238 127.578 109.238C128.245 109.238 128.825 109.393 129.318 109.704C129.821 110.015 130.209 110.453 130.483 111.02C130.757 111.586 130.894 112.258 130.894 113.035C130.894 113.802 130.757 114.474 130.483 115.049C130.209 115.616 129.821 116.054 129.318 116.365C128.825 116.676 128.245 116.831 127.578 116.831ZM127.578 115.735C128.245 115.735 128.761 115.497 129.126 115.022C129.501 114.538 129.688 113.875 129.688 113.035C129.688 112.194 129.501 111.536 129.126 111.061C128.761 110.577 128.245 110.334 127.578 110.334C126.911 110.334 126.39 110.577 126.015 111.061C125.641 111.536 125.453 112.194 125.453 113.035C125.453 113.875 125.641 114.538 126.015 115.022C126.39 115.497 126.911 115.735 127.578 115.735ZM133.956 116.667C133.535 116.667 133.193 116.557 132.928 116.338C132.663 116.118 132.53 115.771 132.53 115.296V106.935H133.682V115.2C133.682 115.502 133.832 115.652 134.134 115.652H134.764V116.667H133.956ZM137.476 116.667C137.056 116.667 136.713 116.557 136.448 116.338C136.183 116.118 136.05 115.771 136.05 115.296V106.935H137.202V115.2C137.202 115.502 137.352 115.652 137.654 115.652H138.284V116.667H137.476ZM139.685 118.722V117.708H140.521C140.777 117.708 140.964 117.667 141.083 117.585C141.211 117.512 141.307 117.388 141.371 117.215L141.631 116.529H141.234L138.575 109.402H139.836L142.001 115.46L144.071 109.402H145.332L142.385 117.585C142.248 117.978 142.042 118.265 141.768 118.448C141.494 118.631 141.115 118.722 140.631 118.722H139.685ZM152.734 116.831C152.104 116.831 151.556 116.676 151.09 116.365C150.633 116.054 150.281 115.616 150.034 115.049C149.788 114.483 149.664 113.811 149.664 113.035C149.664 112.258 149.788 111.586 150.034 111.02C150.281 110.453 150.633 110.015 151.09 109.704C151.556 109.393 152.104 109.238 152.734 109.238C153.219 109.238 153.662 109.343 154.064 109.553C154.466 109.754 154.767 110.042 154.968 110.417V106.935H156.12V116.667H155.078L155.037 115.57C154.836 115.963 154.53 116.274 154.119 116.502C153.707 116.721 153.246 116.831 152.734 116.831ZM152.967 115.735C153.616 115.735 154.109 115.492 154.448 115.008C154.795 114.524 154.968 113.866 154.968 113.035C154.968 112.194 154.795 111.536 154.448 111.061C154.109 110.577 153.616 110.334 152.967 110.334C152.328 110.334 151.816 110.577 151.432 111.061C151.058 111.536 150.87 112.194 150.87 113.035C150.87 113.866 151.058 114.524 151.432 115.008C151.816 115.492 152.328 115.735 152.967 115.735ZM158.443 116.667V109.402H159.457L159.498 110.746C159.745 109.85 160.32 109.402 161.225 109.402H161.938V110.499H161.239C160.142 110.499 159.594 111.093 159.594 112.281V116.667H158.443ZM165.924 116.831C165.239 116.831 164.645 116.676 164.142 116.365C163.649 116.054 163.265 115.616 162.991 115.049C162.726 114.474 162.594 113.802 162.594 113.035C162.594 112.267 162.726 111.6 162.991 111.033C163.265 110.467 163.644 110.028 164.129 109.718C164.622 109.398 165.202 109.238 165.869 109.238C166.5 109.238 167.057 109.389 167.541 109.69C168.026 109.983 168.4 110.412 168.665 110.979C168.939 111.545 169.076 112.235 169.076 113.048V113.391H163.8C163.845 114.168 164.051 114.752 164.416 115.145C164.791 115.538 165.294 115.735 165.924 115.735C166.399 115.735 166.788 115.625 167.089 115.406C167.4 115.177 167.614 114.88 167.733 114.515L168.967 114.611C168.775 115.259 168.409 115.794 167.87 116.214C167.34 116.625 166.692 116.831 165.924 116.831ZM163.8 112.377H167.815C167.761 111.673 167.555 111.157 167.199 110.828C166.851 110.499 166.408 110.334 165.869 110.334C165.312 110.334 164.85 110.508 164.485 110.855C164.129 111.193 163.9 111.7 163.8 112.377ZM172.783 116.831C172.034 116.831 171.435 116.657 170.988 116.31C170.549 115.963 170.33 115.479 170.33 114.857C170.33 114.236 170.513 113.747 170.878 113.391C171.244 113.035 171.824 112.783 172.619 112.637L175.195 112.157C175.195 110.942 174.62 110.334 173.468 110.334C172.966 110.334 172.568 110.449 172.276 110.677C171.984 110.896 171.783 111.216 171.673 111.637L170.453 111.541C170.59 110.855 170.919 110.303 171.44 109.882C171.97 109.453 172.646 109.238 173.468 109.238C174.4 109.238 175.113 109.503 175.607 110.033C176.1 110.554 176.347 111.276 176.347 112.198V115.2C176.347 115.502 176.475 115.652 176.73 115.652H177.018V116.667C176.909 116.685 176.762 116.694 176.58 116.694C176.141 116.694 175.812 116.607 175.593 116.434C175.383 116.251 175.255 115.954 175.209 115.543C175.026 115.917 174.711 116.228 174.263 116.475C173.816 116.712 173.322 116.831 172.783 116.831ZM172.893 115.817C173.624 115.817 174.19 115.611 174.592 115.2C174.994 114.789 175.195 114.282 175.195 113.679V113.158L172.838 113.596C172.345 113.688 172.002 113.829 171.81 114.021C171.627 114.204 171.536 114.446 171.536 114.748C171.536 115.086 171.655 115.351 171.892 115.543C172.139 115.725 172.472 115.817 172.893 115.817ZM178.319 116.667V109.402H179.374L179.401 110.677C179.584 110.229 179.854 109.878 180.21 109.622C180.566 109.366 180.977 109.238 181.443 109.238C181.992 109.238 182.453 109.371 182.828 109.635C183.202 109.9 183.467 110.275 183.623 110.759C183.778 110.275 184.038 109.9 184.404 109.635C184.769 109.371 185.217 109.238 185.747 109.238C186.524 109.238 187.118 109.48 187.529 109.964C187.94 110.44 188.145 111.116 188.145 111.993V116.667H186.994V112.24C186.994 110.915 186.487 110.252 185.473 110.252C184.97 110.252 184.564 110.435 184.253 110.8C183.952 111.166 183.801 111.673 183.801 112.322V116.667H182.65V112.322C182.65 111.673 182.535 111.166 182.307 110.8C182.078 110.435 181.695 110.252 181.156 110.252C180.653 110.252 180.246 110.435 179.936 110.8C179.625 111.166 179.47 111.673 179.47 112.322V116.667H178.319ZM193.014 116.831C192.329 116.831 191.735 116.676 191.232 116.365C190.739 116.054 190.355 115.616 190.081 115.049C189.816 114.474 189.684 113.802 189.684 113.035C189.684 112.267 189.816 111.6 190.081 111.033C190.355 110.467 190.734 110.028 191.219 109.718C191.712 109.398 192.292 109.238 192.959 109.238C193.59 109.238 194.147 109.389 194.631 109.69C195.116 109.983 195.49 110.412 195.755 110.979C196.029 111.545 196.167 112.235 196.167 113.048V113.391H190.89C190.936 114.168 191.141 114.752 191.507 115.145C191.881 115.538 192.384 115.735 193.014 115.735C193.489 115.735 193.878 115.625 194.179 115.406C194.49 115.177 194.705 114.88 194.823 114.515L196.057 114.611C195.865 115.259 195.499 115.794 194.96 116.214C194.43 116.625 193.782 116.831 193.014 116.831ZM190.89 112.377H194.906C194.851 111.673 194.645 111.157 194.289 110.828C193.942 110.499 193.498 110.334 192.959 110.334C192.402 110.334 191.941 110.508 191.575 110.855C191.219 111.193 190.99 111.7 190.89 112.377ZM200.303 116.831C199.672 116.831 199.124 116.676 198.658 116.365C198.201 116.054 197.849 115.616 197.603 115.049C197.356 114.483 197.233 113.811 197.233 113.035C197.233 112.258 197.356 111.586 197.603 111.02C197.849 110.453 198.201 110.015 198.658 109.704C199.124 109.393 199.672 109.238 200.303 109.238C200.787 109.238 201.23 109.343 201.632 109.553C202.034 109.754 202.336 110.042 202.537 110.417V106.935H203.688V116.667H202.646L202.605 115.57C202.404 115.963 202.098 116.274 201.687 116.502C201.276 116.721 200.814 116.831 200.303 116.831ZM200.536 115.735C201.184 115.735 201.678 115.492 202.016 115.008C202.363 114.524 202.537 113.866 202.537 113.035C202.537 112.194 202.363 111.536 202.016 111.061C201.678 110.577 201.184 110.334 200.536 110.334C199.896 110.334 199.384 110.577 199.001 111.061C198.626 111.536 198.439 112.194 198.439 113.035C198.439 113.866 198.626 114.524 199.001 115.008C199.384 115.492 199.896 115.735 200.536 115.735ZM212.141 116.831C211.474 116.831 210.889 116.676 210.386 116.365C209.884 116.054 209.495 115.616 209.221 115.049C208.947 114.474 208.81 113.802 208.81 113.035C208.81 112.258 208.947 111.586 209.221 111.02C209.495 110.453 209.884 110.015 210.386 109.704C210.889 109.393 211.474 109.238 212.141 109.238C212.808 109.238 213.388 109.393 213.881 109.704C214.384 110.015 214.772 110.453 215.046 111.02C215.32 111.586 215.457 112.258 215.457 113.035C215.457 113.802 215.32 114.474 215.046 115.049C214.772 115.616 214.384 116.054 213.881 116.365C213.388 116.676 212.808 116.831 212.141 116.831ZM212.141 115.735C212.808 115.735 213.324 115.497 213.689 115.022C214.064 114.538 214.251 113.875 214.251 113.035C214.251 112.194 214.064 111.536 213.689 111.061C213.324 110.577 212.808 110.334 212.141 110.334C211.474 110.334 210.953 110.577 210.578 111.061C210.204 111.536 210.016 112.194 210.016 113.035C210.016 113.875 210.204 114.538 210.578 115.022C210.953 115.497 211.474 115.735 212.141 115.735ZM217.458 116.667V110.417H216.471V109.402H217.458V108.745C217.458 108.206 217.613 107.772 217.924 107.443C218.235 107.105 218.719 106.935 219.377 106.935H220.528V107.95H219.459C219.176 107.95 218.961 108.027 218.815 108.183C218.678 108.338 218.609 108.553 218.609 108.827V109.402H220.432V110.417H218.609V116.667H217.458ZM22.1814 142.831C21.4321 142.831 20.8336 142.657 20.3859 142.31C19.9473 141.963 19.728 141.479 19.728 140.857C19.728 140.236 19.9108 139.747 20.2763 139.391C20.6418 139.035 21.222 138.783 22.0169 138.637L24.5936 138.157C24.5936 136.942 24.0179 136.334 22.8666 136.334C22.3641 136.334 21.9666 136.449 21.6743 136.677C21.3819 136.896 21.1809 137.216 21.0712 137.637L19.8514 137.541C19.9885 136.855 20.3174 136.303 20.8382 135.882C21.3682 135.453 22.0443 135.238 22.8666 135.238C23.7986 135.238 24.5113 135.503 25.0047 136.033C25.4981 136.554 25.7448 137.276 25.7448 138.198V141.2C25.7448 141.502 25.8728 141.652 26.1286 141.652H26.4164V142.667C26.3068 142.685 26.1606 142.694 25.9778 142.694C25.5393 142.694 25.2103 142.607 24.991 142.434C24.7809 142.251 24.653 141.954 24.6073 141.543C24.4245 141.917 24.1093 142.228 23.6616 142.475C23.2139 142.712 22.7205 142.831 22.1814 142.831ZM22.291 141.817C23.022 141.817 23.5885 141.611 23.9905 141.2C24.3925 140.789 24.5936 140.282 24.5936 139.679V139.158L22.2362 139.596C21.7428 139.688 21.4001 139.829 21.2083 140.021C21.0255 140.204 20.9341 140.446 20.9341 140.748C20.9341 141.086 21.0529 141.351 21.2905 141.543C21.5372 141.725 21.8707 141.817 22.291 141.817ZM30.2666 142.831C29.6362 142.831 29.088 142.676 28.622 142.365C28.1651 142.054 27.8133 141.616 27.5666 141.049C27.3199 140.483 27.1966 139.811 27.1966 139.035C27.1966 138.258 27.3199 137.586 27.5666 137.02C27.8133 136.453 28.1651 136.015 28.622 135.704C29.088 135.393 29.6362 135.238 30.2666 135.238C30.7509 135.238 31.1941 135.343 31.5961 135.553C31.9981 135.754 32.2996 136.042 32.5007 136.417V132.935H33.6519V142.667H32.6103L32.5692 141.57C32.3682 141.963 32.0621 142.274 31.6509 142.502C31.2397 142.721 30.7783 142.831 30.2666 142.831ZM30.4996 141.735C31.1484 141.735 31.6418 141.492 31.9798 141.008C32.3271 140.524 32.5007 139.866 32.5007 139.035C32.5007 138.194 32.3271 137.536 31.9798 137.061C31.6418 136.577 31.1484 136.334 30.4996 136.334C29.86 136.334 29.3484 136.577 28.9646 137.061C28.59 137.536 28.4027 138.194 28.4027 139.035C28.4027 139.866 28.59 140.524 28.9646 141.008C29.3484 141.492 29.86 141.735 30.4996 141.735ZM37.5575 142.667L34.9123 135.402H36.1732L38.2839 141.488L40.3946 135.402H41.6555L39.0103 142.667H37.5575ZM45.531 142.831C44.8457 142.831 44.2518 142.676 43.7493 142.365C43.2559 142.054 42.8721 141.616 42.598 141.049C42.333 140.474 42.2005 139.802 42.2005 139.035C42.2005 138.267 42.333 137.6 42.598 137.033C42.8721 136.467 43.2513 136.028 43.7356 135.718C44.229 135.398 44.8092 135.238 45.4762 135.238C46.1066 135.238 46.664 135.389 47.1483 135.69C47.6325 135.983 48.0072 136.412 48.2721 136.979C48.5463 137.545 48.6833 138.235 48.6833 139.048V139.391H43.4066C43.4523 140.168 43.6579 140.752 44.0234 141.145C44.398 141.538 44.9005 141.735 45.531 141.735C46.0061 141.735 46.3945 141.625 46.696 141.406C47.0067 141.177 47.2214 140.88 47.3402 140.515L48.5737 140.611C48.3818 141.259 48.0163 141.794 47.4772 142.214C46.9473 142.625 46.2985 142.831 45.531 142.831ZM43.4066 138.377H47.4224C47.3676 137.673 47.162 137.157 46.8056 136.828C46.4584 136.499 46.0153 136.334 45.4762 136.334C44.9188 136.334 44.4574 136.508 44.0919 136.855C43.7356 137.193 43.5071 137.7 43.4066 138.377ZM50.2428 142.667V135.402H51.2981L51.3255 136.705C51.5174 136.202 51.8098 135.832 52.2027 135.594C52.6047 135.357 53.057 135.238 53.5595 135.238C54.1169 135.238 54.5738 135.361 54.9301 135.608C55.2956 135.855 55.5651 136.188 55.7387 136.609C55.9215 137.02 56.0129 137.481 56.0129 137.993V142.667H54.8616V138.322C54.8616 137.646 54.7382 137.134 54.4915 136.787C54.254 136.43 53.8611 136.252 53.3128 136.252C52.7555 136.252 52.294 136.43 51.9286 136.787C51.5722 137.134 51.394 137.646 51.394 138.322V142.667H50.2428ZM60.3166 142.667C59.6588 142.667 59.1699 142.516 58.8501 142.214C58.5395 141.913 58.3841 141.442 58.3841 140.803V136.417H57.3151V135.402H58.3841V133.703H59.5354V135.402H61.4679V136.417H59.5354V140.775C59.5354 141.104 59.6085 141.332 59.7547 141.46C59.9009 141.588 60.1248 141.652 60.4263 141.652H61.4679V142.667H60.3166ZM65.3294 142.831C64.6076 142.831 64.0274 142.589 63.5888 142.105C63.1593 141.611 62.9446 140.935 62.9446 140.076V135.402H64.0959V139.761C64.0959 140.464 64.2192 140.985 64.4659 141.323C64.7218 141.652 65.1101 141.817 65.6309 141.817C66.2066 141.817 66.6588 141.634 66.9878 141.269C67.3167 140.894 67.4812 140.382 67.4812 139.733V135.402H68.6325V142.667H67.536V141.488C67.134 142.383 66.3984 142.831 65.3294 142.831ZM70.828 142.667V135.402H71.8423L71.8834 136.746C72.1301 135.85 72.7057 135.402 73.6103 135.402H74.323V136.499H73.624C72.5275 136.499 71.9793 137.093 71.9793 138.281V142.667H70.828ZM78.3095 142.831C77.6242 142.831 77.0303 142.676 76.5278 142.365C76.0344 142.054 75.6506 141.616 75.3765 141.049C75.1115 140.474 74.979 139.802 74.979 139.035C74.979 138.267 75.1115 137.6 75.3765 137.033C75.6506 136.467 76.0298 136.028 76.5141 135.718C77.0075 135.398 77.5877 135.238 78.2547 135.238C78.8852 135.238 79.4425 135.389 79.9268 135.69C80.4111 135.983 80.7857 136.412 81.0507 136.979C81.3248 137.545 81.4618 138.235 81.4618 139.048V139.391H76.1851C76.2308 140.168 76.4364 140.752 76.8019 141.145C77.1765 141.538 77.6791 141.735 78.3095 141.735C78.7846 141.735 79.173 141.625 79.4745 141.406C79.7852 141.177 79.9999 140.88 80.1187 140.515L81.3522 140.611C81.1603 141.259 80.7948 141.794 80.2557 142.214C79.7258 142.625 79.077 142.831 78.3095 142.831ZM76.1851 138.377H80.2009C80.1461 137.673 79.9405 137.157 79.5841 136.828C79.2369 136.499 78.7938 136.334 78.2547 136.334C77.6973 136.334 77.2359 136.508 76.8704 136.855C76.5141 137.193 76.2856 137.7 76.1851 138.377ZM82.6617 142.667V141.118H84.2104V142.667H82.6617ZM93.2997 142.886C92.3768 142.886 91.5819 142.685 90.9149 142.283C90.257 141.872 89.7499 141.287 89.3935 140.528C89.0372 139.77 88.859 138.865 88.859 137.815C88.859 136.764 89.0372 135.859 89.3935 135.101C89.7499 134.333 90.257 133.744 90.9149 133.333C91.5819 132.922 92.3768 132.716 93.2997 132.716C94.2317 132.716 95.0266 132.922 95.6845 133.333C96.3515 133.744 96.8631 134.333 97.2195 135.101C97.5758 135.859 97.754 136.764 97.754 137.815C97.754 138.865 97.5758 139.77 97.2195 140.528C96.8631 141.287 96.3515 141.872 95.6845 142.283C95.0266 142.685 94.2317 142.886 93.2997 142.886ZM93.2997 141.735C94.3047 141.735 95.0905 141.392 95.657 140.707C96.2327 140.012 96.5205 139.048 96.5205 137.815C96.5205 136.581 96.2327 135.617 95.657 134.923C95.0905 134.219 94.3047 133.867 93.2997 133.867C92.3037 133.867 91.5179 134.219 90.9423 134.923C90.3758 135.617 90.0925 136.581 90.0925 137.815C90.0925 139.048 90.3758 140.012 90.9423 140.707C91.5179 141.392 92.3037 141.735 93.2997 141.735ZM99.4708 142.667V135.402H100.526L100.553 136.705C100.745 136.202 101.038 135.832 101.431 135.594C101.833 135.357 102.285 135.238 102.788 135.238C103.345 135.238 103.802 135.361 104.158 135.608C104.524 135.855 104.793 136.188 104.967 136.609C105.149 137.02 105.241 137.481 105.241 137.993V142.667H104.09V138.322C104.09 137.646 103.966 137.134 103.72 136.787C103.482 136.43 103.089 136.252 102.541 136.252C101.983 136.252 101.522 136.43 101.157 136.787C100.8 137.134 100.622 137.646 100.622 138.322V142.667H99.4708ZM110.111 142.831C109.426 142.831 108.832 142.676 108.329 142.365C107.836 142.054 107.452 141.616 107.178 141.049C106.913 140.474 106.78 139.802 106.78 139.035C106.78 138.267 106.913 137.6 107.178 137.033C107.452 136.467 107.831 136.028 108.316 135.718C108.809 135.398 109.389 135.238 110.056 135.238C110.687 135.238 111.244 135.389 111.728 135.69C112.212 135.983 112.587 136.412 112.852 136.979C113.126 137.545 113.263 138.235 113.263 139.048V139.391H107.987C108.032 140.168 108.238 140.752 108.603 141.145C108.978 141.538 109.48 141.735 110.111 141.735C110.586 141.735 110.974 141.625 111.276 141.406C111.587 141.177 111.801 140.88 111.92 140.515L113.154 140.611C112.962 141.259 112.596 141.794 112.057 142.214C111.527 142.625 110.878 142.831 110.111 142.831ZM107.987 138.377H112.002C111.948 137.673 111.742 137.157 111.386 136.828C111.038 136.499 110.595 136.334 110.056 136.334C109.499 136.334 109.037 136.508 108.672 136.855C108.316 137.193 108.087 137.7 107.987 138.377ZM121.028 142.831C120.06 142.831 119.306 142.603 118.767 142.146C118.237 141.68 117.944 141.09 117.89 140.378L119.096 140.295C119.169 140.734 119.356 141.086 119.658 141.351C119.959 141.607 120.416 141.735 121.028 141.735C121.522 141.735 121.914 141.657 122.207 141.502C122.508 141.337 122.659 141.077 122.659 140.72C122.659 140.528 122.613 140.369 122.522 140.241C122.431 140.113 122.253 140.003 121.988 139.912C121.723 139.811 121.33 139.715 120.809 139.624C120.114 139.496 119.566 139.341 119.164 139.158C118.771 138.966 118.488 138.733 118.314 138.459C118.15 138.176 118.068 137.842 118.068 137.458C118.068 136.81 118.305 136.28 118.78 135.868C119.256 135.448 119.932 135.238 120.809 135.238C121.403 135.238 121.901 135.348 122.303 135.567C122.714 135.777 123.034 136.06 123.262 136.417C123.5 136.764 123.655 137.148 123.728 137.568L122.522 137.65C122.44 137.266 122.253 136.951 121.96 136.705C121.677 136.458 121.289 136.334 120.795 136.334C120.274 136.334 119.891 136.435 119.644 136.636C119.397 136.837 119.274 137.093 119.274 137.404C119.274 137.76 119.406 138.025 119.671 138.198C119.936 138.363 120.389 138.5 121.028 138.61C121.768 138.738 122.344 138.893 122.755 139.076C123.166 139.258 123.454 139.482 123.619 139.747C123.783 140.012 123.865 140.337 123.865 140.72C123.865 141.168 123.737 141.552 123.481 141.872C123.235 142.182 122.897 142.42 122.467 142.584C122.047 142.749 121.567 142.831 121.028 142.831ZM127.821 142.831C127.1 142.831 126.519 142.589 126.081 142.105C125.651 141.611 125.437 140.935 125.437 140.076V135.402H126.588V139.761C126.588 140.464 126.711 140.985 126.958 141.323C127.214 141.652 127.602 141.817 128.123 141.817C128.699 141.817 129.151 141.634 129.48 141.269C129.809 140.894 129.973 140.382 129.973 139.733V135.402H131.124V142.667H130.028V141.488C129.626 142.383 128.89 142.831 127.821 142.831ZM133.32 142.667V135.402H134.375L134.403 136.705C134.595 136.202 134.887 135.832 135.28 135.594C135.682 135.357 136.134 135.238 136.637 135.238C137.194 135.238 137.651 135.361 138.007 135.608C138.373 135.855 138.642 136.188 138.816 136.609C138.999 137.02 139.09 137.481 139.09 137.993V142.667H137.939V138.322C137.939 137.646 137.815 137.134 137.569 136.787C137.331 136.43 136.938 136.252 136.39 136.252C135.833 136.252 135.371 136.43 135.006 136.787C134.649 137.134 134.471 137.646 134.471 138.322V142.667H133.32ZM141.016 142.667V135.402H142.071L142.099 136.705C142.291 136.202 142.583 135.832 142.976 135.594C143.378 135.357 143.83 135.238 144.333 135.238C144.89 135.238 145.347 135.361 145.703 135.608C146.069 135.855 146.338 136.188 146.512 136.609C146.695 137.02 146.786 137.481 146.786 137.993V142.667H145.635V138.322C145.635 137.646 145.512 137.134 145.265 136.787C145.027 136.43 144.634 136.252 144.086 136.252C143.529 136.252 143.067 136.43 142.702 136.787C142.346 137.134 142.167 137.646 142.167 138.322V142.667H141.016ZM148.827 144.722V143.708H149.663C149.918 143.708 150.106 143.667 150.225 143.585C150.352 143.512 150.448 143.388 150.512 143.215L150.773 142.529H150.375L147.716 135.402H148.977L151.143 141.46L153.212 135.402H154.473L151.527 143.585C151.39 143.978 151.184 144.265 150.91 144.448C150.636 144.631 150.257 144.722 149.772 144.722H148.827ZM159.299 142.667V135.402H160.355L160.382 136.677C160.565 136.229 160.834 135.878 161.191 135.622C161.547 135.366 161.958 135.238 162.424 135.238C162.972 135.238 163.434 135.371 163.808 135.635C164.183 135.9 164.448 136.275 164.603 136.759C164.759 136.275 165.019 135.9 165.385 135.635C165.75 135.371 166.198 135.238 166.728 135.238C167.504 135.238 168.098 135.48 168.509 135.964C168.921 136.44 169.126 137.116 169.126 137.993V142.667H167.975V138.24C167.975 136.915 167.468 136.252 166.454 136.252C165.951 136.252 165.544 136.435 165.234 136.8C164.932 137.166 164.781 137.673 164.781 138.322V142.667H163.63V138.322C163.63 137.673 163.516 137.166 163.288 136.8C163.059 136.435 162.675 136.252 162.136 136.252C161.634 136.252 161.227 136.435 160.916 136.8C160.606 137.166 160.45 137.673 160.45 138.322V142.667H159.299ZM173.995 142.831C173.328 142.831 172.743 142.676 172.241 142.365C171.738 142.054 171.35 141.616 171.076 141.049C170.801 140.474 170.664 139.802 170.664 139.035C170.664 138.258 170.801 137.586 171.076 137.02C171.35 136.453 171.738 136.015 172.241 135.704C172.743 135.393 173.328 135.238 173.995 135.238C174.662 135.238 175.242 135.393 175.736 135.704C176.238 136.015 176.626 136.453 176.901 137.02C177.175 137.586 177.312 138.258 177.312 139.035C177.312 139.802 177.175 140.474 176.901 141.049C176.626 141.616 176.238 142.054 175.736 142.365C175.242 142.676 174.662 142.831 173.995 142.831ZM173.995 141.735C174.662 141.735 175.178 141.497 175.544 141.022C175.918 140.538 176.106 139.875 176.106 139.035C176.106 138.194 175.918 137.536 175.544 137.061C175.178 136.577 174.662 136.334 173.995 136.334C173.328 136.334 172.807 136.577 172.432 137.061C172.058 137.536 171.871 138.194 171.871 139.035C171.871 139.875 172.058 140.538 172.432 141.022C172.807 141.497 173.328 141.735 173.995 141.735ZM178.881 142.667V135.402H179.895L179.936 136.746C180.183 135.85 180.758 135.402 181.663 135.402H182.376V136.499H181.677C180.58 136.499 180.032 137.093 180.032 138.281V142.667H178.881ZM183.525 142.667V135.402H184.58L184.608 136.705C184.8 136.202 185.092 135.832 185.485 135.594C185.887 135.357 186.339 135.238 186.842 135.238C187.399 135.238 187.856 135.361 188.212 135.608C188.578 135.855 188.847 136.188 189.021 136.609C189.204 137.02 189.295 137.481 189.295 137.993V142.667H188.144V138.322C188.144 137.646 188.021 137.134 187.774 136.787C187.536 136.43 187.143 136.252 186.595 136.252C186.038 136.252 185.576 136.43 185.211 136.787C184.855 137.134 184.676 137.646 184.676 138.322V142.667H183.525ZM191.355 142.667V135.402H192.506V142.667H191.355ZM191.328 134.265V132.922H192.534V134.265H191.328ZM194.835 142.667V135.402H195.89L195.918 136.705C196.11 136.202 196.402 135.832 196.795 135.594C197.197 135.357 197.649 135.238 198.152 135.238C198.709 135.238 199.166 135.361 199.522 135.608C199.888 135.855 200.157 136.188 200.331 136.609C200.514 137.02 200.605 137.481 200.605 137.993V142.667H199.454V138.322C199.454 137.646 199.33 137.134 199.084 136.787C198.846 136.43 198.453 136.252 197.905 136.252C197.348 136.252 196.886 136.43 196.521 136.787C196.164 137.134 195.986 137.646 195.986 138.322V142.667H194.835ZM205.393 144.887C204.616 144.887 203.954 144.695 203.406 144.311C202.857 143.927 202.506 143.416 202.35 142.776L203.556 142.694C203.666 143.032 203.858 143.297 204.132 143.489C204.415 143.69 204.836 143.79 205.393 143.79C206.051 143.79 206.553 143.658 206.901 143.393C207.257 143.128 207.435 142.73 207.435 142.201V141.022C207.261 141.406 206.983 141.712 206.599 141.94C206.215 142.169 205.786 142.283 205.311 142.283C204.698 142.283 204.15 142.132 203.666 141.83C203.191 141.529 202.816 141.113 202.542 140.583C202.277 140.053 202.145 139.446 202.145 138.76C202.145 138.066 202.277 137.458 202.542 136.938C202.807 136.408 203.173 135.992 203.639 135.69C204.114 135.389 204.653 135.238 205.256 135.238C205.768 135.238 206.224 135.357 206.626 135.594C207.038 135.832 207.325 136.152 207.49 136.554V135.402H208.586V142.173C208.586 143.023 208.303 143.685 207.737 144.16C207.179 144.645 206.398 144.887 205.393 144.887ZM205.365 141.186C205.987 141.186 206.485 140.972 206.859 140.542C207.234 140.104 207.426 139.51 207.435 138.76C207.453 138.011 207.271 137.422 206.887 136.992C206.503 136.554 205.996 136.334 205.365 136.334C204.726 136.334 204.228 136.554 203.872 136.992C203.524 137.422 203.351 138.011 203.351 138.76C203.351 139.51 203.529 140.104 203.885 140.542C204.251 140.972 204.744 141.186 205.365 141.186ZM210.324 144.805L210.981 142.667H210.282V141.118H211.831V142.475L211.009 144.805H210.324ZM216.959 142.667V132.935H218.111V136.54C218.303 136.092 218.59 135.763 218.974 135.553C219.358 135.343 219.801 135.238 220.304 135.238C221.089 135.238 221.688 135.494 222.099 136.006C222.519 136.517 222.729 137.18 222.729 137.993V142.667H221.578V138.322C221.578 136.942 221.071 136.252 220.057 136.252C219.472 136.252 219.002 136.43 218.645 136.787C218.289 137.143 218.111 137.659 218.111 138.336V142.667H216.959ZM227.6 142.831C226.914 142.831 226.32 142.676 225.818 142.365C225.324 142.054 224.941 141.616 224.667 141.049C224.402 140.474 224.269 139.802 224.269 139.035C224.269 138.267 224.402 137.6 224.667 137.033C224.941 136.467 225.32 136.028 225.804 135.718C226.298 135.398 226.878 135.238 227.545 135.238C228.175 135.238 228.733 135.389 229.217 135.69C229.701 135.983 230.076 136.412 230.341 136.979C230.615 137.545 230.752 138.235 230.752 139.048V139.391H225.475C225.521 140.168 225.726 140.752 226.092 141.145C226.467 141.538 226.969 141.735 227.6 141.735C228.075 141.735 228.463 141.625 228.765 141.406C229.075 141.177 229.29 140.88 229.409 140.515L230.642 140.611C230.45 141.259 230.085 141.794 229.546 142.214C229.016 142.625 228.367 142.831 227.6 142.831ZM225.475 138.377H229.491C229.436 137.673 229.231 137.157 228.874 136.828C228.527 136.499 228.084 136.334 227.545 136.334C226.987 136.334 226.526 136.508 226.161 136.855C225.804 137.193 225.576 137.7 225.475 138.377ZM22.7981 168.831C22.1677 168.831 21.6194 168.676 21.1534 168.365C20.6966 168.054 20.3448 167.616 20.0981 167.049C19.8514 166.483 19.728 165.811 19.728 165.035C19.728 164.258 19.8514 163.586 20.0981 163.02C20.3448 162.453 20.6966 162.015 21.1534 161.704C21.6194 161.393 22.1677 161.238 22.7981 161.238C23.2824 161.238 23.7255 161.343 24.1276 161.553C24.5296 161.754 24.8311 162.042 25.0321 162.417V158.935H26.1834V168.667H25.1418L25.1007 167.57C24.8997 167.963 24.5936 168.274 24.1824 168.502C23.7712 168.721 23.3098 168.831 22.7981 168.831ZM23.0311 167.735C23.6799 167.735 24.1733 167.492 24.5113 167.008C24.8585 166.524 25.0321 165.866 25.0321 165.035C25.0321 164.194 24.8585 163.536 24.5113 163.061C24.1733 162.577 23.6799 162.334 23.0311 162.334C22.3915 162.334 21.8798 162.577 21.4961 163.061C21.1215 163.536 20.9341 164.194 20.9341 165.035C20.9341 165.866 21.1215 166.524 21.4961 167.008C21.8798 167.492 22.3915 167.735 23.0311 167.735ZM31.0758 168.831C30.3905 168.831 29.7966 168.676 29.2941 168.365C28.8007 168.054 28.4169 167.616 28.1428 167.049C27.8778 166.474 27.7453 165.802 27.7453 165.035C27.7453 164.267 27.8778 163.6 28.1428 163.033C28.4169 162.467 28.7961 162.028 29.2804 161.718C29.7738 161.398 30.354 161.238 31.021 161.238C31.6514 161.238 32.2088 161.389 32.6931 161.69C33.1773 161.983 33.552 162.412 33.8169 162.979C34.0911 163.545 34.2281 164.235 34.2281 165.048V165.391H28.9514C28.9971 166.168 29.2027 166.752 29.5682 167.145C29.9428 167.538 30.4453 167.735 31.0758 167.735C31.5509 167.735 31.9393 167.625 32.2408 167.406C32.5515 167.177 32.7662 166.88 32.885 166.515L34.1185 166.611C33.9266 167.259 33.5611 167.794 33.022 168.214C32.4921 168.625 31.8433 168.831 31.0758 168.831ZM28.9514 164.377H32.9672C32.9124 163.673 32.7068 163.157 32.3504 162.828C32.0032 162.499 31.5601 162.334 31.021 162.334C30.4636 162.334 30.0022 162.508 29.6367 162.855C29.2804 163.193 29.0519 163.7 28.9514 164.377ZM38.6246 168.831C37.9485 168.831 37.3591 168.676 36.8566 168.365C36.3632 168.054 35.9794 167.616 35.7053 167.049C35.4312 166.474 35.2942 165.802 35.2942 165.035C35.2942 164.267 35.4312 163.6 35.7053 163.033C35.9794 162.467 36.3632 162.028 36.8566 161.718C37.3591 161.398 37.9485 161.238 38.6246 161.238C39.4653 161.238 40.1597 161.457 40.7079 161.896C41.2561 162.325 41.5896 162.947 41.7084 163.76L40.5023 163.842C40.4201 163.358 40.2099 162.988 39.8719 162.732C39.5338 162.467 39.118 162.334 38.6246 162.334C37.9576 162.334 37.4368 162.577 37.0622 163.061C36.6876 163.536 36.5003 164.194 36.5003 165.035C36.5003 165.875 36.6876 166.538 37.0622 167.022C37.4368 167.497 37.9576 167.735 38.6246 167.735C39.118 167.735 39.5338 167.597 39.8719 167.323C40.2099 167.049 40.4201 166.638 40.5023 166.09L41.7084 166.172C41.5896 166.976 41.2561 167.62 40.7079 168.105C40.1597 168.589 39.4653 168.831 38.6246 168.831ZM43.1356 168.667V161.402H44.2869V168.667H43.1356ZM43.1082 160.265V158.922H44.3143V160.265H43.1082ZM48.9246 168.831C48.2941 168.831 47.7459 168.676 47.2799 168.365C46.823 168.054 46.4712 167.616 46.2245 167.049C45.9778 166.483 45.8545 165.811 45.8545 165.035C45.8545 164.258 45.9778 163.586 46.2245 163.02C46.4712 162.453 46.823 162.015 47.2799 161.704C47.7459 161.393 48.2941 161.238 48.9246 161.238C49.4088 161.238 49.852 161.343 50.254 161.553C50.656 161.754 50.9576 162.042 51.1586 162.417V158.935H52.3099V168.667H51.2682L51.2271 167.57C51.0261 167.963 50.72 168.274 50.3088 168.502C49.8977 168.721 49.4362 168.831 48.9246 168.831ZM49.1576 167.735C49.8063 167.735 50.2997 167.492 50.6378 167.008C50.985 166.524 51.1586 165.866 51.1586 165.035C51.1586 164.194 50.985 163.536 50.6378 163.061C50.2997 162.577 49.8063 162.334 49.1576 162.334C48.518 162.334 48.0063 162.577 47.6225 163.061C47.2479 163.536 47.0606 164.194 47.0606 165.035C47.0606 165.866 47.2479 166.524 47.6225 167.008C48.0063 167.492 48.518 167.735 49.1576 167.735ZM57.2022 168.831C56.517 168.831 55.9231 168.676 55.4205 168.365C54.9271 168.054 54.5433 167.616 54.2692 167.049C54.0043 166.474 53.8718 165.802 53.8718 165.035C53.8718 164.267 54.0043 163.6 54.2692 163.033C54.5433 162.467 54.9225 162.028 55.4068 161.718C55.9002 161.398 56.4804 161.238 57.1474 161.238C57.7779 161.238 58.3352 161.389 58.8195 161.69C59.3038 161.983 59.6784 162.412 59.9434 162.979C60.2175 163.545 60.3546 164.235 60.3546 165.048V165.391H55.0779C55.1236 166.168 55.3291 166.752 55.6946 167.145C56.0692 167.538 56.5718 167.735 57.2022 167.735C57.6774 167.735 58.0657 167.625 58.3672 167.406C58.6779 167.177 58.8926 166.88 59.0114 166.515L60.2449 166.611C60.053 167.259 59.6875 167.794 59.1485 168.214C58.6185 168.625 57.9698 168.831 57.2022 168.831ZM55.0779 164.377H59.0936C59.0388 163.673 58.8332 163.157 58.4769 162.828C58.1297 162.499 57.6865 162.334 57.1474 162.334C56.5901 162.334 56.1286 162.508 55.7632 162.855C55.4068 163.193 55.1784 163.7 55.0779 164.377ZM64.4907 168.831C63.8602 168.831 63.312 168.676 62.846 168.365C62.3891 168.054 62.0374 167.616 61.7906 167.049C61.5439 166.483 61.4206 165.811 61.4206 165.035C61.4206 164.258 61.5439 163.586 61.7906 163.02C62.0374 162.453 62.3891 162.015 62.846 161.704C63.312 161.393 63.8602 161.238 64.4907 161.238C64.9749 161.238 65.4181 161.343 65.8201 161.553C66.2222 161.754 66.5237 162.042 66.7247 162.417V158.935H67.876V168.667H66.8343L66.7932 167.57C66.5922 167.963 66.2861 168.274 65.8749 168.502C65.4638 168.721 65.0023 168.831 64.4907 168.831ZM64.7237 167.735C65.3724 167.735 65.8658 167.492 66.2039 167.008C66.5511 166.524 66.7247 165.866 66.7247 165.035C66.7247 164.194 66.5511 163.536 66.2039 163.061C65.8658 162.577 65.3724 162.334 64.7237 162.334C64.0841 162.334 63.5724 162.577 63.1886 163.061C62.814 163.536 62.6267 164.194 62.6267 165.035C62.6267 165.866 62.814 166.524 63.1886 167.008C63.5724 167.492 64.0841 167.735 64.7237 167.735ZM76.1504 168.667C75.4926 168.667 75.0037 168.516 74.6839 168.214C74.3733 167.913 74.2179 167.442 74.2179 166.803V162.417H73.1489V161.402H74.2179V159.703H75.3692V161.402H77.3017V162.417H75.3692V166.775C75.3692 167.104 75.4423 167.332 75.5885 167.46C75.7347 167.588 75.9586 167.652 76.2601 167.652H77.3017V168.667H76.1504ZM81.4816 168.831C80.8146 168.831 80.2298 168.676 79.7273 168.365C79.2248 168.054 78.8364 167.616 78.5623 167.049C78.2882 166.474 78.1511 165.802 78.1511 165.035C78.1511 164.258 78.2882 163.586 78.5623 163.02C78.8364 162.453 79.2248 162.015 79.7273 161.704C80.2298 161.393 80.8146 161.238 81.4816 161.238C82.1486 161.238 82.7288 161.393 83.2222 161.704C83.7248 162.015 84.1131 162.453 84.3872 163.02C84.6613 163.586 84.7984 164.258 84.7984 165.035C84.7984 165.802 84.6613 166.474 84.3872 167.049C84.1131 167.616 83.7248 168.054 83.2222 168.365C82.7288 168.676 82.1486 168.831 81.4816 168.831ZM81.4816 167.735C82.1486 167.735 82.6649 167.497 83.0304 167.022C83.405 166.538 83.5923 165.875 83.5923 165.035C83.5923 164.194 83.405 163.536 83.0304 163.061C82.6649 162.577 82.1486 162.334 81.4816 162.334C80.8146 162.334 80.2938 162.577 79.9192 163.061C79.5446 163.536 79.3572 164.194 79.3572 165.035C79.3572 165.875 79.5446 166.538 79.9192 167.022C80.2938 167.497 80.8146 167.735 81.4816 167.735ZM92.7647 168.831C92.0794 168.831 91.4855 168.676 90.983 168.365C90.4896 168.054 90.1058 167.616 89.8317 167.049C89.5667 166.474 89.4342 165.802 89.4342 165.035C89.4342 164.267 89.5667 163.6 89.8317 163.033C90.1058 162.467 90.485 162.028 90.9693 161.718C91.4627 161.398 92.0429 161.238 92.7099 161.238C93.3404 161.238 93.8977 161.389 94.382 161.69C94.8662 161.983 95.2409 162.412 95.5058 162.979C95.78 163.545 95.917 164.235 95.917 165.048V165.391H90.6403C90.686 166.168 90.8916 166.752 91.2571 167.145C91.6317 167.538 92.1343 167.735 92.7647 167.735C93.2398 167.735 93.6282 167.625 93.9297 167.406C94.2404 167.177 94.4551 166.88 94.5739 166.515L95.8074 166.611C95.6155 167.259 95.25 167.794 94.7109 168.214C94.181 168.625 93.5322 168.831 92.7647 168.831ZM90.6403 164.377H94.6561C94.6013 163.673 94.3957 163.157 94.0393 162.828C93.6921 162.499 93.249 162.334 92.7099 162.334C92.1525 162.334 91.6911 162.508 91.3256 162.855C90.9693 163.193 90.7408 163.7 90.6403 164.377ZM96.4754 168.667L99.148 164.939L96.6125 161.402H97.9556L99.8607 164.144L101.711 161.402H103.082L100.573 164.966L103.205 168.667H101.862L99.8607 165.733L97.846 168.667H96.4754ZM104.369 170.722V161.402H105.438L105.452 162.513C105.671 162.092 105.973 161.777 106.357 161.567C106.75 161.348 107.193 161.238 107.686 161.238C108.408 161.238 108.997 161.416 109.454 161.773C109.92 162.129 110.263 162.595 110.482 163.171C110.711 163.746 110.825 164.367 110.825 165.035C110.825 165.702 110.711 166.323 110.482 166.898C110.263 167.474 109.92 167.94 109.454 168.296C108.997 168.653 108.408 168.831 107.686 168.831C107.211 168.831 106.777 168.73 106.384 168.529C106 168.328 105.713 168.054 105.521 167.707V170.722H104.369ZM107.577 167.735C108.207 167.735 108.705 167.497 109.071 167.022C109.436 166.547 109.619 165.884 109.619 165.035C109.619 164.185 109.436 163.522 109.071 163.047C108.705 162.572 108.207 162.334 107.577 162.334C106.946 162.334 106.444 162.563 106.069 163.02C105.703 163.467 105.521 164.139 105.521 165.035C105.521 165.921 105.703 166.592 106.069 167.049C106.434 167.506 106.937 167.735 107.577 167.735ZM113.879 168.667C113.459 168.667 113.116 168.557 112.851 168.338C112.586 168.118 112.454 167.771 112.454 167.296V158.935H113.605V167.2C113.605 167.502 113.756 167.652 114.057 167.652H114.688V168.667H113.879ZM118.864 168.831C118.197 168.831 117.613 168.676 117.11 168.365C116.608 168.054 116.219 167.616 115.945 167.049C115.671 166.474 115.534 165.802 115.534 165.035C115.534 164.258 115.671 163.586 115.945 163.02C116.219 162.453 116.608 162.015 117.11 161.704C117.613 161.393 118.197 161.238 118.864 161.238C119.531 161.238 120.112 161.393 120.605 161.704C121.108 162.015 121.496 162.453 121.77 163.02C122.044 163.586 122.181 164.258 122.181 165.035C122.181 165.802 122.044 166.474 121.77 167.049C121.496 167.616 121.108 168.054 120.605 168.365C120.112 168.676 119.531 168.831 118.864 168.831ZM118.864 167.735C119.531 167.735 120.048 167.497 120.413 167.022C120.788 166.538 120.975 165.875 120.975 165.035C120.975 164.194 120.788 163.536 120.413 163.061C120.048 162.577 119.531 162.334 118.864 162.334C118.197 162.334 117.677 162.577 117.302 163.061C116.927 163.536 116.74 164.194 116.74 165.035C116.74 165.875 116.927 166.538 117.302 167.022C117.677 167.497 118.197 167.735 118.864 167.735ZM123.75 168.667V161.402H124.764L124.805 162.746C125.052 161.85 125.628 161.402 126.532 161.402H127.245V162.499H126.546C125.45 162.499 124.901 163.093 124.901 164.281V168.667H123.75ZM131.232 168.831C130.546 168.831 129.952 168.676 129.45 168.365C128.956 168.054 128.573 167.616 128.299 167.049C128.034 166.474 127.901 165.802 127.901 165.035C127.901 164.267 128.034 163.6 128.299 163.033C128.573 162.467 128.952 162.028 129.436 161.718C129.93 161.398 130.51 161.238 131.177 161.238C131.807 161.238 132.365 161.389 132.849 161.69C133.333 161.983 133.708 162.412 133.973 162.979C134.247 163.545 134.384 164.235 134.384 165.048V165.391H129.107C129.153 166.168 129.358 166.752 129.724 167.145C130.099 167.538 130.601 167.735 131.232 167.735C131.707 167.735 132.095 167.625 132.397 167.406C132.707 167.177 132.922 166.88 133.041 166.515L134.274 166.611C134.082 167.259 133.717 167.794 133.178 168.214C132.648 168.625 131.999 168.831 131.232 168.831ZM129.107 164.377H133.123C133.068 163.673 132.863 163.157 132.506 162.828C132.159 162.499 131.716 162.334 131.177 162.334C130.619 162.334 130.158 162.508 129.793 162.855C129.436 163.193 129.208 163.7 129.107 164.377ZM142.834 168.831C142.332 168.831 141.884 168.721 141.491 168.502C141.098 168.274 140.797 167.963 140.586 167.57L140.545 168.667H139.504V158.935H140.655V162.417C140.838 162.115 141.121 161.846 141.505 161.608C141.888 161.361 142.332 161.238 142.834 161.238C143.474 161.238 144.026 161.393 144.492 161.704C144.958 162.015 145.319 162.453 145.575 163.02C145.831 163.586 145.959 164.258 145.959 165.035C145.959 165.811 145.831 166.483 145.575 167.049C145.319 167.616 144.958 168.054 144.492 168.365C144.026 168.676 143.474 168.831 142.834 168.831ZM142.766 167.735C143.378 167.735 143.862 167.492 144.218 167.008C144.575 166.524 144.753 165.866 144.753 165.035C144.753 164.194 144.575 163.536 144.218 163.061C143.862 162.577 143.387 162.334 142.793 162.334C142.126 162.334 141.601 162.577 141.217 163.061C140.842 163.536 140.655 164.194 140.655 165.035C140.655 165.866 140.842 166.524 141.217 167.008C141.591 167.492 142.108 167.735 142.766 167.735ZM150.358 168.831C149.673 168.831 149.079 168.676 148.576 168.365C148.083 168.054 147.699 167.616 147.425 167.049C147.16 166.474 147.028 165.802 147.028 165.035C147.028 164.267 147.16 163.6 147.425 163.033C147.699 162.467 148.078 162.028 148.563 161.718C149.056 161.398 149.636 161.238 150.303 161.238C150.934 161.238 151.491 161.389 151.975 161.69C152.46 161.983 152.834 162.412 153.099 162.979C153.373 163.545 153.51 164.235 153.51 165.048V165.391H148.234C148.279 166.168 148.485 166.752 148.85 167.145C149.225 167.538 149.728 167.735 150.358 167.735C150.833 167.735 151.221 167.625 151.523 167.406C151.834 167.177 152.048 166.88 152.167 166.515L153.401 166.611C153.209 167.259 152.843 167.794 152.304 168.214C151.774 168.625 151.125 168.831 150.358 168.831ZM148.234 164.377H152.249C152.195 163.673 151.989 163.157 151.633 162.828C151.285 162.499 150.842 162.334 150.303 162.334C149.746 162.334 149.284 162.508 148.919 162.855C148.563 163.193 148.334 163.7 148.234 164.377ZM155.171 170.722V169.708H156.007C156.263 169.708 156.45 169.667 156.569 169.585C156.697 169.512 156.793 169.388 156.857 169.215L157.117 168.529H156.72L154.061 161.402H155.322L157.487 167.46L159.557 161.402H160.818L157.871 169.585C157.734 169.978 157.528 170.265 157.254 170.448C156.98 170.631 156.601 170.722 156.117 170.722H155.171ZM164.693 168.831C164.026 168.831 163.441 168.676 162.938 168.365C162.436 168.054 162.048 167.616 161.773 167.049C161.499 166.474 161.362 165.802 161.362 165.035C161.362 164.258 161.499 163.586 161.773 163.02C162.048 162.453 162.436 162.015 162.938 161.704C163.441 161.393 164.026 161.238 164.693 161.238C165.36 161.238 165.94 161.393 166.433 161.704C166.936 162.015 167.324 162.453 167.598 163.02C167.872 163.586 168.009 164.258 168.009 165.035C168.009 165.802 167.872 166.474 167.598 167.049C167.324 167.616 166.936 168.054 166.433 168.365C165.94 168.676 165.36 168.831 164.693 168.831ZM164.693 167.735C165.36 167.735 165.876 167.497 166.241 167.022C166.616 166.538 166.803 165.875 166.803 165.035C166.803 164.194 166.616 163.536 166.241 163.061C165.876 162.577 165.36 162.334 164.693 162.334C164.026 162.334 163.505 162.577 163.13 163.061C162.756 163.536 162.568 164.194 162.568 165.035C162.568 165.875 162.756 166.538 163.13 167.022C163.505 167.497 164.026 167.735 164.693 167.735ZM169.578 168.667V161.402H170.634L170.661 162.705C170.853 162.202 171.145 161.832 171.538 161.594C171.94 161.357 172.393 161.238 172.895 161.238C173.453 161.238 173.909 161.361 174.266 161.608C174.631 161.855 174.901 162.188 175.074 162.609C175.257 163.02 175.349 163.481 175.349 163.993V168.667H174.197V164.322C174.197 163.646 174.074 163.134 173.827 162.787C173.59 162.43 173.197 162.252 172.649 162.252C172.091 162.252 171.63 162.43 171.264 162.787C170.908 163.134 170.73 163.646 170.73 164.322V168.667H169.578ZM179.958 168.831C179.328 168.831 178.78 168.676 178.314 168.365C177.857 168.054 177.505 167.616 177.258 167.049C177.012 166.483 176.888 165.811 176.888 165.035C176.888 164.258 177.012 163.586 177.258 163.02C177.505 162.453 177.857 162.015 178.314 161.704C178.78 161.393 179.328 161.238 179.958 161.238C180.443 161.238 180.886 161.343 181.288 161.553C181.69 161.754 181.991 162.042 182.192 162.417V158.935H183.344V168.667H182.302L182.261 167.57C182.06 167.963 181.754 168.274 181.343 168.502C180.931 168.721 180.47 168.831 179.958 168.831ZM180.191 167.735C180.84 167.735 181.333 167.492 181.671 167.008C182.019 166.524 182.192 165.866 182.192 165.035C182.192 164.194 182.019 163.536 181.671 163.061C181.333 162.577 180.84 162.334 180.191 162.334C179.552 162.334 179.04 162.577 178.656 163.061C178.282 163.536 178.094 164.194 178.094 165.035C178.094 165.866 178.282 166.524 178.656 167.008C179.04 167.492 179.552 167.735 180.191 167.735ZM191.618 168.667C190.96 168.667 190.471 168.516 190.152 168.214C189.841 167.913 189.686 167.442 189.686 166.803V162.417H188.617V161.402H189.686V159.703H190.837V161.402H192.769V162.417H190.837V166.775C190.837 167.104 190.91 167.332 191.056 167.46C191.202 167.588 191.426 167.652 191.728 167.652H192.769V168.667H191.618ZM194.246 168.667V158.935H195.397V162.54C195.589 162.092 195.877 161.763 196.261 161.553C196.644 161.343 197.088 161.238 197.59 161.238C198.376 161.238 198.974 161.494 199.386 162.006C199.806 162.517 200.016 163.18 200.016 163.993V168.667H198.865V164.322C198.865 162.942 198.358 162.252 197.343 162.252C196.759 162.252 196.288 162.43 195.932 162.787C195.575 163.143 195.397 163.659 195.397 164.336V168.667H194.246ZM204.886 168.831C204.201 168.831 203.607 168.676 203.104 168.365C202.611 168.054 202.227 167.616 201.953 167.049C201.688 166.474 201.556 165.802 201.556 165.035C201.556 164.267 201.688 163.6 201.953 163.033C202.227 162.467 202.607 162.028 203.091 161.718C203.584 161.398 204.164 161.238 204.831 161.238C205.462 161.238 206.019 161.389 206.503 161.69C206.988 161.983 207.362 162.412 207.627 162.979C207.901 163.545 208.039 164.235 208.039 165.048V165.391H202.762C202.808 166.168 203.013 166.752 203.379 167.145C203.753 167.538 204.256 167.735 204.886 167.735C205.361 167.735 205.75 167.625 206.051 167.406C206.362 167.177 206.577 166.88 206.695 166.515L207.929 166.611C207.737 167.259 207.372 167.794 206.832 168.214C206.302 168.625 205.654 168.831 204.886 168.831ZM202.762 164.377H206.778C206.723 163.673 206.517 163.157 206.161 162.828C205.814 162.499 205.37 162.334 204.831 162.334C204.274 162.334 203.813 162.508 203.447 162.855C203.091 163.193 202.862 163.7 202.762 164.377ZM20.9341 194.667V188.417H19.9473V187.402H20.9341V186.745C20.9341 186.206 21.0895 185.772 21.4001 185.443C21.7108 185.105 22.1951 184.935 22.8529 184.935H24.0042V185.95H22.9352C22.6519 185.95 22.4372 186.027 22.291 186.183C22.154 186.338 22.0854 186.553 22.0854 186.827V187.402H23.9083V188.417H22.0854V194.667H20.9341ZM27.1738 194.831C26.4245 194.831 25.826 194.657 25.3783 194.31C24.9397 193.963 24.7204 193.479 24.7204 192.857C24.7204 192.236 24.9032 191.747 25.2687 191.391C25.6342 191.035 26.2144 190.783 27.0093 190.637L29.586 190.157C29.586 188.942 29.0103 188.334 27.859 188.334C27.3565 188.334 26.959 188.449 26.6667 188.677C26.3743 188.896 26.1732 189.216 26.0636 189.637L24.8438 189.541C24.9809 188.855 25.3098 188.303 25.8306 187.882C26.3606 187.453 27.0367 187.238 27.859 187.238C28.791 187.238 29.5037 187.503 29.9971 188.033C30.4905 188.554 30.7372 189.276 30.7372 190.198V193.2C30.7372 193.502 30.8652 193.652 31.121 193.652H31.4088V194.667C31.2992 194.685 31.153 194.694 30.9702 194.694C30.5317 194.694 30.2027 194.607 29.9834 194.434C29.7733 194.251 29.6454 193.954 29.5997 193.543C29.4169 193.917 29.1017 194.228 28.654 194.475C28.2063 194.712 27.7129 194.831 27.1738 194.831ZM27.2834 193.817C28.0144 193.817 28.5809 193.611 28.9829 193.2C29.3849 192.789 29.586 192.282 29.586 191.679V191.158L27.2286 191.596C26.7352 191.688 26.3925 191.829 26.2007 192.021C26.0179 192.204 25.9265 192.446 25.9265 192.748C25.9265 193.086 26.0453 193.351 26.2829 193.543C26.5296 193.725 26.8631 193.817 27.2834 193.817ZM32.7091 194.667V187.402H33.7645L33.7919 188.677C33.9746 188.229 34.2442 187.878 34.6005 187.622C34.9569 187.366 35.368 187.238 35.834 187.238C36.3823 187.238 36.8437 187.371 37.2183 187.635C37.5929 187.9 37.8579 188.275 38.0132 188.759C38.1686 188.275 38.429 187.9 38.7945 187.635C39.1599 187.371 39.6077 187.238 40.1376 187.238C40.9143 187.238 41.5082 187.48 41.9194 187.964C42.3305 188.44 42.5361 189.116 42.5361 189.993V194.667H41.3848V190.24C41.3848 188.915 40.8777 188.252 39.8635 188.252C39.361 188.252 38.9544 188.435 38.6437 188.8C38.3422 189.166 38.1914 189.673 38.1914 190.322V194.667H37.0401V190.322C37.0401 189.673 36.9259 189.166 36.6975 188.8C36.4691 188.435 36.0853 188.252 35.5462 188.252C35.0437 188.252 34.6371 188.435 34.3264 188.8C34.0157 189.166 33.8604 189.673 33.8604 190.322V194.667H32.7091ZM44.5945 194.667V187.402H45.7458V194.667H44.5945ZM44.5671 186.265V184.922H45.7732V186.265H44.5671ZM49.2322 194.667C48.8119 194.667 48.4692 194.557 48.2043 194.338C47.9393 194.118 47.8068 193.771 47.8068 193.296V184.935H48.9581V193.2C48.9581 193.502 49.1088 193.652 49.4104 193.652H50.0408V194.667H49.2322ZM51.3537 194.667V187.402H52.5049V194.667H51.3537ZM51.3263 186.265V184.922H52.5324V186.265H51.3263ZM56.5258 194.831C55.7766 194.831 55.1781 194.657 54.7304 194.31C54.2918 193.963 54.0725 193.479 54.0725 192.857C54.0725 192.236 54.2553 191.747 54.6208 191.391C54.9862 191.035 55.5665 190.783 56.3614 190.637L58.938 190.157C58.938 188.942 58.3624 188.334 57.2111 188.334C56.7086 188.334 56.3111 188.449 56.0187 188.677C55.7263 188.896 55.5253 189.216 55.4157 189.637L54.1959 189.541C54.3329 188.855 54.6619 188.303 55.1827 187.882C55.7126 187.453 56.3888 187.238 57.2111 187.238C58.1431 187.238 58.8558 187.503 59.3492 188.033C59.8426 188.554 60.0893 189.276 60.0893 190.198V193.2C60.0893 193.502 60.2172 193.652 60.4731 193.652H60.7609V194.667C60.6513 194.685 60.5051 194.694 60.3223 194.694C59.8837 194.694 59.5548 194.607 59.3355 194.434C59.1254 194.251 58.9974 193.954 58.9518 193.543C58.769 193.917 58.4538 194.228 58.0061 194.475C57.5583 194.712 57.0649 194.831 56.5258 194.831ZM56.6355 193.817C57.3665 193.817 57.933 193.611 58.335 193.2C58.737 192.789 58.938 192.282 58.938 191.679V191.158L56.5807 191.596C56.0873 191.688 55.7446 191.829 55.5527 192.021C55.37 192.204 55.2786 192.446 55.2786 192.748C55.2786 193.086 55.3974 193.351 55.635 193.543C55.8817 193.725 56.2152 193.817 56.6355 193.817ZM62.0612 194.667V187.402H63.0754L63.1166 188.746C63.3633 187.85 63.9389 187.402 64.8435 187.402H65.5562V188.499H64.8572C63.7607 188.499 63.2125 189.093 63.2125 190.281V194.667H62.0612ZM70.6808 196.722V187.402H71.7499L71.7636 188.513C71.9828 188.092 72.2844 187.777 72.6681 187.567C73.061 187.348 73.5042 187.238 73.9976 187.238C74.7194 187.238 75.3088 187.416 75.7656 187.773C76.2316 188.129 76.5742 188.595 76.7935 189.171C77.022 189.746 77.1362 190.367 77.1362 191.035C77.1362 191.702 77.022 192.323 76.7935 192.898C76.5742 193.474 76.2316 193.94 75.7656 194.296C75.3088 194.653 74.7194 194.831 73.9976 194.831C73.5225 194.831 73.0884 194.73 72.6955 194.529C72.3118 194.328 72.024 194.054 71.8321 193.707V196.722H70.6808ZM73.8879 193.735C74.5184 193.735 75.0164 193.497 75.3819 193.022C75.7473 192.547 75.9301 191.884 75.9301 191.035C75.9301 190.185 75.7473 189.522 75.3819 189.047C75.0164 188.572 74.5184 188.334 73.8879 188.334C73.2575 188.334 72.7549 188.563 72.3803 189.02C72.0148 189.467 71.8321 190.139 71.8321 191.035C71.8321 191.921 72.0148 192.592 72.3803 193.049C72.7458 193.506 73.2483 193.735 73.8879 193.735ZM80.8454 194.831C80.0961 194.831 79.4977 194.657 79.0499 194.31C78.6114 193.963 78.3921 193.479 78.3921 192.857C78.3921 192.236 78.5748 191.747 78.9403 191.391C79.3058 191.035 79.886 190.783 80.6809 190.637L83.2576 190.157C83.2576 188.942 82.6819 188.334 81.5307 188.334C81.0281 188.334 80.6307 188.449 80.3383 188.677C80.0459 188.896 79.8449 189.216 79.7352 189.637L78.5154 189.541C78.6525 188.855 78.9814 188.303 79.5022 187.882C80.0322 187.453 80.7083 187.238 81.5307 187.238C82.4627 187.238 83.1753 187.503 83.6688 188.033C84.1622 188.554 84.4089 189.276 84.4089 190.198V193.2C84.4089 193.502 84.5368 193.652 84.7926 193.652H85.0804V194.667C84.9708 194.685 84.8246 194.694 84.6419 194.694C84.2033 194.694 83.8743 194.607 83.655 194.434C83.4449 194.251 83.317 193.954 83.2713 193.543C83.0885 193.917 82.7733 194.228 82.3256 194.475C81.8779 194.712 81.3845 194.831 80.8454 194.831ZM80.955 193.817C81.686 193.817 82.2525 193.611 82.6545 193.2C83.0566 192.789 83.2576 192.282 83.2576 191.679V191.158L80.9002 191.596C80.4068 191.688 80.0642 191.829 79.8723 192.021C79.6895 192.204 79.5982 192.446 79.5982 192.748C79.5982 193.086 79.717 193.351 79.9545 193.543C80.2012 193.725 80.5347 193.817 80.955 193.817ZM88.9992 194.831C88.0307 194.831 87.2768 194.603 86.7378 194.146C86.2078 193.68 85.9154 193.09 85.8606 192.378L87.0667 192.295C87.1398 192.734 87.3271 193.086 87.6286 193.351C87.9301 193.607 88.387 193.735 88.9992 193.735C89.4926 193.735 89.8855 193.657 90.1779 193.502C90.4794 193.337 90.6302 193.077 90.6302 192.72C90.6302 192.528 90.5845 192.369 90.4931 192.241C90.4017 192.113 90.2236 192.003 89.9586 191.912C89.6936 191.811 89.3007 191.715 88.7799 191.624C88.0855 191.496 87.5372 191.341 87.1352 191.158C86.7423 190.966 86.4591 190.733 86.2855 190.459C86.121 190.176 86.0388 189.842 86.0388 189.458C86.0388 188.81 86.2763 188.28 86.7515 187.868C87.2266 187.448 87.9027 187.238 88.7799 187.238C89.3738 187.238 89.8718 187.348 90.2738 187.567C90.685 187.777 91.0048 188.06 91.2332 188.417C91.4708 188.764 91.6261 189.148 91.6992 189.568L90.4931 189.65C90.4109 189.266 90.2236 188.951 89.9312 188.705C89.6479 188.458 89.2596 188.334 88.7662 188.334C88.2454 188.334 87.8616 188.435 87.6149 188.636C87.3682 188.837 87.2449 189.093 87.2449 189.404C87.2449 189.76 87.3773 190.025 87.6423 190.198C87.9073 190.363 88.3596 190.5 88.9992 190.61C89.7393 190.738 90.3149 190.893 90.7261 191.076C91.1373 191.258 91.4251 191.482 91.5896 191.747C91.754 192.012 91.8363 192.337 91.8363 192.72C91.8363 193.168 91.7083 193.552 91.4525 193.872C91.2058 194.182 90.8677 194.42 90.4383 194.584C90.018 194.749 89.5383 194.831 88.9992 194.831ZM95.7854 194.667C95.1275 194.667 94.6387 194.516 94.3189 194.214C94.0083 193.913 93.8529 193.442 93.8529 192.803V188.417H92.7839V187.402H93.8529V185.703H95.0042V187.402H96.9367V188.417H95.0042V192.775C95.0042 193.104 95.0773 193.332 95.2235 193.46C95.3697 193.588 95.5935 193.652 95.8951 193.652H96.9367V194.667H95.7854ZM100.798 194.831C100.076 194.831 99.4961 194.589 99.0575 194.105C98.6281 193.611 98.4134 192.935 98.4134 192.076V187.402H99.5647V191.761C99.5647 192.464 99.688 192.985 99.9347 193.323C100.191 193.652 100.579 193.817 101.1 193.817C101.675 193.817 102.128 193.634 102.457 193.269C102.785 192.894 102.95 192.382 102.95 191.733V187.402H104.101V194.667H103.005V193.488C102.603 194.383 101.867 194.831 100.798 194.831ZM106.297 194.667V187.402H107.311L107.352 188.746C107.599 187.85 108.174 187.402 109.079 187.402H109.792V188.499H109.093C107.996 188.499 107.448 189.093 107.448 190.281V194.667H106.297ZM113.778 194.831C113.093 194.831 112.499 194.676 111.997 194.365C111.503 194.054 111.119 193.616 110.845 193.049C110.58 192.474 110.448 191.802 110.448 191.035C110.448 190.267 110.58 189.6 110.845 189.033C111.119 188.467 111.499 188.028 111.983 187.718C112.476 187.398 113.056 187.238 113.723 187.238C114.354 187.238 114.911 187.389 115.396 187.69C115.88 187.983 116.254 188.412 116.519 188.979C116.794 189.545 116.931 190.235 116.931 191.048V191.391H111.654C111.7 192.168 111.905 192.752 112.271 193.145C112.645 193.538 113.148 193.735 113.778 193.735C114.253 193.735 114.642 193.625 114.943 193.406C115.254 193.177 115.469 192.88 115.587 192.515L116.821 192.611C116.629 193.259 116.264 193.794 115.724 194.214C115.195 194.625 114.546 194.831 113.778 194.831ZM111.654 190.377H115.67C115.615 189.673 115.409 189.157 115.053 188.828C114.706 188.499 114.263 188.334 113.723 188.334C113.166 188.334 112.705 188.508 112.339 188.855C111.983 189.193 111.754 189.7 111.654 190.377ZM121.135 194.831C120.167 194.831 119.413 194.603 118.874 194.146C118.344 193.68 118.051 193.09 117.997 192.378L119.203 192.295C119.276 192.734 119.463 193.086 119.765 193.351C120.066 193.607 120.523 193.735 121.135 193.735C121.629 193.735 122.022 193.657 122.314 193.502C122.615 193.337 122.766 193.077 122.766 192.72C122.766 192.528 122.721 192.369 122.629 192.241C122.538 192.113 122.36 192.003 122.095 191.912C121.83 191.811 121.437 191.715 120.916 191.624C120.222 191.496 119.673 191.341 119.271 191.158C118.878 190.966 118.595 190.733 118.422 190.459C118.257 190.176 118.175 189.842 118.175 189.458C118.175 188.81 118.412 188.28 118.888 187.868C119.363 187.448 120.039 187.238 120.916 187.238C121.51 187.238 122.008 187.348 122.41 187.567C122.821 187.777 123.141 188.06 123.369 188.417C123.607 188.764 123.762 189.148 123.835 189.568L122.629 189.65C122.547 189.266 122.36 188.951 122.067 188.705C121.784 188.458 121.396 188.334 120.902 188.334C120.381 188.334 119.998 188.435 119.751 188.636C119.504 188.837 119.381 189.093 119.381 189.404C119.381 189.76 119.513 190.025 119.778 190.198C120.043 190.363 120.496 190.5 121.135 190.61C121.875 190.738 122.451 190.893 122.862 191.076C123.273 191.258 123.561 191.482 123.726 191.747C123.89 192.012 123.972 192.337 123.972 192.72C123.972 193.168 123.844 193.552 123.589 193.872C123.342 194.182 123.004 194.42 122.574 194.584C122.154 194.749 121.674 194.831 121.135 194.831ZM125.184 194.667V193.118H126.733V194.667H125.184ZM131.039 194.667L134.547 184.935H136.137L139.646 194.667H138.358L137.384 191.898H133.3L132.327 194.667H131.039ZM133.698 190.747H136.987L135.342 185.963L133.698 190.747ZM143.42 194.831C142.452 194.831 141.698 194.603 141.159 194.146C140.629 193.68 140.337 193.09 140.282 192.378L141.488 192.295C141.561 192.734 141.748 193.086 142.05 193.351C142.351 193.607 142.808 193.735 143.42 193.735C143.914 193.735 144.307 193.657 144.599 193.502C144.901 193.337 145.051 193.077 145.051 192.72C145.051 192.528 145.006 192.369 144.914 192.241C144.823 192.113 144.645 192.003 144.38 191.912C144.115 191.811 143.722 191.715 143.201 191.624C142.507 191.496 141.958 191.341 141.556 191.158C141.163 190.966 140.88 190.733 140.707 190.459C140.542 190.176 140.46 189.842 140.46 189.458C140.46 188.81 140.697 188.28 141.173 187.868C141.648 187.448 142.324 187.238 143.201 187.238C143.795 187.238 144.293 187.348 144.695 187.567C145.106 187.777 145.426 188.06 145.654 188.417C145.892 188.764 146.047 189.148 146.12 189.568L144.914 189.65C144.832 189.266 144.645 188.951 144.352 188.705C144.069 188.458 143.681 188.334 143.187 188.334C142.667 188.334 142.283 188.435 142.036 188.636C141.789 188.837 141.666 189.093 141.666 189.404C141.666 189.76 141.799 190.025 142.063 190.198C142.328 190.363 142.781 190.5 143.42 190.61C144.16 190.738 144.736 190.893 145.147 191.076C145.558 191.258 145.846 191.482 146.011 191.747C146.175 192.012 146.257 192.337 146.257 192.72C146.257 193.168 146.129 193.552 145.874 193.872C145.627 194.182 145.289 194.42 144.859 194.584C144.439 194.749 143.959 194.831 143.42 194.831Z\"\n    fill=\"black\"\n    fill-opacity=\"0.7\"\n  />\n  <path\n    d=\"M134.337 12.6665V5.4025H135.489V12.6665H134.337ZM134.31 4.26493V2.92177H135.516V4.26493H134.31ZM137.817 12.6665V5.4025H138.873L138.9 6.70454C139.092 6.202 139.384 5.83194 139.777 5.59438C140.179 5.35681 140.631 5.23803 141.134 5.23803C141.691 5.23803 142.148 5.36138 142.505 5.60808C142.87 5.85478 143.14 6.18829 143.313 6.6086C143.496 7.01977 143.587 7.48119 143.587 7.99287V12.6665H142.436V8.32181C142.436 7.64566 142.313 7.13398 142.066 6.78677C141.828 6.43042 141.435 6.25225 140.887 6.25225C140.33 6.25225 139.868 6.43042 139.503 6.78677C139.147 7.13398 138.968 7.64566 138.968 8.32181V12.6665H137.817ZM151.866 12.6665C151.208 12.6665 150.72 12.5157 150.4 12.2142C150.089 11.9127 149.934 11.4421 149.934 10.8025V6.41672H148.865V5.4025H149.934V3.70299H151.085V5.4025H153.018V6.41672H151.085V10.7751C151.085 11.1041 151.158 11.3325 151.304 11.4604C151.451 11.5883 151.674 11.6523 151.976 11.6523H153.018V12.6665H151.866ZM154.494 12.6665V2.93548H155.645V6.54007C155.837 6.09235 156.125 5.76341 156.509 5.55326C156.893 5.34311 157.336 5.23803 157.838 5.23803C158.624 5.23803 159.223 5.49387 159.634 6.00555C160.054 6.51723 160.264 7.17967 160.264 7.99287V12.6665H159.113V8.32181C159.113 6.9421 158.606 6.25225 157.592 6.25225C157.007 6.25225 156.536 6.43042 156.18 6.78677C155.824 7.14312 155.645 7.65937 155.645 8.33551V12.6665H154.494ZM165.134 12.831C164.449 12.831 163.855 12.6756 163.353 12.365C162.859 12.0543 162.476 11.6157 162.201 11.0492C161.936 10.4736 161.804 9.80202 161.804 9.0345C161.804 8.26698 161.936 7.59997 162.201 7.03347C162.476 6.46697 162.855 6.02839 163.339 5.71773C163.832 5.39793 164.413 5.23803 165.08 5.23803C165.71 5.23803 166.267 5.38879 166.752 5.69032C167.236 5.9827 167.611 6.41215 167.876 6.97865C168.15 7.54515 168.287 8.235 168.287 9.04821V9.39085H163.01C163.056 10.1675 163.261 10.7523 163.627 11.1452C164.001 11.5381 164.504 11.7345 165.134 11.7345C165.61 11.7345 165.998 11.6249 166.299 11.4056C166.61 11.1772 166.825 10.8802 166.944 10.5147L168.177 10.6107C167.985 11.2594 167.62 11.7939 167.081 12.2142C166.551 12.6254 165.902 12.831 165.134 12.831ZM163.01 8.37663H167.026C166.971 7.67307 166.765 7.15682 166.409 6.82789C166.062 6.49895 165.619 6.33448 165.08 6.33448C164.522 6.33448 164.061 6.50809 163.695 6.8553C163.339 7.19337 163.111 7.70048 163.01 8.37663Z\"\n    fill=\"#228AFB\"\n  />\n  <rect\n    x=\"19.1509\"\n    y=\"47.2222\"\n    width=\"39.1591\"\n    height=\"21.6405\"\n    fill=\"black\"\n    fill-opacity=\"0.1\"\n  />\n  <rect\n    x=\"130.445\"\n    y=\"-5.3335\"\n    width=\"40.1896\"\n    height=\"21.6405\"\n    fill=\"#228AFB\"\n    fill-opacity=\"0.3\"\n  />\n  <g filter=\"url(#filter0_d_2159_162)\">\n    <path\n      d=\"M78.6799 94.081C79.143 94.7976 79.3637 97.7892 77.7745 98.7794C76.1853 99.7696 73.8744 99.4849 73.8744 99.4849L68.4684 92.2634L62.8486 100.365L57.2432 69.3784L82.0627 86.594L74.232 88.0106C74.7496 88.6779 78.0915 93.1705 78.6799 94.081Z\"\n      fill=\"white\"\n    />\n    <path\n      fill-rule=\"evenodd\"\n      clip-rule=\"evenodd\"\n      d=\"M59.8877 73.4907L63.8848 95.5869L68.4096 89.0636L74.6784 97.4376C74.6784 97.4376 75.9956 97.6097 76.5198 97.1045C77.0439 96.5994 77.492 95.6918 77.1073 95.0966C75.2914 92.2867 70.8385 86.7226 70.8385 86.7226L77.2832 85.5568L59.8877 73.4907Z\"\n      fill=\"black\"\n    />\n  </g>\n  <path\n    d=\"M83.1787 105.146H114.297C120.12 105.146 124.841 109.867 124.841 115.69C124.841 121.513 120.12 126.234 114.297 126.234H94.4061C88.2054 126.234 83.1787 121.207 83.1787 115.007V105.146Z\"\n    fill=\"black\"\n  />\n  <path\n    d=\"M93.8391 120.19V114.066H94.8677V115.043H94.9475C95.075 114.71 95.281 114.451 95.5654 114.265C95.8525 114.079 96.1967 113.986 96.5981 113.986C97.0047 113.986 97.345 114.079 97.6187 114.265C97.8952 114.451 98.1038 114.71 98.2447 115.043H98.3085C98.46 114.721 98.6939 114.465 99.0102 114.273C99.3265 114.082 99.7053 113.986 100.147 113.986C100.697 113.986 101.146 114.159 101.494 114.504C101.845 114.85 102.02 115.378 102.02 116.087V120.19H100.956V116.143C100.956 115.71 100.838 115.399 100.601 115.21C100.364 115.019 100.084 114.923 99.7598 114.923C99.3504 114.923 99.0328 115.048 98.8069 115.298C98.5809 115.545 98.468 115.86 98.468 116.243V120.19H97.3955V116.059C97.3955 115.719 97.2865 115.445 97.0685 115.238C96.8532 115.028 96.5742 114.923 96.2313 114.923C95.9947 114.923 95.7754 114.986 95.5734 115.11C95.3741 115.235 95.2133 115.408 95.091 115.629C94.9687 115.847 94.9076 116.099 94.9076 116.386V120.19H93.8391ZM105.258 120.329C104.87 120.329 104.517 120.256 104.201 120.11C103.887 119.964 103.638 119.753 103.452 119.476C103.268 119.197 103.176 118.857 103.176 118.456C103.176 118.107 103.244 117.823 103.38 117.602C103.518 117.379 103.701 117.204 103.93 117.076C104.159 116.949 104.412 116.853 104.691 116.789C104.971 116.725 105.254 116.676 105.541 116.642C105.91 116.594 106.209 116.556 106.438 116.53C106.669 116.503 106.838 116.459 106.944 116.398C107.05 116.337 107.104 116.232 107.104 116.083V116.051C107.104 115.682 107.001 115.395 106.797 115.19C106.592 114.986 106.285 114.883 105.876 114.883C105.45 114.883 105.117 114.976 104.875 115.162C104.633 115.348 104.463 115.552 104.365 115.772L103.36 115.485C103.506 115.113 103.709 114.818 103.97 114.6C104.233 114.38 104.527 114.223 104.851 114.13C105.178 114.034 105.511 113.986 105.852 113.986C106.072 113.986 106.316 114.013 106.581 114.066C106.847 114.116 107.1 114.216 107.339 114.365C107.581 114.511 107.78 114.728 107.937 115.015C108.094 115.299 108.172 115.674 108.172 116.139V120.19H107.12V119.357H107.072C107.003 119.5 106.892 119.648 106.741 119.799C106.589 119.948 106.391 120.074 106.147 120.178C105.902 120.279 105.606 120.329 105.258 120.329ZM105.457 119.428C105.811 119.428 106.11 119.359 106.354 119.221C106.599 119.083 106.785 118.903 106.912 118.683C107.043 118.462 107.108 118.227 107.108 117.977V117.152C107.065 117.197 106.98 117.238 106.852 117.275C106.725 117.313 106.579 117.345 106.414 117.371C106.252 117.398 106.092 117.422 105.935 117.443C105.781 117.461 105.654 117.477 105.553 117.491C105.316 117.523 105.097 117.573 104.895 117.642C104.695 117.711 104.535 117.814 104.412 117.949C104.293 118.082 104.233 118.262 104.233 118.487C104.233 118.798 104.349 119.034 104.58 119.193C104.811 119.35 105.103 119.428 105.457 119.428ZM110.434 114.066L111.845 116.51L113.26 114.066H114.452L112.511 117.128L114.46 120.19H113.268L111.845 117.858L110.426 120.19H109.23L111.151 117.128L109.237 114.066H110.434Z\"\n    fill=\"white\"\n  />\n  <g filter=\"url(#filter1_d_2159_162)\">\n    <path\n      d=\"M190.382 41.2468L182.147 41.3565C182.929 42.8593 184.903 46.7197 185.281 47.5946C185.396 47.8591 185.441 48.2524 185.435 48.6688C185.43 49.0977 185.369 49.5954 185.24 50.0905C184.988 51.0598 184.445 52.1215 183.425 52.5426C182.483 52.9318 181.425 52.9406 180.626 52.8621C180.223 52.8225 179.877 52.7595 179.63 52.7064C179.507 52.6799 179.408 52.6558 179.339 52.638C179.305 52.6292 179.278 52.6218 179.26 52.6166C179.25 52.614 179.242 52.6116 179.237 52.6101C179.235 52.6094 179.233 52.6086 179.231 52.6082L179.229 52.6082L179.228 52.6073L179.085 52.5652L179.016 52.4324L175.098 44.851L167.969 52.1732L167.527 18.9925L190.382 41.2468Z\"\n      fill=\"white\"\n      stroke=\"#228AFB\"\n      stroke-width=\"0.701713\"\n    />\n    <path\n      d=\"M185.232 38.8439L185.84 39.4345L179.03 39.5252C179.109 39.6692 179.204 39.8404 179.31 40.0342C179.657 40.6664 180.134 41.5415 180.652 42.5127C181.686 44.4512 182.893 46.7864 183.564 48.3367C183.765 48.8025 183.638 49.2913 183.423 49.6776C183.206 50.0684 182.868 50.4156 182.529 50.6485C182.313 50.7968 182.057 50.8605 181.822 50.8855C181.584 50.9108 181.341 50.8986 181.129 50.8742C180.917 50.8497 180.725 50.8117 180.588 50.7799C180.519 50.7639 180.463 50.7489 180.423 50.7381C180.404 50.7327 180.388 50.7283 180.376 50.7251C180.371 50.7235 180.367 50.7223 180.364 50.7213C180.362 50.7209 180.361 50.7207 180.36 50.7204L180.358 50.7204L180.358 50.7194L180.216 50.6764L180.147 50.5455L175.568 41.6838L170.37 47.024L169.78 47.6305L169.458 23.4842L185.232 38.8439Z\"\n      fill=\"black\"\n      stroke=\"#228AFB\"\n      stroke-width=\"0.701713\"\n    />\n  </g>\n  <path\n    d=\"M189.846 60.4521H221.698C227.521 60.4521 232.242 65.1729 232.242 70.9962C232.242 76.8195 227.521 81.5402 221.698 81.5402H201.073C194.872 81.5402 189.846 76.5136 189.846 70.3128V60.4521Z\"\n    fill=\"#228AFB\"\n  />\n  <path\n    d=\"M200.373 69.372H201.442V75.9187C201.442 76.3121 201.373 76.6483 201.234 76.9274C201.096 77.2065 200.89 77.4205 200.616 77.5693C200.343 77.7182 200.001 77.7926 199.592 77.7926C199.557 77.7926 199.523 77.7926 199.488 77.7926C199.451 77.7926 199.414 77.7926 199.376 77.7926V76.8397C199.411 76.8397 199.443 76.8397 199.472 76.8397C199.499 76.8397 199.528 76.8397 199.56 76.8397C199.844 76.8397 200.05 76.76 200.178 76.6005C200.308 76.441 200.373 76.2124 200.373 75.9147V69.372ZM200.9 68.3912C200.705 68.3912 200.538 68.3248 200.397 68.1919C200.259 68.059 200.19 67.8995 200.19 67.7135C200.19 67.5301 200.259 67.3719 200.397 67.239C200.538 67.1061 200.705 67.0396 200.9 67.0396C201.096 67.0396 201.264 67.1061 201.402 67.239C201.543 67.3719 201.613 67.5301 201.613 67.7135C201.613 67.8995 201.543 68.059 201.402 68.1919C201.264 68.3248 201.096 68.3912 200.9 68.3912ZM205.429 75.6237C204.865 75.6237 204.372 75.4921 203.949 75.229C203.527 74.9632 203.199 74.5937 202.965 74.1206C202.731 73.6475 202.614 73.0959 202.614 72.466C202.614 71.828 202.731 71.2712 202.965 70.7954C203.199 70.3196 203.527 69.9502 203.949 69.687C204.372 69.4239 204.865 69.2923 205.429 69.2923C205.992 69.2923 206.485 69.4239 206.908 69.687C207.33 69.9502 207.659 70.3196 207.893 70.7954C208.126 71.2712 208.243 71.828 208.243 72.466C208.243 73.0959 208.126 73.6475 207.893 74.1206C207.659 74.5937 207.33 74.9632 206.908 75.229C206.485 75.4921 205.992 75.6237 205.429 75.6237ZM205.429 74.6987C205.827 74.6987 206.154 74.5964 206.409 74.3917C206.667 74.1844 206.859 73.9119 206.984 73.5744C207.108 73.2341 207.171 72.8633 207.171 72.462C207.171 72.0633 207.108 71.6938 206.984 71.3536C206.859 71.0107 206.667 70.7356 206.409 70.5283C206.154 70.3183 205.827 70.2133 205.429 70.2133C205.033 70.2133 204.706 70.3183 204.448 70.5283C204.19 70.7356 203.999 71.0107 203.874 71.3536C203.749 71.6938 203.686 72.0633 203.686 72.462C203.686 72.8633 203.749 73.2341 203.874 73.5744C203.999 73.9119 204.19 74.1844 204.448 74.3917C204.706 74.5964 205.033 74.6987 205.429 74.6987ZM210.483 71.836V75.4961H209.414V67.3307H210.475V70.3489H210.555C210.696 70.0246 210.912 69.7681 211.205 69.5794C211.497 69.388 211.882 69.2923 212.361 69.2923C212.778 69.2923 213.144 69.3774 213.457 69.5475C213.774 69.7149 214.018 69.9701 214.191 70.313C214.366 70.6559 214.454 71.0865 214.454 71.6048V75.4961H213.385V71.7084C213.385 71.2406 213.265 70.8778 213.023 70.62C212.783 70.3622 212.45 70.2332 212.022 70.2332C211.727 70.2332 211.462 70.2957 211.228 70.4206C210.997 70.5456 210.815 70.7276 210.682 70.9669C210.549 71.2061 210.483 71.4958 210.483 71.836ZM216.972 71.836V75.4961H215.903V69.372H216.932V70.3489H217.011C217.152 70.0299 217.372 69.7747 217.669 69.5834C217.967 69.3893 218.346 69.2923 218.806 69.2923C219.223 69.2923 219.587 69.3787 219.898 69.5515C220.209 69.7216 220.451 69.9781 220.624 70.321C220.799 70.6638 220.887 71.0918 220.887 71.6048V75.4961H219.818V71.7084C219.818 71.2433 219.697 70.8818 219.455 70.624C219.216 70.3635 218.887 70.2332 218.467 70.2332C218.18 70.2332 217.923 70.2957 217.697 70.4206C217.471 70.5456 217.293 70.7276 217.163 70.9669C217.035 71.2061 216.972 71.4958 216.972 71.836Z\"\n    fill=\"white\"\n  />\n  <g clip-path=\"url(#clip0_2159_162)\">\n    <g filter=\"url(#filter2_d_2159_162)\">\n      <path\n        d=\"M185.664 198.664L180.115 201.667C181.234 202.448 183.769 204.242 184.316 204.684C184.499 204.832 184.674 205.093 184.819 205.378C184.969 205.673 185.105 206.035 185.195 206.419C185.369 207.167 185.383 208.104 184.822 208.775C184.314 209.383 183.592 209.768 183.024 209.998C182.735 210.115 182.477 210.195 182.291 210.247C182.198 210.273 182.122 210.292 182.069 210.304C182.042 210.31 182.02 210.315 182.005 210.318C181.998 210.32 181.993 210.321 181.988 210.322C181.986 210.322 181.984 210.322 181.983 210.323L181.981 210.324L181.98 210.323L181.836 210.352L181.716 210.268L176.503 206.617L174.268 214.125L162.105 191.652L185.664 198.664Z\"\n        fill=\"white\"\n        stroke=\"#EC6A5E\"\n        stroke-width=\"0.691241\"\n      />\n      <path\n        d=\"M165.785 194.228L181.176 198.808L181.975 199.046L177.38 201.534C177.458 201.585 177.544 201.642 177.637 201.704C178.093 202.007 178.722 202.427 179.412 202.897C180.789 203.835 182.426 204.981 183.425 205.788C183.751 206.052 183.84 206.454 183.832 206.802C183.824 207.154 183.716 207.519 183.565 207.805C183.463 207.997 183.301 208.139 183.146 208.242C182.989 208.347 182.816 208.427 182.662 208.487C182.507 208.546 182.362 208.589 182.256 208.616C182.203 208.63 182.159 208.64 182.128 208.647C182.112 208.65 182.099 208.653 182.09 208.655C182.086 208.656 182.082 208.657 182.079 208.658C182.078 208.658 182.077 208.658 182.076 208.658L182.075 208.658L181.931 208.685L175.705 204.324L174.124 209.636L173.887 210.437L164.985 193.99L165.785 194.228Z\"\n        fill=\"black\"\n        stroke=\"#EC6A5E\"\n        stroke-width=\"0.691241\"\n      />\n    </g>\n  </g>\n  <rect x=\"152.776\" y=\"183.225\" width=\"0.691242\" height=\"12.4423\" fill=\"black\"/>\n  <rect\n    y=\"106.548\"\n    width=\"256.275\"\n    height=\"268.706\"\n    fill=\"url(#paint0_linear_2159_162)\"\n  />\n  <rect\n    x=\"256.275\"\n    y=\"105.254\"\n    width=\"256.275\"\n    height=\"268.706\"\n    transform=\"rotate(-180 256.275 105.254)\"\n    fill=\"url(#paint1_linear_2159_162)\"\n  />\n  <defs>\n    <filter\n      id=\"filter0_d_2159_162\"\n      x=\"53.8749\"\n      y=\"67.8814\"\n      width=\"31.5558\"\n      height=\"37.7233\"\n      filterUnits=\"userSpaceOnUse\"\n      color-interpolation-filters=\"sRGB\"\n    >\n      <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\n      <feColorMatrix\n        in=\"SourceAlpha\"\n        type=\"matrix\"\n        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n        result=\"hardAlpha\"\n      />\n      <feOffset dy=\"1.87124\"/>\n      <feGaussianBlur stdDeviation=\"1.68411\"/>\n      <feColorMatrix\n        type=\"matrix\"\n        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0\"\n      />\n      <feBlend\n        mode=\"normal\"\n        in2=\"BackgroundImageFix\"\n        result=\"effect1_dropShadow_2159_162\"\n      />\n      <feBlend\n        mode=\"normal\"\n        in=\"SourceGraphic\"\n        in2=\"effect1_dropShadow_2159_162\"\n        result=\"shape\"\n      />\n    </filter>\n    <filter\n      id=\"filter1_d_2159_162\"\n      x=\"163.796\"\n      y=\"16.6529\"\n      width=\"30.8048\"\n      height=\"41.8385\"\n      filterUnits=\"userSpaceOnUse\"\n      color-interpolation-filters=\"sRGB\"\n    >\n      <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\n      <feColorMatrix\n        in=\"SourceAlpha\"\n        type=\"matrix\"\n        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n        result=\"hardAlpha\"\n      />\n      <feOffset dy=\"1.87124\"/>\n      <feGaussianBlur stdDeviation=\"1.68411\"/>\n      <feColorMatrix\n        type=\"matrix\"\n        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0\"\n      />\n      <feBlend\n        mode=\"normal\"\n        in2=\"BackgroundImageFix\"\n        result=\"effect1_dropShadow_2159_162\"\n      />\n      <feBlend\n        mode=\"normal\"\n        in=\"SourceGraphic\"\n        in2=\"effect1_dropShadow_2159_162\"\n        result=\"shape\"\n      />\n    </filter>\n    <filter\n      id=\"filter2_d_2159_162\"\n      x=\"158.848\"\n      y=\"189.947\"\n      width=\"30.2684\"\n      height=\"29.0511\"\n      filterUnits=\"userSpaceOnUse\"\n      color-interpolation-filters=\"sRGB\"\n    >\n      <feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"/>\n      <feColorMatrix\n        in=\"SourceAlpha\"\n        type=\"matrix\"\n        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n        result=\"hardAlpha\"\n      />\n      <feOffset dy=\"1.41937\"/>\n      <feGaussianBlur stdDeviation=\"1.27743\"/>\n      <feColorMatrix\n        type=\"matrix\"\n        values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.65 0\"\n      />\n      <feBlend\n        mode=\"normal\"\n        in2=\"BackgroundImageFix\"\n        result=\"effect1_dropShadow_2159_162\"\n      />\n      <feBlend\n        mode=\"normal\"\n        in=\"SourceGraphic\"\n        in2=\"effect1_dropShadow_2159_162\"\n        result=\"shape\"\n      />\n    </filter>\n    <linearGradient\n      id=\"paint0_linear_2159_162\"\n      x1=\"128.137\"\n      y1=\"106.548\"\n      x2=\"128.137\"\n      y2=\"375.254\"\n      gradientUnits=\"userSpaceOnUse\"\n    >\n      <stop stop-color=\"white\" stop-opacity=\"0\"/>\n      <stop offset=\"1\" stop-color=\"white\"/>\n    </linearGradient>\n    <linearGradient\n      id=\"paint1_linear_2159_162\"\n      x1=\"384.412\"\n      y1=\"105.254\"\n      x2=\"384.412\"\n      y2=\"373.96\"\n      gradientUnits=\"userSpaceOnUse\"\n    >\n      <stop stop-color=\"white\" stop-opacity=\"0\"/>\n      <stop offset=\"1\" stop-color=\"white\"/>\n    </linearGradient>\n    <clipPath id=\"clip0_2159_162\">\n      <rect\n        width=\"34.0649\"\n        height=\"34.0649\"\n        fill=\"white\"\n        transform=\"translate(152.085 193.219) rotate(-28.4251)\"\n      />\n    </clipPath>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/Discord.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  role=\"img\"\n  width=\"20\"\n  height=\"20\"\n  fill=\"currentColor\"\n  viewBox=\"0 0 24 24\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <title>Discord</title>\n  <path\n    d=\"M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z\"\n  ></path>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/Github.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  role=\"img\"\n  viewBox=\"0 0 24 24\"\n  width=\"18\"\n  height=\"18\"\n  fill=\"currentColor\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n>\n  <title>GitHub</title>\n  <path\n    d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\"\n    class={classNames}\n    {...attrs}\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/Logo.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"32\"\n  height=\"32\"\n  viewBox=\"0 0 32 32\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n>\n  <rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#1E1E1E\"/>\n  <path\n    d=\"M14.116 11.5C14.2333 11.5213 14.4947 11.932 14.9 12.732C15.3053 13.5213 15.8013 14.5453 16.388 15.804L16.9 16.876C17.124 16.6307 17.332 16.348 17.524 16.028C17.716 15.6973 17.956 15.2547 18.244 14.7C18.5747 14.0707 18.804 13.6653 18.932 13.484C19.444 12.7053 19.9987 11.8787 20.596 11.004C20.6707 10.8867 20.804 10.8067 20.996 10.764C21.028 10.7533 21.0813 10.748 21.156 10.748C21.412 10.748 21.6947 10.8547 22.004 11.068C22.324 11.2813 22.532 11.5 22.628 11.724C22.6493 11.7667 22.66 11.8467 22.66 11.964C22.66 12.0707 22.6547 12.1507 22.644 12.204C22.6227 12.3 22.596 12.4227 22.564 12.572C22.5427 12.7107 22.5213 12.876 22.5 13.068C22.1587 15.6707 21.9133 17.6227 21.764 18.924C21.7213 19.2227 21.7 19.5587 21.7 19.932L21.668 20.444C21.6573 20.5507 21.652 20.6733 21.652 20.812C21.652 20.9507 21.636 21.0467 21.604 21.1C21.572 21.1533 21.5133 21.18 21.428 21.18C21.364 21.18 21.268 21.164 21.14 21.132C20.7987 21.0573 20.5373 20.924 20.356 20.732C20.1747 20.5293 20.0733 20.252 20.052 19.9C20.0413 19.7187 20.036 19.436 20.036 19.052C20.036 18.6573 20.0467 18.0547 20.068 17.244L20.084 16.364L20.116 15.1C20.02 15.1853 19.7747 15.5213 19.38 16.108C18.996 16.684 18.6173 17.2867 18.244 17.916C17.8707 18.5453 17.6253 19.0307 17.508 19.372C17.4013 19.6387 17.2947 19.836 17.188 19.964C17.092 20.092 16.9747 20.156 16.836 20.156C16.708 20.156 16.5427 20.0973 16.34 19.98C15.764 19.628 15.412 19.1427 15.284 18.524C15.0813 17.5747 14.8307 16.588 14.532 15.564C14.4787 15.372 14.372 15.1107 14.212 14.78C14.0627 14.4493 13.9667 14.2413 13.924 14.156L13.716 13.74C12.1587 15.8093 11.1027 17.708 10.548 19.436C10.4413 19.7987 10.34 20.06 10.244 20.22C10.148 20.3693 10.036 20.444 9.908 20.444C9.70533 20.444 9.41733 20.2627 9.044 19.9C8.756 19.644 8.612 19.356 8.612 19.036C8.612 18.8653 8.644 18.6947 8.708 18.524C9.156 17.2013 9.82267 15.948 10.708 14.764L11.204 14.108C11.876 13.2227 12.3827 12.5293 12.724 12.028C12.98 11.6547 13.3213 11.468 13.748 11.468C13.8227 11.468 13.9453 11.4787 14.116 11.5Z\"\n    fill=\"white\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/Media.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  viewBox=\"0 0 307 194\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  aria-hidden=\"true\"\n  {...attrs}\n>\n  <g clip-path=\"url(#clip0_2159_185)\">\n    <rect width=\"540.733\" height=\"268.161\" rx=\"8.22408\" fill=\"white\"/>\n    <rect\n      x=\"21.9307\"\n      y=\"13.707\"\n      width=\"8.22408\"\n      height=\"8.22408\"\n      rx=\"4.11204\"\n      fill=\"#EC6A5E\"\n    />\n    <rect\n      x=\"35.6377\"\n      y=\"13.707\"\n      width=\"8.22408\"\n      height=\"8.22408\"\n      rx=\"4.11204\"\n      fill=\"#F5BE4F\"\n    />\n    <rect\n      x=\"49.3442\"\n      y=\"13.707\"\n      width=\"8.22408\"\n      height=\"8.22408\"\n      rx=\"4.11204\"\n      fill=\"#62C554\"\n    />\n    <g clip-path=\"url(#clip1_2159_185)\">\n      <rect\n        x=\"21.9307\"\n        y=\"38.3794\"\n        width=\"175.447\"\n        height=\"134.327\"\n        rx=\"5.48272\"\n        fill=\"white\"\n      />\n      <rect\n        width=\"175.447\"\n        height=\"91.8356\"\n        transform=\"translate(21.9307 38.3794)\"\n        fill=\"#F1F1F1\"\n      />\n      <path\n        opacity=\"0.2\"\n        d=\"M39.5785 146.835L42.6626 151.461H34.4385L36.7515 147.863L37.8026 149.498L39.5785 146.835ZM42.6626 139.125V142.723H46.2606L42.6626 139.125Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M40.0065 146.549C39.9596 146.479 39.8959 146.421 39.8213 146.381C39.7466 146.341 39.6633 146.32 39.5786 146.32C39.4939 146.32 39.4105 146.341 39.3359 146.381C39.2612 146.421 39.1976 146.479 39.1507 146.549L37.811 148.56L37.184 147.584C37.1374 147.512 37.0734 147.452 36.9979 147.411C36.9223 147.37 36.8376 147.348 36.7515 147.348C36.6655 147.348 36.5808 147.37 36.5052 147.411C36.4297 147.452 36.3657 147.512 36.3191 147.584L34.0061 151.182C33.9561 151.26 33.928 151.35 33.9247 151.442C33.9213 151.534 33.9429 151.626 33.9871 151.707C34.0314 151.788 34.0967 151.855 34.1761 151.903C34.2555 151.95 34.3462 151.975 34.4385 151.974H42.6626C42.7557 151.975 42.847 151.949 42.9269 151.902C43.0068 151.854 43.0722 151.785 43.1161 151.703C43.16 151.621 43.1808 151.529 43.1763 151.436C43.1718 151.343 43.1422 151.253 43.0905 151.175L40.0065 146.549ZM35.3798 150.946L36.7515 148.813L37.3703 149.776C37.4164 149.848 37.4797 149.908 37.5545 149.949C37.6293 149.99 37.7131 150.012 37.7985 150.013C37.8839 150.013 37.9681 149.993 38.0436 149.953C38.119 149.913 38.1833 149.855 38.2306 149.784L39.5799 147.762L41.7021 150.946H35.3798ZM46.6237 142.359L43.0256 138.761C42.9293 138.664 42.7987 138.61 42.6626 138.61H36.4945C36.2219 138.61 35.9604 138.719 35.7676 138.911C35.5748 139.104 35.4665 139.366 35.4665 139.638V145.292C35.4665 145.429 35.5207 145.559 35.6171 145.656C35.7135 145.752 35.8442 145.806 35.9805 145.806C36.1169 145.806 36.2476 145.752 36.344 145.656C36.4404 145.559 36.4945 145.429 36.4945 145.292V139.638H42.1486V142.722C42.1486 142.859 42.2028 142.989 42.2991 143.086C42.3955 143.182 42.5263 143.236 42.6626 143.236H45.7466V150.946H45.2326C45.0963 150.946 44.9656 151.001 44.8692 151.097C44.7728 151.193 44.7186 151.324 44.7186 151.46C44.7186 151.597 44.7728 151.728 44.8692 151.824C44.9656 151.92 45.0963 151.974 45.2326 151.974H45.7466C46.0193 151.974 46.2808 151.866 46.4735 151.673C46.6663 151.481 46.7746 151.219 46.7746 150.946V142.722C46.7747 142.655 46.7615 142.588 46.7357 142.526C46.7099 142.463 46.672 142.407 46.6243 142.359H46.6237ZM43.1766 140.365L45.02 142.208H43.1766V140.365Z\"\n        fill=\"black\"\n      />\n      <path\n        opacity=\"0.65\"\n        d=\"M55.9428 148.234C55.6111 148.234 55.3124 148.173 55.0467 148.053C54.7809 147.933 54.5697 147.755 54.4129 147.521C54.2585 147.285 54.1812 146.996 54.1812 146.653C54.1812 146.355 54.2369 146.111 54.3482 145.92C54.4618 145.727 54.6139 145.574 54.8048 145.46C54.9956 145.346 55.2091 145.261 55.4453 145.205C55.6838 145.145 55.9291 145.102 56.1813 145.075C56.4902 145.039 56.7412 145.008 56.9343 144.983C57.1273 144.958 57.2682 144.918 57.3568 144.864C57.4454 144.809 57.4897 144.722 57.4897 144.601V144.578C57.4897 144.303 57.409 144.089 57.2477 143.937C57.0887 143.785 56.8559 143.709 56.5493 143.709C56.2267 143.709 55.9723 143.779 55.786 143.92C55.5998 144.061 55.4714 144.222 55.401 144.404L54.3482 144.213C54.4504 143.895 54.6083 143.634 54.8218 143.429C55.0353 143.223 55.2886 143.07 55.5816 142.973C55.8769 142.873 56.1949 142.823 56.5356 142.823C56.7673 142.823 57.0036 142.85 57.2443 142.905C57.4874 142.959 57.7123 143.052 57.919 143.184C58.1257 143.316 58.2926 143.5 58.4198 143.736C58.5493 143.972 58.614 144.272 58.614 144.636V148.125H57.5237V147.409H57.4828C57.4147 147.545 57.3136 147.677 57.1796 147.804C57.0456 147.931 56.8764 148.035 56.6719 148.114C56.4675 148.194 56.2244 148.234 55.9428 148.234ZM56.2119 147.375C56.4777 147.375 56.706 147.323 56.8968 147.218C57.0876 147.111 57.2341 146.973 57.3363 146.803C57.4408 146.63 57.4931 146.444 57.4931 146.244V145.596C57.4544 145.63 57.3863 145.662 57.2886 145.692C57.191 145.719 57.0808 145.744 56.9581 145.767C56.8355 145.787 56.7151 145.805 56.597 145.821C56.4788 145.837 56.3778 145.851 56.2937 145.862C56.1052 145.889 55.9337 145.931 55.7792 145.988C55.6248 146.045 55.5021 146.127 55.4112 146.233C55.3204 146.338 55.275 146.475 55.275 146.646C55.275 146.887 55.3635 147.068 55.5407 147.191C55.7179 147.314 55.9416 147.375 56.2119 147.375ZM63.7425 144.223L62.7169 144.366C62.6828 144.25 62.6249 144.141 62.5431 144.039C62.4636 143.935 62.3557 143.85 62.2194 143.784C62.0831 143.718 61.9139 143.685 61.7117 143.685C61.4392 143.685 61.2109 143.745 61.0269 143.865C60.8452 143.986 60.7543 144.141 60.7543 144.332C60.7543 144.496 60.8156 144.628 60.9383 144.728C61.061 144.827 61.2597 144.91 61.5346 144.976L62.4 145.167C62.8884 145.276 63.2518 145.446 63.4903 145.678C63.7288 145.91 63.8492 146.212 63.8515 146.584C63.8492 146.907 63.7549 147.192 63.5687 147.44C63.3847 147.685 63.128 147.878 62.7987 148.019C62.4716 148.16 62.0934 148.23 61.664 148.23C61.0485 148.23 60.551 148.101 60.1717 147.842C59.7923 147.58 59.5618 147.214 59.48 146.741L60.5737 146.605C60.6328 146.852 60.7543 147.04 60.9383 147.167C61.1223 147.292 61.3619 147.354 61.6572 147.354C61.9707 147.354 62.2217 147.29 62.4102 147.16C62.5988 147.029 62.6942 146.868 62.6964 146.68C62.6942 146.521 62.634 146.39 62.5159 146.288C62.4 146.184 62.2183 146.104 61.9707 146.05L61.0848 145.855C60.5874 145.749 60.2205 145.572 59.9843 145.327C59.748 145.08 59.6311 144.768 59.6333 144.394C59.6311 144.078 59.7174 143.803 59.8923 143.569C60.0672 143.333 60.3102 143.15 60.6214 143.02C60.9349 142.889 61.2949 142.823 61.7015 142.823C62.2898 142.823 62.7532 142.95 63.0917 143.204C63.4324 143.457 63.6493 143.796 63.7425 144.223ZM66.6587 148.22C66.2408 148.22 65.8683 148.113 65.5412 147.9C65.2141 147.686 64.9562 147.378 64.7677 146.976C64.5792 146.572 64.4849 146.086 64.4849 145.518C64.4849 144.943 64.5803 144.455 64.7711 144.053C64.9619 143.651 65.222 143.345 65.5514 143.136C65.8807 142.927 66.2521 142.823 66.6655 142.823C66.9813 142.823 67.238 142.876 67.4356 142.983C67.6332 143.087 67.7888 143.212 67.9024 143.358C68.0159 143.501 68.1034 143.63 68.1647 143.746H68.2192V141.147H69.347V148.125H68.2431V147.303H68.1647C68.1011 147.424 68.0103 147.556 67.8921 147.699C67.7763 147.842 67.6184 147.964 67.4185 148.067C67.2209 148.169 66.9677 148.22 66.6587 148.22ZM66.9449 147.283C67.2221 147.283 67.4572 147.209 67.6502 147.061C67.8433 146.912 67.9898 146.704 68.0898 146.438C68.192 146.17 68.2431 145.86 68.2431 145.508C68.2431 145.156 68.1931 144.849 68.0932 144.588C67.9932 144.327 67.8467 144.124 67.6536 143.981C67.4606 143.836 67.2243 143.763 66.9449 143.763C66.6542 143.763 66.4111 143.839 66.2158 143.992C66.0204 144.141 65.8739 144.348 65.7763 144.612C65.6786 144.873 65.6297 145.172 65.6297 145.508C65.6297 145.846 65.6797 146.149 65.7797 146.417C65.8796 146.686 66.0261 146.897 66.2192 147.051C66.4145 147.206 66.6565 147.283 66.9449 147.283ZM73.0981 142.891V143.777H70.0725V142.891H73.0981ZM70.8629 148.125V142.339C70.8629 142.001 70.9356 141.719 71.081 141.494C71.2264 141.269 71.4195 141.101 71.6602 140.99C71.9033 140.876 72.169 140.819 72.4575 140.819C72.6688 140.819 72.8516 140.836 73.0061 140.871C73.1628 140.902 73.2787 140.932 73.3536 140.959L73.1083 141.852C73.0606 141.836 72.9981 141.819 72.9209 141.801C72.8437 141.783 72.7539 141.773 72.6517 141.773C72.4132 141.773 72.2429 141.831 72.1406 141.947C72.0407 142.063 71.9907 142.23 71.9907 142.448V148.125H70.8629ZM75.2469 148.234C74.9152 148.234 74.6165 148.173 74.3508 148.053C74.085 147.933 73.8738 147.755 73.717 147.521C73.5626 147.285 73.4853 146.996 73.4853 146.653C73.4853 146.355 73.541 146.111 73.6523 145.92C73.7659 145.727 73.9181 145.574 74.1089 145.46C74.2997 145.346 74.5132 145.261 74.7494 145.205C74.9879 145.145 75.2332 145.102 75.4854 145.075C75.7943 145.039 76.0453 145.008 76.2384 144.983C76.4315 144.958 76.5723 144.918 76.6609 144.864C76.7495 144.809 76.7938 144.722 76.7938 144.601V144.578C76.7938 144.303 76.7131 144.089 76.5518 143.937C76.3928 143.785 76.16 143.709 75.8534 143.709C75.5308 143.709 75.2764 143.779 75.0901 143.92C74.9039 144.061 74.7755 144.222 74.7051 144.404L73.6523 144.213C73.7545 143.895 73.9124 143.634 74.1259 143.429C74.3394 143.223 74.5927 143.07 74.8857 142.973C75.181 142.873 75.499 142.823 75.8397 142.823C76.0714 142.823 76.3077 142.85 76.5484 142.905C76.7915 142.959 77.0164 143.052 77.2231 143.184C77.4298 143.316 77.5967 143.5 77.7239 143.736C77.8534 143.972 77.9181 144.272 77.9181 144.636V148.125H76.8278V147.409H76.7869C76.7188 147.545 76.6177 147.677 76.4837 147.804C76.3497 147.931 76.1805 148.035 75.976 148.114C75.7716 148.194 75.5285 148.234 75.2469 148.234ZM75.516 147.375C75.7818 147.375 76.0101 147.323 76.2009 147.218C76.3917 147.111 76.5382 146.973 76.6404 146.803C76.7449 146.63 76.7972 146.444 76.7972 146.244V145.596C76.7585 145.63 76.6904 145.662 76.5927 145.692C76.4951 145.719 76.3849 145.744 76.2622 145.767C76.1396 145.787 76.0192 145.805 75.9011 145.821C75.7829 145.837 75.6819 145.851 75.5978 145.862C75.4093 145.889 75.2378 145.931 75.0833 145.988C74.9289 146.045 74.8062 146.127 74.7153 146.233C74.6245 146.338 74.5791 146.475 74.5791 146.646C74.5791 146.887 74.6676 147.068 74.8448 147.191C75.022 147.314 75.2457 147.375 75.516 147.375ZM83.0466 144.223L82.021 144.366C81.9869 144.25 81.929 144.141 81.8472 144.039C81.7677 143.935 81.6598 143.85 81.5235 143.784C81.3872 143.718 81.218 143.685 81.0159 143.685C80.7433 143.685 80.515 143.745 80.331 143.865C80.1493 143.986 80.0584 144.141 80.0584 144.332C80.0584 144.496 80.1198 144.628 80.2424 144.728C80.3651 144.827 80.5638 144.91 80.8387 144.976L81.7041 145.167C82.1925 145.276 82.5559 145.446 82.7944 145.678C83.0329 145.91 83.1533 146.212 83.1556 146.584C83.1533 146.907 83.0591 147.192 82.8728 147.44C82.6888 147.685 82.4321 147.878 82.1028 148.019C81.7757 148.16 81.3975 148.23 80.9682 148.23C80.3526 148.23 79.8551 148.101 79.4758 147.842C79.0964 147.58 78.8659 147.214 78.7841 146.741L79.8778 146.605C79.9369 146.852 80.0584 147.04 80.2424 147.167C80.4264 147.292 80.666 147.354 80.9613 147.354C81.2748 147.354 81.5258 147.29 81.7143 147.16C81.9029 147.029 81.9983 146.868 82.0005 146.68C81.9983 146.521 81.9381 146.39 81.82 146.288C81.7041 146.184 81.5224 146.104 81.2748 146.05L80.3889 145.855C79.8915 145.749 79.5246 145.572 79.2884 145.327C79.0522 145.08 78.9352 144.768 78.9374 144.394C78.9352 144.078 79.0215 143.803 79.1964 143.569C79.3713 143.333 79.6143 143.15 79.9255 143.02C80.239 142.889 80.599 142.823 81.0056 142.823C81.5939 142.823 82.0573 142.95 82.3958 143.204C82.7365 143.457 82.9534 143.796 83.0466 144.223ZM85.9628 148.22C85.5449 148.22 85.1724 148.113 84.8453 147.9C84.5182 147.686 84.2604 147.378 84.0718 146.976C83.8833 146.572 83.789 146.086 83.789 145.518C83.789 144.943 83.8844 144.455 84.0752 144.053C84.266 143.651 84.5261 143.345 84.8555 143.136C85.1849 142.927 85.5562 142.823 85.9696 142.823C86.2854 142.823 86.5421 142.876 86.7397 142.983C86.9373 143.087 87.0929 143.212 87.2065 143.358C87.32 143.501 87.4075 143.63 87.4688 143.746H87.5233V141.147H88.6511V148.125H87.5472V147.303H87.4688C87.4052 147.424 87.3144 147.556 87.1963 147.699C87.0804 147.842 86.9225 147.964 86.7226 148.067C86.525 148.169 86.2718 148.22 85.9628 148.22ZM86.249 147.283C86.5262 147.283 86.7613 147.209 86.9543 147.061C87.1474 146.912 87.2939 146.704 87.3939 146.438C87.4961 146.17 87.5472 145.86 87.5472 145.508C87.5472 145.156 87.4972 144.849 87.3973 144.588C87.2973 144.327 87.1508 144.124 86.9577 143.981C86.7647 143.836 86.5284 143.763 86.249 143.763C85.9583 143.763 85.7152 143.839 85.5199 143.992C85.3245 144.141 85.178 144.348 85.0804 144.612C84.9827 144.873 84.9339 145.172 84.9339 145.508C84.9339 145.846 84.9838 146.149 85.0838 146.417C85.1837 146.686 85.3302 146.897 85.5233 147.051C85.7186 147.206 85.9606 147.283 86.249 147.283ZM92.4022 142.891V143.777H89.3766V142.891H92.4022ZM90.167 148.125V142.339C90.167 142.001 90.2397 141.719 90.3851 141.494C90.5305 141.269 90.7236 141.101 90.9643 140.99C91.2074 140.876 91.4731 140.819 91.7616 140.819C91.9729 140.819 92.1557 140.836 92.3102 140.871C92.4669 140.902 92.5828 140.932 92.6577 140.959L92.4124 141.852C92.3647 141.836 92.3022 141.819 92.225 141.801C92.1478 141.783 92.0581 141.773 91.9558 141.773C91.7173 141.773 91.547 141.831 91.4448 141.947C91.3448 142.063 91.2948 142.23 91.2948 142.448V148.125H90.167ZM94.551 148.234C94.2193 148.234 93.9206 148.173 93.6549 148.053C93.3891 147.933 93.1779 147.755 93.0211 147.521C92.8667 147.285 92.7894 146.996 92.7894 146.653C92.7894 146.355 92.8451 146.111 92.9564 145.92C93.07 145.727 93.2222 145.574 93.413 145.46C93.6038 145.346 93.8173 145.261 94.0535 145.205C94.292 145.145 94.5374 145.102 94.7895 145.075C95.0984 145.039 95.3494 145.008 95.5425 144.983C95.7356 144.958 95.8764 144.918 95.965 144.864C96.0536 144.809 96.0979 144.722 96.0979 144.601V144.578C96.0979 144.303 96.0172 144.089 95.8559 143.937C95.6969 143.785 95.4641 143.709 95.1575 143.709C94.8349 143.709 94.5805 143.779 94.3942 143.92C94.208 144.061 94.0796 144.222 94.0092 144.404L92.9564 144.213C93.0586 143.895 93.2165 143.634 93.43 143.429C93.6435 143.223 93.8968 143.07 94.1898 142.973C94.4851 142.873 94.8031 142.823 95.1438 142.823C95.3755 142.823 95.6118 142.85 95.8525 142.905C96.0956 142.959 96.3205 143.052 96.5272 143.184C96.7339 143.316 96.9008 143.5 97.028 143.736C97.1575 143.972 97.2222 144.272 97.2222 144.636V148.125H96.1319V147.409H96.091C96.0229 147.545 95.9218 147.677 95.7878 147.804C95.6538 147.931 95.4846 148.035 95.2801 148.114C95.0757 148.194 94.8326 148.234 94.551 148.234ZM94.8202 147.375C95.0859 147.375 95.3142 147.323 95.505 147.218C95.6958 147.111 95.8423 146.973 95.9445 146.803C96.049 146.63 96.1013 146.444 96.1013 146.244V145.596C96.0627 145.63 95.9945 145.662 95.8968 145.692C95.7992 145.719 95.689 145.744 95.5663 145.767C95.4437 145.787 95.3233 145.805 95.2052 145.821C95.0871 145.837 94.986 145.851 94.9019 145.862C94.7134 145.889 94.5419 145.931 94.3874 145.988C94.233 146.045 94.1103 146.127 94.0195 146.233C93.9286 146.338 93.8832 146.475 93.8832 146.646C93.8832 146.887 93.9718 147.068 94.1489 147.191C94.3261 147.314 94.5498 147.375 94.8202 147.375ZM102.351 144.223L101.325 144.366C101.291 144.25 101.233 144.141 101.151 144.039C101.072 143.935 100.964 143.85 100.828 143.784C100.691 143.718 100.522 143.685 100.32 143.685C100.047 143.685 99.8191 143.745 99.6351 143.865C99.4534 143.986 99.3625 144.141 99.3625 144.332C99.3625 144.496 99.4239 144.628 99.5465 144.728C99.6692 144.827 99.8679 144.91 100.143 144.976L101.008 145.167C101.497 145.276 101.86 145.446 102.099 145.678C102.337 145.91 102.457 146.212 102.46 146.584C102.457 146.907 102.363 147.192 102.177 147.44C101.993 147.685 101.736 147.878 101.407 148.019C101.08 148.16 100.702 148.23 100.272 148.23C99.6567 148.23 99.1592 148.101 98.7799 147.842C98.4005 147.58 98.17 147.214 98.0882 146.741L99.1819 146.605C99.241 146.852 99.3625 147.04 99.5465 147.167C99.7305 147.292 99.9701 147.354 100.265 147.354C100.579 147.354 100.83 147.29 101.018 147.16C101.207 147.029 101.302 146.868 101.305 146.68C101.302 146.521 101.242 146.39 101.124 146.288C101.008 146.184 100.826 146.104 100.579 146.05L99.693 145.855C99.1956 145.749 98.8287 145.572 98.5925 145.327C98.3563 145.08 98.2393 144.768 98.2415 144.394C98.2393 144.078 98.3256 143.803 98.5005 143.569C98.6754 143.333 98.9184 143.15 99.2296 143.02C99.5431 142.889 99.9031 142.823 100.31 142.823C100.898 142.823 101.361 142.95 101.7 143.204C102.041 143.457 102.258 143.796 102.351 144.223ZM105.267 148.22C104.849 148.22 104.476 148.113 104.149 147.9C103.822 147.686 103.564 147.378 103.376 146.976C103.187 146.572 103.093 146.086 103.093 145.518C103.093 144.943 103.189 144.455 103.379 144.053C103.57 143.651 103.83 143.345 104.16 143.136C104.489 142.927 104.86 142.823 105.274 142.823C105.589 142.823 105.846 142.876 106.044 142.983C106.241 143.087 106.397 143.212 106.511 143.358C106.624 143.501 106.712 143.63 106.773 143.746H106.827V141.147H107.955V148.125H106.851V147.303H106.773C106.709 147.424 106.618 147.556 106.5 147.699C106.385 147.842 106.227 147.964 106.027 148.067C105.829 148.169 105.576 148.22 105.267 148.22ZM105.553 147.283C105.83 147.283 106.065 147.209 106.258 147.061C106.452 146.912 106.598 146.704 106.698 146.438C106.8 146.17 106.851 145.86 106.851 145.508C106.851 145.156 106.801 144.849 106.701 144.588C106.601 144.327 106.455 144.124 106.262 143.981C106.069 143.836 105.833 143.763 105.553 143.763C105.262 143.763 105.019 143.839 104.824 143.992C104.629 144.141 104.482 144.348 104.384 144.612C104.287 144.873 104.238 145.172 104.238 145.508C104.238 145.846 104.288 146.149 104.388 146.417C104.488 146.686 104.634 146.897 104.827 147.051C105.023 147.206 105.265 147.283 105.553 147.283ZM111.706 142.891V143.777H108.681V142.891H111.706ZM109.471 148.125V142.339C109.471 142.001 109.544 141.719 109.689 141.494C109.835 141.269 110.028 141.101 110.268 140.99C110.511 140.876 110.777 140.819 111.066 140.819C111.277 140.819 111.46 140.836 111.614 140.871C111.771 140.902 111.887 140.932 111.962 140.959L111.717 141.852C111.669 141.836 111.606 141.819 111.529 141.801C111.452 141.783 111.362 141.773 111.26 141.773C111.021 141.773 110.851 141.831 110.749 141.947C110.649 142.063 110.599 142.23 110.599 142.448V148.125H109.471ZM112.683 148.196C112.485 148.196 112.316 148.127 112.175 147.988C112.034 147.847 111.965 147.678 111.967 147.481C111.965 147.285 112.034 147.118 112.175 146.98C112.316 146.839 112.485 146.768 112.683 146.768C112.874 146.768 113.04 146.839 113.18 146.98C113.324 147.118 113.396 147.285 113.399 147.481C113.396 147.61 113.362 147.729 113.296 147.838C113.23 147.947 113.144 148.035 113.037 148.101C112.931 148.164 112.812 148.196 112.683 148.196ZM114.504 150.087V142.891H115.608V143.746H115.686C115.75 143.63 115.838 143.501 115.952 143.358C116.065 143.212 116.221 143.087 116.419 142.983C116.616 142.876 116.873 142.823 117.189 142.823C117.6 142.823 117.97 142.927 118.3 143.136C118.629 143.345 118.889 143.651 119.08 144.053C119.271 144.455 119.366 144.943 119.366 145.518C119.366 146.086 119.272 146.572 119.083 146.976C118.895 147.378 118.637 147.686 118.31 147.9C117.983 148.113 117.61 148.22 117.192 148.22C116.883 148.22 116.629 148.169 116.429 148.067C116.231 147.964 116.073 147.842 115.955 147.699C115.839 147.556 115.75 147.424 115.686 147.303H115.632V150.087H114.504ZM115.611 145.508C115.611 145.86 115.661 146.17 115.761 146.438C115.863 146.704 116.011 146.912 116.204 147.061C116.397 147.209 116.632 147.283 116.909 147.283C117.196 147.283 117.436 147.206 117.632 147.051C117.827 146.897 117.974 146.686 118.071 146.417C118.171 146.149 118.221 145.846 118.221 145.508C118.221 145.172 118.172 144.873 118.075 144.612C117.977 144.348 117.83 144.141 117.635 143.992C117.442 143.839 117.2 143.763 116.909 143.763C116.628 143.763 116.39 143.836 116.197 143.981C116.004 144.124 115.858 144.327 115.758 144.588C115.66 144.849 115.611 145.156 115.611 145.508ZM121.385 145.038V148.125H120.257V142.891H121.338V143.763H121.402C121.523 143.477 121.714 143.249 121.975 143.078C122.236 142.908 122.562 142.823 122.953 142.823C123.312 142.823 123.625 142.899 123.893 143.051C124.161 143.203 124.369 143.426 124.517 143.719C124.666 144.012 124.741 144.371 124.741 144.796V148.125H123.614V144.952C123.614 144.587 123.519 144.302 123.331 144.097C123.142 143.89 122.882 143.787 122.551 143.787C122.323 143.787 122.122 143.836 121.947 143.934C121.773 144.031 121.635 144.173 121.535 144.36C121.435 144.546 121.385 144.772 121.385 145.038ZM128.078 150.196C127.648 150.196 127.279 150.139 126.97 150.026C126.664 149.912 126.415 149.76 126.224 149.569C126.033 149.378 125.896 149.169 125.812 148.942L126.803 148.629C126.86 148.729 126.938 148.832 127.038 148.939C127.138 149.046 127.274 149.135 127.444 149.208C127.614 149.283 127.831 149.32 128.095 149.32C128.463 149.32 128.767 149.231 129.008 149.051C129.249 148.874 129.369 148.586 129.369 148.186V147.177H129.304C129.243 147.298 129.153 147.426 129.035 147.562C128.917 147.696 128.758 147.811 128.558 147.906C128.358 148 128.104 148.046 127.795 148.046C127.388 148.046 127.02 147.951 126.691 147.76C126.364 147.567 126.104 147.281 125.911 146.901C125.718 146.522 125.621 146.051 125.621 145.487C125.621 144.924 125.716 144.444 125.907 144.046C126.098 143.649 126.358 143.345 126.687 143.136C127.017 142.927 127.388 142.823 127.802 142.823C128.117 142.823 128.374 142.876 128.572 142.983C128.772 143.087 128.929 143.212 129.045 143.358C129.161 143.501 129.25 143.63 129.311 143.746H129.379V142.891H130.487V148.227C130.487 148.674 130.381 149.043 130.17 149.334C129.961 149.625 129.674 149.841 129.311 149.981C128.948 150.125 128.536 150.196 128.078 150.196ZM128.081 147.14C128.358 147.14 128.593 147.074 128.786 146.942C128.979 146.81 129.126 146.621 129.226 146.373C129.328 146.123 129.379 145.826 129.379 145.481C129.379 145.138 129.329 144.838 129.229 144.581C129.129 144.324 128.983 144.124 128.79 143.981C128.597 143.836 128.36 143.763 128.081 143.763C127.79 143.763 127.547 143.838 127.352 143.988C127.157 144.138 127.01 144.342 126.912 144.601C126.815 144.86 126.766 145.153 126.766 145.481C126.766 145.812 126.816 146.103 126.916 146.353C127.016 146.6 127.162 146.793 127.355 146.932C127.551 147.071 127.793 147.14 128.081 147.14Z\"\n        fill=\"black\"\n      />\n      <path\n        opacity=\"0.65\"\n        d=\"M54.5017 160.146V159.557L56.5022 157.428C56.7261 157.186 56.9101 156.976 57.0542 156.797C57.2002 156.618 57.3092 156.447 57.3813 156.286C57.4533 156.124 57.4893 155.954 57.4893 155.775C57.4893 155.57 57.4407 155.394 57.3433 155.246C57.246 155.096 57.1136 154.98 56.9461 154.898C56.7787 154.817 56.5908 154.776 56.3825 154.776C56.1605 154.776 55.9668 154.821 55.8013 154.913C55.6377 155.004 55.5112 155.132 55.4216 155.296C55.3321 155.459 55.2873 155.652 55.2873 155.874H54.5134C54.5134 155.518 54.5951 155.205 54.7587 154.936C54.9222 154.668 55.1471 154.458 55.4333 154.308C55.7195 154.159 56.0427 154.084 56.4029 154.084C56.767 154.084 57.0883 154.159 57.3667 154.308C57.6451 154.456 57.8632 154.657 58.0209 154.91C58.1786 155.163 58.2574 155.446 58.2574 155.76C58.2574 155.982 58.2165 156.199 58.1348 156.411C58.0549 156.621 57.9157 156.856 57.7171 157.115C57.5185 157.372 57.2421 157.685 56.8877 158.052L55.6231 159.393V159.44H58.3567V160.146H54.5017ZM59.5033 160.146L62.1463 154.919V154.872H59.0944V154.165H62.9874V154.904L60.3532 160.146H59.5033ZM63.3564 160.199C63.2046 160.199 63.0741 160.145 62.9651 160.036C62.8561 159.926 62.8016 159.796 62.8016 159.644C62.8016 159.492 62.8561 159.362 62.9651 159.253C63.0741 159.144 63.2046 159.089 63.3564 159.089C63.5083 159.089 63.6388 159.144 63.7478 159.253C63.8568 159.362 63.9113 159.492 63.9113 159.644C63.9113 159.743 63.886 159.836 63.8354 159.922C63.7848 160.005 63.7176 160.072 63.6339 160.123C63.5521 160.174 63.4596 160.199 63.3564 160.199ZM64.7644 158.949V158.286L67.3724 154.165H67.8747V155.164H67.5476L65.6289 158.199V158.245H69.1714V158.949H64.7644ZM67.5885 160.146V158.748L67.5944 158.444V154.165H68.3654V160.146H67.5885ZM71.9447 160.228C71.5904 160.228 71.272 160.159 70.9897 160.021C70.7074 159.881 70.4815 159.69 70.3122 159.448C70.1428 159.205 70.0513 158.929 70.0376 158.619H70.8262C70.8515 158.882 70.9702 159.099 71.1825 159.27C71.3947 159.442 71.6488 159.527 71.9447 159.527C72.1822 159.527 72.3935 159.472 72.5785 159.361C72.7654 159.25 72.9114 159.098 73.0165 158.905C73.1236 158.711 73.1772 158.49 73.1772 158.242C73.1772 157.989 73.1217 157.764 73.0107 157.568C72.9017 157.369 72.7508 157.213 72.558 157.1C72.3672 156.986 72.1491 156.927 71.9038 156.925C71.7228 156.923 71.5378 156.952 71.3489 157.013C71.162 157.073 71.0082 157.151 70.8875 157.246L70.1369 157.144L70.4845 154.165H73.6853V154.872H71.1649L70.9634 156.592H70.9985C71.1192 156.487 71.274 156.4 71.4628 156.329C71.6536 156.259 71.8542 156.224 72.0644 156.224C72.4285 156.224 72.7537 156.311 73.0399 156.484C73.328 156.657 73.5539 156.895 73.7174 157.197C73.881 157.497 73.9628 157.84 73.9628 158.228C73.9628 158.613 73.8761 158.957 73.7028 159.259C73.5296 159.558 73.2911 159.795 72.9873 159.968C72.6836 160.142 72.3361 160.228 71.9447 160.228ZM77.0058 160.146V154.165H77.8206V157.077H77.8936L80.4899 154.165H81.5325L79.0676 156.861L81.5354 160.146H80.5542L78.5478 157.419L77.8206 158.248V160.146H77.0058ZM82.2133 160.146V154.165H84.3569C84.7775 154.165 85.125 154.236 85.3995 154.379C85.6741 154.519 85.8785 154.709 86.0128 154.948C86.1472 155.186 86.2143 155.451 86.2143 155.745C86.2143 155.998 86.1686 156.21 86.0771 156.379C85.9875 156.547 85.8678 156.681 85.7179 156.782C85.5679 156.881 85.4044 156.954 85.2272 157.001V157.06C85.418 157.071 85.6069 157.135 85.7938 157.252C85.9807 157.369 86.1365 157.537 86.2611 157.755C86.3857 157.971 86.448 158.235 86.448 158.546C86.448 158.85 86.3779 159.122 86.2377 159.364C86.0995 159.603 85.8824 159.794 85.5864 159.936C85.2905 160.076 84.9089 160.146 84.4416 160.146H82.2133ZM83.0281 159.44H84.3978C84.8514 159.44 85.1746 159.352 85.3674 159.177C85.5602 159 85.6565 158.783 85.6565 158.526C85.6565 158.329 85.6069 158.149 85.5076 157.985C85.4083 157.82 85.2671 157.688 85.0841 157.591C84.9011 157.492 84.684 157.442 84.4328 157.442H83.0281V159.44ZM83.0281 156.773H84.3043C84.5146 156.773 84.7045 156.732 84.8738 156.651C85.0432 156.569 85.1776 156.454 85.2769 156.306C85.3781 156.156 85.4287 155.981 85.4287 155.78C85.4287 155.525 85.3392 155.31 85.16 155.135C84.9829 154.96 84.7054 154.872 84.3277 154.872H83.0281V156.773ZM91.5098 154.165V160.146H90.6949V154.998H90.6599L89.2143 155.95V155.15L90.7066 154.165H91.5098ZM93.1674 160.146L95.8105 154.919V154.872H92.7586V154.165H96.6516V154.904L94.0173 160.146H93.1674ZM104.756 157.156C104.756 157.791 104.64 158.338 104.408 158.797C104.178 159.257 103.862 159.61 103.459 159.857C103.058 160.105 102.601 160.228 102.089 160.228C101.575 160.228 101.118 160.105 100.717 159.857C100.316 159.608 99.9993 159.255 99.7676 158.797C99.5378 158.338 99.423 157.791 99.423 157.156C99.423 156.521 99.5378 155.974 99.7676 155.515C99.9993 155.055 100.316 154.702 100.717 154.454C101.118 154.207 101.575 154.084 102.089 154.084C102.601 154.084 103.058 154.207 103.459 154.454C103.862 154.702 104.178 155.055 104.408 155.515C104.64 155.974 104.756 156.521 104.756 157.156ZM103.958 157.156C103.958 156.654 103.876 156.23 103.71 155.885C103.547 155.541 103.324 155.28 103.041 155.103C102.759 154.924 102.442 154.834 102.089 154.834C101.737 154.834 101.42 154.924 101.137 155.103C100.855 155.28 100.631 155.541 100.466 155.885C100.302 156.23 100.22 156.654 100.22 157.156C100.22 157.658 100.302 158.082 100.466 158.426C100.631 158.771 100.855 159.033 101.137 159.212C101.42 159.389 101.737 159.478 102.089 159.478C102.442 159.478 102.759 159.389 103.041 159.212C103.324 159.033 103.547 158.771 103.71 158.426C103.876 158.082 103.958 157.658 103.958 157.156ZM107.492 160.24C107.065 160.24 106.697 160.141 106.388 159.942C106.08 159.743 105.844 159.47 105.678 159.121C105.513 158.773 105.43 158.375 105.43 157.927C105.43 157.469 105.515 157.066 105.684 156.718C105.855 156.369 106.094 156.097 106.4 155.9C106.707 155.702 107.067 155.602 107.48 155.602C107.809 155.602 108.103 155.663 108.362 155.783C108.623 155.904 108.835 156.073 108.999 156.291C109.162 156.51 109.262 156.765 109.297 157.057H108.526C108.477 156.848 108.365 156.667 108.19 156.513C108.017 156.358 107.785 156.28 107.495 156.28C107.238 156.28 107.013 156.347 106.82 156.481C106.629 156.614 106.48 156.802 106.373 157.045C106.268 157.288 106.216 157.575 106.216 157.904C106.216 158.24 106.268 158.532 106.373 158.78C106.478 159.027 106.626 159.219 106.817 159.355C107.01 159.491 107.236 159.559 107.495 159.559C107.668 159.559 107.825 159.528 107.965 159.466C108.105 159.404 108.224 159.315 108.321 159.2C108.419 159.083 108.487 158.944 108.526 158.783H109.297C109.26 159.059 109.163 159.307 109.008 159.527C108.854 159.747 108.648 159.922 108.391 160.05C108.134 160.177 107.834 160.24 107.492 160.24ZM112.087 155.661V156.303H109.701V155.661H112.087ZM110.367 154.586H111.149V158.847C111.149 159.028 111.176 159.164 111.228 159.256C111.283 159.345 111.353 159.406 111.438 159.437C111.524 159.468 111.617 159.484 111.716 159.484C111.788 159.484 111.849 159.48 111.9 159.472C111.95 159.462 111.99 159.454 112.02 159.448L112.169 160.117C112.12 160.135 112.051 160.153 111.961 160.173C111.874 160.194 111.763 160.205 111.628 160.205C111.416 160.207 111.214 160.166 111.021 160.082C110.828 159.997 110.67 159.866 110.548 159.691C110.427 159.516 110.367 159.295 110.367 159.028V154.586ZM114.917 160.146V159.557L116.917 157.428C117.141 157.186 117.325 156.976 117.469 156.797C117.615 156.618 117.724 156.447 117.796 156.286C117.868 156.124 117.904 155.954 117.904 155.775C117.904 155.57 117.856 155.394 117.758 155.246C117.661 155.096 117.528 154.98 117.361 154.898C117.194 154.817 117.006 154.776 116.797 154.776C116.575 154.776 116.382 154.821 116.216 154.913C116.053 155.004 115.926 155.132 115.837 155.296C115.747 155.459 115.702 155.652 115.702 155.874H114.928C114.928 155.518 115.01 155.205 115.174 154.936C115.337 154.668 115.562 154.458 115.848 154.308C116.134 154.159 116.458 154.084 116.818 154.084C117.182 154.084 117.503 154.159 117.782 154.308C118.06 154.456 118.278 154.657 118.436 154.91C118.593 155.163 118.672 155.446 118.672 155.76C118.672 155.982 118.631 156.199 118.55 156.411C118.47 156.621 118.331 156.856 118.132 157.115C117.933 157.372 117.657 157.685 117.303 158.052L116.038 159.393V159.44H118.772V160.146H114.917ZM121.741 160.237C121.291 160.237 120.906 160.116 120.587 159.875C120.27 159.633 120.026 159.283 119.857 158.824C119.687 158.364 119.604 157.808 119.606 157.156C119.606 156.51 119.69 155.958 119.86 155.5C120.029 155.041 120.273 154.69 120.59 154.449C120.909 154.205 121.293 154.084 121.741 154.084C122.186 154.084 122.569 154.205 122.888 154.449C123.208 154.69 123.452 155.041 123.621 155.5C123.791 155.958 123.875 156.51 123.875 157.156C123.875 157.808 123.791 158.364 123.621 158.824C123.454 159.283 123.211 159.633 122.891 159.875C122.574 160.116 122.19 160.237 121.741 160.237ZM121.741 159.527C122.161 159.527 122.489 159.324 122.725 158.917C122.96 158.508 123.077 157.921 123.075 157.156C123.075 156.65 123.022 156.22 122.915 155.868C122.808 155.514 122.655 155.244 122.456 155.059C122.257 154.874 122.019 154.782 121.741 154.782C121.324 154.782 120.997 154.988 120.759 155.401C120.522 155.812 120.403 156.397 120.403 157.156C120.403 157.664 120.456 158.095 120.561 158.45C120.668 158.802 120.821 159.07 121.019 159.253C121.22 159.436 121.46 159.527 121.741 159.527ZM124.745 160.146V159.557L126.746 157.428C126.97 157.186 127.154 156.976 127.298 156.797C127.444 156.618 127.553 156.447 127.625 156.286C127.697 156.124 127.733 155.954 127.733 155.775C127.733 155.57 127.684 155.394 127.587 155.246C127.49 155.096 127.357 154.98 127.19 154.898C127.022 154.817 126.834 154.776 126.626 154.776C126.404 154.776 126.21 154.821 126.045 154.913C125.881 155.004 125.755 155.132 125.665 155.296C125.576 155.459 125.531 155.652 125.531 155.874H124.757C124.757 155.518 124.839 155.205 125.002 154.936C125.166 154.668 125.391 154.458 125.677 154.308C125.963 154.159 126.286 154.084 126.647 154.084C127.011 154.084 127.332 154.159 127.61 154.308C127.889 154.456 128.107 154.657 128.264 154.91C128.422 155.163 128.501 155.446 128.501 155.76C128.501 155.982 128.46 156.199 128.378 156.411C128.299 156.621 128.159 156.856 127.961 157.115C127.762 157.372 127.486 157.685 127.131 158.052L125.867 159.393V159.44H128.6V160.146H124.745ZM131.473 160.228C131.119 160.228 130.8 160.159 130.518 160.021C130.236 159.881 130.01 159.69 129.84 159.448C129.671 159.205 129.579 158.929 129.566 158.619H130.354C130.38 158.882 130.498 159.099 130.711 159.27C130.923 159.442 131.177 159.527 131.473 159.527C131.71 159.527 131.922 159.472 132.107 159.361C132.294 159.25 132.44 159.098 132.545 158.905C132.652 158.711 132.705 158.49 132.705 158.242C132.705 157.989 132.65 157.764 132.539 157.568C132.43 157.369 132.279 157.213 132.086 157.1C131.895 156.986 131.677 156.927 131.432 156.925C131.251 156.923 131.066 156.952 130.877 157.013C130.69 157.073 130.536 157.151 130.416 157.246L129.665 157.144L130.013 154.165H133.214V154.872H130.693L130.492 156.592H130.527C130.647 156.487 130.802 156.4 130.991 156.329C131.182 156.259 131.382 156.224 131.593 156.224C131.957 156.224 132.282 156.311 132.568 156.484C132.856 156.657 133.082 156.895 133.246 157.197C133.409 157.497 133.491 157.84 133.491 158.228C133.491 158.613 133.404 158.957 133.231 159.259C133.058 159.558 132.819 159.795 132.516 159.968C132.212 160.142 131.864 160.228 131.473 160.228Z\"\n        fill=\"black\"\n      />\n    </g>\n    <rect\n      x=\"22.4447\"\n      y=\"38.8934\"\n      width=\"174.419\"\n      height=\"133.299\"\n      rx=\"4.96872\"\n      stroke=\"black\"\n      stroke-opacity=\"0.12\"\n      stroke-width=\"1.02801\"\n    />\n    <g clip-path=\"url(#clip2_2159_185)\">\n      <rect\n        x=\"213.826\"\n        y=\"38.3794\"\n        width=\"175.447\"\n        height=\"134.327\"\n        rx=\"5.48272\"\n        fill=\"white\"\n      />\n      <rect\n        width=\"175.447\"\n        height=\"91.8356\"\n        transform=\"translate(213.826 38.3794)\"\n        fill=\"#F1F1F1\"\n      />\n      <path\n        opacity=\"0.2\"\n        d=\"M231.474 146.835L234.558 151.461H226.333L228.647 147.863L229.698 149.498L231.474 146.835ZM234.558 139.125V142.723H238.156L234.558 139.125Z\"\n        fill=\"black\"\n      />\n      <path\n        d=\"M231.902 146.549C231.855 146.479 231.791 146.421 231.716 146.381C231.642 146.341 231.558 146.32 231.474 146.32C231.389 146.32 231.306 146.341 231.231 146.381C231.156 146.421 231.093 146.479 231.046 146.549L229.706 148.56L229.079 147.584C229.032 147.512 228.968 147.452 228.893 147.411C228.817 147.37 228.733 147.348 228.647 147.348C228.56 147.348 228.476 147.37 228.4 147.411C228.325 147.452 228.261 147.512 228.214 147.584L225.901 151.182C225.851 151.26 225.823 151.35 225.82 151.442C225.816 151.534 225.838 151.626 225.882 151.707C225.926 151.788 225.992 151.855 226.071 151.903C226.151 151.95 226.241 151.975 226.334 151.974H234.558C234.651 151.975 234.742 151.949 234.822 151.902C234.902 151.854 234.967 151.785 235.011 151.703C235.055 151.621 235.076 151.529 235.071 151.436C235.067 151.343 235.037 151.253 234.986 151.175L231.902 146.549ZM227.275 150.946L228.647 148.813L229.265 149.776C229.311 149.848 229.375 149.908 229.45 149.949C229.524 149.99 229.608 150.012 229.694 150.013C229.779 150.013 229.863 149.993 229.939 149.953C230.014 149.913 230.078 149.855 230.126 149.784L231.475 147.762L233.597 150.946H227.275ZM238.519 142.359L234.921 138.761C234.824 138.664 234.694 138.61 234.558 138.61H228.39C228.117 138.61 227.855 138.719 227.663 138.911C227.47 139.104 227.362 139.366 227.362 139.638V145.292C227.362 145.429 227.416 145.559 227.512 145.656C227.608 145.752 227.739 145.806 227.876 145.806C228.012 145.806 228.143 145.752 228.239 145.656C228.335 145.559 228.39 145.429 228.39 145.292V139.638H234.044V142.722C234.044 142.859 234.098 142.989 234.194 143.086C234.291 143.182 234.421 143.236 234.558 143.236H237.642V150.946H237.128C236.991 150.946 236.861 151.001 236.764 151.097C236.668 151.193 236.614 151.324 236.614 151.46C236.614 151.597 236.668 151.728 236.764 151.824C236.861 151.92 236.991 151.974 237.128 151.974H237.642C237.914 151.974 238.176 151.866 238.369 151.673C238.561 151.481 238.67 151.219 238.67 150.946V142.722C238.67 142.655 238.656 142.588 238.631 142.526C238.605 142.463 238.567 142.407 238.519 142.359H238.519ZM235.072 140.365L236.915 142.208H235.072V140.365Z\"\n        fill=\"black\"\n      />\n      <path\n        opacity=\"0.65\"\n        d=\"M247.525 148.234C247.194 148.234 246.895 148.173 246.629 148.053C246.364 147.933 246.152 147.755 245.996 147.521C245.841 147.285 245.764 146.996 245.764 146.653C245.764 146.355 245.819 146.111 245.931 145.92C246.044 145.727 246.197 145.574 246.387 145.46C246.578 145.346 246.792 145.261 247.028 145.205C247.266 145.145 247.512 145.102 247.764 145.075C248.073 145.039 248.324 145.008 248.517 144.983C248.71 144.958 248.851 144.918 248.939 144.864C249.028 144.809 249.072 144.722 249.072 144.601V144.578C249.072 144.303 248.992 144.089 248.83 143.937C248.671 143.785 248.439 143.709 248.132 143.709C247.809 143.709 247.555 143.779 247.369 143.92C247.182 144.061 247.054 144.222 246.984 144.404L245.931 144.213C246.033 143.895 246.191 143.634 246.404 143.429C246.618 143.223 246.871 143.07 247.164 142.973C247.46 142.873 247.778 142.823 248.118 142.823C248.35 142.823 248.586 142.85 248.827 142.905C249.07 142.959 249.295 143.052 249.502 143.184C249.708 143.316 249.875 143.5 250.002 143.736C250.132 143.972 250.197 144.272 250.197 144.636V148.125H249.106V147.409H249.065C248.997 147.545 248.896 147.677 248.762 147.804C248.628 147.931 248.459 148.035 248.255 148.114C248.05 148.194 247.807 148.234 247.525 148.234ZM247.795 147.375C248.06 147.375 248.289 147.323 248.479 147.218C248.67 147.111 248.817 146.973 248.919 146.803C249.023 146.63 249.076 146.444 249.076 146.244V145.596C249.037 145.63 248.969 145.662 248.871 145.692C248.774 145.719 248.663 145.744 248.541 145.767C248.418 145.787 248.298 145.805 248.18 145.821C248.061 145.837 247.96 145.851 247.876 145.862C247.688 145.889 247.516 145.931 247.362 145.988C247.207 146.045 247.085 146.127 246.994 146.233C246.903 146.338 246.858 146.475 246.858 146.646C246.858 146.887 246.946 147.068 247.123 147.191C247.3 147.314 247.524 147.375 247.795 147.375ZM255.325 144.223L254.299 144.366C254.265 144.25 254.207 144.141 254.126 144.039C254.046 143.935 253.938 143.85 253.802 143.784C253.666 143.718 253.497 143.685 253.294 143.685C253.022 143.685 252.793 143.745 252.609 143.865C252.428 143.986 252.337 144.141 252.337 144.332C252.337 144.496 252.398 144.628 252.521 144.728C252.644 144.827 252.842 144.91 253.117 144.976L253.983 145.167C254.471 145.276 254.834 145.446 255.073 145.678C255.311 145.91 255.432 146.212 255.434 146.584C255.432 146.907 255.338 147.192 255.151 147.44C254.967 147.685 254.711 147.878 254.381 148.019C254.054 148.16 253.676 148.23 253.247 148.23C252.631 148.23 252.134 148.101 251.754 147.842C251.375 147.58 251.144 147.214 251.063 146.741L252.156 146.605C252.215 146.852 252.337 147.04 252.521 147.167C252.705 147.292 252.945 147.354 253.24 147.354C253.553 147.354 253.804 147.29 253.993 147.16C254.181 147.029 254.277 146.868 254.279 146.68C254.277 146.521 254.217 146.39 254.098 146.288C253.983 146.184 253.801 146.104 253.553 146.05L252.667 145.855C252.17 145.749 251.803 145.572 251.567 145.327C251.331 145.08 251.214 144.768 251.216 144.394C251.214 144.078 251.3 143.803 251.475 143.569C251.65 143.333 251.893 143.15 252.204 143.02C252.518 142.889 252.878 142.823 253.284 142.823C253.872 142.823 254.336 142.95 254.674 143.204C255.015 143.457 255.232 143.796 255.325 144.223ZM258.241 148.22C257.823 148.22 257.451 148.113 257.124 147.9C256.797 147.686 256.539 147.378 256.35 146.976C256.162 146.572 256.068 146.086 256.068 145.518C256.068 144.943 256.163 144.455 256.354 144.053C256.545 143.651 256.805 143.345 257.134 143.136C257.463 142.927 257.835 142.823 258.248 142.823C258.564 142.823 258.821 142.876 259.018 142.983C259.216 143.087 259.371 143.212 259.485 143.358C259.599 143.501 259.686 143.63 259.747 143.746H259.802V141.147H260.93V148.125H259.826V147.303H259.747C259.684 147.424 259.593 147.556 259.475 147.699C259.359 147.842 259.201 147.964 259.001 148.067C258.804 148.169 258.55 148.22 258.241 148.22ZM258.528 147.283C258.805 147.283 259.04 147.209 259.233 147.061C259.426 146.912 259.572 146.704 259.672 146.438C259.775 146.17 259.826 145.86 259.826 145.508C259.826 145.156 259.776 144.849 259.676 144.588C259.576 144.327 259.429 144.124 259.236 143.981C259.043 143.836 258.807 143.763 258.528 143.763C258.237 143.763 257.994 143.839 257.798 143.992C257.603 144.141 257.457 144.348 257.359 144.612C257.261 144.873 257.212 145.172 257.212 145.508C257.212 145.846 257.262 146.149 257.362 146.417C257.462 146.686 257.609 146.897 257.802 147.051C257.997 147.206 258.239 147.283 258.528 147.283ZM264.681 142.891V143.777H261.655V142.891H264.681ZM262.446 148.125V142.339C262.446 142.001 262.518 141.719 262.664 141.494C262.809 141.269 263.002 141.101 263.243 140.99C263.486 140.876 263.752 140.819 264.04 140.819C264.251 140.819 264.434 140.836 264.589 140.871C264.745 140.902 264.861 140.932 264.936 140.959L264.691 141.852C264.643 141.836 264.581 141.819 264.504 141.801C264.426 141.783 264.337 141.773 264.234 141.773C263.996 141.773 263.825 141.831 263.723 141.947C263.623 142.063 263.573 142.23 263.573 142.448V148.125H262.446ZM266.829 148.234C266.498 148.234 266.199 148.173 265.933 148.053C265.668 147.933 265.456 147.755 265.3 147.521C265.145 147.285 265.068 146.996 265.068 146.653C265.068 146.355 265.124 146.111 265.235 145.92C265.348 145.727 265.501 145.574 265.691 145.46C265.882 145.346 266.096 145.261 266.332 145.205C266.571 145.145 266.816 145.102 267.068 145.075C267.377 145.039 267.628 145.008 267.821 144.983C268.014 144.958 268.155 144.918 268.243 144.864C268.332 144.809 268.376 144.722 268.376 144.601V144.578C268.376 144.303 268.296 144.089 268.134 143.937C267.975 143.785 267.743 143.709 267.436 143.709C267.113 143.709 266.859 143.779 266.673 143.92C266.486 144.061 266.358 144.222 266.288 144.404L265.235 144.213C265.337 143.895 265.495 143.634 265.709 143.429C265.922 143.223 266.175 143.07 266.468 142.973C266.764 142.873 267.082 142.823 267.422 142.823C267.654 142.823 267.89 142.85 268.131 142.905C268.374 142.959 268.599 143.052 268.806 143.184C269.012 143.316 269.179 143.5 269.307 143.736C269.436 143.972 269.501 144.272 269.501 144.636V148.125H268.41V147.409H268.37C268.301 147.545 268.2 147.677 268.066 147.804C267.932 147.931 267.763 148.035 267.559 148.114C267.354 148.194 267.111 148.234 266.829 148.234ZM267.099 147.375C267.364 147.375 267.593 147.323 267.784 147.218C267.974 147.111 268.121 146.973 268.223 146.803C268.328 146.63 268.38 146.444 268.38 146.244V145.596C268.341 145.63 268.273 145.662 268.175 145.692C268.078 145.719 267.967 145.744 267.845 145.767C267.722 145.787 267.602 145.805 267.484 145.821C267.366 145.837 267.264 145.851 267.18 145.862C266.992 145.889 266.82 145.931 266.666 145.988C266.511 146.045 266.389 146.127 266.298 146.233C266.207 146.338 266.162 146.475 266.162 146.646C266.162 146.887 266.25 147.068 266.427 147.191C266.605 147.314 266.828 147.375 267.099 147.375ZM274.629 144.223L273.604 144.366C273.57 144.25 273.512 144.141 273.43 144.039C273.35 143.935 273.242 143.85 273.106 143.784C272.97 143.718 272.801 143.685 272.598 143.685C272.326 143.685 272.098 143.745 271.914 143.865C271.732 143.986 271.641 144.141 271.641 144.332C271.641 144.496 271.702 144.628 271.825 144.728C271.948 144.827 272.146 144.91 272.421 144.976L273.287 145.167C273.775 145.276 274.139 145.446 274.377 145.678C274.616 145.91 274.736 146.212 274.738 146.584C274.736 146.907 274.642 147.192 274.455 147.44C274.271 147.685 274.015 147.878 273.685 148.019C273.358 148.16 272.98 148.23 272.551 148.23C271.935 148.23 271.438 148.101 271.058 147.842C270.679 147.58 270.448 147.214 270.367 146.741L271.46 146.605C271.52 146.852 271.641 147.04 271.825 147.167C272.009 147.292 272.249 147.354 272.544 147.354C272.857 147.354 273.108 147.29 273.297 147.16C273.485 147.029 273.581 146.868 273.583 146.68C273.581 146.521 273.521 146.39 273.403 146.288C273.287 146.184 273.105 146.104 272.857 146.05L271.972 145.855C271.474 145.749 271.107 145.572 270.871 145.327C270.635 145.08 270.518 144.768 270.52 144.394C270.518 144.078 270.604 143.803 270.779 143.569C270.954 143.333 271.197 143.15 271.508 143.02C271.822 142.889 272.182 142.823 272.588 142.823C273.177 142.823 273.64 142.95 273.978 143.204C274.319 143.457 274.536 143.796 274.629 144.223ZM277.545 148.22C277.127 148.22 276.755 148.113 276.428 147.9C276.101 147.686 275.843 147.378 275.654 146.976C275.466 146.572 275.372 146.086 275.372 145.518C275.372 144.943 275.467 144.455 275.658 144.053C275.849 143.651 276.109 143.345 276.438 143.136C276.767 142.927 277.139 142.823 277.552 142.823C277.868 142.823 278.125 142.876 278.322 142.983C278.52 143.087 278.676 143.212 278.789 143.358C278.903 143.501 278.99 143.63 279.051 143.746H279.106V141.147H280.234V148.125H279.13V147.303H279.051C278.988 147.424 278.897 147.556 278.779 147.699C278.663 147.842 278.505 147.964 278.305 148.067C278.108 148.169 277.854 148.22 277.545 148.22ZM277.832 147.283C278.109 147.283 278.344 147.209 278.537 147.061C278.73 146.912 278.877 146.704 278.976 146.438C279.079 146.17 279.13 145.86 279.13 145.508C279.13 145.156 279.08 144.849 278.98 144.588C278.88 144.327 278.733 144.124 278.54 143.981C278.347 143.836 278.111 143.763 277.832 143.763C277.541 143.763 277.298 143.839 277.103 143.992C276.907 144.141 276.761 144.348 276.663 144.612C276.565 144.873 276.516 145.172 276.516 145.508C276.516 145.846 276.566 146.149 276.666 146.417C276.766 146.686 276.913 146.897 277.106 147.051C277.301 147.206 277.543 147.283 277.832 147.283ZM283.985 142.891V143.777H280.959V142.891H283.985ZM281.75 148.125V142.339C281.75 142.001 281.822 141.719 281.968 141.494C282.113 141.269 282.306 141.101 282.547 140.99C282.79 140.876 283.056 140.819 283.344 140.819C283.555 140.819 283.738 140.836 283.893 140.871C284.05 140.902 284.165 140.932 284.24 140.959L283.995 141.852C283.947 141.836 283.885 141.819 283.808 141.801C283.73 141.783 283.641 141.773 283.538 141.773C283.3 141.773 283.13 141.831 283.027 141.947C282.927 142.063 282.877 142.23 282.877 142.448V148.125H281.75ZM286.134 148.234C285.802 148.234 285.503 148.173 285.237 148.053C284.972 147.933 284.76 147.755 284.604 147.521C284.449 147.285 284.372 146.996 284.372 146.653C284.372 146.355 284.428 146.111 284.539 145.92C284.653 145.727 284.805 145.574 284.996 145.46C285.186 145.346 285.4 145.261 285.636 145.205C285.875 145.145 286.12 145.102 286.372 145.075C286.681 145.039 286.932 145.008 287.125 144.983C287.318 144.958 287.459 144.918 287.548 144.864C287.636 144.809 287.68 144.722 287.68 144.601V144.578C287.68 144.303 287.6 144.089 287.439 143.937C287.28 143.785 287.047 143.709 286.74 143.709C286.418 143.709 286.163 143.779 285.977 143.92C285.791 144.061 285.662 144.222 285.592 144.404L284.539 144.213C284.641 143.895 284.799 143.634 285.013 143.429C285.226 143.223 285.479 143.07 285.772 142.973C286.068 142.873 286.386 142.823 286.726 142.823C286.958 142.823 287.194 142.85 287.435 142.905C287.678 142.959 287.903 143.052 288.11 143.184C288.316 143.316 288.483 143.5 288.611 143.736C288.74 143.972 288.805 144.272 288.805 144.636V148.125H287.715V147.409H287.674C287.606 147.545 287.504 147.677 287.37 147.804C287.236 147.931 287.067 148.035 286.863 148.114C286.658 148.194 286.415 148.234 286.134 148.234ZM286.403 147.375C286.669 147.375 286.897 147.323 287.088 147.218C287.278 147.111 287.425 146.973 287.527 146.803C287.632 146.63 287.684 146.444 287.684 146.244V145.596C287.645 145.63 287.577 145.662 287.479 145.692C287.382 145.719 287.272 145.744 287.149 145.767C287.026 145.787 286.906 145.805 286.788 145.821C286.67 145.837 286.569 145.851 286.485 145.862C286.296 145.889 286.125 145.931 285.97 145.988C285.816 146.045 285.693 146.127 285.602 146.233C285.511 146.338 285.466 146.475 285.466 146.646C285.466 146.887 285.554 147.068 285.732 147.191C285.909 147.314 286.132 147.375 286.403 147.375ZM293.933 144.223L292.908 144.366C292.874 144.25 292.816 144.141 292.734 144.039C292.654 143.935 292.547 143.85 292.41 143.784C292.274 143.718 292.105 143.685 291.903 143.685C291.63 143.685 291.402 143.745 291.218 143.865C291.036 143.986 290.945 144.141 290.945 144.332C290.945 144.496 291.006 144.628 291.129 144.728C291.252 144.827 291.451 144.91 291.725 144.976L292.591 145.167C293.079 145.276 293.443 145.446 293.681 145.678C293.92 145.91 294.04 146.212 294.042 146.584C294.04 146.907 293.946 147.192 293.76 147.44C293.576 147.685 293.319 147.878 292.989 148.019C292.662 148.16 292.284 148.23 291.855 148.23C291.239 148.23 290.742 148.101 290.362 147.842C289.983 147.58 289.753 147.214 289.671 146.741L290.765 146.605C290.824 146.852 290.945 147.04 291.129 147.167C291.313 147.292 291.553 147.354 291.848 147.354C292.162 147.354 292.413 147.29 292.601 147.16C292.79 147.029 292.885 146.868 292.887 146.68C292.885 146.521 292.825 146.39 292.707 146.288C292.591 146.184 292.409 146.104 292.162 146.05L291.276 145.855C290.778 145.749 290.411 145.572 290.175 145.327C289.939 145.08 289.822 144.768 289.824 144.394C289.822 144.078 289.908 143.803 290.083 143.569C290.258 143.333 290.501 143.15 290.812 143.02C291.126 142.889 291.486 142.823 291.892 142.823C292.481 142.823 292.944 142.95 293.282 143.204C293.623 143.457 293.84 143.796 293.933 144.223ZM296.85 148.22C296.432 148.22 296.059 148.113 295.732 147.9C295.405 147.686 295.147 147.378 294.959 146.976C294.77 146.572 294.676 146.086 294.676 145.518C294.676 144.943 294.771 144.455 294.962 144.053C295.153 143.651 295.413 143.345 295.742 143.136C296.072 142.927 296.443 142.823 296.856 142.823C297.172 142.823 297.429 142.876 297.626 142.983C297.824 143.087 297.98 143.212 298.093 143.358C298.207 143.501 298.294 143.63 298.356 143.746H298.41V141.147H299.538V148.125H298.434V147.303H298.356C298.292 147.424 298.201 147.556 298.083 147.699C297.967 147.842 297.809 147.964 297.609 148.067C297.412 148.169 297.158 148.22 296.85 148.22ZM297.136 147.283C297.413 147.283 297.648 147.209 297.841 147.061C298.034 146.912 298.181 146.704 298.281 146.438C298.383 146.17 298.434 145.86 298.434 145.508C298.434 145.156 298.384 144.849 298.284 144.588C298.184 144.327 298.038 144.124 297.844 143.981C297.651 143.836 297.415 143.763 297.136 143.763C296.845 143.763 296.602 143.839 296.407 143.992C296.211 144.141 296.065 144.348 295.967 144.612C295.869 144.873 295.821 145.172 295.821 145.508C295.821 145.846 295.871 146.149 295.97 146.417C296.07 146.686 296.217 146.897 296.41 147.051C296.605 147.206 296.847 147.283 297.136 147.283ZM303.289 142.891V143.777H300.263V142.891H303.289ZM301.054 148.125V142.339C301.054 142.001 301.126 141.719 301.272 141.494C301.417 141.269 301.61 141.101 301.851 140.99C302.094 140.876 302.36 140.819 302.648 140.819C302.86 140.819 303.042 140.836 303.197 140.871C303.354 140.902 303.469 140.932 303.544 140.959L303.299 141.852C303.251 141.836 303.189 141.819 303.112 141.801C303.034 141.783 302.945 141.773 302.843 141.773C302.604 141.773 302.434 141.831 302.331 141.947C302.232 142.063 302.182 142.23 302.182 142.448V148.125H301.054ZM306.987 144.721V145.671H303.866V144.721H306.987ZM307.834 148.125V147.286L310.23 144.874C310.47 144.624 310.67 144.404 310.829 144.213C310.988 144.022 311.108 143.839 311.187 143.664C311.267 143.487 311.306 143.3 311.306 143.102C311.306 142.875 311.254 142.68 311.15 142.516C311.045 142.353 310.902 142.227 310.72 142.138C310.539 142.049 310.333 142.005 310.104 142.005C309.865 142.005 309.656 142.054 309.477 142.152C309.297 142.249 309.159 142.388 309.061 142.567C308.963 142.747 308.914 142.959 308.914 143.204H307.814C307.814 142.771 307.913 142.392 308.11 142.07C308.308 141.747 308.581 141.497 308.928 141.32C309.278 141.141 309.677 141.051 310.124 141.051C310.578 141.051 310.977 141.139 311.32 141.313C311.665 141.488 311.933 141.727 312.124 142.029C312.317 142.329 312.414 142.67 312.414 143.051C312.414 143.312 312.365 143.568 312.267 143.818C312.169 144.068 311.998 144.346 311.753 144.653C311.51 144.957 311.168 145.326 310.727 145.76L309.436 147.096V147.147H312.523V148.125H307.834ZM314.283 148.196C314.085 148.196 313.916 148.127 313.775 147.988C313.635 147.847 313.565 147.678 313.568 147.481C313.565 147.285 313.635 147.118 313.775 146.98C313.916 146.839 314.085 146.768 314.283 146.768C314.474 146.768 314.64 146.839 314.781 146.98C314.924 147.118 314.996 147.285 314.999 147.481C314.996 147.61 314.962 147.729 314.896 147.838C314.831 147.947 314.744 148.035 314.637 148.101C314.531 148.164 314.413 148.196 314.283 148.196ZM316.104 150.087V142.891H317.208V143.746H317.286C317.35 143.63 317.438 143.501 317.552 143.358C317.666 143.212 317.821 143.087 318.019 142.983C318.216 142.876 318.473 142.823 318.789 142.823C319.2 142.823 319.57 142.927 319.9 143.136C320.229 143.345 320.489 143.651 320.68 144.053C320.871 144.455 320.966 144.943 320.966 145.518C320.966 146.086 320.872 146.572 320.683 146.976C320.495 147.378 320.237 147.686 319.91 147.9C319.583 148.113 319.21 148.22 318.792 148.22C318.483 148.22 318.229 148.169 318.029 148.067C317.831 147.964 317.674 147.842 317.555 147.699C317.44 147.556 317.35 147.424 317.286 147.303H317.232V150.087H316.104ZM317.211 145.508C317.211 145.86 317.261 146.17 317.361 146.438C317.463 146.704 317.611 146.912 317.804 147.061C317.997 147.209 318.232 147.283 318.509 147.283C318.796 147.283 319.036 147.206 319.232 147.051C319.427 146.897 319.574 146.686 319.671 146.417C319.771 146.149 319.821 145.846 319.821 145.508C319.821 145.172 319.772 144.873 319.675 144.612C319.577 144.348 319.431 144.141 319.235 143.992C319.042 143.839 318.8 143.763 318.509 143.763C318.228 143.763 317.99 143.836 317.797 143.981C317.604 144.124 317.458 144.327 317.358 144.588C317.26 144.849 317.211 145.156 317.211 145.508ZM322.985 145.038V148.125H321.858V142.891H322.938V143.763H323.002C323.123 143.477 323.314 143.249 323.575 143.078C323.836 142.908 324.162 142.823 324.553 142.823C324.912 142.823 325.225 142.899 325.493 143.051C325.761 143.203 325.969 143.426 326.117 143.719C326.267 144.012 326.341 144.371 326.341 144.796V148.125H325.214V144.952C325.214 144.587 325.119 144.302 324.931 144.097C324.742 143.89 324.482 143.787 324.151 143.787C323.923 143.787 323.722 143.836 323.548 143.934C323.373 144.031 323.235 144.173 323.135 144.36C323.035 144.546 322.985 144.772 322.985 145.038ZM329.678 150.196C329.248 150.196 328.879 150.139 328.57 150.026C328.264 149.912 328.015 149.76 327.824 149.569C327.633 149.378 327.496 149.169 327.412 148.942L328.403 148.629C328.46 148.729 328.539 148.832 328.638 148.939C328.738 149.046 328.874 149.135 329.044 149.208C329.214 149.283 329.431 149.32 329.695 149.32C330.063 149.32 330.367 149.231 330.608 149.051C330.849 148.874 330.969 148.586 330.969 148.186V147.177H330.904C330.843 147.298 330.753 147.426 330.635 147.562C330.517 147.696 330.358 147.811 330.158 147.906C329.958 148 329.704 148.046 329.395 148.046C328.988 148.046 328.62 147.951 328.291 147.76C327.964 147.567 327.704 147.281 327.511 146.901C327.318 146.522 327.221 146.051 327.221 145.487C327.221 144.924 327.316 144.444 327.507 144.046C327.698 143.649 327.958 143.345 328.288 143.136C328.617 142.927 328.988 142.823 329.402 142.823C329.717 142.823 329.974 142.876 330.172 142.983C330.372 143.087 330.53 143.212 330.645 143.358C330.761 143.501 330.85 143.63 330.911 143.746H330.979V142.891H332.087V148.227C332.087 148.674 331.981 149.043 331.77 149.334C331.561 149.625 331.275 149.841 330.911 149.981C330.548 150.125 330.137 150.196 329.678 150.196ZM329.681 147.14C329.958 147.14 330.193 147.074 330.386 146.942C330.579 146.81 330.726 146.621 330.826 146.373C330.928 146.123 330.979 145.826 330.979 145.481C330.979 145.138 330.929 144.838 330.829 144.581C330.729 144.324 330.583 144.124 330.39 143.981C330.197 143.836 329.961 143.763 329.681 143.763C329.39 143.763 329.147 143.838 328.952 143.988C328.757 144.138 328.61 144.342 328.512 144.601C328.415 144.86 328.366 145.153 328.366 145.481C328.366 145.812 328.416 146.103 328.516 146.353C328.616 146.6 328.762 146.793 328.955 146.932C329.151 147.071 329.393 147.14 329.681 147.14Z\"\n        fill=\"black\"\n      />\n      <path\n        opacity=\"0.65\"\n        d=\"M246.397 160.146V159.557L248.397 157.428C248.621 157.186 248.805 156.976 248.949 156.797C249.095 156.618 249.204 156.447 249.276 156.286C249.348 156.124 249.384 155.954 249.384 155.775C249.384 155.57 249.336 155.394 249.238 155.246C249.141 155.096 249.009 154.98 248.841 154.898C248.674 154.817 248.486 154.776 248.277 154.776C248.056 154.776 247.862 154.821 247.696 154.913C247.533 155.004 247.406 155.132 247.317 155.296C247.227 155.459 247.182 155.652 247.182 155.874H246.408C246.408 155.518 246.49 155.205 246.654 154.936C246.817 154.668 247.042 154.458 247.328 154.308C247.615 154.159 247.938 154.084 248.298 154.084C248.662 154.084 248.983 154.159 249.262 154.308C249.54 154.456 249.758 154.657 249.916 154.91C250.074 155.163 250.152 155.446 250.152 155.76C250.152 155.982 250.112 156.199 250.03 156.411C249.95 156.621 249.811 156.856 249.612 157.115C249.414 157.372 249.137 157.685 248.783 158.052L247.518 159.393V159.44H250.252V160.146H246.397ZM251.398 160.146L254.041 154.919V154.872H250.989V154.165H254.882V154.904L252.248 160.146H251.398ZM255.251 160.199C255.1 160.199 254.969 160.145 254.86 160.036C254.751 159.926 254.697 159.796 254.697 159.644C254.697 159.492 254.751 159.362 254.86 159.253C254.969 159.144 255.1 159.089 255.251 159.089C255.403 159.089 255.534 159.144 255.643 159.253C255.752 159.362 255.806 159.492 255.806 159.644C255.806 159.743 255.781 159.836 255.73 159.922C255.68 160.005 255.613 160.072 255.529 160.123C255.447 160.174 255.355 160.199 255.251 160.199ZM256.659 158.949V158.286L259.267 154.165H259.77V155.164H259.443L257.524 158.199V158.245H261.066V158.949H256.659ZM259.484 160.146V158.748L259.489 158.444V154.165H260.26V160.146H259.484ZM263.84 160.228C263.485 160.228 263.167 160.159 262.885 160.021C262.602 159.881 262.377 159.69 262.207 159.448C262.038 159.205 261.946 158.929 261.933 158.619H262.721C262.746 158.882 262.865 159.099 263.077 159.27C263.29 159.442 263.544 159.527 263.84 159.527C264.077 159.527 264.289 159.472 264.473 159.361C264.66 159.25 264.806 159.098 264.912 158.905C265.019 158.711 265.072 158.49 265.072 158.242C265.072 157.989 265.017 157.764 264.906 157.568C264.797 157.369 264.646 157.213 264.453 157.1C264.262 156.986 264.044 156.927 263.799 156.925C263.618 156.923 263.433 156.952 263.244 157.013C263.057 157.073 262.903 157.151 262.783 157.246L262.032 157.144L262.379 154.165H265.58V154.872H263.06L262.858 156.592H262.893C263.014 156.487 263.169 156.4 263.358 156.329C263.549 156.259 263.749 156.224 263.959 156.224C264.324 156.224 264.649 156.311 264.935 156.484C265.223 156.657 265.449 156.895 265.612 157.197C265.776 157.497 265.858 157.84 265.858 158.228C265.858 158.613 265.771 158.957 265.598 159.259C265.425 159.558 265.186 159.795 264.882 159.968C264.579 160.142 264.231 160.228 263.84 160.228ZM268.901 160.146V154.165H269.716V157.077H269.789L272.385 154.165H273.428L270.963 156.861L273.43 160.146H272.449L270.443 157.419L269.716 158.248V160.146H268.901ZM274.108 160.146V154.165H276.252C276.672 154.165 277.02 154.236 277.295 154.379C277.569 154.519 277.774 154.709 277.908 154.948C278.042 155.186 278.109 155.451 278.109 155.745C278.109 155.998 278.064 156.21 277.972 156.379C277.883 156.547 277.763 156.681 277.613 156.782C277.463 156.881 277.299 156.954 277.122 157.001V157.06C277.313 157.071 277.502 157.135 277.689 157.252C277.876 157.369 278.031 157.537 278.156 157.755C278.281 157.971 278.343 158.235 278.343 158.546C278.343 158.85 278.273 159.122 278.133 159.364C277.994 159.603 277.777 159.794 277.481 159.936C277.186 160.076 276.804 160.146 276.337 160.146H274.108ZM274.923 159.44H276.293C276.746 159.44 277.07 159.352 277.262 159.177C277.455 159 277.552 158.783 277.552 158.526C277.552 158.329 277.502 158.149 277.403 157.985C277.303 157.82 277.162 157.688 276.979 157.591C276.796 157.492 276.579 157.442 276.328 157.442H274.923V159.44ZM274.923 156.773H276.199C276.41 156.773 276.599 156.732 276.769 156.651C276.938 156.569 277.073 156.454 277.172 156.306C277.273 156.156 277.324 155.981 277.324 155.78C277.324 155.525 277.234 155.31 277.055 155.135C276.878 154.96 276.6 154.872 276.223 154.872H274.923V156.773ZM283.405 154.165V160.146H282.59V154.998H282.555L281.109 155.95V155.15L282.602 154.165H283.405ZM285.062 160.146L287.705 154.919V154.872H284.654V154.165H288.547V154.904L285.912 160.146H285.062ZM296.651 157.156C296.651 157.791 296.535 158.338 296.303 158.797C296.074 159.257 295.757 159.61 295.354 159.857C294.953 160.105 294.496 160.228 293.984 160.228C293.47 160.228 293.013 160.105 292.612 159.857C292.211 159.608 291.894 159.255 291.663 158.797C291.433 158.338 291.318 157.791 291.318 157.156C291.318 156.521 291.433 155.974 291.663 155.515C291.894 155.055 292.211 154.702 292.612 154.454C293.013 154.207 293.47 154.084 293.984 154.084C294.496 154.084 294.953 154.207 295.354 154.454C295.757 154.702 296.074 155.055 296.303 155.515C296.535 155.974 296.651 156.521 296.651 157.156ZM295.853 157.156C295.853 156.654 295.771 156.23 295.605 155.885C295.442 155.541 295.219 155.28 294.936 155.103C294.654 154.924 294.337 154.834 293.984 154.834C293.632 154.834 293.315 154.924 293.032 155.103C292.75 155.28 292.526 155.541 292.361 155.885C292.197 156.23 292.115 156.654 292.115 157.156C292.115 157.658 292.197 158.082 292.361 158.426C292.526 158.771 292.75 159.033 293.032 159.212C293.315 159.389 293.632 159.478 293.984 159.478C294.337 159.478 294.654 159.389 294.936 159.212C295.219 159.033 295.442 158.771 295.605 158.426C295.771 158.082 295.853 157.658 295.853 157.156ZM299.387 160.24C298.96 160.24 298.592 160.141 298.283 159.942C297.975 159.743 297.739 159.47 297.573 159.121C297.408 158.773 297.325 158.375 297.325 157.927C297.325 157.469 297.41 157.066 297.579 156.718C297.75 156.369 297.989 156.097 298.295 155.9C298.602 155.702 298.962 155.602 299.375 155.602C299.704 155.602 299.998 155.663 300.257 155.783C300.518 155.904 300.73 156.073 300.894 156.291C301.057 156.51 301.157 156.765 301.192 157.057H300.421C300.372 156.848 300.26 156.667 300.085 156.513C299.912 156.358 299.68 156.28 299.39 156.28C299.133 156.28 298.908 156.347 298.715 156.481C298.524 156.614 298.375 156.802 298.268 157.045C298.163 157.288 298.111 157.575 298.111 157.904C298.111 158.24 298.163 158.532 298.268 158.78C298.373 159.027 298.521 159.219 298.712 159.355C298.905 159.491 299.131 159.559 299.39 159.559C299.563 159.559 299.72 159.528 299.86 159.466C300 159.404 300.119 159.315 300.216 159.2C300.314 159.083 300.382 158.944 300.421 158.783H301.192C301.155 159.059 301.058 159.307 300.903 159.527C300.749 159.747 300.543 159.922 300.286 160.05C300.029 160.177 299.73 160.24 299.387 160.24ZM303.982 155.661V156.303H301.596V155.661H303.982ZM302.262 154.586H303.044V158.847C303.044 159.028 303.071 159.164 303.123 159.256C303.178 159.345 303.248 159.406 303.333 159.437C303.419 159.468 303.512 159.484 303.611 159.484C303.683 159.484 303.744 159.48 303.795 159.472C303.845 159.462 303.885 159.454 303.915 159.448L304.064 160.117C304.015 160.135 303.946 160.153 303.856 160.173C303.769 160.194 303.658 160.205 303.523 160.205C303.311 160.207 303.109 160.166 302.916 160.082C302.723 159.997 302.565 159.866 302.443 159.691C302.322 159.516 302.262 159.295 302.262 159.028V154.586ZM306.812 160.146V159.557L308.812 157.428C309.036 157.186 309.22 156.976 309.364 156.797C309.51 156.618 309.619 156.447 309.691 156.286C309.763 156.124 309.799 155.954 309.799 155.775C309.799 155.57 309.751 155.394 309.653 155.246C309.556 155.096 309.423 154.98 309.256 154.898C309.089 154.817 308.901 154.776 308.692 154.776C308.47 154.776 308.277 154.821 308.111 154.913C307.948 155.004 307.821 155.132 307.732 155.296C307.642 155.459 307.597 155.652 307.597 155.874H306.823C306.823 155.518 306.905 155.205 307.069 154.936C307.232 154.668 307.457 154.458 307.743 154.308C308.029 154.159 308.353 154.084 308.713 154.084C309.077 154.084 309.398 154.159 309.677 154.308C309.955 154.456 310.173 154.657 310.331 154.91C310.488 155.163 310.567 155.446 310.567 155.76C310.567 155.982 310.526 156.199 310.445 156.411C310.365 156.621 310.226 156.856 310.027 157.115C309.828 157.372 309.552 157.685 309.198 158.052L307.933 159.393V159.44H310.667V160.146H306.812ZM313.636 160.237C313.186 160.237 312.801 160.116 312.482 159.875C312.165 159.633 311.921 159.283 311.752 158.824C311.582 158.364 311.499 157.808 311.501 157.156C311.501 156.51 311.585 155.958 311.755 155.5C311.924 155.041 312.168 154.69 312.485 154.449C312.804 154.205 313.188 154.084 313.636 154.084C314.081 154.084 314.464 154.205 314.783 154.449C315.103 154.69 315.347 155.041 315.516 155.5C315.686 155.958 315.77 156.51 315.77 157.156C315.77 157.808 315.686 158.364 315.516 158.824C315.349 159.283 315.106 159.633 314.786 159.875C314.469 160.116 314.085 160.237 313.636 160.237ZM313.636 159.527C314.056 159.527 314.384 159.324 314.62 158.917C314.855 158.508 314.972 157.921 314.97 157.156C314.97 156.65 314.917 156.22 314.81 155.868C314.703 155.514 314.55 155.244 314.351 155.059C314.153 154.874 313.914 154.782 313.636 154.782C313.219 154.782 312.892 154.988 312.654 155.401C312.417 155.812 312.298 156.397 312.298 157.156C312.298 157.664 312.351 158.095 312.456 158.45C312.563 158.802 312.716 159.07 312.914 159.253C313.115 159.436 313.355 159.527 313.636 159.527ZM316.64 160.146V159.557L318.641 157.428C318.865 157.186 319.049 156.976 319.193 156.797C319.339 156.618 319.448 156.447 319.52 156.286C319.592 156.124 319.628 155.954 319.628 155.775C319.628 155.57 319.579 155.394 319.482 155.246C319.385 155.096 319.252 154.98 319.085 154.898C318.917 154.817 318.729 154.776 318.521 154.776C318.299 154.776 318.105 154.821 317.94 154.913C317.776 155.004 317.65 155.132 317.56 155.296C317.471 155.459 317.426 155.652 317.426 155.874H316.652C316.652 155.518 316.734 155.205 316.897 154.936C317.061 154.668 317.286 154.458 317.572 154.308C317.858 154.159 318.181 154.084 318.542 154.084C318.906 154.084 319.227 154.159 319.505 154.308C319.784 154.456 320.002 154.657 320.16 154.91C320.317 155.163 320.396 155.446 320.396 155.76C320.396 155.982 320.355 156.199 320.273 156.411C320.194 156.621 320.054 156.856 319.856 157.115C319.657 157.372 319.381 157.685 319.026 158.052L317.762 159.393V159.44H320.495V160.146H316.64ZM323.368 160.228C323.014 160.228 322.695 160.159 322.413 160.021C322.131 159.881 321.905 159.69 321.735 159.448C321.566 159.205 321.475 158.929 321.461 158.619H322.249C322.275 158.882 322.393 159.099 322.606 159.27C322.818 159.442 323.072 159.527 323.368 159.527C323.605 159.527 323.817 159.472 324.002 159.361C324.189 159.25 324.335 159.098 324.44 158.905C324.547 158.711 324.6 158.49 324.6 158.242C324.6 157.989 324.545 157.764 324.434 157.568C324.325 157.369 324.174 157.213 323.981 157.1C323.79 156.986 323.572 156.927 323.327 156.925C323.146 156.923 322.961 156.952 322.772 157.013C322.585 157.073 322.431 157.151 322.311 157.246L321.56 157.144L321.908 154.165H325.109V154.872H322.588L322.387 156.592H322.422C322.542 156.487 322.697 156.4 322.886 156.329C323.077 156.259 323.277 156.224 323.488 156.224C323.852 156.224 324.177 156.311 324.463 156.484C324.751 156.657 324.977 156.895 325.141 157.197C325.304 157.497 325.386 157.84 325.386 158.228C325.386 158.613 325.299 158.957 325.126 159.259C324.953 159.558 324.714 159.795 324.411 159.968C324.107 160.142 323.759 160.228 323.368 160.228Z\"\n        fill=\"black\"\n      />\n    </g>\n    <rect\n      x=\"214.34\"\n      y=\"38.8934\"\n      width=\"174.419\"\n      height=\"133.299\"\n      rx=\"4.96872\"\n      stroke=\"black\"\n      stroke-opacity=\"0.12\"\n      stroke-width=\"1.02801\"\n    />\n    <rect width=\"789\" height=\"391\" fill=\"url(#paint0_linear_2159_185)\"/>\n  </g>\n  <rect\n    x=\"0.514005\"\n    y=\"0.514005\"\n    width=\"539.705\"\n    height=\"267.133\"\n    rx=\"7.71008\"\n    stroke=\"black\"\n    stroke-opacity=\"0.12\"\n    stroke-width=\"1.02801\"\n  />\n  <defs>\n    <linearGradient\n      id=\"paint0_linear_2159_185\"\n      x1=\"395\"\n      y1=\"67\"\n      x2=\"393.435\"\n      y2=\"390.996\"\n      gradientUnits=\"userSpaceOnUse\"\n    >\n      <stop stop-color=\"white\" stop-opacity=\"0\"/>\n      <stop offset=\"1\" stop-color=\"white\"/>\n    </linearGradient>\n    <clipPath id=\"clip0_2159_185\">\n      <rect width=\"540.733\" height=\"268.161\" rx=\"8.22408\" fill=\"white\"/>\n    </clipPath>\n    <clipPath id=\"clip1_2159_185\">\n      <rect\n        x=\"21.9307\"\n        y=\"38.3794\"\n        width=\"175.447\"\n        height=\"134.327\"\n        rx=\"5.48272\"\n        fill=\"white\"\n      />\n    </clipPath>\n    <clipPath id=\"clip2_2159_185\">\n      <rect\n        x=\"213.826\"\n        y=\"38.3794\"\n        width=\"175.447\"\n        height=\"134.327\"\n        rx=\"5.48272\"\n        fill=\"white\"\n      />\n    </clipPath>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/WordMark.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"114\"\n  height=\"32\"\n  viewBox=\"0 0 114 32\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  {...attrs}\n>\n  <rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#1E1E1E\"/>\n  <path\n    d=\"M14.116 11.5C14.2333 11.5213 14.4947 11.932 14.9 12.732C15.3053 13.5213 15.8013 14.5453 16.388 15.804L16.9 16.876C17.124 16.6307 17.332 16.348 17.524 16.028C17.716 15.6973 17.956 15.2547 18.244 14.7C18.5747 14.0707 18.804 13.6653 18.932 13.484C19.444 12.7053 19.9987 11.8787 20.596 11.004C20.6707 10.8867 20.804 10.8067 20.996 10.764C21.028 10.7533 21.0813 10.748 21.156 10.748C21.412 10.748 21.6947 10.8547 22.004 11.068C22.324 11.2813 22.532 11.5 22.628 11.724C22.6493 11.7667 22.66 11.8467 22.66 11.964C22.66 12.0707 22.6547 12.1507 22.644 12.204C22.6227 12.3 22.596 12.4227 22.564 12.572C22.5427 12.7107 22.5213 12.876 22.5 13.068C22.1587 15.6707 21.9133 17.6227 21.764 18.924C21.7213 19.2227 21.7 19.5587 21.7 19.932L21.668 20.444C21.6573 20.5507 21.652 20.6733 21.652 20.812C21.652 20.9507 21.636 21.0467 21.604 21.1C21.572 21.1533 21.5133 21.18 21.428 21.18C21.364 21.18 21.268 21.164 21.14 21.132C20.7987 21.0573 20.5373 20.924 20.356 20.732C20.1747 20.5293 20.0733 20.252 20.052 19.9C20.0413 19.7187 20.036 19.436 20.036 19.052C20.036 18.6573 20.0467 18.0547 20.068 17.244L20.084 16.364L20.116 15.1C20.02 15.1853 19.7747 15.5213 19.38 16.108C18.996 16.684 18.6173 17.2867 18.244 17.916C17.8707 18.5453 17.6253 19.0307 17.508 19.372C17.4013 19.6387 17.2947 19.836 17.188 19.964C17.092 20.092 16.9747 20.156 16.836 20.156C16.708 20.156 16.5427 20.0973 16.34 19.98C15.764 19.628 15.412 19.1427 15.284 18.524C15.0813 17.5747 14.8307 16.588 14.532 15.564C14.4787 15.372 14.372 15.1107 14.212 14.78C14.0627 14.4493 13.9667 14.2413 13.924 14.156L13.716 13.74C12.1587 15.8093 11.1027 17.708 10.548 19.436C10.4413 19.7987 10.34 20.06 10.244 20.22C10.148 20.3693 10.036 20.444 9.908 20.444C9.70533 20.444 9.41733 20.2627 9.044 19.9C8.756 19.644 8.612 19.356 8.612 19.036C8.612 18.8653 8.644 18.6947 8.708 18.524C9.156 17.2013 9.82267 15.948 10.708 14.764L11.204 14.108C11.876 13.2227 12.3827 12.5293 12.724 12.028C12.98 11.6547 13.3213 11.468 13.748 11.468C13.8227 11.468 13.9453 11.4787 14.116 11.5Z\"\n    fill=\"white\"\n  />\n  <path\n    d=\"M41.84 23V8.8H44.24L48.78 21.12L53.3 8.8H55.7V23H53.98V12L49.92 23H47.62L43.56 12V23H41.84ZM63.8038 23.24C62.7104 23.24 61.8371 22.9867 61.1838 22.48C60.5438 21.9733 60.2238 21.2667 60.2238 20.36C60.2238 19.4533 60.4904 18.74 61.0238 18.22C61.5571 17.7 62.4038 17.3333 63.5638 17.12L67.3238 16.42C67.3238 15.5267 67.1104 14.86 66.6838 14.42C66.2704 13.98 65.6438 13.76 64.8038 13.76C64.0704 13.76 63.4904 13.9267 63.0638 14.26C62.6371 14.58 62.3438 15.0467 62.1838 15.66L60.4038 15.52C60.6038 14.52 61.0838 13.7133 61.8438 13.1C62.6171 12.4733 63.6038 12.16 64.8038 12.16C66.1638 12.16 67.2038 12.5467 67.9238 13.32C68.6438 14.08 69.0038 15.1333 69.0038 16.48V20.86C69.0038 21.1 69.0438 21.2733 69.1238 21.38C69.2171 21.4733 69.3638 21.52 69.5638 21.52H69.9838V23C69.9304 23.0133 69.8438 23.02 69.7238 23.02C69.6038 23.0333 69.4771 23.04 69.3438 23.04C68.8638 23.04 68.4704 22.9667 68.1638 22.82C67.8704 22.66 67.6571 22.42 67.5238 22.1C67.3904 21.7667 67.3238 21.3333 67.3238 20.8L67.5238 20.84C67.4304 21.2933 67.2038 21.7067 66.8438 22.08C66.4838 22.44 66.0371 22.7267 65.5038 22.94C64.9704 23.14 64.4038 23.24 63.8038 23.24ZM63.9638 21.76C64.6704 21.76 65.2704 21.6267 65.7638 21.36C66.2704 21.08 66.6571 20.7067 66.9238 20.24C67.1904 19.76 67.3238 19.2267 67.3238 18.64V17.88L63.8838 18.52C63.1638 18.6533 62.6638 18.86 62.3838 19.14C62.1171 19.4067 61.9837 19.76 61.9837 20.2C61.9837 20.6933 62.1571 21.08 62.5038 21.36C62.8638 21.6267 63.3504 21.76 63.9638 21.76ZM73.8813 23V12.4H75.3613L75.4413 15.08L75.2813 15.02C75.4146 14.1133 75.7013 13.4533 76.1413 13.04C76.5946 12.6133 77.1946 12.4 77.9413 12.4H78.9813V14H77.9613C77.4279 14 76.9813 14.1 76.6213 14.3C76.2746 14.4867 76.0079 14.7733 75.8213 15.16C75.6479 15.5333 75.5613 16.0133 75.5613 16.6V23H73.8813ZM87.7139 23.24C86.9806 23.24 86.3272 23.08 85.7539 22.76C85.1806 22.4267 84.7406 21.9733 84.4339 21.4L84.3739 23H82.8539V8.8H84.5339V13.88C84.8006 13.44 85.2139 13.0467 85.7739 12.7C86.3339 12.34 86.9806 12.16 87.7139 12.16C88.6472 12.16 89.4539 12.3867 90.1339 12.84C90.8139 13.2933 91.3406 13.9333 91.7139 14.76C92.0872 15.5867 92.2739 16.5667 92.2739 17.7C92.2739 18.8333 92.0872 19.8133 91.7139 20.64C91.3406 21.4667 90.8139 22.1067 90.1339 22.56C89.4539 23.0133 88.6472 23.24 87.7139 23.24ZM87.6139 21.64C88.5072 21.64 89.2139 21.2867 89.7339 20.58C90.2539 19.8733 90.5139 18.9133 90.5139 17.7C90.5139 16.4733 90.2539 15.5133 89.7339 14.82C89.2139 14.1133 88.5206 13.76 87.6539 13.76C87.0006 13.76 86.4406 13.92 85.9739 14.24C85.5072 14.5467 85.1472 14.9933 84.8939 15.58C84.6539 16.1667 84.5339 16.8733 84.5339 17.7C84.5339 18.5 84.6539 19.2 84.8939 19.8C85.1472 20.3867 85.5006 20.84 85.9539 21.16C86.4206 21.48 86.9739 21.64 87.6139 21.64ZM98.7308 23C98.1174 23 97.6174 22.84 97.2308 22.52C96.8441 22.2 96.6508 21.6933 96.6508 21V8.8H98.3308V20.86C98.3308 21.0733 98.3841 21.24 98.4908 21.36C98.6108 21.4667 98.7774 21.52 98.9908 21.52H99.9108V23H98.7308ZM108.006 23.24C107.006 23.24 106.139 23.0133 105.406 22.56C104.686 22.1067 104.126 21.4667 103.726 20.64C103.339 19.8 103.146 18.82 103.146 17.7C103.146 16.58 103.339 15.6067 103.726 14.78C104.126 13.9533 104.679 13.3133 105.386 12.86C106.106 12.3933 106.952 12.16 107.926 12.16C108.846 12.16 109.659 12.38 110.366 12.82C111.072 13.2467 111.619 13.8733 112.006 14.7C112.406 15.5267 112.606 16.5333 112.606 17.72V18.22H104.906C104.972 19.3533 105.272 20.2067 105.806 20.78C106.352 21.3533 107.086 21.64 108.006 21.64C108.699 21.64 109.266 21.48 109.706 21.16C110.159 20.8267 110.472 20.3933 110.646 19.86L112.446 20C112.166 20.9467 111.632 21.7267 110.846 22.34C110.072 22.94 109.126 23.24 108.006 23.24ZM104.906 16.74H110.766C110.686 15.7133 110.386 14.96 109.866 14.48C109.359 14 108.712 13.76 107.926 13.76C107.112 13.76 106.439 14.0133 105.906 14.52C105.386 15.0133 105.052 15.7533 104.906 16.74Z\"\n    fill=\"black\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/WordMarkAlt.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"108\"\n  height=\"32\"\n  viewBox=\"0 0 108 32\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  {...attrs}\n>\n  <rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#1E1E1E\"/>\n  <path\n    d=\"M14.116 11.5C14.2333 11.5213 14.4947 11.932 14.9 12.732C15.3053 13.5213 15.8013 14.5453 16.388 15.804L16.9 16.876C17.124 16.6307 17.332 16.348 17.524 16.028C17.716 15.6973 17.956 15.2547 18.244 14.7C18.5747 14.0707 18.804 13.6653 18.932 13.484C19.444 12.7053 19.9987 11.8787 20.596 11.004C20.6707 10.8867 20.804 10.8067 20.996 10.764C21.028 10.7533 21.0813 10.748 21.156 10.748C21.412 10.748 21.6947 10.8547 22.004 11.068C22.324 11.2813 22.532 11.5 22.628 11.724C22.6493 11.7667 22.66 11.8467 22.66 11.964C22.66 12.0707 22.6547 12.1507 22.644 12.204C22.6227 12.3 22.596 12.4227 22.564 12.572C22.5427 12.7107 22.5213 12.876 22.5 13.068C22.1587 15.6707 21.9133 17.6227 21.764 18.924C21.7213 19.2227 21.7 19.5587 21.7 19.932L21.668 20.444C21.6573 20.5507 21.652 20.6733 21.652 20.812C21.652 20.9507 21.636 21.0467 21.604 21.1C21.572 21.1533 21.5133 21.18 21.428 21.18C21.364 21.18 21.268 21.164 21.14 21.132C20.7987 21.0573 20.5373 20.924 20.356 20.732C20.1747 20.5293 20.0733 20.252 20.052 19.9C20.0413 19.7187 20.036 19.436 20.036 19.052C20.036 18.6573 20.0467 18.0547 20.068 17.244L20.084 16.364L20.116 15.1C20.02 15.1853 19.7747 15.5213 19.38 16.108C18.996 16.684 18.6173 17.2867 18.244 17.916C17.8707 18.5453 17.6253 19.0307 17.508 19.372C17.4013 19.6387 17.2947 19.836 17.188 19.964C17.092 20.092 16.9747 20.156 16.836 20.156C16.708 20.156 16.5427 20.0973 16.34 19.98C15.764 19.628 15.412 19.1427 15.284 18.524C15.0813 17.5747 14.8307 16.588 14.532 15.564C14.4787 15.372 14.372 15.1107 14.212 14.78C14.0627 14.4493 13.9667 14.2413 13.924 14.156L13.716 13.74C12.1587 15.8093 11.1027 17.708 10.548 19.436C10.4413 19.7987 10.34 20.06 10.244 20.22C10.148 20.3693 10.036 20.444 9.908 20.444C9.70533 20.444 9.41733 20.2627 9.044 19.9C8.756 19.644 8.612 19.356 8.612 19.036C8.612 18.8653 8.644 18.6947 8.708 18.524C9.156 17.2013 9.82267 15.948 10.708 14.764L11.204 14.108C11.876 13.2227 12.3827 12.5293 12.724 12.028C12.98 11.6547 13.3213 11.468 13.748 11.468C13.8227 11.468 13.9453 11.4787 14.116 11.5Z\"\n    fill=\"white\"\n  />\n  <path\n    d=\"M47.02 10.5C47.1667 10.5267 47.4933 11.04 48 12.04C48.5067 13.0267 49.1267 14.3067 49.86 15.88L50.5 17.22C50.78 16.9133 51.04 16.56 51.28 16.16C51.52 15.7467 51.82 15.1933 52.18 14.5C52.5933 13.7133 52.88 13.2067 53.04 12.98C53.68 12.0067 54.3733 10.9733 55.12 9.88C55.2133 9.73333 55.38 9.63333 55.62 9.58C55.66 9.56667 55.7267 9.56 55.82 9.56C56.14 9.56 56.4933 9.69333 56.88 9.96C57.28 10.2267 57.54 10.5 57.66 10.78C57.6867 10.8333 57.7 10.9333 57.7 11.08C57.7 11.2133 57.6933 11.3133 57.68 11.38C57.6533 11.5 57.62 11.6533 57.58 11.84C57.5533 12.0133 57.5267 12.22 57.5 12.46C57.0733 15.7133 56.7667 18.1533 56.58 19.78C56.5267 20.1533 56.5 20.5733 56.5 21.04L56.46 21.68C56.4467 21.8133 56.44 21.9667 56.44 22.14C56.44 22.3133 56.42 22.4333 56.38 22.5C56.34 22.5667 56.2667 22.6 56.16 22.6C56.08 22.6 55.96 22.58 55.8 22.54C55.3733 22.4467 55.0467 22.28 54.82 22.04C54.5933 21.7867 54.4667 21.44 54.44 21C54.4267 20.7733 54.42 20.42 54.42 19.94C54.42 19.4467 54.4333 18.6933 54.46 17.68L54.48 16.58L54.52 15C54.4 15.1067 54.0933 15.5267 53.6 16.26C53.12 16.98 52.6467 17.7333 52.18 18.52C51.7133 19.3067 51.4067 19.9133 51.26 20.34C51.1267 20.6733 50.9933 20.92 50.86 21.08C50.74 21.24 50.5933 21.32 50.42 21.32C50.26 21.32 50.0533 21.2467 49.8 21.1C49.08 20.66 48.64 20.0533 48.48 19.28C48.2267 18.0933 47.9133 16.86 47.54 15.58C47.4733 15.34 47.34 15.0133 47.14 14.6C46.9533 14.1867 46.8333 13.9267 46.78 13.82L46.52 13.3C44.5733 15.8867 43.2533 18.26 42.56 20.42C42.4267 20.8733 42.3 21.2 42.18 21.4C42.06 21.5867 41.92 21.68 41.76 21.68C41.5067 21.68 41.1467 21.4533 40.68 21C40.32 20.68 40.14 20.32 40.14 19.92C40.14 19.7067 40.18 19.4933 40.26 19.28C40.82 17.6267 41.6533 16.06 42.76 14.58L43.38 13.76C44.22 12.6533 44.8533 11.7867 45.28 11.16C45.6 10.6933 46.0267 10.46 46.56 10.46C46.6533 10.46 46.8067 10.4733 47.02 10.5ZM67.1859 21.98C67.0659 21.9267 66.9459 21.9 66.8259 21.9C66.7059 21.9 66.5993 21.9333 66.5059 22C66.4259 22.0533 66.3326 22.14 66.2259 22.26C66.0659 22.42 65.8993 22.5533 65.7259 22.66C65.5659 22.7533 65.2726 22.84 64.8459 22.92C64.6593 22.9467 64.5059 22.96 64.3859 22.96C63.8259 22.96 63.2393 22.84 62.6259 22.6C62.0259 22.3467 61.5993 22.0467 61.3459 21.7C61.0793 21.3267 60.9526 20.9267 60.9659 20.5C60.9793 19.86 61.2459 19.22 61.7659 18.58C62.2726 17.9667 62.7326 17.5333 63.1459 17.28C63.5726 17.0267 64.1526 16.8667 64.8859 16.8C65.0459 16.8 65.2726 16.7733 65.5659 16.72C65.8726 16.6667 66.0259 16.5933 66.0259 16.5C66.0259 16.3133 65.9393 16.1467 65.7659 16C65.6059 15.84 65.2726 15.76 64.7659 15.76C64.5793 15.76 64.2993 15.7733 63.9259 15.8C63.5526 15.8267 63.2926 15.84 63.1459 15.84C62.6926 15.84 62.3659 15.7667 62.1659 15.62C62.1126 15.5933 62.0859 15.4667 62.0859 15.24C62.0859 14.84 62.2259 14.5867 62.5059 14.48C63.0793 14.2667 63.6726 14.1533 64.2859 14.14C65.0593 14.14 65.7726 14.3133 66.4259 14.66C67.0926 14.9933 67.5793 15.4133 67.8859 15.92C68.1926 16.4267 68.3926 17.1267 68.4859 18.02C68.5926 19.02 68.6459 20.2933 68.6459 21.84C68.6459 22.2667 68.5859 22.5133 68.4659 22.58C68.3593 22.6467 68.2593 22.68 68.1659 22.68C68.0459 22.68 67.9326 22.6467 67.8259 22.58C67.7326 22.5 67.6259 22.3933 67.5059 22.26C67.3859 22.1133 67.2793 22.02 67.1859 21.98ZM64.0259 21.74C64.6526 21.5533 65.2659 21.2533 65.8659 20.84C65.9993 20.7467 66.1193 20.6267 66.2259 20.48C66.3326 20.3333 66.3993 20.24 66.4259 20.2C66.4526 20.16 66.4726 20.0467 66.4859 19.86C66.5126 19.66 66.5193 19.5 66.5059 19.38C66.4926 19.34 66.4659 19.2333 66.4259 19.06C66.3993 18.8867 66.3526 18.7333 66.2859 18.6C66.2193 18.4933 66.1126 18.3933 65.9659 18.3C65.8193 18.2067 65.6859 18.1667 65.5659 18.18C65.3126 18.1933 65.0393 18.2867 64.7459 18.46C64.4526 18.6333 64.1793 18.8667 63.9259 19.16C63.6726 19.4667 63.4726 19.78 63.3259 20.1C63.1926 20.4067 63.1259 20.6933 63.1259 20.96C63.1526 21.32 63.2526 21.56 63.4259 21.68C63.5193 21.7467 63.6326 21.78 63.7659 21.78C63.8726 21.78 63.9593 21.7667 64.0259 21.74ZM73.2534 20.98C73.2801 20.7933 73.2934 20.52 73.2934 20.16L73.2734 18.98L73.0334 15.5L73.0134 15.2C73.0134 14.4933 73.3134 14.14 73.9134 14.14C74.0601 14.14 74.1868 14.1933 74.2934 14.3C74.4134 14.4067 74.5201 14.46 74.6134 14.46C74.8134 14.46 74.9868 14.3733 75.1334 14.2C75.2934 14.0133 75.4601 13.8267 75.6334 13.64C75.9134 13.3467 76.2401 13.1133 76.6134 12.94C77.0001 12.7667 77.3868 12.68 77.7734 12.68C78.1468 12.68 78.4468 12.7267 78.6734 12.82C78.7401 12.8467 78.8134 12.9067 78.8934 13C78.9868 13.08 79.0334 13.1467 79.0334 13.2C79.0334 13.2533 78.9934 13.3333 78.9134 13.44C78.8468 13.5333 78.7868 13.5867 78.7334 13.6C78.2134 13.7067 77.7601 13.9267 77.3734 14.26C77.0001 14.5933 76.7534 14.9 76.6334 15.18C76.5268 15.4467 76.3668 15.9467 76.1534 16.68C76.0334 17.0933 75.9401 17.52 75.8734 17.96C75.8201 18.3867 75.8001 18.9333 75.8134 19.6C75.8001 19.6667 75.7934 19.76 75.7934 19.88C75.7934 20.0533 75.8068 20.2867 75.8334 20.58C75.8601 20.9 75.8734 21.1267 75.8734 21.26C75.8734 21.9267 75.8001 22.3067 75.6534 22.4C75.4401 22.5067 75.1601 22.56 74.8134 22.56C74.5334 22.56 74.3068 22.5267 74.1334 22.46C73.5334 22.2333 73.2334 21.82 73.2334 21.22C73.2334 21.1133 73.2401 21.0333 73.2534 20.98ZM83.6722 14.56C83.6722 14.8133 83.6389 15.16 83.5722 15.6C83.5189 16.1067 83.4855 16.5133 83.4722 16.82C83.6322 16.7533 83.8189 16.6 84.0322 16.36C84.1522 16.2267 84.2722 16.1133 84.3922 16.02C84.9789 15.6067 85.5389 15.4 86.0722 15.4C86.6322 15.4 87.2255 15.6333 87.8522 16.1C88.7989 16.7933 89.2722 17.6067 89.2722 18.54C89.2722 19.2733 88.9655 20.0467 88.3522 20.86C87.4722 22.0467 86.2322 22.76 84.6322 23C84.5522 23.0133 84.4322 23.02 84.2722 23.02C84.0189 23.02 83.5989 22.9667 83.0122 22.86C83.0122 22.7267 82.9922 22.58 82.9522 22.42C83.0455 22.38 83.1655 22.3267 83.3122 22.26C83.4722 22.18 83.6122 22.1333 83.7322 22.12C84.1455 22.08 84.6055 21.8667 85.1122 21.48C85.6322 21.08 86.0855 20.62 86.4722 20.1C86.8589 19.58 87.0789 19.1267 87.1322 18.74C87.1455 18.6867 87.1589 18.6133 87.1722 18.52C87.1855 18.4267 87.1922 18.3133 87.1922 18.18C87.1922 18.0467 87.1722 17.9333 87.1322 17.84C86.9189 17.3467 86.6722 17.02 86.3922 16.86C86.3122 16.82 86.1722 16.8667 85.9722 17C85.7722 17.12 85.6122 17.2533 85.4922 17.4C84.6122 18.36 84.0922 19.46 83.9322 20.7C83.8789 21.18 83.6055 21.4133 83.1122 21.4C82.7122 21.4 82.3455 21.24 82.0122 20.92C81.6789 20.5867 81.5122 20.22 81.5122 19.82C81.4989 17.8067 81.4922 14.7933 81.4922 10.78C81.4922 10.4467 81.5855 10.22 81.7722 10.1C81.9722 9.96667 82.2589 9.86 82.6322 9.78C82.7922 9.74 82.9722 9.77333 83.1722 9.88C83.3722 9.98667 83.5455 10.1267 83.6922 10.3C83.8389 10.4733 83.9122 10.6467 83.9122 10.82C83.9122 11.6067 83.8522 12.5867 83.7322 13.76L83.6722 14.56ZM93.5573 22.32C93.5973 22.3067 93.6707 22.3 93.7773 22.3C93.7773 17.38 93.7707 13.6867 93.7573 11.22C93.7573 11.0467 93.7107 10.8467 93.6173 10.62C93.524 10.38 93.4573 10.2 93.4173 10.08C93.324 9.82667 93.2773 9.61333 93.2773 9.44C93.2773 9.09333 93.4507 8.82 93.7973 8.62C94.0107 8.48667 94.284 8.42 94.6173 8.42C94.8307 8.42 95.0307 8.45333 95.2173 8.52C95.404 8.58667 95.5373 8.69333 95.6173 8.84C95.9507 9.42667 96.1107 9.96 96.0973 10.44C96.0707 11.1067 96.0307 11.86 95.9773 12.7C95.924 13.5267 95.8907 14.1 95.8773 14.42C95.7973 15.3 95.7373 16.1867 95.6973 17.08L95.5973 19.78C95.584 20.38 95.5507 21.28 95.4973 22.48C95.4707 22.8267 95.384 23.0733 95.2373 23.22C95.104 23.3667 94.884 23.44 94.5773 23.44C94.444 23.44 94.3373 23.4333 94.2573 23.42C93.9373 23.3933 93.7373 23.2867 93.6573 23.1C93.5773 22.9133 93.544 22.6533 93.5573 22.32ZM102.582 15.2C102.702 15.1867 102.802 15.1867 102.882 15.2C102.962 15.2 103.035 15.2 103.102 15.2C103.195 15.1867 103.335 15.18 103.522 15.18C104.428 15.18 105.322 15.44 106.202 15.96C106.588 16.2 106.782 16.4533 106.782 16.72C106.782 17.0133 106.582 17.2733 106.182 17.5C105.848 17.7 105.502 17.9333 105.142 18.2C104.502 18.6267 103.955 18.96 103.502 19.2C103.062 19.44 102.575 19.6067 102.042 19.7C101.962 19.7267 101.875 19.8 101.782 19.92C101.702 20.0267 101.668 20.1067 101.682 20.16C101.708 20.24 101.755 20.4067 101.822 20.66C101.888 20.9 101.962 21.1067 102.042 21.28C102.135 21.44 102.242 21.5733 102.362 21.68C102.495 21.7867 102.695 21.84 102.962 21.84C103.228 21.84 103.475 21.8 103.702 21.72C104.502 21.3467 105.262 20.88 105.982 20.32C106.195 20.1733 106.382 20.1 106.542 20.1C106.742 20.1 106.955 20.2067 107.182 20.42C107.342 20.5667 107.422 20.72 107.422 20.88C107.422 21.0533 107.295 21.2667 107.042 21.52C105.908 22.6267 104.702 23.18 103.422 23.18C103.075 23.18 102.648 23.1133 102.142 22.98C101.288 22.7667 100.635 22.2933 100.182 21.56C99.7282 20.8267 99.5016 19.9867 99.5016 19.04C99.5016 18.7467 99.5216 18.4667 99.5616 18.2C99.5749 18.1067 99.6016 17.9133 99.6416 17.62C99.6949 17.3133 99.7749 17.06 99.8816 16.86C100.388 15.86 101.288 15.3067 102.582 15.2ZM103.742 16.64C103.502 16.56 103.288 16.52 103.102 16.52C102.795 16.52 102.555 16.6133 102.382 16.8C102.208 16.9867 102.068 17.2667 101.962 17.64C102.282 17.68 102.622 17.6 102.982 17.4C103.355 17.1867 103.608 16.9333 103.742 16.64Z\"\n    fill=\"black\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/X.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  role=\"img\"\n  viewBox=\"0 0 24 24\"\n  width=\"18\"\n  height=\"18\"\n  fill=\"currentColor\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <title>X</title>\n  <path\n    d=\"M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Bounty.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"120\"\n  height=\"44\"\n  viewBox=\"0 0 4457 1611\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <path\n    d=\"M379.493 491.959L379.492 632.181C379.492 709.028 441.789 771.325 518.636 771.325C595.484 771.325 657.781 709.028 657.781 632.181V491.959\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M657.781 1066.87L657.781 926.649C657.781 849.802 595.484 787.505 518.637 787.505L195.417 926.649\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M2962.85 624.847L3091.66 680.264C3162.25 710.634 3244.1 678.028 3274.47 607.437C3304.84 536.845 3272.23 455 3201.64 424.63L3072.83 369.213\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M3345.46 486.499C3416.06 516.87 3497.9 484.264 3528.27 413.672C3558.64 343.081 3526.04 261.235 3455.45 230.865C3384.85 200.495 3303.01 233.1 3272.64 303.692C3242.27 374.283 3274.87 456.129 3345.46 486.499ZM3345.46 486.499L3073.55 369.514\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M3960.57 1103.06C3960.57 1026.22 3898.27 963.918 3821.43 963.918L3498.21 1103.06L3925.45 918.476C3960.75 916.835 3960.57 1026.22 3960.57 1103.06ZM3960.57 1103.06L3960.57 1243.28\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M3960.58 965.162C3960.58 1042.01 4022.88 1104.31 4099.72 1104.31C4176.57 1104.31 4238.87 1042.01 4238.87 965.162C4238.87 888.314 4176.57 826.017 4099.72 826.017C4022.88 826.017 3960.58 888.314 3960.58 965.162ZM3960.58 965.162L3960.58 669.146\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M1052.37 1231.51L1192.59 1231.51C1269.43 1231.51 1331.73 1169.21 1331.73 1092.36C1331.73 1015.51 1269.43 953.217 1192.59 953.217L1052.37 953.217\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M1627.28 953.218L1487.06 953.218C1410.21 953.218 1347.91 1015.51 1347.91 1092.36L1487.06 1415.58\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M2066.48 1262.7L2066.48 1122.48C2066.48 1045.63 2004.19 983.333 1927.34 983.333L1604.12 1122.48\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M2344.78 984.576C2344.78 1061.42 2282.48 1123.72 2205.64 1123.72C2128.79 1123.72 2066.49 1061.42 2066.49 984.576C2066.49 907.729 2128.79 845.432 2205.64 845.432C2282.48 845.432 2344.78 907.729 2344.78 984.576Z\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M2534.5 378.502L2534.5 518.724C2534.5 595.571 2596.8 657.868 2673.64 657.868L2996.86 518.724\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M2256.2 656.625C2256.2 579.778 2318.5 517.481 2395.34 517.481C2472.19 517.481 2534.49 579.778 2534.49 656.625C2534.49 733.472 2472.19 795.77 2395.34 795.77C2318.5 795.77 2256.2 733.472 2256.2 656.625Z\"\n    stroke=\"#E7E7E7\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M3959.2 667.11L3959.2 1243.32M3682.29 668.372L3682.29 808.593C3682.29 885.441 3744.58 947.738 3821.43 947.738C3898.28 947.738 3960.57 885.441 3960.57 808.593V668.372\"\n    stroke=\"#C3C3C3\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M3472.14 541.006C3401.55 510.636 3319.7 543.241 3289.33 613.833L3289.41 965.731L3288.7 500.315C3301.08 467.41 3400.55 510.205 3471.08 540.551L3472.14 541.006ZM3472.14 541.006L3600.94 596.423M3071.13 369.974L3600.44 597.694\"\n    stroke=\"#C3C3C3\"\n    stroke-width=\"99.2347\"\n  />\n  <path\n    d=\"M2812.79 953.414L2812.79 813.192C2812.79 736.345 2750.49 674.048 2673.64 674.048C2596.8 674.048 2534.5 736.345 2534.5 813.192L2534.5 953.414L2534.5 633.175\"\n    stroke=\"#C3C3C3\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M1788.2 687.787L1788.2 828.009C1788.2 904.856 1850.49 967.153 1927.34 967.153C2004.19 967.153 2066.48 904.856 2066.48 828.009L2066.48 687.787\"\n    stroke=\"#C3C3C3\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M1349.15 674.921C1426 674.921 1488.3 737.218 1488.3 814.066C1488.3 890.913 1426 953.21 1349.15 953.21C1272.3 953.21 1210.01 890.913 1210.01 814.066C1210.01 737.218 1272.3 674.921 1349.15 674.921Z\"\n    stroke=\"#C3C3C3\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M657.79 788.749C657.79 865.596 720.087 927.893 796.934 927.893C873.781 927.893 936.078 865.596 936.078 788.749C936.078 711.901 873.781 649.604 796.934 649.604C720.087 649.604 657.79 711.901 657.79 788.749ZM657.79 788.749L657.789 492.733\"\n    stroke=\"#C3C3C3\"\n    stroke-width=\"94.9201\"\n  />\n  <path\n    d=\"M3959.2 667.11V828.199M3959.2 828.199L3959.2 1243.32M3959.2 828.199C3960.11 821.793 3960.57 815.248 3960.57 808.593V668.371M3959.2 828.199C3949.67 895.762 3891.62 947.737 3821.43 947.737C3744.58 947.737 3682.29 885.44 3682.29 808.593L3682.29 668.371M3472.14 541.006C3401.55 510.636 3319.7 543.241 3289.33 613.833L3289.41 965.731L3288.7 500.315C3301.08 467.41 3400.55 510.205 3471.08 540.551L3472.14 541.006ZM3472.14 541.006L3600.94 596.423M3071.13 369.974L3600.44 597.694M2534.5 813.192C2534.5 736.345 2596.8 674.048 2673.64 674.048C2750.49 674.048 2812.79 736.345 2812.79 813.192L2812.79 953.414M2534.5 813.192V953.414M2534.5 813.192V633.175M1788.19 687.787L1788.19 828.009C1788.19 904.856 1850.49 967.153 1927.34 967.153C2004.18 967.153 2066.48 904.856 2066.48 828.009V687.787M657.79 788.748C657.79 865.595 720.087 927.892 796.934 927.892C873.781 927.892 936.078 865.595 936.078 788.748C936.078 711.901 873.781 649.604 796.934 649.604C720.087 649.604 657.79 711.901 657.79 788.748ZM657.79 788.748L657.789 492.733M1349.15 674.921C1426 674.921 1488.3 737.218 1488.3 814.065C1488.3 890.912 1426 953.209 1349.15 953.209C1272.31 953.209 1210.01 890.912 1210.01 814.065C1210.01 737.218 1272.31 674.921 1349.15 674.921Z\"\n    stroke=\"url(#paint0_linear_9763_163)\"\n    stroke-width=\"94.9201\"\n  />\n  <defs>\n    <linearGradient\n      id=\"paint0_linear_9763_163\"\n      x1=\"2309.18\"\n      y1=\"369.974\"\n      x2=\"2309.18\"\n      y2=\"1243.32\"\n      gradientUnits=\"userSpaceOnUse\"\n    >\n      <stop stop-color=\"#C3C3C3\"/>\n      <stop offset=\"1\" stop-color=\"#5D5D5D\"/>\n    </linearGradient>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Candle.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"120\"\n  height=\"29\"\n  viewBox=\"0 0 634 152\"\n  fill=\"none\"\n  class={classNames}\n  {...attrs}\n>\n  <g clip-path=\"url(#clip0_3852_3171)\">\n    <mask\n      id=\"path-1-outside-1_3852_3171\"\n      maskUnits=\"userSpaceOnUse\"\n      x=\"138.836\"\n      y=\"25.998\"\n      width=\"483\"\n      height=\"123\"\n      fill=\"black\"\n    >\n      <rect fill=\"white\" x=\"138.836\" y=\"25.998\" width=\"483\" height=\"123\"/>\n      <path\n        d=\"M208.989 138.811C203.729 141.675 198.624 143.811 193.677 145.217C188.729 146.571 183.755 147.248 178.755 147.248C167.296 147.248 158.026 143.628 150.942 136.389C143.911 129.149 140.395 119.67 140.395 107.951C140.395 95.8158 144.249 85.8678 151.958 78.1074C159.718 70.347 169.614 66.4668 181.645 66.4668C189.51 66.4668 195.864 68.1074 200.708 71.3887C205.604 74.6178 208.052 78.7845 208.052 83.8887C208.052 87.0658 206.958 89.696 204.77 91.7793C202.635 93.8105 199.901 94.8262 196.567 94.8262C194.744 94.8262 193.052 94.5918 191.489 94.123C189.979 93.6022 188.52 92.821 187.114 91.7793V74.8262C186.229 74.6178 185.447 74.4876 184.77 74.4355C184.145 74.3314 183.572 74.2793 183.052 74.2793C177.114 74.2793 172.14 77.0918 168.13 82.7168C164.119 88.2897 162.114 95.3991 162.114 104.045C162.114 113.524 164.328 120.998 168.755 126.467C173.182 131.936 179.197 134.67 186.802 134.67C190.239 134.67 193.833 134.071 197.583 132.873C201.333 131.623 205.135 129.8 208.989 127.404V138.811ZM259.464 130.295V104.201C248.943 106.545 241.756 109.097 237.902 111.857C234.047 114.618 232.12 118.368 232.12 123.107C232.12 126.805 233.188 129.748 235.323 131.936C237.459 134.071 240.349 135.139 243.995 135.139C246.027 135.139 248.318 134.748 250.87 133.967C253.474 133.133 256.339 131.91 259.464 130.295ZM259.464 136.857C254.047 140.555 249.126 143.238 244.698 144.904C240.271 146.571 236.027 147.404 231.964 147.404C225.818 147.404 220.792 145.477 216.886 141.623C212.98 137.717 211.027 132.717 211.027 126.623C211.027 119.123 213.891 113.342 219.62 109.279C225.349 105.217 238.709 100.816 259.698 96.0762V91.3105C259.698 85.5293 258.474 81.3105 256.027 78.6543C253.579 75.998 249.724 74.6699 244.464 74.6699C243.318 74.6699 242.12 74.748 240.87 74.9043C239.62 75.0605 238.266 75.2949 236.808 75.6074V90.6074H222.042C219.49 90.6074 217.615 90.1387 216.417 89.2012C215.271 88.2637 214.698 86.7793 214.698 84.748C214.698 79.748 218.058 75.3991 224.777 71.7012C231.495 68.0033 239.751 66.1543 249.542 66.1543C260.532 66.1543 268.266 68.2637 272.745 72.4824C277.224 76.7012 279.464 84.0449 279.464 94.5137V135.92L281.573 138.186L290.089 138.967V145.998H261.573L259.464 136.857ZM290.173 145.998V138.811L298.377 138.186L300.642 135.764V80.5293H289.705V74.123L312.752 66.623H320.486V76.0762C326.58 72.6908 331.84 70.2168 336.267 68.6543C340.694 67.0918 344.548 66.3105 347.83 66.3105C355.121 66.3105 360.564 68.498 364.158 72.873C367.752 77.248 369.548 83.9147 369.548 92.873V135.764L371.658 138.186L380.095 138.811V145.998H340.955V138.811L347.83 138.186L350.017 135.764V97.4824C350.017 91.5449 348.871 87.196 346.58 84.4355C344.288 81.623 340.642 80.2168 335.642 80.2168C333.715 80.2168 331.476 80.4512 328.923 80.9199C326.423 81.3887 323.611 82.0658 320.486 82.9512V135.764L322.673 138.186L329.705 138.811V145.998H290.173ZM437.992 130.764V77.3262C435.596 76.5449 433.435 75.972 431.508 75.6074C429.581 75.1908 427.784 74.9824 426.117 74.9824C419.138 74.9824 413.539 77.7428 409.32 83.2637C405.154 88.7845 403.07 96.2064 403.07 105.529C403.07 114.383 404.971 121.311 408.773 126.311C412.576 131.311 417.836 133.811 424.555 133.811C426.326 133.811 428.305 133.576 430.492 133.107C432.732 132.587 435.232 131.805 437.992 130.764ZM437.992 41.7012H426.273V35.2949L450.102 27.7949H457.523V135.764L459.711 138.342L467.914 138.967V145.998H437.992V137.014C433.096 140.607 428.617 143.238 424.555 144.904C420.492 146.571 416.534 147.404 412.68 147.404C403.617 147.404 396.169 143.837 390.336 136.701C384.555 129.514 381.664 120.243 381.664 108.889C381.664 96.1283 385.466 85.7897 393.07 77.873C400.727 69.9564 410.674 65.998 422.914 65.998C425.206 65.998 427.602 66.1283 430.102 66.3887C432.602 66.6491 435.232 67.0397 437.992 67.5605V41.7012ZM468.467 145.998V138.967L477.764 138.029L480.03 135.764V41.7012H468.467V35.2949L492.608 27.7949H499.639V135.451L502.061 138.029L511.358 138.967V145.998H468.467ZM564.88 96.7012V94.9824C564.88 88.472 563.682 83.498 561.286 80.0605C558.89 76.571 555.453 74.8262 550.973 74.8262C546.078 74.8262 542.145 76.7793 539.177 80.6855C536.208 84.5397 534.593 89.8783 534.333 96.7012H564.88ZM585.895 126.154V138.029C580.427 141.154 574.906 143.498 569.333 145.061C563.76 146.623 558.187 147.404 552.614 147.404C540.687 147.404 531.104 143.732 523.864 136.389C516.677 129.045 513.083 119.331 513.083 107.248C513.083 95.1126 516.755 85.2168 524.098 77.5605C531.494 69.8522 540.999 65.998 552.614 65.998C563.135 65.998 571.312 69.1751 577.145 75.5293C582.979 81.8314 585.895 90.7116 585.895 102.17V105.217H533.552V105.686C533.552 114.8 535.843 121.962 540.427 127.17C545.01 132.378 551.312 134.982 559.333 134.982C563.552 134.982 567.848 134.253 572.223 132.795C576.598 131.337 581.156 129.123 585.895 126.154ZM607.698 121.857C611.188 121.857 614.183 123.107 616.683 125.607C619.235 128.107 620.511 131.128 620.511 134.67C620.511 138.16 619.261 141.128 616.761 143.576C614.261 146.024 611.24 147.248 607.698 147.248C604.261 147.248 601.318 146.024 598.87 143.576C596.422 141.128 595.198 138.16 595.198 134.67C595.198 131.076 596.396 128.055 598.792 125.607C601.24 123.107 604.209 121.857 607.698 121.857Z\"\n      />\n    </mask>\n    <path\n      d=\"M208.989 138.811C203.729 141.675 198.624 143.811 193.677 145.217C188.729 146.571 183.755 147.248 178.755 147.248C167.296 147.248 158.026 143.628 150.942 136.389C143.911 129.149 140.395 119.67 140.395 107.951C140.395 95.8158 144.249 85.8678 151.958 78.1074C159.718 70.347 169.614 66.4668 181.645 66.4668C189.51 66.4668 195.864 68.1074 200.708 71.3887C205.604 74.6178 208.052 78.7845 208.052 83.8887C208.052 87.0658 206.958 89.696 204.77 91.7793C202.635 93.8105 199.901 94.8262 196.567 94.8262C194.744 94.8262 193.052 94.5918 191.489 94.123C189.979 93.6022 188.52 92.821 187.114 91.7793V74.8262C186.229 74.6178 185.447 74.4876 184.77 74.4355C184.145 74.3314 183.572 74.2793 183.052 74.2793C177.114 74.2793 172.14 77.0918 168.13 82.7168C164.119 88.2897 162.114 95.3991 162.114 104.045C162.114 113.524 164.328 120.998 168.755 126.467C173.182 131.936 179.197 134.67 186.802 134.67C190.239 134.67 193.833 134.071 197.583 132.873C201.333 131.623 205.135 129.8 208.989 127.404V138.811ZM259.464 130.295V104.201C248.943 106.545 241.756 109.097 237.902 111.857C234.047 114.618 232.12 118.368 232.12 123.107C232.12 126.805 233.188 129.748 235.323 131.936C237.459 134.071 240.349 135.139 243.995 135.139C246.027 135.139 248.318 134.748 250.87 133.967C253.474 133.133 256.339 131.91 259.464 130.295ZM259.464 136.857C254.047 140.555 249.126 143.238 244.698 144.904C240.271 146.571 236.027 147.404 231.964 147.404C225.818 147.404 220.792 145.477 216.886 141.623C212.98 137.717 211.027 132.717 211.027 126.623C211.027 119.123 213.891 113.342 219.62 109.279C225.349 105.217 238.709 100.816 259.698 96.0762V91.3105C259.698 85.5293 258.474 81.3105 256.027 78.6543C253.579 75.998 249.724 74.6699 244.464 74.6699C243.318 74.6699 242.12 74.748 240.87 74.9043C239.62 75.0605 238.266 75.2949 236.808 75.6074V90.6074H222.042C219.49 90.6074 217.615 90.1387 216.417 89.2012C215.271 88.2637 214.698 86.7793 214.698 84.748C214.698 79.748 218.058 75.3991 224.777 71.7012C231.495 68.0033 239.751 66.1543 249.542 66.1543C260.532 66.1543 268.266 68.2637 272.745 72.4824C277.224 76.7012 279.464 84.0449 279.464 94.5137V135.92L281.573 138.186L290.089 138.967V145.998H261.573L259.464 136.857ZM290.173 145.998V138.811L298.377 138.186L300.642 135.764V80.5293H289.705V74.123L312.752 66.623H320.486V76.0762C326.58 72.6908 331.84 70.2168 336.267 68.6543C340.694 67.0918 344.548 66.3105 347.83 66.3105C355.121 66.3105 360.564 68.498 364.158 72.873C367.752 77.248 369.548 83.9147 369.548 92.873V135.764L371.658 138.186L380.095 138.811V145.998H340.955V138.811L347.83 138.186L350.017 135.764V97.4824C350.017 91.5449 348.871 87.196 346.58 84.4355C344.288 81.623 340.642 80.2168 335.642 80.2168C333.715 80.2168 331.476 80.4512 328.923 80.9199C326.423 81.3887 323.611 82.0658 320.486 82.9512V135.764L322.673 138.186L329.705 138.811V145.998H290.173ZM437.992 130.764V77.3262C435.596 76.5449 433.435 75.972 431.508 75.6074C429.581 75.1908 427.784 74.9824 426.117 74.9824C419.138 74.9824 413.539 77.7428 409.32 83.2637C405.154 88.7845 403.07 96.2064 403.07 105.529C403.07 114.383 404.971 121.311 408.773 126.311C412.576 131.311 417.836 133.811 424.555 133.811C426.326 133.811 428.305 133.576 430.492 133.107C432.732 132.587 435.232 131.805 437.992 130.764ZM437.992 41.7012H426.273V35.2949L450.102 27.7949H457.523V135.764L459.711 138.342L467.914 138.967V145.998H437.992V137.014C433.096 140.607 428.617 143.238 424.555 144.904C420.492 146.571 416.534 147.404 412.68 147.404C403.617 147.404 396.169 143.837 390.336 136.701C384.555 129.514 381.664 120.243 381.664 108.889C381.664 96.1283 385.466 85.7897 393.07 77.873C400.727 69.9564 410.674 65.998 422.914 65.998C425.206 65.998 427.602 66.1283 430.102 66.3887C432.602 66.6491 435.232 67.0397 437.992 67.5605V41.7012ZM468.467 145.998V138.967L477.764 138.029L480.03 135.764V41.7012H468.467V35.2949L492.608 27.7949H499.639V135.451L502.061 138.029L511.358 138.967V145.998H468.467ZM564.88 96.7012V94.9824C564.88 88.472 563.682 83.498 561.286 80.0605C558.89 76.571 555.453 74.8262 550.973 74.8262C546.078 74.8262 542.145 76.7793 539.177 80.6855C536.208 84.5397 534.593 89.8783 534.333 96.7012H564.88ZM585.895 126.154V138.029C580.427 141.154 574.906 143.498 569.333 145.061C563.76 146.623 558.187 147.404 552.614 147.404C540.687 147.404 531.104 143.732 523.864 136.389C516.677 129.045 513.083 119.331 513.083 107.248C513.083 95.1126 516.755 85.2168 524.098 77.5605C531.494 69.8522 540.999 65.998 552.614 65.998C563.135 65.998 571.312 69.1751 577.145 75.5293C582.979 81.8314 585.895 90.7116 585.895 102.17V105.217H533.552V105.686C533.552 114.8 535.843 121.962 540.427 127.17C545.01 132.378 551.312 134.982 559.333 134.982C563.552 134.982 567.848 134.253 572.223 132.795C576.598 131.337 581.156 129.123 585.895 126.154ZM607.698 121.857C611.188 121.857 614.183 123.107 616.683 125.607C619.235 128.107 620.511 131.128 620.511 134.67C620.511 138.16 619.261 141.128 616.761 143.576C614.261 146.024 611.24 147.248 607.698 147.248C604.261 147.248 601.318 146.024 598.87 143.576C596.422 141.128 595.198 138.16 595.198 134.67C595.198 131.076 596.396 128.055 598.792 125.607C601.24 123.107 604.209 121.857 607.698 121.857Z\"\n      fill=\"black\"\n    />\n    <path\n      d=\"M208.989 138.811L209.467 139.689L209.989 139.405V138.811H208.989ZM193.677 145.217L193.941 146.181L193.95 146.179L193.677 145.217ZM150.942 136.389L150.225 137.085L150.227 137.088L150.942 136.389ZM151.958 78.1074L151.251 77.4003L151.248 77.4027L151.958 78.1074ZM200.708 71.3887L200.147 72.2167L200.157 72.2234L200.708 71.3887ZM204.77 91.7793L205.46 92.5039L205.46 92.5034L204.77 91.7793ZM191.489 94.123L191.163 95.0684L191.182 95.075L191.202 95.0809L191.489 94.123ZM187.114 91.7793H186.114V92.283L186.519 92.5829L187.114 91.7793ZM187.114 74.8262H188.114V74.0342L187.343 73.8528L187.114 74.8262ZM184.77 74.4355L184.606 75.4219L184.65 75.4292L184.694 75.4326L184.77 74.4355ZM168.13 82.7168L168.941 83.3009L168.944 83.2973L168.13 82.7168ZM197.583 132.873L197.887 133.826L197.899 133.822L197.583 132.873ZM208.989 127.404H209.989V125.605L208.461 126.555L208.989 127.404ZM208.989 138.811L208.511 137.932C203.305 140.767 198.269 142.872 193.403 144.255L193.677 145.217L193.95 146.179C198.98 144.749 204.152 142.583 209.467 139.689L208.989 138.811ZM193.677 145.217L193.413 144.252C188.548 145.584 183.663 146.248 178.755 146.248V147.248V148.248C183.846 148.248 188.909 147.558 193.941 146.181L193.677 145.217ZM178.755 147.248V146.248C167.527 146.248 158.527 142.711 151.657 135.689L150.942 136.389L150.227 137.088C157.524 144.546 167.066 148.248 178.755 148.248V147.248ZM150.942 136.389L151.66 135.692C144.839 128.669 141.395 119.455 141.395 107.951H140.395H139.395C139.395 119.885 142.983 129.629 150.225 137.085L150.942 136.389ZM140.395 107.951H141.395C141.395 96.0482 145.166 86.3639 152.667 78.8121L151.958 78.1074L151.248 77.4027C143.333 85.3718 139.395 95.5834 139.395 107.951H140.395ZM151.958 78.1074L152.665 78.8145C160.219 71.2606 169.851 67.4668 181.645 67.4668V66.4668V65.4668C169.377 65.4668 159.218 69.4334 151.251 77.4003L151.958 78.1074ZM181.645 66.4668V67.4668C189.377 67.4668 195.517 69.0801 200.147 72.2166L200.708 71.3887L201.269 70.5608C196.211 67.1347 189.642 65.4668 181.645 65.4668V66.4668ZM200.708 71.3887L200.157 72.2234C204.821 75.2997 207.052 79.1751 207.052 83.8887H208.052H209.052C209.052 78.3939 206.386 73.9359 201.258 70.5539L200.708 71.3887ZM208.052 83.8887H207.052C207.052 86.8079 206.059 89.1712 204.081 91.0552L204.77 91.7793L205.46 92.5034C207.857 90.2207 209.052 87.3236 209.052 83.8887H208.052ZM204.77 91.7793L204.081 91.0547C202.156 92.8856 199.68 93.8262 196.567 93.8262V94.8262V95.8262C200.121 95.8262 203.113 94.7355 205.46 92.5039L204.77 91.7793ZM196.567 94.8262V93.8262C194.83 93.8262 193.235 93.6029 191.776 93.1652L191.489 94.123L191.202 95.0809C192.868 95.5807 194.658 95.8262 196.567 95.8262V94.8262ZM191.489 94.123L191.815 93.1777C190.41 92.6931 189.041 91.9624 187.709 90.9757L187.114 91.7793L186.519 92.5829C187.999 93.6795 189.547 94.5113 191.163 95.0684L191.489 94.123ZM187.114 91.7793H188.114V74.8262H187.114H186.114V91.7793H187.114ZM187.114 74.8262L187.343 73.8528C186.423 73.6363 185.589 73.4956 184.847 73.4385L184.77 74.4355L184.694 75.4326C185.306 75.4797 186.034 75.5994 186.885 75.7996L187.114 74.8262ZM184.77 74.4355L184.935 73.4492C184.267 73.3378 183.638 73.2793 183.052 73.2793V74.2793V75.2793C183.507 75.2793 184.024 75.325 184.606 75.4219L184.77 74.4355ZM183.052 74.2793V73.2793C176.735 73.2793 171.478 76.2974 167.315 82.1363L168.13 82.7168L168.944 83.2973C172.802 77.8862 177.493 75.2793 183.052 75.2793V74.2793ZM168.13 82.7168L167.318 82.1327C163.157 87.9154 161.114 95.2442 161.114 104.045H162.114H163.114C163.114 95.554 165.082 88.664 168.941 83.3009L168.13 82.7168ZM162.114 104.045H161.114C161.114 113.674 163.363 121.396 167.977 127.096L168.755 126.467L169.532 125.838C165.292 120.6 163.114 113.374 163.114 104.045H162.114ZM168.755 126.467L167.977 127.096C172.611 132.82 178.924 135.67 186.802 135.67V134.67V133.67C179.471 133.67 173.752 131.051 169.532 125.838L168.755 126.467ZM186.802 134.67V135.67C190.356 135.67 194.053 135.05 197.887 133.826L197.583 132.873L197.279 131.92C193.613 133.091 190.122 133.67 186.802 133.67V134.67ZM197.583 132.873L197.899 133.822C201.733 132.544 205.606 130.685 209.517 128.254L208.989 127.404L208.461 126.555C204.664 128.915 200.932 130.702 197.267 131.924L197.583 132.873ZM208.989 127.404H207.989V138.811H208.989H209.989V127.404H208.989ZM259.464 130.295L259.923 131.183L260.464 130.904V130.295H259.464ZM259.464 104.201H260.464V102.954L259.247 103.225L259.464 104.201ZM235.323 131.936L234.608 132.634L234.616 132.643L235.323 131.936ZM250.87 133.967L251.163 134.923L251.175 134.919L250.87 133.967ZM259.464 136.857L260.438 136.633L260.109 135.206L258.9 136.032L259.464 136.857ZM216.886 141.623L216.179 142.33L216.184 142.335L216.886 141.623ZM259.698 96.0762L259.919 97.0516L260.698 96.8755V96.0762H259.698ZM236.808 75.6074L236.598 74.6296L235.808 74.799V75.6074H236.808ZM236.808 90.6074V91.6074H237.808V90.6074H236.808ZM216.417 89.2012L215.784 89.9751L215.792 89.982L215.801 89.9887L216.417 89.2012ZM279.464 135.92H278.464V136.313L278.732 136.601L279.464 135.92ZM281.573 138.186L280.842 138.867L281.102 139.146L281.482 139.181L281.573 138.186ZM290.089 138.967H291.089V138.054L290.18 137.971L290.089 138.967ZM290.089 145.998V146.998H291.089V145.998H290.089ZM261.573 145.998L260.599 146.223L260.778 146.998H261.573V145.998ZM259.464 130.295H260.464V104.201H259.464H258.464V130.295H259.464ZM259.464 104.201L259.247 103.225C248.72 105.57 241.353 108.155 237.319 111.044L237.902 111.857L238.484 112.67C242.158 110.039 249.167 107.52 259.682 105.177L259.464 104.201ZM237.902 111.857L237.319 111.044C233.203 113.993 231.12 118.045 231.12 123.107H232.12H233.12C233.12 118.69 234.892 115.243 238.484 112.67L237.902 111.857ZM232.12 123.107H231.12C231.12 127.008 232.253 130.222 234.608 132.634L235.323 131.936L236.039 131.237C234.123 129.274 233.12 126.603 233.12 123.107H232.12ZM235.323 131.936L234.616 132.643C236.977 135.003 240.143 136.139 243.995 136.139V135.139V134.139C240.556 134.139 237.941 133.139 236.031 131.228L235.323 131.936ZM243.995 135.139V136.139C246.151 136.139 248.544 135.725 251.163 134.923L250.87 133.967L250.578 133.011C248.092 133.771 245.902 134.139 243.995 134.139V135.139ZM250.87 133.967L251.175 134.919C253.845 134.065 256.762 132.817 259.923 131.183L259.464 130.295L259.005 129.406C255.916 131.002 253.104 132.202 250.566 133.014L250.87 133.967ZM259.464 136.857L258.9 136.032C253.526 139.7 248.676 142.338 244.346 143.968L244.698 144.904L245.051 145.84C249.575 144.137 254.569 141.41 260.028 137.683L259.464 136.857ZM244.698 144.904L244.346 143.968C240.013 145.6 235.888 146.404 231.964 146.404V147.404V148.404C236.165 148.404 240.529 147.542 245.051 145.84L244.698 144.904ZM231.964 147.404V146.404C226.047 146.404 221.284 144.558 217.588 140.911L216.886 141.623L216.184 142.335C220.3 146.397 225.59 148.404 231.964 148.404V147.404ZM216.886 141.623L217.593 140.916C213.895 137.217 212.027 132.482 212.027 126.623H211.027H210.027C210.027 132.952 212.065 138.216 216.179 142.33L216.886 141.623ZM211.027 126.623H212.027C212.027 119.421 214.754 113.956 220.199 110.095L219.62 109.279L219.042 108.464C213.028 112.728 210.027 118.825 210.027 126.623H211.027ZM219.62 109.279L220.199 110.095C222.936 108.154 227.59 106.075 234.25 103.881C240.887 101.695 249.441 99.4176 259.919 97.0516L259.698 96.0762L259.478 95.1007C248.967 97.4743 240.345 99.7675 233.624 101.982C226.924 104.189 222.034 106.342 219.042 108.464L219.62 109.279ZM259.698 96.0762H260.698V91.3105H259.698H258.698V96.0762H259.698ZM259.698 91.3105H260.698C260.698 85.4216 259.457 80.9012 256.762 77.9766L256.027 78.6543L255.291 79.332C257.492 81.7199 258.698 85.637 258.698 91.3105H259.698ZM256.027 78.6543L256.762 77.9766C254.052 75.0361 249.875 73.6699 244.464 73.6699V74.6699V75.6699C249.574 75.6699 253.105 76.96 255.291 79.332L256.027 78.6543ZM244.464 74.6699V73.6699C243.273 73.6699 242.033 73.7511 240.746 73.912L240.87 74.9043L240.994 75.8966C242.207 75.745 243.364 75.6699 244.464 75.6699V74.6699ZM240.87 74.9043L240.746 73.912C239.463 74.0724 238.08 74.3121 236.598 74.6296L236.808 75.6074L237.017 76.5852C238.452 76.2777 239.778 76.0487 240.994 75.8966L240.87 74.9043ZM236.808 75.6074H235.808V90.6074H236.808H237.808V75.6074H236.808ZM236.808 90.6074V89.6074H222.042V90.6074V91.6074H236.808V90.6074ZM222.042 90.6074V89.6074C219.575 89.6074 217.971 89.1475 217.033 88.4137L216.417 89.2012L215.801 89.9887C217.259 91.1299 219.405 91.6074 222.042 91.6074V90.6074ZM216.417 89.2012L217.05 88.4272C216.215 87.7435 215.698 86.5973 215.698 84.748H214.698H213.698C213.698 86.9613 214.328 88.7839 215.784 89.9751L216.417 89.2012ZM214.698 84.748H215.698C215.698 80.2839 218.674 76.2013 225.259 72.5772L224.777 71.7012L224.294 70.8251C217.441 74.5968 213.698 79.2122 213.698 84.748H214.698ZM224.777 71.7012L225.259 72.5772C231.794 68.9801 239.874 67.1543 249.542 67.1543V66.1543V65.1543C239.628 65.1543 231.196 67.0264 224.294 70.8251L224.777 71.7012ZM249.542 66.1543V67.1543C260.459 67.1543 267.866 69.2604 272.06 73.2104L272.745 72.4824L273.431 71.7545C268.666 67.267 260.605 65.1543 249.542 65.1543V66.1543ZM272.745 72.4824L272.06 73.2104C276.236 77.1436 278.464 84.1377 278.464 94.5137H279.464H280.464C280.464 83.9521 278.213 76.2588 273.431 71.7545L272.745 72.4824ZM279.464 94.5137H278.464V135.92H279.464H280.464V94.5137H279.464ZM279.464 135.92L278.732 136.601L280.842 138.867L281.573 138.186L282.305 137.504L280.196 135.239L279.464 135.92ZM281.573 138.186L281.482 139.181L289.998 139.963L290.089 138.967L290.18 137.971L281.665 137.19L281.573 138.186ZM290.089 138.967H289.089V145.998H290.089H291.089V138.967H290.089ZM290.089 145.998V144.998H261.573V145.998V146.998H290.089V145.998ZM261.573 145.998L262.548 145.773L260.438 136.633L259.464 136.857L258.49 137.082L260.599 146.223L261.573 145.998ZM290.173 145.998H289.173V146.998H290.173V145.998ZM290.173 138.811L290.097 137.813L289.173 137.884V138.811H290.173ZM298.377 138.186L298.453 139.183L298.841 139.153L299.107 138.869L298.377 138.186ZM300.642 135.764L301.372 136.447L301.642 136.158V135.764H300.642ZM300.642 80.5293H301.642V79.5293H300.642V80.5293ZM289.705 80.5293H288.705V81.5293H289.705V80.5293ZM289.705 74.123L289.395 73.1721L288.705 73.3969V74.123H289.705ZM312.752 66.623V65.623H312.593L312.442 65.6721L312.752 66.623ZM320.486 66.623H321.486V65.623H320.486V66.623ZM320.486 76.0762H319.486V77.7757L320.972 76.9503L320.486 76.0762ZM369.548 135.764H368.548V136.138L368.794 136.42L369.548 135.764ZM371.658 138.186L370.904 138.842L371.174 139.152L371.584 139.183L371.658 138.186ZM380.095 138.811H381.095V137.882L380.169 137.813L380.095 138.811ZM380.095 145.998V146.998H381.095V145.998H380.095ZM340.955 145.998H339.955V146.998H340.955V145.998ZM340.955 138.811L340.864 137.815L339.955 137.897V138.811H340.955ZM347.83 138.186L347.92 139.181L348.31 139.146L348.572 138.856L347.83 138.186ZM350.017 135.764L350.759 136.434L351.017 136.148V135.764H350.017ZM346.58 84.4355L345.804 85.0672L345.81 85.0743L346.58 84.4355ZM328.923 80.9199L328.743 79.9364L328.739 79.937L328.923 80.9199ZM320.486 82.9512L320.213 81.989L319.486 82.1951V82.9512H320.486ZM320.486 135.764H319.486V136.148L319.744 136.434L320.486 135.764ZM322.673 138.186L321.931 138.856L322.194 139.147L322.585 139.182L322.673 138.186ZM329.705 138.811H330.705V137.895L329.793 137.814L329.705 138.811ZM329.705 145.998V146.998H330.705V145.998H329.705ZM290.173 145.998H291.173V138.811H290.173H289.173V145.998H290.173ZM290.173 138.811L290.249 139.808L298.453 139.183L298.377 138.186L298.301 137.188L290.097 137.813L290.173 138.811ZM298.377 138.186L299.107 138.869L301.372 136.447L300.642 135.764L299.912 135.081L297.646 137.502L298.377 138.186ZM300.642 135.764H301.642V80.5293H300.642H299.642V135.764H300.642ZM300.642 80.5293V79.5293H289.705V80.5293V81.5293H300.642V80.5293ZM289.705 80.5293H290.705V74.123H289.705H288.705V80.5293H289.705ZM289.705 74.123L290.014 75.074L313.061 67.574L312.752 66.623L312.442 65.6721L289.395 73.1721L289.705 74.123ZM312.752 66.623V67.623H320.486V66.623V65.623H312.752V66.623ZM320.486 66.623H319.486V76.0762H320.486H321.486V66.623H320.486ZM320.486 76.0762L320.972 76.9503C327.037 73.5804 332.244 71.1346 336.6 69.5973L336.267 68.6543L335.934 67.7113C331.436 69.299 326.122 71.8011 320 75.202L320.486 76.0762ZM336.267 68.6543L336.6 69.5973C340.962 68.0576 344.699 67.3105 347.83 67.3105V66.3105V65.3105C344.398 65.3105 340.426 66.1259 335.934 67.7113L336.267 68.6543ZM347.83 66.3105V67.3105C354.915 67.3105 360.033 69.4274 363.385 73.5078L364.158 72.873L364.931 72.2383C361.095 67.5687 355.328 65.3105 347.83 65.3105V66.3105ZM364.158 72.873L363.385 73.5078C366.768 77.6257 368.548 84.0194 368.548 92.873H369.548H370.548C370.548 83.81 368.735 76.8704 364.931 72.2383L364.158 72.873ZM369.548 92.873H368.548V135.764H369.548H370.548V92.873H369.548ZM369.548 135.764L368.794 136.42L370.904 138.842L371.658 138.186L372.412 137.529L370.303 135.107L369.548 135.764ZM371.658 138.186L371.584 139.183L380.021 139.808L380.095 138.811L380.169 137.813L371.732 137.188L371.658 138.186ZM380.095 138.811H379.095V145.998H380.095H381.095V138.811H380.095ZM380.095 145.998V144.998H340.955V145.998V146.998H380.095V145.998ZM340.955 145.998H341.955V138.811H340.955H339.955V145.998H340.955ZM340.955 138.811L341.045 139.806L347.92 139.181L347.83 138.186L347.739 137.19L340.864 137.815L340.955 138.811ZM347.83 138.186L348.572 138.856L350.759 136.434L350.017 135.764L349.275 135.093L347.088 137.515L347.83 138.186ZM350.017 135.764H351.017V97.4824H350.017H349.017V135.764H350.017ZM350.017 97.4824H351.017C351.017 91.4529 349.86 86.8215 347.349 83.7968L346.58 84.4355L345.81 85.0743C347.882 87.5704 349.017 91.637 349.017 97.4824H350.017ZM346.58 84.4355L347.355 83.8039C344.809 80.6789 340.823 79.2168 335.642 79.2168V80.2168V81.2168C340.462 81.2168 343.767 82.5672 345.804 85.0672L346.58 84.4355ZM335.642 80.2168V79.2168C333.638 79.2168 331.335 79.4602 328.743 79.9364L328.923 80.9199L329.104 81.9035C331.616 81.4422 333.792 81.2168 335.642 81.2168V80.2168ZM328.923 80.9199L328.739 79.937C326.203 80.4127 323.36 81.0975 320.213 81.989L320.486 82.9512L320.759 83.9133C323.862 83.034 326.644 82.3647 329.108 81.9028L328.923 80.9199ZM320.486 82.9512H319.486V135.764H320.486H321.486V82.9512H320.486ZM320.486 135.764L319.744 136.434L321.931 138.856L322.673 138.186L323.416 137.515L321.228 135.093L320.486 135.764ZM322.673 138.186L322.585 139.182L329.616 139.807L329.705 138.811L329.793 137.814L322.762 137.189L322.673 138.186ZM329.705 138.811H328.705V145.998H329.705H330.705V138.811H329.705ZM329.705 145.998V144.998H290.173V145.998V146.998H329.705V145.998ZM437.992 130.764L438.345 131.699L438.992 131.455V130.764H437.992ZM437.992 77.3262H438.992V76.6004L438.302 76.3754L437.992 77.3262ZM431.508 75.6074L431.296 76.5848L431.309 76.5876L431.322 76.59L431.508 75.6074ZM409.32 83.2637L408.526 82.6565L408.522 82.6613L409.32 83.2637ZM430.492 133.107L430.702 134.085L430.71 134.083L430.719 134.081L430.492 133.107ZM437.992 41.7012H438.992V40.7012H437.992V41.7012ZM426.273 41.7012H425.273V42.7012H426.273V41.7012ZM426.273 35.2949L425.973 34.3411L425.273 34.5613V35.2949H426.273ZM450.102 27.7949V26.7949H449.948L449.801 26.8411L450.102 27.7949ZM457.523 27.7949H458.523V26.7949H457.523V27.7949ZM457.523 135.764H456.523V136.131L456.761 136.411L457.523 135.764ZM459.711 138.342L458.948 138.989L459.219 139.307L459.635 139.339L459.711 138.342ZM467.914 138.967H468.914V138.04L467.99 137.97L467.914 138.967ZM467.914 145.998V146.998H468.914V145.998H467.914ZM437.992 145.998H436.992V146.998H437.992V145.998ZM437.992 137.014H438.992V135.039L437.4 136.208L437.992 137.014ZM390.336 136.701L389.557 137.328L389.562 137.334L390.336 136.701ZM393.07 77.873L392.351 77.1779L392.349 77.1803L393.07 77.873ZM437.992 67.5605L437.807 68.5432L438.992 68.7669V67.5605H437.992ZM437.992 130.764H438.992V77.3262H437.992H436.992V130.764H437.992ZM437.992 77.3262L438.302 76.3754C435.877 75.5845 433.673 74.9992 431.694 74.6249L431.508 75.6074L431.322 76.59C433.197 76.9448 435.316 77.5054 437.682 78.2769L437.992 77.3262ZM431.508 75.6074L431.719 74.63C429.735 74.201 427.866 73.9824 426.117 73.9824V74.9824V75.9824C427.701 75.9824 429.427 76.1805 431.296 76.5848L431.508 75.6074ZM426.117 74.9824V73.9824C418.828 73.9824 412.935 76.8868 408.526 82.6565L409.32 83.2637L410.115 83.8708C414.143 78.5989 419.448 75.9824 426.117 75.9824V74.9824ZM409.32 83.2637L408.522 82.6613C404.186 88.4072 402.07 96.0639 402.07 105.529H403.07H404.07C404.07 96.3489 406.122 89.1619 410.118 83.8661L409.32 83.2637ZM403.07 105.529H402.07C402.07 114.515 403.998 121.683 407.977 126.916L408.773 126.311L409.569 125.705C405.944 120.938 404.07 114.252 404.07 105.529H403.07ZM408.773 126.311L407.977 126.916C411.983 132.184 417.549 134.811 424.555 134.811V133.811V132.811C418.123 132.811 413.168 130.437 409.569 125.705L408.773 126.311ZM424.555 133.811V134.811C426.412 134.811 428.462 134.565 430.702 134.085L430.492 133.107L430.283 132.13C428.147 132.587 426.24 132.811 424.555 132.811V133.811ZM430.492 133.107L430.719 134.081C433.011 133.548 435.554 132.752 438.345 131.699L437.992 130.764L437.639 129.828C434.909 130.858 432.452 131.625 430.266 132.133L430.492 133.107ZM437.992 41.7012V40.7012H426.273V41.7012V42.7012H437.992V41.7012ZM426.273 41.7012H427.273V35.2949H426.273H425.273V41.7012H426.273ZM426.273 35.2949L426.574 36.2488L450.402 28.7488L450.102 27.7949L449.801 26.8411L425.973 34.3411L426.273 35.2949ZM450.102 27.7949V28.7949H457.523V27.7949V26.7949H450.102V27.7949ZM457.523 27.7949H456.523V135.764H457.523H458.523V27.7949H457.523ZM457.523 135.764L456.761 136.411L458.948 138.989L459.711 138.342L460.473 137.695L458.286 135.117L457.523 135.764ZM459.711 138.342L459.635 139.339L467.838 139.964L467.914 138.967L467.99 137.97L459.787 137.345L459.711 138.342ZM467.914 138.967H466.914V145.998H467.914H468.914V138.967H467.914ZM467.914 145.998V144.998H437.992V145.998V146.998H467.914V145.998ZM437.992 145.998H438.992V137.014H437.992H436.992V145.998H437.992ZM437.992 137.014L437.4 136.208C432.547 139.77 428.14 142.353 424.175 143.979L424.555 144.904L424.934 145.829C429.095 144.123 433.646 141.445 438.584 137.82L437.992 137.014ZM424.555 144.904L424.175 143.979C420.216 145.603 416.387 146.404 412.68 146.404V147.404V148.404C416.681 148.404 420.768 147.539 424.934 145.829L424.555 144.904ZM412.68 147.404V146.404C403.923 146.404 396.757 142.976 391.11 136.068L390.336 136.701L389.562 137.334C395.581 144.697 403.311 148.404 412.68 148.404V147.404ZM390.336 136.701L391.115 136.074C385.507 129.103 382.664 120.069 382.664 108.889H381.664H380.664C380.664 120.417 383.602 129.925 389.557 137.328L390.336 136.701ZM381.664 108.889H382.664C382.664 96.3414 386.395 86.266 393.792 78.5658L393.07 77.873L392.349 77.1803C384.537 85.3134 380.664 95.9151 380.664 108.889H381.664ZM393.07 77.873L393.789 78.5682C401.235 70.869 410.912 66.998 422.914 66.998V65.998V64.998C410.437 64.998 400.218 69.0437 392.351 77.1779L393.07 77.873ZM422.914 65.998V66.998C425.168 66.998 427.529 67.1261 429.998 67.3833L430.102 66.3887L430.205 65.3941C427.674 65.1304 425.243 64.998 422.914 64.998V65.998ZM430.102 66.3887L429.998 67.3833C432.468 67.6405 435.07 68.0269 437.807 68.5432L437.992 67.5605L438.178 66.5779C435.393 66.0525 432.736 65.6576 430.205 65.3941L430.102 66.3887ZM437.992 67.5605H438.992V41.7012H437.992H436.992V67.5605H437.992ZM468.467 145.998H467.467V146.998H468.467V145.998ZM468.467 138.967L468.367 137.972L467.467 138.063V138.967H468.467ZM477.764 138.029L477.864 139.024L478.219 138.988L478.471 138.736L477.764 138.029ZM480.03 135.764L480.737 136.471L481.03 136.178V135.764H480.03ZM480.03 41.7012H481.03V40.7012H480.03V41.7012ZM468.467 41.7012H467.467V42.7012H468.467V41.7012ZM468.467 35.2949L468.17 34.3399L467.467 34.5585V35.2949H468.467ZM492.608 27.7949V26.7949H492.456L492.311 26.8399L492.608 27.7949ZM499.639 27.7949H500.639V26.7949H499.639V27.7949ZM499.639 135.451H498.639V135.847L498.91 136.136L499.639 135.451ZM502.061 138.029L501.332 138.714L501.588 138.987L501.961 139.024L502.061 138.029ZM511.358 138.967H512.358V138.063L511.458 137.972L511.358 138.967ZM511.358 145.998V146.998H512.358V145.998H511.358ZM468.467 145.998H469.467V138.967H468.467H467.467V145.998H468.467ZM468.467 138.967L468.568 139.962L477.864 139.024L477.764 138.029L477.664 137.034L468.367 137.972L468.467 138.967ZM477.764 138.029L478.471 138.736L480.737 136.471L480.03 135.764L479.323 135.057L477.057 137.322L477.764 138.029ZM480.03 135.764H481.03V41.7012H480.03H479.03V135.764H480.03ZM480.03 41.7012V40.7012H468.467V41.7012V42.7012H480.03V41.7012ZM468.467 41.7012H469.467V35.2949H468.467H467.467V41.7012H468.467ZM468.467 35.2949L468.764 36.2499L492.904 28.7499L492.608 27.7949L492.311 26.8399L468.17 34.3399L468.467 35.2949ZM492.608 27.7949V28.7949H499.639V27.7949V26.7949H492.608V27.7949ZM499.639 27.7949H498.639V135.451H499.639H500.639V27.7949H499.639ZM499.639 135.451L498.91 136.136L501.332 138.714L502.061 138.029L502.79 137.345L500.368 134.766L499.639 135.451ZM502.061 138.029L501.961 139.024L511.257 139.962L511.358 138.967L511.458 137.972L502.161 137.034L502.061 138.029ZM511.358 138.967H510.358V145.998H511.358H512.358V138.967H511.358ZM511.358 145.998V144.998H468.467V145.998V146.998H511.358V145.998ZM564.88 96.7012V97.7012H565.88V96.7012H564.88ZM561.286 80.0605L560.462 80.6266L560.466 80.6323L561.286 80.0605ZM539.177 80.6855L539.969 81.2958L539.973 81.2906L539.177 80.6855ZM534.333 96.7012L533.334 96.663L533.294 97.7012H534.333V96.7012ZM585.895 126.154H586.895V124.348L585.365 125.307L585.895 126.154ZM585.895 138.029L586.391 138.898L586.895 138.61V138.029H585.895ZM523.864 136.389L523.149 137.088L523.152 137.091L523.864 136.389ZM524.098 77.5605L523.377 76.8682L523.377 76.8683L524.098 77.5605ZM577.145 75.5293L576.409 76.2056L576.411 76.2086L577.145 75.5293ZM585.895 105.217V106.217H586.895V105.217H585.895ZM533.552 105.217V104.217H532.552V105.217H533.552ZM564.88 96.7012H565.88V94.9824H564.88H563.88V96.7012H564.88ZM564.88 94.9824H565.88C565.88 88.3728 564.668 83.1635 562.106 79.4888L561.286 80.0605L560.466 80.6323C562.696 83.8326 563.88 88.5712 563.88 94.9824H564.88ZM561.286 80.0605L562.11 79.4945C559.516 75.7151 555.759 73.8262 550.973 73.8262V74.8262V75.8262C555.146 75.8262 558.265 77.4268 560.462 80.6266L561.286 80.0605ZM550.973 74.8262V73.8262C545.764 73.8262 541.538 75.926 538.38 80.0805L539.177 80.6855L539.973 81.2906C542.753 77.6326 546.391 75.8262 550.973 75.8262V74.8262ZM539.177 80.6855L538.384 80.0753C535.247 84.1484 533.599 89.7137 533.334 96.663L534.333 96.7012L535.332 96.7393C535.588 90.0428 537.169 84.931 539.969 81.2958L539.177 80.6855ZM534.333 96.7012V97.7012H564.88V96.7012V95.7012H534.333V96.7012ZM585.895 126.154H584.895V138.029H585.895H586.895V126.154H585.895ZM585.895 138.029L585.399 137.161C579.995 140.249 574.55 142.559 569.063 144.098L569.333 145.061L569.603 146.023C575.261 144.437 580.858 142.06 586.391 138.898L585.895 138.029ZM569.333 145.061L569.063 144.098C563.574 145.636 558.092 146.404 552.614 146.404V147.404V148.404C558.282 148.404 563.945 147.61 569.603 146.023L569.333 145.061ZM552.614 147.404V146.404C540.909 146.404 531.598 142.81 524.576 135.687L523.864 136.389L523.152 137.091C530.609 144.655 540.465 148.404 552.614 148.404V147.404ZM523.864 136.389L524.579 135.689C517.606 128.565 514.083 119.119 514.083 107.248H513.083H512.083C512.083 119.543 515.747 129.525 523.149 137.088L523.864 136.389ZM513.083 107.248H514.083C514.083 95.3306 517.681 85.6958 524.82 78.2528L524.098 77.5605L523.377 76.8683C515.828 84.7378 512.083 94.8947 512.083 107.248H513.083ZM524.098 77.5605L524.82 78.2529C532.01 70.7593 541.246 66.998 552.614 66.998V65.998V64.998C540.753 64.998 530.979 68.9451 523.377 76.8682L524.098 77.5605ZM552.614 65.998V66.998C562.921 66.998 570.805 70.102 576.409 76.2056L577.145 75.5293L577.882 74.853C571.819 68.2482 563.349 64.998 552.614 64.998V65.998ZM577.145 75.5293L576.411 76.2086C582.023 82.2712 584.895 90.8775 584.895 102.17H585.895H586.895C586.895 90.5457 583.934 81.3916 577.879 74.85L577.145 75.5293ZM585.895 102.17H584.895V105.217H585.895H586.895V102.17H585.895ZM585.895 105.217V104.217H533.552V105.217V106.217H585.895V105.217ZM533.552 105.217H532.552V105.686H533.552H534.552V105.217H533.552ZM533.552 105.686H532.552C532.552 114.966 534.887 122.389 539.676 127.831L540.427 127.17L541.177 126.509C536.799 121.535 534.552 114.635 534.552 105.686H533.552ZM540.427 127.17L539.676 127.831C544.48 133.289 551.074 135.982 559.333 135.982V134.982V133.982C551.55 133.982 545.54 131.467 541.177 126.509L540.427 127.17ZM559.333 134.982V135.982C563.669 135.982 568.072 135.233 572.54 133.744L572.223 132.795L571.907 131.846C567.625 133.274 563.435 133.982 559.333 133.982V134.982ZM572.223 132.795L572.54 133.744C577.004 132.256 581.633 130.004 586.426 127.002L585.895 126.154L585.365 125.307C580.678 128.242 576.193 130.418 571.907 131.846L572.223 132.795ZM616.683 125.607L615.976 126.315L615.983 126.322L616.683 125.607ZM598.792 125.607L598.078 124.908L598.078 124.908L598.792 125.607ZM607.698 121.857V122.857C610.919 122.857 613.66 123.999 615.976 126.315L616.683 125.607L617.39 124.9C614.705 122.216 611.457 120.857 607.698 120.857V121.857ZM616.683 125.607L615.983 126.322C618.345 128.636 619.511 131.4 619.511 134.67H620.511H621.511C621.511 130.857 620.125 127.579 617.383 124.893L616.683 125.607ZM620.511 134.67H619.511C619.511 137.89 618.37 140.601 616.061 142.862L616.761 143.576L617.461 144.291C620.152 141.655 621.511 138.429 621.511 134.67H620.511ZM616.761 143.576L616.061 142.862C613.751 145.124 610.982 146.248 607.698 146.248V147.248V148.248C611.498 148.248 614.771 146.924 617.461 144.291L616.761 143.576ZM607.698 147.248V146.248C604.528 146.248 601.839 145.131 599.577 142.869L598.87 143.576L598.163 144.283C600.797 146.917 603.994 148.248 607.698 148.248V147.248ZM598.87 143.576L599.577 142.869C597.319 140.61 596.198 137.897 596.198 134.67H595.198H594.198C594.198 138.422 595.526 141.646 598.163 144.283L598.87 143.576ZM595.198 134.67H596.198C596.198 131.323 597.304 128.558 599.507 126.307L598.792 125.607L598.078 124.908C595.489 127.553 594.198 130.829 594.198 134.67H595.198ZM598.792 125.607L599.507 126.307C601.767 123.999 604.479 122.857 607.698 122.857V121.857V120.857C603.939 120.857 600.713 122.216 598.078 124.908L598.792 125.607Z\"\n      fill=\"black\"\n      mask=\"url(#path-1-outside-1_3852_3171)\"\n    />\n    <path\n      d=\"M102.671 2.12939C99.7092 1.32451 97.1676 1.91479 94.2869 2.73695C93.6928 2.90044 93.0959 3.046 92.4976 3.19134C91.2416 3.50603 90.0253 3.90538 88.8042 4.34424C88.5893 4.42103 88.3745 4.49783 88.1532 4.57695C84.9755 5.72596 81.9163 7.06879 78.9081 8.64151C78.6887 8.75616 78.4693 8.87082 78.2432 8.98895C77.631 9.31287 77.0213 9.64144 76.4121 9.97148C76.1706 10.1009 75.9291 10.2303 75.6803 10.3636C73.3836 11.5983 71.1436 12.9009 69.0168 14.4406C68.8978 14.5233 68.7788 14.606 68.6562 14.6912C65.8874 16.6262 63.28 18.8283 60.7717 21.1173C60.6534 21.2248 60.5352 21.3322 60.4133 21.4429C59.7255 22.0765 59.1209 22.743 58.5131 23.4631C58.0744 23.9026 57.6223 24.3262 57.1721 24.7528C55.7296 26.144 54.4271 27.6219 53.164 29.1923C53.0472 29.3368 52.9303 29.4812 52.8099 29.6301C50.683 32.2711 48.8001 35.0119 47.064 37.95C46.9822 38.088 46.9003 38.2259 46.8159 38.3681C46.4371 39.0213 46.0924 39.5285 45.7022 40.2105C45.8388 40.2492 45.5697 40.1729 45.7022 40.2105C45.535 40.5028 45.447 40.6137 45.2846 40.9979C44.6441 42.4382 44.997 41.606 44.8551 41.9376C44.7506 42.1854 44.7506 42.1854 44.6441 42.4382C44.2742 43.3157 43.869 43.9933 43.4514 44.7807C43.0895 45.5291 42.6514 46.2586 42.3123 47.0187C41.9439 47.732 41.443 48.2344 40.9073 48.8148C40.4241 49.0174 40.2969 49.0945 39.6025 48.4445C38.5561 46.7594 37.8431 44.838 37.2027 42.9474C36.4697 40.785 35.9487 38.0719 33.9011 37.0878C33.3258 36.8112 33.329 36.8105 32.642 36.9569C32.387 37.111 31.8308 37.1796 31.478 37.4865C31.6138 37.4636 31.3462 37.5086 31.478 37.4865C29.6361 38.5951 27.9712 39.6121 26.5023 40.9259C26.1825 41.2106 25.8591 41.4909 25.5355 41.7707C22.9259 44.0398 20.4903 46.4776 18.2975 49.1925C18.1012 49.4326 17.9049 49.6727 17.7086 49.9127C16.7704 51.0654 15.8525 52.2205 15.0426 53.4778C14.7557 53.9155 14.4682 54.353 14.1805 54.7901C14.0273 55.0232 13.8742 55.2562 13.7164 55.4962C13.367 56.0178 13.0091 56.5242 12.6365 57.0273C10.1221 60.5344 8.24496 64.4329 6.55955 68.4376L6.23365 69.3226C6.23365 69.3226 4.26399 74.2302 3.59313 76.9169C3.44623 77.5047 3.29594 78.0913 3.14523 78.678C3.04997 79.0531 2.9549 79.4282 2.86004 79.8033C2.81544 79.978 2.74986 80.3709 2.7039 80.5508C2.28931 82.5847 2.44392 82.4021 2.28054 83.2404C2.28054 83.2404 1.37133 87.6791 1.27458 92.8281C1.20376 96.5971 1.74224 100.849 2.52183 103.509C2.6927 104.114 2.82281 104.719 2.95071 105.336C3.36932 107.197 4.00125 108.981 4.63886 110.769C4.68976 110.912 4.74065 111.056 4.79309 111.204C5.6864 113.688 6.86438 115.996 8.08684 118.315C8.21487 118.559 8.34289 118.802 8.47479 119.053C11.1535 124.04 14.7204 128.645 18.7054 132.542C19.1525 132.988 19.5799 133.451 20.0076 133.919C21.2528 135.266 22.594 136.337 24.0639 137.399C24.4459 137.681 24.8182 137.973 25.1893 138.271C28.8192 141.179 32.7427 143.469 36.8665 145.507C37.1018 145.625 37.3371 145.743 37.5795 145.864C44.5803 149.186 52.4104 151.181 60.0672 151.639C60.2883 151.653 60.5094 151.666 60.7372 151.68C63.8085 151.86 66.8427 151.861 69.9126 151.662C70.3509 151.634 70.7894 151.613 71.228 151.592C72.3302 151.54 73.3739 151.433 74.4503 151.176C75.1038 151.025 75.7656 150.941 76.4284 150.847C82.4414 149.944 88.3587 148.123 93.7524 145.159C94.5503 144.722 95.3617 144.317 96.1736 143.91C96.5866 143.658 96.5866 143.658 96.695 143.236C96.8788 143.215 96.8788 143.215 97.0663 143.193C97.7827 142.993 98.3457 142.602 98.959 142.18C99.1478 142.052 99.1478 142.052 99.3404 141.921C100.464 141.152 101.549 140.335 102.615 139.481C102.768 139.358 102.922 139.236 103.08 139.109C105.06 137.509 105.497 137.064 105.637 136.941C105.769 136.979 105.5 136.902 105.637 136.941C106.061 136.568 106.455 136.191 106.849 135.785C106.965 135.666 107.082 135.547 107.201 135.425C110.008 132.504 110.73 131.366 110.73 131.366C110.863 131.404 110.594 131.327 110.73 131.366C110.885 131.183 111.766 129.848 111.766 129.848C111.899 129.886 111.63 129.809 111.766 129.848C111.766 129.848 113.159 127.639 113.22 127.543C113.353 127.581 113.084 127.504 113.22 127.543C113.893 126.491 114.891 124.393 114.891 124.393C115.024 124.431 114.755 124.355 114.891 124.393C118.038 117.876 119.829 110.72 120.495 103.463C120.524 103.152 120.524 103.152 120.554 102.834C120.654 101.605 120.693 98.8624 120.693 98.8624C120.65 99.3059 120.709 98.417 120.693 98.8624C120.645 99.1038 120.74 98.6282 120.693 98.8624C120.736 97.8555 120.377 92.5444 119.946 90.0591C119.708 87.1055 119.093 84.3102 118.38 81.4463C118.333 81.2558 118.287 81.0652 118.239 80.8688C117.336 77.1712 116.139 73.6324 114.681 70.1382C114.504 69.7076 114.335 69.2745 114.169 68.8396C113.276 66.53 112.187 64.3596 111.048 62.1747C110.781 61.658 110.519 61.1389 110.26 60.6176C109.45 58.9897 108.618 57.3793 107.742 55.7899C107.626 55.5792 107.626 55.5792 107.507 55.3643C107.122 54.6662 106.736 53.9693 106.346 53.274C105.524 51.7959 104.79 50.3037 104.141 48.7308C103.848 48.0229 103.524 47.3424 103.184 46.6581C102.778 45.8652 102.282 44.7176 102.282 44.7176C102.419 44.7563 102.15 44.68 102.282 44.7176C102.272 44.5521 101.448 42.5676 101.438 42.3971C100.859 41.0775 100.354 39.766 99.9228 38.386C99.8756 38.2389 99.8285 38.0918 99.7799 37.9403C98.5098 33.9242 97.6918 29.6332 97.7859 25.3868C97.7919 24.8719 97.7453 24.6322 97.7453 24.6322C97.7453 24.6322 97.757 23.5797 97.8239 23.0691C97.9605 23.1079 97.6914 23.0315 97.8239 23.0691C98.0183 20.633 98.4892 17.8744 99.1311 15.3576C99.1877 15.1334 99.2442 14.9092 99.3026 14.6783C99.6266 13.4259 100.004 12.2153 100.444 11.0028C100.665 10.3749 102.126 7.30404 102.454 6.71767C102.399 6.93884 102.507 6.503 102.454 6.71767C102.396 6.95729 102.51 6.48279 102.454 6.71767C102.586 6.75528 102.317 6.67892 102.454 6.71767C102.579 6.23004 102.325 7.22007 102.454 6.71767C102.586 6.75528 102.317 6.67892 102.454 6.71767C103.141 5.48874 103.748 4.3373 103.396 2.87025C103.137 2.38178 103.137 2.38178 102.671 2.12939Z\"\n      fill=\"#FDB100\"\n    />\n    <path\n      d=\"M80.3537 41.1789C78.0178 41.1994 75.8933 41.9891 73.7846 43.0012C73.5787 43.0921 73.3727 43.1831 73.1606 43.2768C69.6157 44.8489 65.5905 46.8361 62.6732 49.5182L62.2187 49.8568C61.7121 50.2268 61.2327 50.6056 60.7513 51.01C60.6044 51.1329 60.4575 51.2557 60.3061 51.3823C57.6066 53.6709 55.1269 56.1597 52.9069 58.965C52.7242 59.1956 52.7242 59.1956 52.5378 59.4308C48.4073 64.6632 44.9053 70.2812 42.9124 76.8205C42.7806 77.2988 42.7806 77.2988 42.4423 77.6537C41.7841 77.4452 41.2404 77.2676 40.8167 76.6702C40.5829 76.2091 40.3728 75.7424 40.1683 75.2659C39.2574 73.2932 38.1055 70.8354 36.0355 70.0239C34.7938 69.7631 34.0134 69.8753 32.9087 70.5396C28.3841 73.7379 24.7206 78.8324 22.3113 83.9218C22.1894 84.1786 22.0676 84.4355 21.942 84.7001C20.9076 86.9472 20.1959 89.2162 19.5933 91.6285C19.4439 92.224 19.288 92.8173 19.1314 93.4108C18.2102 96.9816 17.9309 100.428 17.9947 104.125C17.9927 104.345 17.9907 104.565 17.9887 104.791C18.1262 114.027 23.5846 123.34 29.6329 129.558C34.5019 134.472 40.5727 137.967 47.0607 139.84C47.2754 139.902 47.4901 139.964 47.7113 140.028C48.1627 140.159 48.6142 140.288 49.066 140.417C49.7477 140.613 50.4281 140.813 51.1086 141.014C53.2291 141.627 55.251 142.167 57.4572 142.22C58.1195 142.237 58.7527 142.317 59.4076 142.418C68.8634 143.537 78.5828 140.346 86.0834 134.264C90.7083 130.204 93.9018 125.286 96.1475 119.441C96.2979 119.056 96.46 118.677 96.6248 118.299C97.8398 115.118 98.4973 111.756 98.6228 108.344C98.6522 108.075 98.6816 107.807 98.7119 107.53C99.2014 103.047 98.5527 98.5206 97.4637 94.1906C97.4 93.9329 97.4 93.9329 97.3349 93.67C96.6821 91.0545 95.9336 88.531 94.7573 86.1223C94.4575 85.4906 94.192 84.8504 93.9279 84.2012C92.6209 81.0922 91.0019 78.1278 89.3747 75.1968C87.7852 72.3168 86.3718 69.175 85.4323 65.9854C85.2414 65.3453 85.0299 64.7143 84.8189 64.0815C82.8963 58.2307 82.4929 52.7653 83.6761 46.6824C84.2543 43.6586 84.2543 43.6586 83.671 42.4903C82.7559 41.5164 81.6124 41.3381 80.3537 41.1789Z\"\n      fill=\"#FFC800\"\n    />\n    <path\n      d=\"M26.4589 105.695C26.4789 105.686 26.4789 105.686 26.4992 105.676C26.8367 105.516 26.8699 105.314 27.123 105.023C27.6412 104.428 27.8706 99.6271 28.7657 96.3744C29.5651 93.4696 31.6161 89.2316 31.6161 89.2316C31.6054 89.2577 31.9834 88.3232 32.0707 87.9206C32.3667 86.5596 32.3726 85.4002 31.9648 84.2699C31.9581 84.2514 31.9515 84.2329 31.9447 84.2138C31.8922 84.0712 31.8318 83.9427 31.7637 83.8169C31.7531 83.7973 31.7426 83.7778 31.7317 83.7576C31.6264 83.5657 31.5127 83.3886 31.3904 83.2199C31.3801 83.2057 31.3699 83.1915 31.3593 83.1769C31.0684 82.7791 30.727 82.4966 30.3505 82.2713C30.3279 82.2577 30.3279 82.2577 30.3047 82.2438C29.3273 81.6759 28.0839 81.8467 27.0096 82.5042C26.456 82.8637 25.8916 83.3283 25.403 83.8827C25.391 83.896 25.3791 83.9094 25.3668 83.9231C24.5437 84.844 23.8523 85.9697 23.2619 87.2008C23.2494 87.2269 23.2369 87.253 23.224 87.2799C22.1817 89.4721 20.7454 94.2892 21.231 99.4189C21.6352 103.689 22.3457 104.546 22.3457 104.546C23.4927 106.447 25.6761 106.135 26.4589 105.695Z\"\n      fill=\"#FFDB00\"\n    />\n    <path\n      d=\"M45.7935 107.344C45.5886 107.263 45.9945 107.423 45.7935 107.344C45.7935 107.232 45.7935 107.451 45.7935 107.344C45.9406 107.364 45.6421 107.323 45.7935 107.344C46.1147 107.437 47.6942 108.223 48.1894 108.417C48.6845 108.61 48.8486 108.673 49.2784 108.815C51.2582 109.47 52.7489 109.374 54.534 109.408C55.5198 109.431 56.4063 109.47 57.3568 109.797C57.3568 109.685 57.3568 109.904 57.3568 109.797C57.1818 109.76 57.5283 109.832 57.3568 109.797C58.1771 110.079 58.6837 111.15 58.9408 111.922C59.1057 113.213 58.9489 114.331 58.4656 115.52C58.4123 115.668 58.359 115.816 58.3041 115.968C57.8785 117.131 57.3568 117.81 57.3568 117.81L55.9285 119.499C55.9285 119.499 56.0895 119.281 55.4659 119.895C55.3566 119.995 55.2474 120.095 55.1348 120.199C54.8224 120.426 54.8224 120.426 54.3472 120.426C54.3472 120.534 54.3472 120.642 54.3472 120.753C53.9278 120.992 53.7303 121.08 53.2384 121.08C53.2384 121.188 53.2384 121.296 53.2384 121.407C51.0062 122.05 47.8099 122.406 45.6549 121.213C45.4345 121.064 45.2167 120.91 45.0015 120.753C44.8737 120.672 44.7459 120.592 44.6142 120.509C43.0887 119.499 42.1337 118.194 41.3583 116.501C41.2899 116.35 41.2215 116.199 41.151 116.044C40.683 115.009 40.487 114.294 40.3925 113.09C40.393 112.908 40.3276 112.192 40.3282 112.004C40.3295 111.815 40.3272 112.2 40.3286 112.004C40.3293 111.812 40.3285 111.548 40.3292 111.35C40.3311 110.876 40.3291 110.451 40.4079 109.96C40.5125 109.96 40.2992 109.96 40.4069 109.96C40.391 110.06 40.4225 109.863 40.4069 109.96C40.5587 109.132 41.0724 108.276 41.6751 107.671C41.7993 107.546 41.9234 107.421 42.0513 107.292C43.0817 106.797 44.7125 107.011 45.7935 107.344Z\"\n      fill=\"#9F5115\"\n    />\n    <path\n      d=\"M70.8304 98.0065C71.0137 98.0086 71.197 98.0108 71.3859 98.013C71.8325 98.0184 72.279 98.0261 72.7255 98.0359C72.7255 97.9293 72.7255 98.1394 72.7255 98.0359C72.5128 98.0458 72.9339 98.0262 72.7255 98.0359C73.2867 98.0562 74.1285 98.4686 74.6506 98.663C74.6506 98.5564 74.6506 98.7664 74.6506 98.663C75.0523 98.898 75.4534 99.2116 75.4534 99.2116C75.4534 99.105 75.4534 99.315 75.4534 99.2116C75.5428 99.2504 75.8545 99.6035 75.9347 99.6819C75.9347 99.6819 77.7324 101.327 78.1801 102.582C78.3004 103.036 78.3801 103.49 78.4508 103.954C78.4713 104.085 78.4917 104.217 78.5128 104.352C78.7677 106.207 78.6239 108.083 78.5818 109.95C78.4759 109.95 78.6909 109.95 78.5818 109.95C78.5721 110.046 78.5918 109.851 78.5818 109.95C78.5617 110.14 78.5016 110.577 78.5016 110.577C78.4821 110.765 78.3412 111.047 78.3412 111.047C78.3412 111.047 78.0203 112.145 77.0571 112.772C77.0042 112.875 77.1116 112.665 77.0571 112.772C76.4735 112.962 76.0149 113.085 75.4534 112.929C74.6513 112.537 74.5066 111.873 74.3298 111.047C74.2547 110.526 74.2054 110.004 74.1593 109.48C74.1343 109.209 74.109 108.938 74.0835 108.667C74.0715 108.539 74.0959 108.798 74.0835 108.667C74.0048 107.829 73.5282 106.815 73.5282 106.815C73.4224 106.815 73.6367 106.815 73.5276 106.815C73.4887 106.722 73.4499 106.629 73.4098 106.534C72.9143 105.392 72.4238 104.686 71.2822 104.071C70.7483 103.961 70.2218 103.856 69.6773 103.836C69.5367 103.826 69.396 103.816 69.2511 103.806C68.3142 103.74 67.1919 103.836 66.4693 104.463C66.2293 104.62 66.7255 104.323 66.4693 104.463C66.3477 104.53 66.5946 104.394 66.4693 104.463C66.1559 104.613 65.5067 105.404 65.1852 105.717C65.1323 105.873 65.2398 105.558 65.1852 105.717C65.0264 105.795 65.3473 105.638 65.1852 105.717C65.0092 106.003 64.2223 106.912 64.0628 107.207C63.9764 107.365 64.1519 107.043 64.0628 107.207C63.9971 107.329 64.1306 107.08 64.0628 107.207C63.9569 107.207 64.1719 107.207 64.0628 107.207C64.0231 107.326 64.1037 107.083 64.0628 107.207C63.836 107.683 62.9913 108.502 62.5387 108.774C61.8674 108.924 61.3061 109.005 60.69 108.684C60.2228 108.26 60.0715 107.897 59.9844 107.282C60.0071 105.515 60.8298 104.028 61.8162 102.582C61.7071 102.582 61.9221 102.582 61.8162 102.582C61.7807 102.731 61.8509 102.437 61.8162 102.582C62.2981 101.876 62.4585 101.72 62.5387 101.641C62.4296 101.641 62.6446 101.641 62.5387 101.641C62.5012 101.731 62.5751 101.554 62.5387 101.641C62.8095 101.175 63.8162 100.275 64.2226 99.9171C64.1135 99.9171 64.3285 99.9171 64.2226 99.9171C64.2226 99.8136 64.2226 100.024 64.2226 99.9171C64.7039 99.6036 65.3456 99.1333 65.3456 99.1333C65.2911 99.2932 65.3986 98.9781 65.3456 99.1333C65.6502 98.9667 65.6502 98.9667 66.0475 98.8197C66.1745 98.7712 66.3015 98.7227 66.4323 98.6728C66.6031 98.6178 66.774 98.5628 66.95 98.5062C67.0739 98.4655 67.1979 98.4248 67.3257 98.3829C68.5097 98.0267 69.5977 97.979 70.8304 98.0065Z\"\n      fill=\"#673E00\"\n    />\n    <path\n      d=\"M39.3927 88.3618C39.3927 88.2504 39.3927 88.4699 39.3927 88.3618C39.5468 88.4438 40.2401 88.8533 40.2401 88.8533L40.7793 89.2628C41.0565 89.4375 41.3955 89.8359 41.3955 89.8359C42.0888 90.4911 42.6782 91.2337 43.0901 92.1288C43.0901 92.0175 43.0901 92.2369 43.0901 92.1288C42.8567 92.0461 43.3189 92.2099 43.0901 92.1288C43.6693 93.7744 44.2311 95.6898 44.2456 97.4313C44.2484 97.6063 44.2512 97.7812 44.2541 97.9614C44.2414 99.7256 43.8769 101.242 42.7182 102.541C42.3521 102.891 42.166 102.939 41.6651 103C40.8966 102.918 40.6908 102.707 40.163 102.12C39.982 101.587 39.9854 101.061 39.9765 100.498C39.9721 100.311 39.9677 100.125 39.9632 99.9332C39.9552 99.5421 39.9484 99.151 39.9428 98.7599C39.938 98.5733 39.9332 98.3868 39.9283 98.1946C39.9237 97.9397 39.9237 97.9397 39.9191 97.6796C39.8464 97.1436 39.6635 96.8395 39.3927 96.3872L38.9306 95.7321C38.841 95.685 39.0228 95.7806 38.9306 95.7321C38.3914 94.9131 38.0321 94.7436 38.0321 94.7436C36.938 94.1402 35.8263 94.1281 34.6169 94.0943C34.6169 93.9829 34.6169 94.2024 34.6169 94.0943C34.4743 94.1275 34.7625 94.0603 34.6169 94.0943C33.1162 94.4998 31.8584 95.767 30.9196 97.0424C30.7988 97.1606 30.6781 97.2788 30.5537 97.4006C30.17 97.8377 29.9048 98.2554 29.6101 98.7621C29.2224 99.4274 28.3006 100.482 28.3006 100.482C28.3006 100.482 27.8385 100.809 27.7614 100.809C27.5212 100.836 27.5114 100.855 27.2703 100.871C27.1363 100.882 27.0022 100.894 26.8641 100.905C26.3643 100.789 26.1352 100.562 25.8311 100.129C25.5616 99.5842 25.5676 99.0596 25.5853 98.455C25.587 98.332 25.5887 98.2091 25.5905 98.0824C25.6283 97.0956 26.2208 96.2234 26.4519 95.6502C26.3472 95.6502 26.5536 95.6502 26.4519 95.6502C26.4356 95.779 26.4678 95.5252 26.4519 95.6502C26.5704 95.1578 27.4269 93.781 27.7614 93.4391C27.6123 93.7693 27.9208 93.1144 27.7614 93.4391C28.0021 92.9682 28.1562 92.9682 28.3776 92.6202C28.4793 92.6202 28.658 92.1288 28.7628 92.1288C28.7104 92.2959 28.8136 91.9667 28.7628 92.1288C29.4494 91.4737 29.0643 91.4737 29.456 91.4737C29.4974 91.3757 28.7202 92.2298 28.7628 92.1288C29.1549 91.3608 30.9966 90.1634 31.6899 89.6721C31.6375 89.7835 31.7407 89.564 31.6899 89.6721C31.9444 89.5561 33.1274 88.8806 33.3845 88.7712C33.0822 88.9872 33.7162 88.6106 33.3845 88.7712C35.2332 87.876 37.5441 87.7886 39.3927 88.3618Z\"\n      fill=\"#673E00\"\n    />\n    <path\n      d=\"M53.5636 116.209C53.5636 116.099 53.5636 116.316 53.5636 116.209C53.4784 116.195 53.6464 116.222 53.5636 116.209C53.9831 116.367 54.4307 116.958 54.7097 117.34C54.7097 117.176 54.7097 117.5 54.7097 117.34C54.6123 117.34 54.8043 117.34 54.7097 117.34C54.9963 117.906 55.1106 119.023 55.0553 119.541C54.7372 120.022 53.9934 120.573 53.492 120.896C53.492 120.786 53.492 121.003 53.492 120.896L52.5849 121.442C52.5849 121.332 52.5849 121.549 52.5849 121.442C50.5792 122.089 47.651 122.368 45.702 121.189C45.5021 121.042 44.8037 120.567 44.6096 120.411C44.4212 120.272 44.8018 120.553 44.6096 120.411C44.0336 119.858 43.7485 119.541 43.2734 118.907L43.6784 117.825C44.1922 116.714 45.6816 115.817 46.6869 115.401C49.0014 114.612 51.4383 115.043 53.5636 116.209Z\"\n      fill=\"#D96821\"\n    />\n  </g>\n  <defs>\n    <clipPath id=\"clip0_3852_3171\">\n      <rect width=\"634\" height=\"152\" fill=\"white\"/>\n    </clipPath>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Databuddy.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"28\"\n  height=\"28\"\n  viewBox=\"0 0 29 29\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <path\n    fill-rule=\"evenodd\"\n    clip-rule=\"evenodd\"\n    d=\"M9.667 9.667H14.5V14.5H9.667zM14.5 14.5h4.833v4.833H14.5z\"\n    fill=\"#000\"\n  />\n  <path d=\"M29 4.833h-4.833v19.334H29z\" fill=\"#000\"/>\n  <path\n    fill-rule=\"evenodd\"\n    clip-rule=\"evenodd\"\n    d=\"M0 0h24.167v4.833H4.833v19.334h19.334V29H0z\"\n    fill=\"#000\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Helix.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"28\"\n  height=\"28\"\n  viewBox=\"0 0 28 46\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <g clip-path=\"url(#clip0_2150_402)\">\n    <path\n      d=\"M0.769531 0.759277H8.32876V15.662L13.9899 18.8784L19.6676 15.662V0.759277H27.2268V19.9639L21.5546 23.1764L27.1497 26.3401V45.2411H19.5905L19.6059 30.6669L13.9766 27.4682L8.32876 30.6669V45.1652H0.769531V26.416L6.41304 23.1706L0.769531 19.9639V0.759277Z\"\n      fill=\"black\"\n    />\n  </g>\n  <defs>\n    <clipPath id=\"clip0_2150_402\">\n      <rect width=\"28\" height=\"46\" fill=\"white\"/>\n    </clipPath>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Ia.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"46\"\n  height=\"46\"\n  viewBox=\"0 0 80 80\"\n  fill=\"none\"\n  class={classNames}\n  {...attrs}\n>\n  <path d=\"M0 0h46v46H0z\"/>\n  <g clip-path=\"url(#a)\" fill=\"#231F20\">\n    <path\n      d=\"M52.236 19.91 41.752 30.409V7.947h-2.975v22.462l-10.483-10.5-2.093 2.11 14.056 14.056L54.329 22.02zm19.817 18.338H49.591l10.5-10.484-2.11-2.093-14.056 14.056L57.98 53.8l2.11-2.093-10.5-10.483h22.462zM54.33 57.451 40.256 43.395 26.201 57.45l2.093 2.11 10.483-10.5v22.462h2.975V49.061l10.484 10.5zM22.549 25.67l-2.11 2.094 10.5 10.484H8.477v2.975h22.462l-10.5 10.483 2.11 2.093 14.056-14.072z\"\n    />\n  </g>\n  <defs>\n    <clipPath id=\"a\">\n      <path fill=\"#fff\" d=\"M8.477 7.947h63.576v63.576H8.477z\"/>\n    </clipPath>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Mantlz.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"46\"\n  height=\"46\"\n  viewBox=\"0 0 500 500\"\n  fill=\"none\"\n  class={classNames}\n  {...attrs}\n>\n  <title>Mantlz Logo</title>\n  <path\n    d=\"M194 119h-89v262h109v-71h-38V202l75 101 75-101v108h-38v71h108V119h-88l-57 78z\"\n    fill=\"#FF6900\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/brand/Opencut.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  width=\"28\"\n  height=\"28\"\n  viewBox=\"0 0 32 32\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  class={classNames}\n  {...attrs}\n>\n  <g clip-path=\"url(#clip0_10_2)\">\n    <path\n      d=\"M32 9.37305V22.627L22.627 32H9.37305L0 22.627V9.37305L9.37305 0H22.627L32 9.37305ZM8 8V24H24V8H8Z\"\n      fill=\"currentColor\"\n    />\n  </g>\n  <defs>\n    <clipPath id=\"clip0_10_2\">\n      <rect width=\"32\" height=\"32\" fill=\"white\"/>\n    </clipPath>\n  </defs>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/sponsors/Neon.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  version=\"1.2\"\n  viewBox=\"0 0 58 57\"\n  class={classNames}\n  {...attrs}\n>\n  <title>Neon Logomark Color</title>\n  <defs>\n    <linearGradient\n      id=\"g1\"\n      x2=\"1\"\n      gradientUnits=\"userSpaceOnUse\"\n      gradientTransform=\"matrix(-50.032,-57.652,62.466,-54.21,57.588,57)\"\n    >\n      <stop offset=\"0\" stop-color=\"#2ef51c\" stop-opacity=\"1\"/>\n      <stop offset=\"1\" stop-color=\"#2ef51c\" stop-opacity=\"0\"/>\n    </linearGradient>\n    <linearGradient\n      id=\"g2\"\n      x2=\"1\"\n      gradientUnits=\"userSpaceOnUse\"\n      gradientTransform=\"matrix(-34.011,-13.404,13.812,-35.046,57.461,56.966)\"\n    >\n      <stop offset=\"0\" stop-color=\"#000000\" stop-opacity=\".9\"/>\n      <stop offset=\"1\" stop-color=\"#1a1a1a\" stop-opacity=\"0\"/>\n    </linearGradient>\n  </defs>\n  <style>\n    .s0 {\n      fill: #32c0ed;\n    }\n    .s1 {\n      fill: url(#g1);\n    }\n    .s2 {\n      opacity: 0.3;\n      fill: url(#g2);\n    }\n    .s3 {\n      fill: #63f655;\n    }\n  </style>\n  <path\n    fill-rule=\"evenodd\"\n    class=\"s0\"\n    d=\"m0 9.8c0-5.4 4.4-9.8 9.9-9.8h37.7c5.4 0 9.9 4.4 9.9 9.8v31.8c0 5.6-7.2 8-10.7 3.6l-10.8-13.9v16.9c0 4.8-4 8.8-9 8.8h-17.1c-5.5 0-9.9-4.4-9.9-9.8zm9.9-2c-1.1 0-2 0.9-2 2v37.3c0 1.1 0.9 2 2 2h17.4c0.6 0 0.7-0.5 0.7-1v-22.5c0-5.7 7.2-8.1 10.7-3.7l10.8 13.9v-26c0-1.1 0.1-2-1-2z\"\n  />\n  <path\n    fill-rule=\"evenodd\"\n    class=\"s1\"\n    d=\"m0 9.8c0-5.4 4.4-9.8 9.9-9.8h37.7c5.4 0 9.9 4.4 9.9 9.8v31.8c0 5.6-7.2 8-10.7 3.6l-10.8-13.9v16.9c0 4.8-4 8.8-9 8.8h-17.1c-5.5 0-9.9-4.4-9.9-9.8zm9.9-2c-1.1 0-2 0.9-2 2v37.3c0 1.1 0.9 2 2 2h17.4c0.6 0 0.7-0.5 0.7-1v-22.5c0-5.7 7.2-8.1 10.7-3.7l10.8 13.9v-26c0-1.1 0.1-2-1-2z\"\n  />\n  <path\n    fill-rule=\"evenodd\"\n    class=\"s2\"\n    d=\"m0 9.8c0-5.4 4.4-9.8 9.9-9.8h37.7c5.4 0 9.9 4.4 9.9 9.8v31.8c0 5.6-7.2 8-10.7 3.6l-10.8-13.9v16.9c0 4.8-4 8.8-9 8.8h-17.1c-5.5 0-9.9-4.4-9.9-9.8zm9.9-2c-1.1 0-2 0.9-2 2v37.3c0 1.1 0.9 2 2 2h17.4c0.6 0 0.7-0.5 0.7-1v-22.5c0-5.7 7.2-8.1 10.7-3.7l10.8 13.9v-26c0-1.1 0.1-2-1-2z\"\n  />\n  <path\n    class=\"s3\"\n    d=\"m47.6 0c5.4 0 9.9 4.4 9.9 9.8v31.8c0 5.6-7.2 8-10.7 3.6l-10.8-13.9v16.9c0 4.8-4 8.8-9 8.8 0.6 0 1-0.4 1-1v-30.4c0-5.6 7.2-8 10.7-3.6l10.8 13.9v-33.9c0-1.1-0.9-2-1.9-2z\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/sponsors/Upstash.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  viewBox=\"0 0 256 341\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  preserveAspectRatio=\"xMidYMid\"\n  class={classNames}\n  {...attrs}\n>\n  <path\n    fill=\"#00C98D\"\n    d=\"M0 298.417c56.554 56.553 148.247 56.553 204.801 0 56.554-56.554 56.554-148.247 0-204.801l-25.6 25.6c42.415 42.416 42.415 111.185 0 153.6-42.416 42.416-111.185 42.416-153.601 0z\"\n  />\n  <path\n    fill=\"#00C98D\"\n    d=\"M51.2 247.216c28.277 28.277 74.123 28.277 102.4 0 28.277-28.276 28.277-74.123 0-102.4l-25.6 25.6c14.14 14.138 14.14 37.061 0 51.2-14.138 14.139-37.061 14.139-51.2 0zM256 42.415c-56.554-56.553-148.247-56.553-204.8 0-56.555 56.555-56.555 148.247 0 204.801l25.599-25.6c-42.415-42.415-42.415-111.185 0-153.6 42.416-42.416 111.185-42.416 153.6 0z\"\n  />\n  <path\n    fill=\"#00C98D\"\n    d=\"M204.8 93.616c-28.276-28.277-74.124-28.277-102.4 0-28.278 28.277-28.278 74.123 0 102.4l25.6-25.6c-14.14-14.138-14.14-37.061 0-51.2 14.138-14.139 37.06-14.139 51.2 0z\"\n  />\n  <path\n    fill=\"#FFF\"\n    fill-opacity=\".4\"\n    d=\"M256 42.415c-56.554-56.553-148.247-56.553-204.8 0-56.555 56.555-56.555 148.247 0 204.801l25.599-25.6c-42.415-42.415-42.415-111.185 0-153.6 42.416-42.416 111.185-42.416 153.6 0z\"\n  />\n  <path\n    fill=\"#FFF\"\n    fill-opacity=\".4\"\n    d=\"M204.8 93.616c-28.276-28.277-74.124-28.277-102.4 0-28.278 28.277-28.278 74.123 0 102.4l25.6-25.6c-14.14-14.138-14.14-37.061 0-51.2 14.138-14.139 37.06-14.139 51.2 0z\"\n  />\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/icons/sponsors/Vercel.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n\n  interface Props extends HTMLAttributes<\"svg\"> {}\n  const { class: classNames, ...attrs } = Astro.props;\n---\n<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  viewBox=\"0 0 256 222\"\n  preserveAspectRatio=\"xMidYMid\"\n  class={classNames}\n  {...attrs}\n>\n  <path fill=\"#000000\" d=\"m128 0 128 221.705H0z\"/>\n</svg>\n"
  },
  {
    "path": "apps/web/src/components/sections/Pricing.astro",
    "content": "---\n  import { PRICING_PLANS } from \"@marble/utils\";\n  import Container from \"@/components/Container.astro\";\n  import Button from \"@/components/ui/Button.astro\";\n  import { cn } from \"@/lib/utils\";\n---\n<section>\n  <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n    <Container class=\"space-y-10 lg:space-y-14 py-20\">\n      <div class=\"relative flex flex-col items-center space-y-4\">\n        <h2\n          class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n        >\n          Simple Pricing\n        </h2>\n        <p class=\"max-w-prose md:text-lg text-muted-foreground text-center\">\n          Choose a plan that fits your needs.\n        </p>\n      </div>\n\n      <!-- Billing Toggle -->\n      <div class=\"flex items-center justify-center gap-3\">\n        <span class=\"text-sm text-muted-foreground\">Monthly</span>\n        <button\n          id=\"billing-toggle\"\n          type=\"button\"\n          role=\"switch\"\n          aria-checked=\"true\"\n          class=\"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-accent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n        >\n          <span\n            id=\"toggle-thumb\"\n            class=\"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out translate-x-5\"\n          ></span>\n        </button>\n        <span class=\"text-sm text-muted-foreground\">Yearly</span>\n      </div>\n\n      <ul\n        class=\"flex flex-col items-center md:items-stretch justify-center gap-5 md:flex-row\"\n      >\n        {PRICING_PLANS.map((plan, index) => (\n          <li class={cn(\"min-w-[300px] w-full max-w-[350px] rounded-xl\", index === 2 && 'sm:col-span-2 lg:col-span-1 sm:h-fit')}>\n            <div class={cn(\"flex flex-col gap-5 rounded-xl bg-white w-full px-4 py-6\", index === 2 ? 'sm:flex-row sm:px-14 sm:py-10 lg:py-6 sm:justify-center sm:gap-20 lg:gap-4 lg:px-4 lg:flex-col min-h-96 sm:min-h-fit lg:min-h-96' : 'min-h-96 h-full')}>\n              {index === 2 ? (\n                <>\n                  <div class=\"flex flex-col gap-5 sm:min-w-60 lg:min-w-0\">\n                    <div class=\"flex flex-col gap-4\">\n                      <div class=\"space-y-1\">\n                        <h3 class=\"text-medium text-xl\">{plan.title}</h3>\n                        <p class=\"text-muted-foreground text-sm\">{plan.description}</p>\n                      </div>\n                      <div>\n                        <p>\n                          <span class=\"font-bold text-2xl\" data-plan-id={plan.id} data-monthly={plan.price.monthly} data-yearly={plan.price.yearly}>\n                            {plan.price.yearly}\n                          </span>{' '}\n                          <span class=\"text-muted-foreground\" data-plan-period={plan.id} data-monthly=\"/ month\" data-yearly=\"/ year\">\n                            / year\n                          </span>\n                        </p>\n                      </div>\n                    </div>\n                    <div class=\"border-y border-dashed py-4\">\n                      <Button \n                        href={plan.button.href}\n                        target=\"_blank\"\n                        class=\"w-full\"\n                      >\n                        {plan.button.label}\n                      </Button>\n                    </div>\n                  </div>\n                  <ul class=\"flex flex-col gap-2 text-sm\">\n                    {plan.features.map((feature) => (\n                      <li class=\"flex items-center gap-2\">\n                        <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" class=\"size-5 text-emerald-500\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\"></path>\n                        </svg>\n                        <span>{feature}</span>\n                      </li>\n                    ))}\n                  </ul>\n                </>\n              ) : (\n                <>\n                  <div class=\"flex flex-col gap-4\">\n                    <div class=\"space-y-1\">\n                      <h3 class=\"text-medium text-2xl\">{plan.title}</h3>\n                      <p class=\"text-muted-foreground text-sm\">{plan.description}</p>\n                    </div>\n                    <div>\n                      <p>\n                        <span class=\"font-bold text-2xl\" data-plan-id={plan.id} data-monthly={plan.price.monthly} data-yearly={plan.price.yearly}>\n                          {plan.price.yearly}\n                        </span>{' '}\n                        <span class=\"text-muted-foreground\" data-plan-period={plan.id} data-monthly=\"/ month\" data-yearly=\"/ year\">\n                          / year\n                        </span>\n                      </p>\n                    </div>\n                  </div>\n                  <div class=\"border-y border-dashed py-4\">\n                    <Button \n                      href={plan.button.href}\n                      target=\"_blank\"\n                      class=\"w-full\"\n                      size=\"sm\"\n                    >\n                      {plan.button.label}\n                    </Button>\n                  </div>\n                  <ul class=\"flex flex-col gap-2 text-sm\">\n                    {plan.features.map((feature) => (\n                      <li class=\"flex items-center gap-2\">\n                        <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" class=\"size-5 text-emerald-500\">\n                          <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\"></path>\n                        </svg>\n                        <span>{feature}</span>\n                      </li>\n                    ))}\n                  </ul>\n                </>\n              )}\n            </div>\n          </li>\n        ))}\n      </ul>\n    </Container>\n  </div>\n</section>\n\n<script>\n  function initBillingToggle() {\n    const toggle = document.getElementById(\"billing-toggle\");\n    const thumb = document.getElementById(\"toggle-thumb\");\n    const priceElements = document.querySelectorAll(\"[data-plan-id]\");\n    const periodElements = document.querySelectorAll(\"[data-plan-period]\");\n\n    let isYearly = true;\n\n    toggle?.addEventListener(\"click\", () => {\n      isYearly = !isYearly;\n\n      // Update toggle appearance\n      toggle.setAttribute(\"aria-checked\", String(isYearly));\n      if (isYearly) {\n        thumb?.classList.add(\"translate-x-5\");\n        thumb?.classList.remove(\"translate-x-0\");\n        toggle.classList.add(\"bg-accent\");\n        toggle.classList.remove(\"bg-muted\");\n      } else {\n        thumb?.classList.remove(\"translate-x-5\");\n        thumb?.classList.add(\"translate-x-0\");\n        toggle.classList.remove(\"bg-accent\");\n        toggle.classList.add(\"bg-muted\");\n      }\n\n      // Update prices\n      for (const el of priceElements) {\n        const monthly = el.getAttribute(\"data-monthly\");\n        const yearly = el.getAttribute(\"data-yearly\");\n        el.textContent = isYearly ? yearly : monthly;\n      }\n\n      for (const el of periodElements) {\n        const monthly = el.getAttribute(\"data-monthly\");\n        const yearly = el.getAttribute(\"data-yearly\");\n        el.textContent = isYearly ? yearly : monthly;\n      }\n    });\n  }\n\n  // Initialize on first load\n  initBillingToggle();\n\n  // Re-initialize after page transitions\n  document.addEventListener(\"astro:after-swap\", initBillingToggle);\n</script>\n"
  },
  {
    "path": "apps/web/src/components/ui/AccordionItem.astro",
    "content": "---\n  interface Props {\n    open?: boolean;\n    title: string;\n    name?: string;\n    class?: string;\n  }\n\n  const { open, title, name, class: className } = Astro.props;\n---\n<details\n  data-accordion-item\n  class:list={['accordion-item group', className]}\n  name={name}\n  open={open}\n>\n  <summary\n    class=\"group accordion-item-title py-4 flex items-center justify-between gap-3 cursor-pointer text-lg font-medium leading-normal list-none transition-colors duration-300 ease-in-out m-0\"\n  >\n    <span\n      class=\"group-hover:text-muted-foreground group-focus-visible:text-muted-foreground text-base font-medium transition-colors duration-300 ease-in-out\"\n      >{title}</span\n    >\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 24 24\"\n      stroke-width=\"1.5\"\n      stroke=\"currentColor\"\n      class=\"size-4 transition-transform duration-300 ease-in-out group-open:-rotate-45 shrink-0\"\n    >\n      <path\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        d=\"M12 4.5v15m7.5-7.5h-15\"\n      />\n    </svg>\n  </summary>\n  <div class=\"pb-4\">\n    <slot/>\n  </div>\n</details>\n\n<style>\n  .accordion-item {\n    height: var(--accordion-item-collapsed);\n    overflow: hidden;\n\n    &[open] {\n      height: var(--accordion-item-expanded);\n    }\n\n    &.accordion-item--animated {\n      transition: height 0.2s ease-out;\n    }\n  }\n\n  .accordion-item-title {\n    &::marker,\n    &::-webkit-details-marker {\n      display: none;\n    }\n  }\n</style>\n\n<script>\n  import {\n    handleResize,\n    hasViewportWidthChanged,\n    setAccordionHeight,\n  } from \"@/lib/accordion\";\n\n  document.addEventListener(\"astro:page-load\", () => {\n    const accordions = document.querySelectorAll<HTMLDetailsElement>(\n      \"[data-accordion-item]\"\n    );\n    setAccordionHeight(accordions);\n\n    handleResize(() => {\n      if (hasViewportWidthChanged()) {\n        setAccordionHeight(accordions);\n      }\n    });\n  });\n</script>\n"
  },
  {
    "path": "apps/web/src/components/ui/Button.astro",
    "content": "---\n  import type { HTMLAttributes } from \"astro/types\";\n  import { cn } from \"@/lib/utils\";\n\n  type Props = HTMLAttributes<\"a\"> &\n    HTMLAttributes<\"button\"> & {\n      variant?: \"primary\" | \"secondary\" | \"outline\" | \"ghost\";\n      size?: \"default\" | \"sm\" | \"lg\" | \"icon\";\n    };\n\n  const {\n    variant = \"primary\",\n    size = \"default\",\n    class: className,\n    href,\n    ...rest\n  } = Astro.props;\n\n  const variants = {\n    primary: \"bg-accent text-white hover:bg-accent/90\",\n    secondary: \"bg-muted text-muted-foreground hover:bg-muted/80\",\n    outline:\n      \"border border-input bg-background hover:bg-accent hover:text-white\",\n    ghost: \"hover:bg-muted/50 hover:text-foreground\",\n  };\n\n  const sizes = {\n    default: \"h-11 px-8 py-2\",\n    sm: \"h-9 px-3\",\n    lg: \"h-12 px-8\",\n    icon: \"h-10 w-10\",\n  };\n\n  const baseClass =\n    \"inline-flex items-center justify-center transition duration-300 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 outline-accent disabled:pointer-events-none disabled:opacity-50 cursor-pointer\";\n\n  const classes = cn(baseClass, variants[variant], sizes[size], className);\n\n  const Element = href ? \"a\" : \"button\";\n---\n<Element href={href} class={classes} {...rest}>\n  <slot/>\n</Element>\n"
  },
  {
    "path": "apps/web/src/content/pages/privacy.md",
    "content": "---\ntitle: Privacy Policy\npublished: 2024-12-12\ndescription: Marble's Privacy Policy. \nlastUpdated: 2025-09-18\n---\n\nMarble (\"we,\" \"our,\" or \"us\") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our service (\"the Service\").\n\n## 1. Information We Collect\n\nWe collect only the information necessary to provide and improve our service:\n\n### a. Account Information\n\n- **What we collect:** Basic details such as your name, email address, and OAuth provider IDs (e.g., Google, GitHub) when you sign up.\n- **Why we collect it:** To create and manage your account, authenticate you, and provide access to your workspaces.\n\n### b. Workspace Data\n\n- **What we collect:** Content you create within the Service (e.g., posts, tags, workspace metadata).\n- **Why we collect it:** To deliver the core functionality of the CMS and allow collaboration.\n\n### c. AI Features (Optional)\n\n- **What we process:** When you use our AI-powered features, your workspace data (e.g., “posts tagged ‘design’”) may be temporarily processed to generate responses.\n- **What we don’t do:** We do not use your data to train external AI models without your consent.\n\n### d. Analytics Data\n\n- **What we collect:** Non-identifiable usage data such as page views, timestamps, and browser type.\n- **Provider:** We use Databuddy.cc, a GDPR-compliant analytics provider that does not track individuals.\n- **Why we collect it:** To monitor performance and improve the Service.\n\n## 2. How We Use Your Information\n\nWe use the collected information to:\n\n- Provide and operate the Service.\n- Enable authentication and account management.\n- Deliver AI-powered features at your request.\n- Monitor performance and usage trends.\n- Comply with legal obligations.\n\nWe do not sell or rent your information to third parties.\n\n## 3. Data Sharing\n\nWe may share your information with trusted service providers (e.g., hosting providers, analytics, payment processors, AI infrastructure) who help us operate the Service. These providers are bound by strict confidentiality and data protection obligations.\n\nWe may also share data if required by law.\n\n## 4. Data Retention\n\n- Account data is retained for as long as your account is active.\n- Workspace data is retained until you delete it or request deletion.\n- Analytics data is retained only as long as necessary for performance monitoring.\n\nYou may request deletion of your data at any time by contacting us at [support@marblecms.com](mailto:support@marblecms.com).\n\n## 5. Your Rights\n\nDepending on your jurisdiction (e.g., GDPR, CCPA), you may have the right to:\n\n- Access and receive a copy of your data.\n- Request correction or deletion of your data.\n- Restrict or object to certain processing.\n- Export your data in a portable format.\n\nTo exercise these rights, contact us at [support@marblecms.com](mailto:support@marblecms.com).\n\n## 6. Cookies and Tracking\n\nWe do not use cookies for tracking. Analytics is handled by Databuddy, which does not identify individual users.\n\n## 7. Security Measures\n\nWe use industry-standard measures, including encryption in transit and at rest, to protect your data. However, no system is 100% secure, and we cannot guarantee absolute protection.\n\n## 8. Changes to This Policy\n\nWe may update this Privacy Policy from time to time. If significant changes are made, we will notify you via the Service or email.\n\n## 9. Contact Us\n\nIf you have any questions or concerns, please contact us at: [support@marblecms.com](mailto:support@marblecms.com)\n\n---\nBy using the Service, you acknowledge that you have read and understood this Privacy Policy.\n"
  },
  {
    "path": "apps/web/src/content/pages/terms.md",
    "content": "---\ntitle: Terms of Service\npublished: 2024-12-12\ndescription: Marble's Terms of Service.\nlastUpdated: 2025-09-18\n---\n\n## 1. Acceptance of Terms\n\nBy accessing or using Marble (\"the Service\"), you agree to these Terms of Service (\"Terms\"). If you do not agree, do not use the Service.\n\n## 2. Definitions\n\n- **User:** An individual who creates an account or accesses the Service.\n- **Organization / Workspace:** A group account managed by one or more Users.\n- **Content:** Any data, text, images, or other materials uploaded or created through the Service.\n- **Subscription:** A paid plan granting access to certain features of the Service.\n\n## 3. Eligibility & Accounts\n\n- You must be at least 16 years old to use the Service.\n- You are responsible for maintaining the confidentiality of your login credentials.\n- You agree to provide accurate information when creating an account or organization.\n- You are responsible for all activities under your account.\n\n## 4. Content Ownership & License\n\n- You retain ownership of all Content you create.\n- By using the Service, you grant Marble a non-exclusive, worldwide license to host, display, and process your Content as necessary to operate the Service.\n- You represent that you have the rights to any Content you upload.\n- Marble retains all rights, title, and interest in its software, trademarks, and intellectual property.\n\n## 5. Payments & Subscriptions\n\n- Some features require a paid Subscription.\n- Subscriptions are billed in advance on a recurring basis (monthly or yearly, as selected).\n- Payments are processed through third-party providers (e.g., Polar). Marble does not store payment card details.\n- All fees are non-refundable except as required by law.\n- If a payment fails, we may suspend or downgrade your access until payment is resolved.\n- We reserve the right to change pricing with reasonable prior notice.\n\n## 6. Prohibited Activities\n\nYou agree not to:\n\n- Use the Service for unlawful purposes.\n- Upload malicious software or harmful content.\n- Infringe on the rights of others, including intellectual property rights.\n- Attempt to gain unauthorized access to systems or data.\n\n## 7. Data & Privacy\n\n- Your use of the Service is also governed by our [Privacy Policy](/privacy).\n- We may process personal data in accordance with applicable laws (e.g., GDPR, CCPA where applicable).\n- We do not sell your data. We may share data only with service providers necessary to operate the Service.\n\n## 8. Service Availability\n\n- The Service is provided “as is” and “as available.”\n- We do not guarantee uninterrupted uptime or that the Service will be error-free.\n- We may modify, suspend, or discontinue the Service at any time without liability.\n\n## 9. Indemnification\n\nYou agree to indemnify and hold harmless Marble, its affiliates, and its contributors from any claims, damages, liabilities, or expenses arising out of:\n\n- Your use of the Service,\n- Your Content, or\n- Your violation of these Terms.\n\n## 10. Limitation of Liability\n\n- To the maximum extent permitted by law, Marble shall not be liable for indirect, incidental, consequential, or punitive damages.\n- Marble’s total liability for any claim shall not exceed the amount you paid to us in the 12 months preceding the claim.\n\n## 11. Modifications to Terms\n\n- We may update these Terms from time to time.\n- We will notify you of material changes via email or in-app notice.\n- Continued use of the Service after changes take effect constitutes acceptance.\n\n## 12. Termination\n\n- We may suspend or terminate your account for violations of these Terms or misuse of the Service.\n- You may stop using the Service at any time.\n- Upon termination, your access will end but certain provisions (e.g., liability, ownership) will survive.\n\n## 13. Governing Law & Dispute Resolution\n\n- These Terms are governed by the laws of the Federal Republic of Nigeria, without regard to conflict-of-law rules.\n- Any disputes will be resolved through binding arbitration or mediation where permitted, or in the courts of Nigeria.\n\n## 14. Contact\n\nFor questions or concerns about these Terms, contact us at: [support@marblecms.com](mailto:support@marblecms.com)\n"
  },
  {
    "path": "apps/web/src/content.config.ts",
    "content": "import { defineCollection } from \"astro:content\";\nimport { highlightContent } from \"@marble/utils\";\nimport { marble } from \"./lib/marble\";\nimport { categorySchema, postSchema } from \"./lib/schemas\";\n\nconst posts = defineCollection({\n  loader: async () => {\n    const { result } = await marble.posts.list({\n      excludeCategories: [\"legal\", \"changelog\"],\n    });\n    return Promise.all(\n      result.posts.map(async (post) => ({\n        ...post,\n        content: await highlightContent(post.content),\n      }))\n    );\n  },\n  schema: postSchema,\n});\n\nconst page = defineCollection({\n  loader: async () => {\n    const { result } = await marble.posts.list({ categories: [\"legal\"] });\n\n    return result.posts.map((post) => ({\n      ...post,\n      // Astro uses the id as a key to get the entry\n      // We can't know the id of the post so we use the slug\n      id: post.slug,\n    }));\n  },\n  schema: postSchema,\n});\n\nconst changelog = defineCollection({\n  loader: async () => {\n    const { result } = await marble.posts.list({ categories: [\"changelog\"] });\n\n    return Promise.all(\n      result.posts.map(async (post) => ({\n        ...post,\n        id: post.slug,\n        content: await highlightContent(post.content),\n      }))\n    );\n  },\n  schema: postSchema,\n});\n\nconst categories = defineCollection({\n  loader: async () => {\n    const { result } = await marble.categories.list();\n\n    return result.categories.map((category) => ({\n      ...category,\n      id: category.slug,\n    }));\n  },\n  schema: categorySchema,\n});\n\nexport const collections = {\n  posts,\n  page,\n  changelog,\n  categories,\n};\n"
  },
  {
    "path": "apps/web/src/layouts/BlogLayout.astro",
    "content": "---\n  import SpeedInsights from \"@vercel/speed-insights/astro\";\n  import BlogHeader from \"@/components/BlogHeader.astro\";\n  import FooterComponent from \"@/components/Footer.astro\";\n  import HeadComponent from \"@/components/Head.astro\";\n  import ScrollToTop from \"@/components/ScrollToTop.astro\";\n\n  const { title, description, image, structuredData } = Astro.props;\n---\n<!doctype html>\n<html lang=\"en\">\n  <head>\n    <HeadComponent\n      title={title}\n      description={description}\n      image={image}\n      structuredData={structuredData}\n    />\n    <SpeedInsights/>\n  </head>\n  <body>\n    <BlogHeader/>\n    <main>\n      <slot/>\n    </main>\n    <FooterComponent/>\n    <ScrollToTop/>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/web/src/layouts/Layout.astro",
    "content": "---\n  import SpeedInsights from \"@vercel/speed-insights/astro\";\n  import FooterComponent from \"@/components/Footer.astro\";\n  import HeadComponent from \"@/components/Head.astro\";\n  import HeaderComponent from \"@/components/Header.astro\";\n  import ScrollToTop from \"@/components/ScrollToTop.astro\";\n\n  const { title, description, image, canonical, structuredData } = Astro.props;\n---\n<!doctype html>\n<html lang=\"en\">\n  <head>\n    <HeadComponent\n      title={title}\n      description={description}\n      image={image}\n      canonical={canonical}\n      structuredData={structuredData}\n    />\n  </head>\n  <body>\n    <HeaderComponent/>\n    <main>\n      <slot/>\n    </main>\n    <FooterComponent/>\n    <ScrollToTop/>\n    <SpeedInsights/>\n  </body>\n</html>\n"
  },
  {
    "path": "apps/web/src/lib/accordion.ts",
    "content": "export const setAccordionHeight = (\n  accordions: NodeListOf<HTMLDetailsElement>\n) => {\n  const originalStates = Array.from(accordions).map(\n    (accordion) => accordion.open\n  );\n\n  for (const accordion of accordions) {\n    accordion.classList.remove(\"accordion-item--animated\");\n    resetAccordionHeight(accordion);\n    assignHeight(accordion);\n  }\n\n  accordions.forEach((accordion, index) => {\n    accordion.open = originalStates[index];\n    accordion.classList.add(\"accordion-item--animated\");\n  });\n};\n\nconst resetAccordionHeight = (accordion: HTMLDetailsElement) => {\n  accordion.style.removeProperty(\"--accordion-item-expanded\");\n  accordion.style.removeProperty(\"--accordion-item-collapsed\");\n};\n\nconst assignHeight = (accordion: HTMLDetailsElement) => {\n  accordion.open = false;\n  const collapsedHeight = accordion.offsetHeight;\n\n  accordion.open = true;\n  const expandedHeight = accordion.scrollHeight;\n\n  accordion.style.setProperty(\n    \"--accordion-item-expanded\",\n    `${expandedHeight}px`\n  );\n  accordion.style.setProperty(\n    \"--accordion-item-collapsed\",\n    `${collapsedHeight}px`\n  );\n};\n\nexport const debounce = (\n  callback: (...args: unknown[]) => void,\n  delay: number\n) => {\n  let timeout: number;\n\n  return (...args: unknown[]) => {\n    clearTimeout(timeout);\n    timeout = window.setTimeout(() => callback(...args), delay);\n  };\n};\n\nexport const handleResize = (callback: () => void) => {\n  const debouncedCallback = debounce(callback, 300);\n  window.addEventListener(\"resize\", debouncedCallback);\n\n  return () => {\n    window.removeEventListener(\"resize\", debouncedCallback);\n  };\n};\n\nexport const isTouchDevice = () =>\n  window.matchMedia(\"(pointer: coarse)\").matches;\n\nlet lastWidth: number;\n\nexport const hasViewportWidthChanged = (): boolean => {\n  if (typeof window !== \"undefined\") {\n    const currentWidth = window.innerWidth;\n    const widthChanged = currentWidth !== lastWidth;\n\n    if (widthChanged) {\n      lastWidth = currentWidth;\n    }\n\n    return widthChanged;\n  }\n\n  return false;\n};\n"
  },
  {
    "path": "apps/web/src/lib/constants/faqs.ts",
    "content": "export const FAQs: {\n  question: string;\n  answer: string;\n}[] = [\n  {\n    question: \"What is Marble?\",\n    answer:\n      \"Marble is a headless CMS designed specifically for managing blogs, changelogs, and articles. It provides a simple interface for creating and organizing content, along with a powerful API to fetch and display it on your website or app.\",\n  },\n  {\n    question: \"How does Marble work?\",\n    answer:\n      \"Marble is a headless CMS that provides content management through a simple API. You can create, edit and manage content through our dashboard, then fetch it via our API to display on your website or app.\",\n  },\n  {\n    question: \"Is Marble free?\",\n    answer:\n      \"Yes, Marble is free to use with generous limits on all core features. We also offer paid plans for teams needing higher limits and advanced features.\",\n  },\n  {\n    question: \"Who is Marble for?\",\n    answer:\n      \"Marble is for developers, writers, and teams who want a simple, reliable CMS for content-driven sites without the complexity of traditional CMS platforms.\",\n  },\n  {\n    question: \"Do I need technical knowledge to use Marble?\",\n    answer:\n      \"No technical knowledge is required to use our content management dashboard. However, to integrate the API with your website or app, basic development experience is helpful. We provide detailed documentation and templates to make integration easy.\",\n  },\n  {\n    question: \"What kind of content can I manage?\",\n    answer:\n      \"Marble is primarily focused on managing blog posts, changelogs, articles, and static pages. We support rich text, images, and videos to help you create engaging content for your blog or documentation site.\",\n  },\n  {\n    question: \"Is there a limit on API requests?\",\n    answer:\n      \"Free accounts include 10.000 API requests per month. We implement fair usage policies to prevent abuse but typical usage patterns are well within our limits.\",\n  },\n  {\n    question: \"Can I import content from elsewhere?\",\n    answer:\n      \"Yes, you can import content from elsewhere by simply pasting a markdown file into the editor or using the import button on the posts page.\",\n  },\n  {\n    question: \"Is Marble SEO friendly?\",\n    answer:\n      \"Yes, Marble is SEO friendly. We provide a field for every data you might need to generate an SEO optimized page.\",\n  },\n  {\n    question: \"What frameworks work best with Marble?\",\n    answer:\n      \"Marble is framework agnostic but works best with frameworks that support server-side rendering (SSR) and static site generation (SSG).\",\n  },\n  {\n    question: \"Can I manage multiple blogs or projects?\",\n    answer:\n      \"Yes, you can manage multiple blogs or projects by creating multiple workspaces. Each workspace is independent and can have its own set of users and content.\",\n  },\n  {\n    question: \"Is Marble open source?\",\n    answer:\n      \"Yes, Marble is 100% open source. You can find the source code on <a href='https://github.com/usemarble/marble' target='_blank' rel='noopener' aria-label='Marble GitHub repository'>GitHub</a>.\",\n  },\n];\n\nexport const PRICING_FAQS: {\n  question: string;\n  answer: string;\n}[] = [\n  {\n    question: \"How are plans billed?\",\n    answer:\n      \"Our plans are billed per workspace. This means you can invite as many team members as your plan allows to a workspace without any extra charges per member.\",\n  },\n  {\n    question: \"Do you offer a free trial?\",\n    answer:\n      \"Yes! The Pro plan includes a 3-day free trial. You can try all Pro features risk-free for 3 days. If you don't cancel during the trial period, your subscription will automatically renew at the full price. You can cancel anytime during the trial period without being charged.\",\n  },\n  {\n    question: \"How do I get a refund?\",\n    answer:\n      \"To request a refund, please contact us at <a href='mailto:support@marblecms.com'>support@marblecms.com</a> within 7 days of your purchase. We're also available on X at <a href='https://x.com/usemarblecms' target='_blank' rel='noopener'>usemarblecms</a> and on our <a href='https://discord.gg/gU44Pmwqkx' target='_blank' rel='noopener'>Discord</a> channel.\",\n  },\n  {\n    question: \"Can I change my plan later?\",\n    answer:\n      \"Yes, you can upgrade or downgrade your plan at any time from your workspace billing settings. Prorated charges or credits will be applied automatically.\",\n  },\n  {\n    question: \"What payment methods do you accept?\",\n    answer:\n      \"We accept all major credit cards, including Visa, Mastercard, and American Express. All payments are processed securely via <a href='https://polar.sh' target='_blank' rel='noopener'>Polar</a>.\",\n  },\n  {\n    question: \"What happens when I downgrade my plan?\",\n    answer:\n      \"When you downgrade, you'll retain access to paid features until the end of your current billing cycle. Afterward, your workspace will be moved to the Free plan, and some features may become unavailable.\",\n  },\n  {\n    question: \"Can I cancel anytime?\",\n    answer:\n      \"Yes, you can cancel your subscription at any time from your workspace billing settings. Your subscription will remain active until the end of your current billing period, and you won't be charged for the next cycle.\",\n  },\n  {\n    question: \"What happens if I exceed my plan limits?\",\n    answer:\n      \"If you exceed your plan's API request, storage, or webhook limits, we'll notify you. For API requests, you may experience rate limiting. We recommend upgrading to Pro if you consistently exceed limits, or you can monitor your usage in the dashboard.\",\n  },\n  {\n    question: \"Can I have multiple workspaces on one plan?\",\n    answer:\n      \"Each workspace requires its own subscription. If you want multiple workspaces on a paid plan, you'll need to subscribe separately for each workspace. However, you can have unlimited workspaces on the free Hobby plan.\",\n  },\n  {\n    question: \"Is there a discount for annual billing?\",\n    answer:\n      \"Yes! When you choose annual billing, you save about 17% compared to monthly billing. The Pro plan is $200 per year (equivalent to about $16.67/month) instead of $20/month when billed monthly.\",\n  },\n];\n"
  },
  {
    "path": "apps/web/src/lib/constants/landing.ts",
    "content": "import contentIntelImage from \"../../assets/images/content-intelligence.png\";\nimport apiImage from \"../../assets/images/headless-api.png\";\nimport mediaImage from \"../../assets/images/media-management.png\";\nimport webhooksImage from \"../../assets/images/webhooks.png\";\nimport Bounty from \"../../components/icons/brand/Bounty.astro\";\nimport Candle from \"../../components/icons/brand/Candle.astro\";\nimport Databuddy from \"../../components/icons/brand/Databuddy.astro\";\nimport Helix from \"../../components/icons/brand/Helix.astro\";\nimport Ia from \"../../components/icons/brand/Ia.astro\";\nimport Opencut from \"../../components/icons/brand/Opencut.astro\";\n\nexport const FEATURES = [\n  {\n    title: \"Simple Headless API\",\n    description:\n      \"Pull your content into any framework. Works seamlessly with Next.js, Astro, Nuxt, and more.\",\n    link: {\n      text: \"Learn more\",\n      href: \"https://docs.marblecms.com/api/introduction\",\n    },\n    image: apiImage,\n  },\n  {\n    title: \"Media Management\",\n    description:\n      \"Upload, organize, and manage your images and videos in one place. media files are served from a globally distributed CDN for instant loading.\",\n    link: {\n      text: \"Learn more\",\n      href: \"https://docs.marblecms.com/guides/features/media\",\n    },\n    image: mediaImage,\n  },\n  {\n    title: \"Realtime Webhooks\",\n    description:\n      \"Trigger external workflows instantly when your content changes. Integrate with your favorite tools.\",\n    link: {\n      text: \"Learn more\",\n      href: \"https://docs.marblecms.com/guides/features/webhooks\",\n    },\n    image: webhooksImage,\n  },\n  {\n    title: \"Content Intelligence\",\n    description:\n      \"Get Real-time readability scores, and optimization tips powered by AI to improve your writing.\",\n    link: {\n      text: \"Learn more\",\n      href: \"https://docs.marblecms.com/guides/features/editor#analysis-tab\",\n    },\n    image: contentIntelImage,\n  },\n  // {\n  //   title: \"Simple Editor\",\n  //   description: \"Write and format content easily with an intuitive interface.\",\n  // },\n  // {\n  //   title: \"Team Collaboration\",\n  //   description: \"Work together efficiently with shared workspaces.\",\n  // },\n];\n\nexport const USERS = [\n  {\n    name: \"I.A\",\n    url: \"https://independent-arts.org\",\n    component: Ia,\n    showWordmark: true,\n  },\n  {\n    name: \"OpenCut\",\n    url: \"https://opencut.app\",\n    component: Opencut,\n    showWordmark: true,\n  },\n  {\n    name: \"Bounty\",\n    url: \"https://bounty.new\",\n    component: Bounty,\n    showWordmark: false,\n  },\n  {\n    name: \"Helix DB\",\n    url: \"https://www.helix-db.com\",\n    component: Helix,\n    showWordmark: true,\n  },\n  {\n    name: \"Databuddy\",\n    url: \"https://databuddy.cc\",\n    component: Databuddy,\n    showWordmark: true,\n  },\n  {\n    name: \"Candle\",\n    url: \"https://www.trycandle.app/\",\n    component: Candle,\n    showWordmark: false,\n  },\n];\n\nexport const REVIEWS = [\n  {\n    text: \"The best decision I made so far building BookFlow was using @usemarblecms to manage my blogs. Super simple to integrate and offers analytics for posts\",\n    author: \"Tech Nomad\",\n    role: \"Developer\",\n    avatar: \"/avatars/dauda.jpg\",\n    link: \"https://x.com/dauda_kolo/status/1994699291365966178?s=20\",\n  },\n  {\n    text: \"The @usemarblecms writing experience is pretty good. A little rough around the edges but it’s certainly a good entry in the space.\",\n    author: \"James Perkins\",\n    role: \"CEO, Unkey\",\n    avatar: \"/avatars/james.jpg\",\n    link: \"https://x.com/jamesperkins/status/1953899259515773293?s=20\",\n  },\n  {\n    text: \"Marble is now great, I love the new drag and drop image feat, moving all my 3 posts to @usemarblecms 🫡\",\n    author: \"Alex\",\n    role: \"Developer\",\n    avatar: \"/avatars/alex.jpg\",\n    link: \"https://x.com/Cleverbilling/status/1957833083647885338?s=20\",\n  },\n  {\n    text: \"Another W for open-source 🫡\",\n    author: \"joshtriedcoding\",\n    role: \"Dev Rel, Upstash\",\n    avatar: \"/avatars/josh.jpg\",\n    link: \"https://x.com/joshtriedcoding/status/1954973778380820688?s=20\",\n  },\n  {\n    text: \"Only CMS i'll ever integrate again is @usemarblecms if needed for blogs others are just so fucking bloated nowadays and pain in the ass to integrate\",\n    author: \"Valtteri\",\n    role: \"Developer\",\n    avatar: \"/avatars/valtteri.jpg\",\n    link: \"https://x.com/vvaltterisa/status/1999549602668691822?s=20\",\n  },\n  {\n    text: \"Chat, which app is this? such clean UX,\",\n    author: \"Moinul Moin\",\n    role: \"Developer\",\n    avatar: \"/avatars/moinul.jpg\",\n    link: \"https://x.com/moinulmoin/status/1964969896884011362?s=20\",\n  },\n];\n"
  },
  {
    "path": "apps/web/src/lib/constants/navigation.ts",
    "content": "import type { SvgComponent } from \"astro/types\";\nimport Discord from \"../../components/icons/Discord.astro\";\nimport Github from \"../../components/icons/Github.astro\";\nimport X from \"../../components/icons/X.astro\";\nimport { SITE } from \"./site\";\n\nexport interface Link {\n  href: string;\n  label: string;\n}\n\nexport const SOCIAL_LINKS: Link[] = [\n  { href: \"https://github.com/usemarble\", label: \"GitHub\" },\n  { href: \"https://x.com/usemarblecms\", label: \"Twitter\" },\n  { href: \"https://discord.gg/gU44Pmwqkx\", label: \"Discord\" },\n  { href: \"support@marblecms.com\", label: \"Email\" },\n  { href: \"/rss.xml\", label: \"RSS\" },\n];\n\nexport interface FooterLink {\n  label: string;\n  href: string;\n  external?: boolean;\n  target?: string;\n  rel?: string;\n  icon?: SvgComponent;\n}\n\nexport interface FooterSection {\n  title: string;\n  links: FooterLink[];\n}\n\nexport const FOOTER_SECTIONS: FooterSection[] = [\n  {\n    title: \"Product\",\n    links: [\n      {\n        label: \"Get Started\",\n        href: SITE.APP_URL,\n      },\n      {\n        label: \"Pricing\",\n        href: \"/pricing\",\n      },\n      {\n        label: \"Changelog\",\n        href: \"/changelog\",\n      },\n    ],\n  },\n  {\n    title: \"Resources\",\n    links: [\n      {\n        label: \"Blog\",\n        href: \"/blog\",\n      },\n      {\n        label: \"Feed\",\n        href: \"/rss.xml\",\n      },\n      {\n        label: \"Contributors\",\n        href: \"/contributors\",\n      },\n    ],\n  },\n  {\n    title: \"Developers\",\n    links: [\n      {\n        label: \"Documentation\",\n        href: \"https://docs.marblecms.com\",\n        external: true,\n        target: \"_blank\",\n        rel: \"noopener\",\n      },\n      {\n        label: \"Framer Plugin\",\n        href: \"https://www.framer.com/marketplace/plugins/marble\",\n        external: true,\n        target: \"_blank\",\n        rel: \"noopener\",\n      },\n      {\n        label: \"Raycast Extension\",\n        href: \"https://www.raycast.com/dominikdev/marble\",\n        external: true,\n        target: \"_blank\",\n        rel: \"noopener\",\n      },\n      {\n        label: \"Astro Example\",\n        href: \"https://github.com/usemarble/astro-example\",\n        external: true,\n        target: \"_blank\",\n        rel: \"noopener\",\n      },\n      {\n        label: \"Next.js Example\",\n        href: \"https://github.com/usemarble/nextjs-example\",\n        external: true,\n        target: \"_blank\",\n        rel: \"noopener\",\n      },\n      {\n        label: \"TanStack Example\",\n        href: \"https://github.com/usemarble/tanstack-start-example\",\n        external: true,\n        target: \"_blank\",\n        rel: \"noopener\",\n      },\n    ],\n  },\n  {\n    title: \"Company\",\n    links: [\n      {\n        label: \"Contact\",\n        href: \"mailto:support@marblecms.com\",\n      },\n      {\n        label: \"Terms\",\n        href: \"/terms\",\n      },\n      {\n        label: \"Privacy\",\n        href: \"/privacy\",\n      },\n      {\n        label: \"Sponsors\",\n        href: \"/sponsors\",\n      },\n    ],\n  },\n];\n\nexport const FOOTER_SOCIAL_LINKS: FooterLink[] = [\n  {\n    label: \"Twitter\",\n    href: \"https://x.com/usemarblecms\",\n    external: true,\n    target: \"_blank\",\n    rel: \"noopener\",\n    icon: X,\n  },\n  {\n    label: \"Github\",\n    href: \"https://github.com/usemarble\",\n    external: true,\n    target: \"_blank\",\n    rel: \"noopener\",\n    icon: Github,\n  },\n  {\n    label: \"Discord\",\n    href: \"https://discord.marblecms.com\",\n    external: true,\n    target: \"_blank\",\n    rel: \"noopener\",\n    icon: Discord,\n  },\n];\n"
  },
  {
    "path": "apps/web/src/lib/constants/site.ts",
    "content": "export interface Site {\n  TITLE: string;\n  DESCRIPTION: string;\n  EMAIL: string;\n  URL: string;\n  APP_URL: string;\n  TWITTER_URL: string;\n  DISCORD_URL: string;\n}\n\nexport const SITE: Site = {\n  TITLE: \"Marble\",\n  DESCRIPTION: \"A simple headless CMS for managing your blog and media files.\",\n  EMAIL: \"support@marblecms.com\",\n  URL: \"https://marblecms.com\",\n  APP_URL: \"https://app.marblecms.com\",\n  TWITTER_URL: \"https://x.com/usemarblecms\",\n  DISCORD_URL: \"https://discord.gg/gU44Pmwqkx\",\n};\n"
  },
  {
    "path": "apps/web/src/lib/constants/tracking.ts",
    "content": "import { SITE } from \"./site\";\n\nexport const REGISTER_URL = `${SITE.APP_URL}/register`;\n\nexport const TRACKING_EVENTS = {\n  signupClicked: \"signup_clicked\",\n} as const;\n"
  },
  {
    "path": "apps/web/src/lib/marble.ts",
    "content": "import { getSecret } from \"astro:env/server\";\nimport { Marble } from \"@usemarble/sdk\";\n\nconst key = getSecret(\"MARBLE_API_KEY\");\n\nif (!key) {\n  throw new Error(\"Missing MARBLE_API_KEY in environment variables\");\n}\n\nexport const marble = new Marble({\n  apiKey: key,\n});\n"
  },
  {
    "path": "apps/web/src/lib/schemas.ts",
    "content": "import { z } from \"astro/zod\";\n\nexport const paginationSchema = z.object({\n  limit: z.number(),\n  currentPage: z.number(),\n  nextPage: z.number().nullable(),\n  previousPage: z.number().nullable(),\n  totalPages: z.number(),\n  totalItems: z.number(),\n});\n\nexport const postSchema = z.object({\n  id: z.string(),\n  slug: z.string(),\n  title: z.string(),\n  content: z.string(),\n  featured: z.boolean(),\n  description: z.string(),\n  coverImage: z.string().nullable(),\n  publishedAt: z.coerce.date(),\n  updatedAt: z.coerce.date(),\n  authors: z\n    .array(\n      z.object({\n        id: z.string(),\n        name: z.string(),\n        image: z.string().nullable(),\n        bio: z.string().nullable(),\n        role: z.string().nullable(),\n        slug: z.string(),\n        socials: z.array(\n          z.object({\n            url: z.url(),\n            platform: z.string(),\n          })\n        ),\n      })\n    )\n    .min(1),\n  category: z\n    .object({\n      id: z.string(),\n      name: z.string(),\n      slug: z.string(),\n      description: z.string().nullable(),\n    })\n    .nullable(),\n  tags: z.array(\n    z.object({\n      id: z.string(),\n      name: z.string(),\n      slug: z.string(),\n      description: z.string().nullable(),\n    })\n  ),\n});\nexport type Post = z.infer<typeof postSchema>;\n\nexport const postsSchema = z.object({\n  posts: z.array(postSchema),\n  pagination: paginationSchema,\n});\nexport type Posts = z.infer<typeof postsSchema>;\n\nexport const categorySchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  slug: z.string(),\n  description: z.string().nullable(),\n  count: z.object({\n    posts: z.number().int(),\n  }),\n});\nexport type Category = z.infer<typeof categorySchema>;\n"
  },
  {
    "path": "apps/web/src/lib/seo.ts",
    "content": "const DESCRIPTION_MAX_LENGTH = 160;\n\nexport function cleanMetaDescription(\n  description: string | null | undefined,\n  fallback: string\n) {\n  const cleaned = (description || fallback).replace(/\\s+/g, \" \").trim();\n\n  if (cleaned.length <= DESCRIPTION_MAX_LENGTH) {\n    return cleaned;\n  }\n\n  return `${cleaned.slice(0, DESCRIPTION_MAX_LENGTH - 1).trimEnd()}…`;\n}\n\nexport function jsonLd(schema: unknown) {\n  return JSON.stringify(schema).replace(/</g, \"\\\\u003c\");\n}\n\nfunction stripHtml(html: string) {\n  return html\n    .replace(/<[^>]*>/g, \"\")\n    .replace(/\\s+/g, \" \")\n    .trim();\n}\n\nexport function buildSiteJsonLd(site: {\n  title: string;\n  description: string;\n  url: string;\n  twitterUrl?: string;\n}) {\n  return [\n    {\n      \"@context\": \"https://schema.org\",\n      \"@type\": \"Organization\",\n      name: site.title,\n      url: site.url,\n      description: site.description,\n      sameAs: [site.twitterUrl].filter(Boolean),\n    },\n    {\n      \"@context\": \"https://schema.org\",\n      \"@type\": \"WebSite\",\n      name: site.title,\n      url: site.url,\n      description: site.description,\n      publisher: {\n        \"@type\": \"Organization\",\n        name: site.title,\n      },\n    },\n  ];\n}\n\nexport function buildFaqJsonLd(\n  faqs: Array<{ question: string; answer: string }>\n) {\n  return {\n    \"@context\": \"https://schema.org\",\n    \"@type\": \"FAQPage\",\n    mainEntity: faqs.map((faq) => ({\n      \"@type\": \"Question\",\n      name: faq.question,\n      acceptedAnswer: {\n        \"@type\": \"Answer\",\n        text: stripHtml(faq.answer),\n      },\n    })),\n  };\n}\n\nexport function buildArticleJsonLd({\n  type = \"BlogPosting\",\n  title,\n  description,\n  url,\n  image,\n  publishedAt,\n  updatedAt,\n  authors,\n  siteTitle,\n  siteUrl,\n}: {\n  type?: \"Article\" | \"BlogPosting\" | \"TechArticle\";\n  title: string;\n  description: string;\n  url: string;\n  image?: string | null;\n  publishedAt: Date;\n  updatedAt: Date;\n  authors: Array<{\n    name: string;\n    image?: string | null;\n  }>;\n  siteTitle: string;\n  siteUrl: string;\n}) {\n  return {\n    \"@context\": \"https://schema.org\",\n    \"@type\": type,\n    headline: title,\n    description,\n    url,\n    datePublished: publishedAt.toISOString(),\n    dateModified: updatedAt.toISOString(),\n    author: authors.map((author) => ({\n      \"@type\": \"Person\",\n      name: author.name,\n      ...(author.image && { image: author.image }),\n    })),\n    publisher: {\n      \"@type\": \"Organization\",\n      name: siteTitle,\n      url: siteUrl,\n    },\n    ...(image && { image }),\n  };\n}\n"
  },
  {
    "path": "apps/web/src/lib/site.ts",
    "content": "export const HERO_VARIATIONS = {\n  default: {\n    title: \"Simple content management for modern apps\",\n    subtitle:\n      \"A clean, collaborative way to publish articles, changelogs, and product updates to your site\",\n  },\n  simple: {\n    title: \"Super simple headless CMS\",\n    subtitle:\n      \"Marble is a simple way to manage your blog and media. Write, upload, and publish with a clean interface and simple API.\",\n  },\n  smart: {\n    title: \"The smarter way to manage your blog\",\n    subtitle:\n      \"Streamline your content workflow with intuitive media management and a powerful editor.\",\n  },\n};\n\nexport const HERO = HERO_VARIATIONS.simple;\n"
  },
  {
    "path": "apps/web/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function calculateReadTime(content: string) {\n  const wordsPerMinute = 200;\n  const plainText = content.replace(/<[^>]*>/g, \"\").trim();\n  const wordCount = plainText.split(/\\s+/).length;\n\n  const readingTime = Math.ceil(wordCount / wordsPerMinute);\n  return readingTime;\n}\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "apps/web/src/pages/404.astro",
    "content": "---\n  import Container from \"@/components/Container.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n---\n<Layout title=\"Page not found\">\n  <div class=\"border-y border-dashed\">\n    <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n      <Container\n        class=\"flex min-h-[calc(100vh-128px)] flex-col items-center justify-center\"\n      >\n        <h1 class=\"text-5xl font-bold\">404</h1>\n        <p class=\"mt-2 text-2xl mb-6\">Page not found</p>\n\n        <a\n          href=\"/\"\n          class=\"bg-primary text-white rounded flex items-center gap-2 py-2 px-4 text-sm hover:ring-offset-2 hover:ring-primary hover:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary focus-visible:ring-2 transition-all duration-300 ease-out\"\n        >\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            class=\"mr-2 shrink-0\"\n          >\n            <path d=\"M3 7v6h6\"></path>\n            <path d=\"M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13\"></path>\n          </svg>\n          Go back home\n        </a>\n      </Container>\n    </div>\n  </div>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/blog/[slug].astro",
    "content": "---\n  import { getCollection } from \"astro:content\";\n  import { YOUTUBE_VIDEO_ID } from \"@marble/utils\";\n  import Container from \"@/components/Container.astro\";\n  import Prose from \"@/components/Prose.astro\";\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { REGISTER_URL, TRACKING_EVENTS } from \"@/lib/constants/tracking\";\n  import { buildArticleJsonLd, cleanMetaDescription } from \"@/lib/seo\";\n  import { calculateReadTime } from \"@/lib/utils\";\n\n  const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`;\n\n  export const prerender = true;\n\n  export async function getStaticPaths() {\n    const blogEntries = await getCollection(\"posts\");\n    return blogEntries.map((entry) => ({\n      params: { slug: entry.data.slug },\n      props: { entry },\n    }));\n  }\n\n  const { entry } = Astro.props;\n\n  const formattedDate = new Date(entry.data.publishedAt).toLocaleDateString(\n    \"en-US\",\n    {\n      year: \"numeric\",\n      month: \"long\",\n      day: \"numeric\",\n      timeZone: \"UTC\",\n    }\n  );\n\n  const readTime = calculateReadTime(entry.data.content);\n  const description = cleanMetaDescription(\n    entry.data.description,\n    `Read ${entry.data.title} on the Marble blog.`\n  );\n  const structuredData = buildArticleJsonLd({\n    type: \"BlogPosting\",\n    title: entry.data.title,\n    description,\n    url: new URL(Astro.url.pathname, SITE.URL).toString(),\n    image: entry.data.coverImage\n      ? new URL(entry.data.coverImage, SITE.URL).toString()\n      : undefined,\n    publishedAt: entry.data.publishedAt,\n    updatedAt: entry.data.updatedAt,\n    authors: entry.data.authors,\n    siteTitle: SITE.TITLE,\n    siteUrl: SITE.URL,\n  });\n---\n<Layout\n  title={`${entry.data.title} - ${SITE.TITLE}`}\n  description={description}\n  image={entry.data.coverImage}\n  structuredData={structuredData}\n>\n  <section\n    class=\"min-h-[calc(100vh-88px)] sm:min-h-[calc(100vh-103px)] lg:min-h-[calc(100vh-120px)] border-t border-dashed\"\n  >\n    <div\n      class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto h-full\"\n    >\n      <Container class=\"md:py-20 py-10 md:pb-24\">\n        <section class=\"max-w-3xl mx-auto mb-6 md:mb-10 space-y-6\">\n          <div class=\"flex flex-col gap-4 mx-auto\">\n            <h1\n              class=\"text-3xl lg:text-4xl font-medium text-balance leading-[1.2] mb-2\"\n            >\n              {entry.data.title}\n            </h1>\n            <div class=\"flex items-center gap-6 justify-btween\">\n              <div class=\"flex items-center gap-2\">\n                <span class=\"h-4 w-0.5 bg-gray-300 shrink-0\"></span>\n                <time\n                  datetime={entry.data.publishedAt.toISOString()}\n                  class=\"text-muted-foreground text-sm sm:text-base\"\n                >\n                  {formattedDate}\n                </time>\n              </div>\n              <div class=\"flex items-center gap-2\">\n                <span class=\"h-4 w-0.5 bg-gray-300 shrink-0\"></span>\n                <p class=\"text-muted-foreground text-sm sm:text-base\">\n                  {readTime}\n                  {\"\"}minute read\n                </p>\n              </div>\n            </div>\n            <!-- <div class=\"flex items-center gap-2\">\n              {entry.data.authors[0].image ? (\n                <Image src={entry.data.authors[0].image} alt={entry.data.authors[0].name} class=\"size-7 rounded-full\" inferSize />\n              ) : (\n                <span class=\"h-4 w-0.5 bg-gray-300 shrink-0\"></span>\n              )}\n              <p class=\"text-muted-foreground text-sm sm:text-base\">{entry.data.authors[0].name}</p>\n            </div> -->\n          </div>\n        </section>\n        <!-- <div>\n          {entry.data.coverImage && (\n            <Image src={entry.data.coverImage} alt={entry.data.title} class=\"w-full max-w-3xl mx-auto mb-8\" inferSize />\n          )}\n        </div> -->\n        <Prose>\n          <div set:html={entry.data.content}/>\n        </Prose>\n      </Container>\n    </div>\n  </section>\n  <section class=\"border-y border-dashed\">\n    <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n      <Container class=\"py-12 md:py-20\">\n        <div class=\"flex flex-col items-center space-y-6\">\n          <div class=\"space-y-4 text-center\">\n            <h2\n              class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n            >\n              Try Marble today.\n            </h2>\n            <p\n              class=\"mx-auto max-w-2xl text-muted-foreground md:text-lg text-balance\"\n            >\n              A simpler way to publish articles and manage your blog.\n            </p>\n          </div>\n\n          <div\n            class=\"flex flex-col sm:flex-row w-full items-center justify-center gap-4\"\n          >\n            <ButtonComponent\n              href={REGISTER_URL}\n              target=\"_blank\"\n              class=\"w-full sm:w-auto\"\n              data-track={TRACKING_EVENTS.signupClicked}\n              data-context=\"blog_post_cta\"\n              data-label=\"Try Marble for free\"\n            >\n              Try Marble for free\n            </ButtonComponent>\n            <ButtonComponent\n              href={YOUTUBE_WATCH_URL}\n              variant=\"secondary\"\n              class=\"w-full sm:w-auto\"\n            >\n              Watch Demo\n            </ButtonComponent>\n          </div>\n        </div>\n      </Container>\n    </div>\n  </section>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/blog/category/[slug].astro",
    "content": "---\n  import { getCollection } from \"astro:content\";\n  import CategoryCard from \"@/components/CategoryCard.astro\";\n  import CategoryFilter from \"@/components/CategoryFilter.astro\";\n  import Container from \"@/components/Container.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { marble } from \"@/lib/marble\";\n\n  export const prerender = true;\n\n  export async function getStaticPaths() {\n    const blogEntries = await getCollection(\"categories\");\n    return blogEntries\n      .filter(\n        (entry) =>\n          entry.data.slug !== \"legal\" && entry.data.slug !== \"changelog\"\n      )\n      .map((entry) => ({\n        params: { slug: entry.data.slug },\n        props: { entry },\n      }));\n  }\n\n  const { entry } = Astro.props;\n\n  const categoriesEntries = await getCollection(\"categories\");\n  const { result } = await marble.posts.list({ categories: [entry.data.slug] });\n  const fillers = Array.from({\n    length: (3 - (result.posts.length % 3)) % 3,\n  });\n---\n<Layout\n  title={`${entry.data.name} - ${SITE.TITLE}`}\n  description={entry.data.description}\n  canonical=\"/blog\"\n>\n  <section class=\"lg:border-y border-dashed min-h-[calc(100vh-140px)]\">\n    <div\n      class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto h-full\"\n    >\n      <Container class=\"py-20 lg:py-24 h-full\">\n        <div\n          class=\"mb-16 flex flex-col items-start gap-4 md:flex-row md:items-end md:justify-between\"\n        >\n          <div class=\"space-y-4\">\n            <h1\n              class=\"text-2xl tracking-tight md:text-3xl lg:text-4xl font-medium\"\n            >\n              Blog\n            </h1>\n            <p class=\"max-w-prose md:text-lg text-muted-foreground\">\n              {entry.data.description || \"Updates, news, and guides from the team.\"}\n            </p>\n          </div>\n          <div class=\"w-full md:w-auto\">\n            <CategoryFilter categories={categoriesEntries}/>\n          </div>\n        </div>\n        {result.posts.length > 0 ? (\n          <ul class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 border-t border-l border-dashed'>\n            {\n              result.posts.map((post) => (\n                <CategoryCard entry={post} />\n              ))\n            }\n            {fillers.map(() => (\n              <li class=\"border-b border-r border-dashed bg-white hidden md:block h-full\" />\n            ))}\n          </ul>\n        ) : (\n          <p class='text-muted-foreground text-center text-sm md:text-base'>No posts found</p>\n        )}\n      </Container>\n    </div>\n  </section>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/blog/index.astro",
    "content": "---\n  import { getCollection } from \"astro:content\";\n  import CategoryFilter from \"@/components/CategoryFilter.astro\";\n  import Container from \"@/components/Container.astro\";\n  import PostCard from \"@/components/PostCard.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n\n  const unsortedPosts = await getCollection(\"posts\");\n  const posts = unsortedPosts.sort(\n    (a, b) =>\n      new Date(b.data.publishedAt).valueOf() -\n      new Date(a.data.publishedAt).valueOf()\n  );\n  const categories = await getCollection(\"categories\");\n  const fillers = Array.from({ length: (3 - (posts.length % 3)) % 3 });\n---\n<Layout\n  title={`Blog - ${SITE.TITLE}`}\n  description=\"Guides, product notes, and practical articles about building content driven websites.\"\n>\n  <section class=\"lg:border-y border-dashed min-h-[calc(100vh-140px)]\">\n    <div\n      class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto h-full\"\n    >\n      <Container class=\"py-20 lg:py-24 h-full\">\n        <div\n          class=\"mb-16 flex flex-col items-start gap-4 md:flex-row md:items-end md:justify-between\"\n        >\n          <div class=\"space-y-4\">\n            <h1\n              class=\"text-2xl tracking-tight md:text-3xl lg:text-4xl font-medium\"\n            >\n              Blog\n            </h1>\n            <p class=\"max-w-prose md:text-lg text-muted-foreground\">\n              Updates, news, and articles from Marble.\n            </p>\n          </div>\n          <div class=\"w-full md:w-auto\">\n            <CategoryFilter categories={categories}/>\n          </div>\n        </div>\n        <ul\n          class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 border-t border-l border-dashed\"\n        >\n          {posts.map((post) => (\n              <PostCard entry={post} />\n            ))}\n          {fillers.map(() => (\n            <li class=\"border-b border-r border-dashed bg-white hidden md:block h-full\" />\n          ))}\n        </ul>\n      </Container>\n    </div>\n  </section>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/changelog/[slug].astro",
    "content": "---\n  import { getCollection } from \"astro:content\";\n  import { YOUTUBE_VIDEO_ID } from \"@marble/utils\";\n  import Container from \"@/components/Container.astro\";\n  import Prose from \"@/components/Prose.astro\";\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { REGISTER_URL, TRACKING_EVENTS } from \"@/lib/constants/tracking\";\n  import { buildArticleJsonLd, cleanMetaDescription } from \"@/lib/seo\";\n  import { calculateReadTime } from \"@/lib/utils\";\n\n  const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`;\n\n  export const prerender = true;\n\n  export async function getStaticPaths() {\n    const changelogEntries = await getCollection(\"changelog\");\n    return changelogEntries.map((entry) => ({\n      params: { slug: entry.data.slug },\n      props: { entry },\n    }));\n  }\n\n  const { entry } = Astro.props;\n\n  const formattedDate = new Date(entry.data.publishedAt).toLocaleDateString(\n    \"en-US\",\n    {\n      year: \"numeric\",\n      month: \"long\",\n      day: \"numeric\",\n      timeZone: \"UTC\",\n    }\n  );\n\n  const readTime = calculateReadTime(entry.data.content);\n  const description = cleanMetaDescription(\n    entry.data.description,\n    `Read ${entry.data.title} in the Marble changelog.`\n  );\n  const structuredData = buildArticleJsonLd({\n    type: \"Article\",\n    title: entry.data.title,\n    description,\n    url: new URL(Astro.url.pathname, SITE.URL).toString(),\n    image: entry.data.coverImage\n      ? new URL(entry.data.coverImage, SITE.URL).toString()\n      : undefined,\n    publishedAt: entry.data.publishedAt,\n    updatedAt: entry.data.updatedAt,\n    authors: entry.data.authors,\n    siteTitle: SITE.TITLE,\n    siteUrl: SITE.URL,\n  });\n---\n<Layout\n  title={`${entry.data.title} - ${SITE.TITLE}`}\n  description={description}\n  image={entry.data.coverImage}\n  structuredData={structuredData}\n>\n  <section\n    class=\"min-h-[calc(100vh-88px)] sm:min-h-[calc(100vh-103px)] lg:min-h-[calc(100vh-120px)] border-y border-dashed\"\n  >\n    <div\n      class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto h-full\"\n    >\n      <Container class=\"md:py-20 py-10 md:pb-24\">\n        <section class=\"max-w-3xl mx-auto mb-6 md:mb-10 space-y-6\">\n          <!-- <div>\n            <a href=\"/blog\" class=\"group w-fit flex items-center gap-2 relative hover:bg-muted p-2 transition duration-300 rounded\">\n              <svg \n                xmlns=\"http://www.w3.org/2000/svg\" \n                viewBox=\"0 0 24 24\"\n                class=\"size-5 stroke-2 fill-none stroke-current\">\n                <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\" class=\"scale-x-0 group-hover:scale-x-100 origin-left transition-transform duration-300 ease-in-out\" />\n                <polyline points=\"12 5 5 12 12 19\" class=\"translate-x-1 group-hover:translate-x-0 transition-transform duration-300 ease-in-out\" />\n              </svg>\n              <span>Back</span>\n            </a>\n          </div> -->\n          <div class=\"flex flex-col gap-4 max-w-2xl mx-auto\">\n            <h1 class=\"text-3xl lg:text-4xl text-balance leading-[1.2] mb-2\">\n              {entry.data.title}\n            </h1>\n            <div class=\"flex items-center gap-6 justify-btween\">\n              <div class=\"flex items-center gap-2\">\n                <span class=\"h-4 w-0.5 bg-gray-300 shrink-0\"></span>\n                <time\n                  datetime={entry.data.publishedAt.toISOString()}\n                  class=\"text-muted-foreground text-sm sm:text-base\"\n                >\n                  {formattedDate}\n                </time>\n              </div>\n              <!-- <div class=\"flex items-center gap-2\">\n                <span class=\"h-4 w-0.5 bg-gray-300 shrink-0\"></span>\n                <p class=\"text-muted-foreground text-sm sm:text-base\">{readTime} minute read</p>\n              </div> -->\n            </div>\n            <!-- <div class=\"flex items-center gap-2\">\n              {entry.data.authors[0].image ? (\n                <Image src={entry.data.authors[0].image} alt={entry.data.authors[0].name} class=\"size-7 rounded-full\" inferSize />\n              ) : (\n                <span class=\"h-4 w-0.5 bg-gray-300 shrink-0\"></span>\n              )}\n              <p class=\"text-muted-foreground text-sm sm:text-base\">{entry.data.authors[0].name}</p>\n            </div> -->\n          </div>\n        </section>\n        <Prose>\n          <div set:html={entry.data.content}/>\n        </Prose>\n      </Container>\n    </div>\n  </section>\n  <section class=\"border-b border-dashed\">\n    <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n      <Container class=\"py-12 md:py-20\">\n        <div class=\"flex flex-col items-center space-y-6\">\n          <div class=\"space-y-4 text-center\">\n            <h2\n              class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n            >\n              Try Marble today.\n            </h2>\n            <p\n              class=\"mx-auto max-w-2xl text-muted-foreground md:text-lg text-balance\"\n            >\n              A simpler way to publish articles and manage your blog.\n            </p>\n          </div>\n\n          <div\n            class=\"flex flex-col sm:flex-row w-full items-center justify-center gap-4\"\n          >\n            <ButtonComponent\n              href={REGISTER_URL}\n              target=\"_blank\"\n              class=\"w-full sm:w-auto\"\n              data-track={TRACKING_EVENTS.signupClicked}\n              data-context=\"changelog_cta\"\n              data-label=\"Try Marble for free\"\n            >\n              Try Marble for free\n            </ButtonComponent>\n            <ButtonComponent\n              href={YOUTUBE_WATCH_URL}\n              variant=\"secondary\"\n              class=\"w-full sm:w-auto\"\n            >\n              Watch Demo\n            </ButtonComponent>\n          </div>\n        </div>\n      </Container>\n    </div>\n  </section>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/changelog/index.astro",
    "content": "---\n  import { getCollection } from \"astro:content\";\n  import ChangelogCard from \"@/components/ChangelogCard.astro\";\n  import Container from \"@/components/Container.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n\n  const unsortedEntries = await getCollection(\"changelog\");\n  const entries = unsortedEntries.sort(\n    (a, b) =>\n      new Date(b.data.publishedAt).valueOf() -\n      new Date(a.data.publishedAt).valueOf()\n  );\n---\n<Layout\n  title={`Changelog - ${SITE.TITLE}`}\n  description=\"Follow Marble product updates, feature releases, and improvements.\"\n>\n  <section class=\"lg:border-y border-dashed min-h-[calc(100vh-140px)]\">\n    <div\n      class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto h-full\"\n    >\n      <Container class=\"py-20 lg:py-24 h-full\">\n        <div class=\"mb-16 space-y-4 max-w-2xl\">\n          <h1\n            class=\"text-2xl tracking-tight md:text-3xl lg:text-4xl font-medium\"\n          >\n            Changelog\n          </h1>\n          <p class=\"max-w-prose md:text-lg text-muted-foreground\">\n            Product improvements and updates.\n          </p>\n        </div>\n        <ul class=\"grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8 auto-rows-fr\">\n          {entries.map((entry) => (\n              <ChangelogCard entry={entry} />\n            ))}\n        </ul>\n      </Container>\n    </div>\n  </section>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/contributors/index.astro",
    "content": "---\n  export const prerender = false;\n\n  import { YOUTUBE_VIDEO_ID } from \"@marble/utils\";\n  import Container from \"@/components/Container.astro\";\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { REGISTER_URL, TRACKING_EVENTS } from \"@/lib/constants/tracking\";\n  import { cn } from \"@/lib/utils\";\n\n  const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`;\n\n  interface GitHubUser {\n    login: string;\n    id: number;\n    avatar_url: string;\n    html_url: string;\n    contributions: number;\n    type: string;\n  }\n\n  interface GitHubIssue {\n    id: number;\n    number: number;\n    title: string;\n    html_url: string;\n    state: string;\n    user: {\n      login: string;\n      avatar_url: string;\n      html_url: string;\n    };\n    created_at: string;\n    labels: Array<{\n      name: string;\n      color: string;\n    }>;\n    pull_request?: {\n      url: string;\n    };\n  }\n\n  interface GitHubPR {\n    id: number;\n    number: number;\n    title: string;\n    html_url: string;\n    state: string;\n    user: {\n      login: string;\n      avatar_url: string;\n      html_url: string;\n    };\n    created_at: string;\n    draft: boolean;\n  }\n\n  interface GitHubRepo {\n    name: string;\n    full_name: string;\n    html_url: string;\n    stargazers_count: number;\n    forks_count: number;\n    open_issues_count: number;\n    language: string;\n    description: string;\n    private: boolean;\n  }\n\n  async function fetchGitHubData() {\n    const baseUrl = \"https://api.github.com\";\n    const org = \"usemarble\";\n    const headers: Record<string, string> = {\n      Accept: \"application/vnd.github+json\",\n    };\n\n    try {\n      const [repoResponse, contributorsResponse, issuesResponse, prsResponse] =\n        await Promise.all([\n          fetch(`${baseUrl}/repos/${org}/marble`, { headers }),\n          fetch(`${baseUrl}/repos/${org}/marble/contributors?per_page=100`, {\n            headers,\n          }),\n          fetch(\n            `${baseUrl}/repos/${org}/marble/issues?state=open&per_page=20&sort=created`,\n            { headers }\n          ),\n          fetch(\n            `${baseUrl}/repos/${org}/marble/pulls?state=open&per_page=5&sort=created`,\n            { headers }\n          ),\n        ]);\n\n      const repo: GitHubRepo | null = repoResponse.ok\n        ? await repoResponse.json()\n        : null;\n      const contributors: GitHubUser[] = contributorsResponse.ok\n        ? (await contributorsResponse.json()).filter(\n            (c: GitHubUser) => c.login !== \"turbobot-temp\"\n          )\n        : [];\n      const allIssues: GitHubIssue[] = issuesResponse.ok\n        ? await issuesResponse.json()\n        : [];\n      const issues: GitHubIssue[] = allIssues\n        .filter((issue) => !issue.pull_request)\n        .slice(0, 5);\n      const prs: GitHubPR[] = prsResponse.ok\n        ? (await prsResponse.json()).slice(0, 5)\n        : [];\n\n      const totalStars = repo?.stargazers_count || 0;\n      const totalForks = repo?.forks_count || 0;\n      const totalIssues = repo?.open_issues_count || 0;\n\n      return {\n        repo,\n        contributors: contributors\n          .filter((c) => c.type === \"User\")\n          .slice(0, 24),\n        issues,\n        prs,\n        stats: {\n          totalStars,\n          totalForks,\n          totalIssues,\n          totalContributors: contributors.filter((c) => c.type === \"User\")\n            .length,\n        },\n      };\n    } catch (error) {\n      console.error(\"Error fetching GitHub data:\", error);\n      return {\n        repo: null,\n        contributors: [],\n        issues: [],\n        prs: [],\n        stats: {\n          totalStars: 0,\n          totalForks: 0,\n          totalIssues: 0,\n          totalContributors: 0,\n        },\n      };\n    }\n  }\n\n  const githubData = await fetchGitHubData();\n\n  function formatDate(dateString: string) {\n    return new Date(dateString).toLocaleDateString(\"en-US\", {\n      month: \"short\",\n      day: \"numeric\",\n      year: \"numeric\",\n      timeZone: \"UTC\",\n    });\n  }\n\n  function getIssueTypeFromLabels(\n    labels: Array<{ name: string; color: string }>\n  ) {\n    const bugLabel = labels.find((label) =>\n      label.name.toLowerCase().includes(\"bug\")\n    );\n    const featureLabel = labels.find(\n      (label) =>\n        label.name.toLowerCase().includes(\"feature\") ||\n        label.name.toLowerCase().includes(\"enhancement\")\n    );\n    const docLabel = labels.find((label) =>\n      label.name.toLowerCase().includes(\"doc\")\n    );\n\n    if (bugLabel) {\n      return { type: \"Bug\", color: \"bg-red-100 text-red-800\" };\n    }\n    if (featureLabel) {\n      return { type: \"Feature\", color: \"bg-blue-100 text-blue-800\" };\n    }\n    if (docLabel) {\n      return { type: \"Docs\", color: \"bg-green-100 text-green-800\" };\n    }\n    return { type: \"Issue\", color: \"bg-yellow-100 text-yellow-800\" };\n  }\n\n  Astro.response.headers.set(\n    \"Cache-Control\",\n    \"public, s-maxage=3600, stale-while-revalidate=60\"\n  );\n---\n<Layout\n  title={`Contributors & Community - ${SITE.TITLE}`}\n  description=\"Meet the amazing developers and contributors who help build Marble. Explore our open source projects, contribute to issues, and join our growing community.\"\n>\n  <div class=\"divide-y divide-dashed\">\n    <section class=\"border-t border-dashed\">\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"space-y-12 lg:space-y-20 pt-12 pb-20 lg:py-24\">\n          <div\n            class=\"relative flex flex-col items-center space-y-4 text-center\"\n          >\n            <h1\n              class=\"relative text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-[25ch] mx-auto\"\n            >\n              Contributors & Community\n            </h1>\n            <p class=\"mx-auto max-w-[60ch] md:text-lg text-muted-foreground\">\n              Meet the amazing developers and contributors who help build\n              Marble.\n            </p>\n          </div>\n\n          <div class=\"grid gap-6 grid-cols-2 md:grid-cols-4 text-center\">\n            <div class=\"space-y-2\">\n              <div class=\"text-3xl md:text-4xl font-bold text-primary\">\n                {githubData.stats.totalContributors}\n              </div>\n              <div class=\"text-sm text-muted-foreground\">Contributors</div>\n            </div>\n            <div class=\"space-y-2\">\n              <div class=\"text-3xl md:text-4xl font-bold text-primary\">\n                {githubData.stats.totalStars}\n              </div>\n              <div class=\"text-sm text-muted-foreground\">Stars</div>\n            </div>\n            <div class=\"space-y-2\">\n              <div class=\"text-3xl md:text-4xl font-bold text-primary\">\n                {githubData.stats.totalForks}\n              </div>\n              <div class=\"text-sm text-muted-foreground\">Forks</div>\n            </div>\n            <div class=\"space-y-2\">\n              <div class=\"text-3xl md:text-4xl font-bold text-primary\">\n                {githubData.stats.totalIssues}\n              </div>\n              <div class=\"text-sm text-muted-foreground\">Open Issues</div>\n            </div>\n          </div>\n\n          <div class=\"space-y-8\">\n            <div class=\"text-center\">\n              <h2 class=\"text-2xl md:text-3xl mb-4\">\n                Our Contributors<span class=\"text-accent\">.</span>\n              </h2>\n              <p class=\"text-muted-foreground max-w-[50ch] mx-auto\">\n                Thank you to all the amazing people who have contributed to\n                Marble!\n              </p>\n            </div>\n\n            <div\n              class=\"grid gap-4 grid-cols-3 md:grid-cols-6 lg:grid-cols-8 max-w-4xl mx-auto\"\n            >\n              {githubData.contributors.map((contributor) => (\n                <a\n                  href={contributor.html_url}\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  class=\"group flex flex-col items-center space-y-2 p-3 rounded-lg hover:bg-gray-50 transition-colors\"\n                  title={`${contributor.login} - ${contributor.contributions} contributions`}\n                >\n                  <img\n                    src={contributor.avatar_url}\n                    alt={`Avatar of ${contributor.login}`}\n                    class=\"w-12 h-12 rounded-full group-hover:scale-110 transition-transform duration-200\"\n                    loading=\"lazy\"\n                  />\n                  <span class=\"text-xs text-center text-muted-foreground group-hover:text-foreground transition-colors truncate w-full\">\n                    {contributor.login}\n                  </span>\n                </a>\n              ))}\n            </div>\n          </div>\n\n          <div class=\"grid gap-8 lg:gap-12 grid-cols-1 lg:grid-cols-2\">\n            <div class=\"space-y-6\">\n              <div class=\"flex items-center justify-between\">\n                <h2 class=\"text-xl md:text-2xl\">Open Issues</h2>\n                <a\n                  href=\"https://github.com/usemarble/marble/issues\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  class=\"text-sm font-medium text-accent flex items-center gap-1 group\"\n                >\n                  View all\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    viewBox=\"0 0 20 20\"\n                    fill=\"currentColor\"\n                    class=\"w-4 h-4 transition-transform group-hover:translate-x-1\"\n                  >\n                    <path\n                      fill-rule=\"evenodd\"\n                      d=\"M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z\"\n                      clip-rule=\"evenodd\"\n                    />\n                  </svg>\n                </a>\n              </div>\n\n              <div class=\"space-y-3\">\n                {githubData.issues.length === 0 ? (\n                  <div class=\"text-center py-8 text-muted-foreground\">\n                    No open issues at the moment\n                  </div>\n                ) : (\n                  githubData.issues.map((issue) => {\n                    const issueType = getIssueTypeFromLabels(issue.labels);\n                    return (\n                      <div class=\"bg-white rounded-xl p-4 border border-dashed hover:shadow-sm transition-all duration-300\">\n                        <div class=\"flex items-start gap-3\">\n                          <img\n                            src={issue.user.avatar_url}\n                            alt={`Avatar of ${issue.user.login}`}\n                            class=\"w-6 h-6 rounded-full flex-shrink-0 mt-0.5\"\n                            loading=\"lazy\"\n                          />\n                          <div class=\"flex-1 min-w-0\">\n                            <div class=\"flex items-center gap-2 mb-2\">\n                              <span class={cn(\"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium\", issueType.color)}>\n                                {issueType.type}\n                              </span>\n                              <span class=\"text-xs text-muted-foreground\">\n                                #{issue.number}\n                              </span>\n                            </div>\n                            <a\n                              href={issue.html_url}\n                              target=\"_blank\"\n                              rel=\"noopener noreferrer\"\n                              class=\"font-medium text-sm hover:text-primary transition-colors line-clamp-2\"\n                            >\n                              {issue.title}\n                            </a>\n                            <p class=\"text-xs text-muted-foreground mt-1\">\n                              by {issue.user.login} • {formatDate(issue.created_at)}\n                            </p>\n                          </div>\n                        </div>\n                      </div>\n                    );\n                  })\n                )}\n              </div>\n            </div>\n\n            <div class=\"space-y-6\">\n              <div class=\"flex items-center justify-between\">\n                <h2 class=\"text-xl md:text-2xl\">Open Pull Requests</h2>\n                <a\n                  href=\"https://github.com/usemarble/marble/pulls\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  class=\"text-sm font-medium text-accent flex items-center gap-1 group\"\n                >\n                  View all\n                  <svg\n                    xmlns=\"http://www.w3.org/2000/svg\"\n                    viewBox=\"0 0 20 20\"\n                    fill=\"currentColor\"\n                    class=\"w-4 h-4 transition-transform group-hover:translate-x-1\"\n                  >\n                    <path\n                      fill-rule=\"evenodd\"\n                      d=\"M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z\"\n                      clip-rule=\"evenodd\"\n                    />\n                  </svg>\n                </a>\n              </div>\n\n              <div class=\"space-y-3\">\n                {githubData.prs.length === 0 ? (\n                  <div class=\"text-center py-8 text-muted-foreground\">\n                    No open pull requests at the moment\n                  </div>\n                ) : (\n                  githubData.prs.map((pr) => (\n                    <div class=\"bg-white rounded-xl p-4 border border-dashed hover:shadow-sm transition-all duration-300\">\n                      <div class=\"flex items-start gap-3\">\n                        <img\n                          src={pr.user.avatar_url}\n                          alt={`Avatar of ${pr.user.login}`}\n                          class=\"w-6 h-6 rounded-full flex-shrink-0 mt-0.5\"\n                          loading=\"lazy\"\n                        />\n                        <div class=\"flex-1 min-w-0\">\n                          <div class=\"flex items-center gap-2 mb-2\">\n                            <span class={cn(\"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium\", pr.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800')}>\n                              {pr.draft ? 'Draft' : 'Ready'}\n                            </span>\n                            <span class=\"text-xs text-muted-foreground\">\n                              #{pr.number}\n                            </span>\n                          </div>\n                          <a\n                            href={pr.html_url}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            class=\"font-medium text-sm hover:text-primary transition-colors line-clamp-2\"\n                          >\n                            {pr.title}\n                          </a>\n                          <p class=\"text-xs text-muted-foreground mt-1\">\n                            by {pr.user.login} • {formatDate(pr.created_at)}\n                          </p>\n                        </div>\n                      </div>\n                    </div>\n                  ))\n                )}\n              </div>\n            </div>\n          </div>\n        </Container>\n      </div>\n    </section>\n\n    <!-- CTA -->\n    <section class=\"border-b border-dashed\">\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"py-12 md:py-20\">\n          <div class=\"flex flex-col items-center space-y-6\">\n            <div class=\"space-y-4 text-center\">\n              <h2\n                class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n              >\n                Get started today.\n              </h2>\n              <p\n                class=\"mx-auto max-w-2xl text-muted-foreground md:text-lg text-balance\"\n              >\n                Create and manage content, so you can focus on what matters\n                most.\n              </p>\n            </div>\n\n            <div\n              class=\"flex flex-col sm:flex-row w-full items-center justify-center gap-4\"\n            >\n              <ButtonComponent\n                href={REGISTER_URL}\n                target=\"_blank\"\n                class=\"w-full sm:w-auto\"\n                data-track={TRACKING_EVENTS.signupClicked}\n                data-context=\"contributors_cta\"\n                data-label=\"Try Marble for free\"\n              >\n                Try Marble for free\n              </ButtonComponent>\n              <ButtonComponent\n                href={YOUTUBE_WATCH_URL}\n                variant=\"secondary\"\n                class=\"w-full sm:w-auto\"\n              >\n                Watch Demo\n              </ButtonComponent>\n            </div>\n          </div>\n        </Container>\n      </div>\n    </section>\n  </div>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/index.astro",
    "content": "---\n  import { Image } from \"astro:assets\";\n  import { YOUTUBE_VIDEO_ID } from \"@marble/utils\";\n  import heroImage from \"@/assets/images/hero.png\";\n  import Container from \"@/components/Container.astro\";\n  import Pricing from \"@/components/sections/Pricing.astro\";\n  import AccordionItem from \"@/components/ui/AccordionItem.astro\";\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { FAQs } from \"@/lib/constants/faqs\";\n  import { FEATURES, REVIEWS, USERS } from \"@/lib/constants/landing\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { REGISTER_URL, TRACKING_EVENTS } from \"@/lib/constants/tracking\";\n  import { buildFaqJsonLd } from \"@/lib/seo\";\n  import { HERO } from \"@/lib/site\";\n  import { cn } from \"@/lib/utils\";\n\n  const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`;\n  const structuredData = buildFaqJsonLd(FAQs);\n---\n<Layout\n  title={`${SITE.TITLE} - Super Simple Headless CMS`}\n  description={SITE.DESCRIPTION}\n  structuredData={structuredData}\n>\n  <div class=\"divide-y divide-dashed\">\n    <!-- Hero section -->\n    <section class=\"border-t border-dashed\">\n      <div\n        class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] flex flex-col justify-center h-full mx-auto\"\n      >\n        <Container\n          class=\"py-24 md:py-28 w-full lg:py-36 flex flex-col gap-14 md:gap-28\"\n        >\n          <div class=\"flex flex-col gap-8\">\n            <div class=\"relative mx-auto text-center flex flex-col gap-6\">\n              <h1\n                class=\"relative text-4xl md:text-5xl max-w-[25ch] font-medium mx-auto tracking-tight\"\n              >\n                {HERO.title}\n              </h1>\n              <p class=\"mx-auto max-w-[50ch] md:text-lg text-muted-foreground\">\n                {HERO.subtitle}\n              </p>\n            </div>\n            <div\n              class=\"flex flex-col sm:flex-row w-full items-center justify-center gap-4\"\n            >\n              <ButtonComponent\n                href={REGISTER_URL}\n                target=\"_blank\"\n                class=\"w-full sm:w-auto\"\n                data-track={TRACKING_EVENTS.signupClicked}\n                data-context=\"home_hero\"\n                data-label=\"Publish your first post\"\n              >\n                Publish your first post\n              </ButtonComponent>\n              <ButtonComponent\n                href={YOUTUBE_WATCH_URL}\n                target=\"_blank\"\n                variant=\"secondary\"\n                class=\"w-full sm:w-auto\"\n              >\n                Watch Demo\n              </ButtonComponent>\n            </div>\n          </div>\n          <div class=\"w-full overflow-hidden rounded-[8px] aspect-video\">\n            <!-- <iframe\n                src={YOUTUBE_EMBED_URL}\n                title=\"Marble CMS - Super Simple Headless CMS\"\n                allow=\"accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n                allowfullscreen\n                class=\"w-full h-full\"\n              /> -->\n            <Image\n              src={heroImage}\n              inferSize\n              alt=\"marble dashboard showing a chart and list of media files\"\n              loading=\"eager\"\n            />\n          </div>\n        </Container>\n      </div>\n    </section>\n\n    <!--  Social Proof -->\n    <section>\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"py-12\">\n          <div class=\"sr-only\">\n            <h2\n              class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n            >\n              Trusted by\n            </h2>\n          </div>\n          <ul\n            class=\"grid gap-4\"\n            style=\"grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr));\"\n          >\n            {USERS.map((user) => (\n              <li class=\"h-24 rounded-xl bg-white border border-transparent border-dashed hover:border-border transition-colors\">\n                <a\n                  href={user.url + \"?utm_source=marble\"}\n                  target=\"_blank\"\n                  rel=\"noopener\"\n                  class={cn(\n                    \"flex items-center justify-center h-full w-full rounded-xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\",\n                    user.showWordmark && \"gap-2\"\n                  )}\n                >\n                  <user.component class=\"h-8 w-auto\" />\n                  {user.showWordmark && (\n                    <span class=\"text-base font-medium\">{user.name}</span>\n                  )}\n                  {!user.showWordmark && (\n                    <span class=\"text-base font-medium sr-only\">{user.name}</span>\n                  )}\n                </a>\n              </li>\n            ))}\n          </ul>\n        </Container>\n      </div>\n    </section>\n\n    <!-- Features section -->\n    <section>\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"space-y-10 lg:space-y-14 py-20\">\n          <div\n            class=\"relative flex flex-col items-center space-y-3 text-balance\"\n          >\n            <h2\n              class=\"relative text-center text-balance text-acc text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n            >\n              Everything you need to publish.\n            </h2>\n            <!-- <p class=\"max-w-prose md:text-lg text-muted-foreground text-center\">\n              Everything you need to manage your content.\n            </p> -->\n          </div>\n\n          <ul class=\"grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6\">\n            {FEATURES.map((feature, index) => (\n              <li class=\"bg-white p-3 rounded-[16px] flex flex-col gap-4 h-full\">\n                <div class=\"overflow-hidden w-full rounded-lg bg-background\">\n                  <Image\n                    src={feature.image}\n                    alt={feature.title}\n                    inferSize\n                    class=\"w-full h-auto\"\n                  />\n                </div>\n                <div class=\"flex-1 text-left\">\n                  <h3 class=\"text-xl mb-2 font-medium\">\n                    {feature.title}\n                  </h3>\n                  <p class=\"text-muted-foreground text-sm text-balance\">\n                    {feature.description}\n                  </p>\n                  {feature.link && (\n                    <a href={feature.link.href} target=\"_blank\" class=\"group text-accent text-sm hover:text-accent/90 hover:underline w-fit mt-4 flex items-center gap-1\">\n                      {feature.link.text}\n                      <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\" fill=\"currentColor\" class=\"w-4 h-4 transition-transform group-hover:translate-x-0.5\">\n                        <path fill-rule=\"evenodd\" d=\"M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z\" clip-rule=\"evenodd\" />\n                      </svg>\n                    </a>\n                  )}\n                </div>\n              </li>\n            ))}\n          </ul>\n        </Container>\n      </div>\n    </section>\n\n    <!-- Reviews section -->\n    <section>\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"space-y-10 lg:space-y-14 pt-12 pb-20\">\n          <div\n            class=\"relative flex flex-col items-center space-y-3 text-balance\"\n          >\n            <h2\n              class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n            >\n              What people are saying\n            </h2>\n          </div>\n\n          <ul class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4\">\n            {REVIEWS.map((review) => (\n              <li>\n                <a href={review.link} target=\"_blank\" class=\"bg-white p-6 rounded-xl flex flex-col justify-between gap-8 h-full\">\n                  <p class=\"text-muted-foreground leading-relaxed\">\n                    {review.text}\n                  </p>\n                  <div class=\"flex items-center gap-3\">\n                    <div class=\"size-10 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-500\">\n                      <Image\n                        src={review.avatar}\n                        alt={review.author}\n                        class=\"w-full h-auto rounded-full\"\n                        width={40}\n                        height={40}\n                      />\n                    </div>\n                    <div class=\"space-y-0.5\">\n                      <p class=\"font-medium text-sm text-foreground\">\n                        {review.author}\n                      </p>\n                      <p class=\"text-muted-foreground text-xs\">\n                        {review.role}\n                      </p>\n                    </div>\n                  </div>\n                </a>\n              </li>\n            ))}\n          </ul>\n        </Container>\n      </div>\n    </section>\n\n    <!-- Pricing section -->\n    <Pricing/>\n\n    <!-- FAQ -->\n    <section>\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"pt-12 pb-20\">\n          <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-10 lg:gap-20\">\n            <div\n              class=\"flex flex-col items-center lg:items-start space-y-4 text-center lg:text-left\"\n            >\n              <h2\n                class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl lg:text-left  md max-w-4xl\"\n              >\n                Frequently asked Questions\n              </h2>\n              <p\n                class=\"max-w-prose md:text-lg text-muted-foreground text-balance\"\n              >\n                Answers to common questions about marblecms.\n              </p>\n            </div>\n\n            <div class=\"lg:col-span-2 divide-y divide-dashed\">\n              {FAQs.map(faq => (\n              <AccordionItem name=\"details\" title={faq.question}>\n                <p class=\"text-sm faq-answer text-muted-foreground pb-2\" set:html={faq.answer} />\n              </AccordionItem>\n            ))}\n            </div>\n          </div>\n        </Container>\n      </div>\n    </section>\n\n    <!-- CTA -->\n    <section class=\"border-b border-dashed\">\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"py-12 md:py-20\">\n          <div class=\"flex flex-col items-center space-y-6\">\n            <div class=\"space-y-4 text-center\">\n              <h2\n                class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n              >\n                Try Marble today.\n              </h2>\n              <p\n                class=\"mx-auto max-w-2xl text-muted-foreground md:text-lg text-balance\"\n              >\n                A simpler way to publish articles and manage your blog.\n              </p>\n            </div>\n\n            <div\n              class=\"flex flex-col sm:flex-row w-full items-center justify-center gap-4\"\n            >\n              <ButtonComponent\n                href={REGISTER_URL}\n                target=\"_blank\"\n                class=\"w-full sm:w-auto\"\n                data-track={TRACKING_EVENTS.signupClicked}\n                data-context=\"home_bottom_cta\"\n                data-label=\"Try Marble for free\"\n              >\n                Try Marble for free\n              </ButtonComponent>\n              <ButtonComponent\n                href={YOUTUBE_WATCH_URL}\n                variant=\"secondary\"\n                class=\"w-full sm:w-auto\"\n              >\n                Watch Demo\n              </ButtonComponent>\n            </div>\n          </div>\n        </Container>\n      </div>\n    </section>\n  </div>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/pricing/index.astro",
    "content": "---\n  import { PRICING_PLANS } from \"@marble/utils\";\n  import Container from \"@/components/Container.astro\";\n  import AccordionItem from \"@/components/ui/AccordionItem.astro\";\n  import Button from \"@/components/ui/Button.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { PRICING_FAQS } from \"@/lib/constants/faqs\";\n  import { SITE } from \"@/lib/constants/site\";\n  import { buildFaqJsonLd } from \"@/lib/seo\";\n  import { cn } from \"@/lib/utils\";\n\n  const structuredData = buildFaqJsonLd(PRICING_FAQS);\n---\n<Layout\n  title={`Pricing - ${SITE.TITLE}`}\n  description=\"Pricing plans for marble.\"\n  structuredData={structuredData}\n>\n  <div class=\"divide-y divide-dashed\">\n    <section class=\"border-t border-dashed\">\n      <div class=\"md:w-[calc(100%-140px)] mx-auto lg:border-x border-dashed\">\n        <Container class=\"space-y-10 lg:space-y-16 pt-12 pb-20 lg:py-24 \">\n          <div class=\"relative flex flex-col items-center space-y-4\">\n            <h1\n              class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n            >\n              Pricing\n            </h1>\n            <p class=\"max-w-prose md:text-lg text-muted-foreground text-center\">\n              Simple pricing, cancel anytime.\n            </p>\n          </div>\n\n          <!-- Billing Toggle -->\n          <div class=\"flex items-center justify-center gap-3\">\n            <span class=\"text-sm text-muted-foreground\">Monthly</span>\n            <button\n              id=\"billing-toggle\"\n              type=\"button\"\n              role=\"switch\"\n              aria-checked=\"true\"\n              class=\"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-accent transition-colors duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n            >\n              <span\n                id=\"toggle-thumb\"\n                class=\"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out translate-x-5\"\n              ></span>\n            </button>\n            <span class=\"text-sm text-muted-foreground\">Yearly</span>\n          </div>\n\n          <ul\n            class=\"flex flex-col items-center md:items-stretch justify-center gap-5 md:flex-row\"\n          >\n            {PRICING_PLANS.map((plan, index) => (\n                  <li class={cn(\"min-w-[300px] w-full max-w-[350px] rounded-xl\", index === 2 && 'sm:col-span-2 lg:col-span-1 sm:h-fit')}>\n                    <div class={cn(\"flex flex-col gap-5 rounded-[8px] bg-white w-full px-4 py-6\", index === 2 ? 'sm:flex-row sm:px-14 sm:py-10 lg:py-6 sm:justify-center sm:gap-20 lg:gap-4 lg:px-4 lg:flex-col min-h-96 sm:min-h-fit lg:min-h-96' : 'min-h-96 h-full')}>\n                      {index === 2 ? (\n                        <>\n                          <div class=\"flex flex-col gap-5 sm:min-w-60 lg:min-w-0\">\n                            <div class=\"flex flex-col gap-4\">\n                              <div class=\"space-y-1\">\n                                <h3 class=\"text-medium text-xl\">{plan.title}</h3>\n                                <p class=\"text-muted-foreground text-sm\">{plan.description}</p>\n                              </div>\n                              <div>\n                                <p>\n                                  <span class=\"font-bold text-2xl\" data-plan-id={plan.id} data-monthly={plan.price.monthly} data-yearly={plan.price.yearly}>\n                                    {plan.price.yearly}\n                                  </span>{' '}\n                                  <span class=\"text-muted-foreground\" data-plan-period={plan.id} data-monthly=\"/ month\" data-yearly=\"/ year\">\n                                    / year\n                                  </span>\n                                </p>\n                              </div>\n                            </div>\n                            <div class=\"border-y border-dashed py-4\">\n                              <Button \n                                href={plan.button.href}\n                                target=\"_blank\"\n                                class=\"w-full\"\n                              >\n                                {plan.button.label}\n                              </Button>\n                            </div>\n                          </div>\n                          <ul class=\"flex flex-col gap-2 text-sm\">\n                            {plan.features.map((feature) => (\n                              <li class=\"flex items-center gap-2\">\n                                <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" class=\"size-5 text-emerald-500\">\n                                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\"></path>\n                                </svg>\n                                <span>{feature}</span>\n                              </li>\n                            ))}\n                          </ul>\n                        </>\n                      ) : (\n                        <>\n                          <div class=\"flex flex-col gap-4\">\n                            <div class=\"space-y-1\">\n                              <h4 class=\"text-medium text-2xl\">{plan.title}</h4>\n                              <p class=\"text-muted-foreground text-sm\">{plan.description}</p>\n                            </div>\n                            <div>\n                              <p>\n                                <span class=\"font-bold text-2xl\" data-plan-id={plan.id} data-monthly={plan.price.monthly} data-yearly={plan.price.yearly}>\n                                  {plan.price.yearly}\n                                </span>{' '}\n                                <span class=\"text-muted-foreground\" data-plan-period={plan.id} data-monthly=\"/ month\" data-yearly=\"/ year\">\n                                  / year\n                                </span>\n                              </p>\n                            </div>\n                          </div>\n                          <div class=\"border-y border-dashed py-4\">\n                           <Button \n                              href={plan.button.href}\n                              target=\"_blank\"\n                              class=\"w-full\"\n                              size=\"sm\"\n                            >\n                              {plan.button.label}\n                            </Button>\n                          </div>\n                          <ul class=\"flex flex-col gap-2 text-sm\">\n                            {plan.features.map((feature) => (\n                              <li class=\"flex items-center gap-2\">\n                                <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" class=\"size-5 text-emerald-500\">\n                                  <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M5 13l4 4L19 7\"></path>\n                                </svg>\n                                <span>{feature}</span>\n                              </li>\n                            ))}\n                          </ul>\n                        </>\n                      )}\n                    </div>\n                  </li>\n                ))}\n          </ul>\n        </Container>\n      </div>\n    </section>\n\n    <section>\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"pt-12 pb-20\">\n          <div class=\"grid grid-cols-1 lg:grid-cols-3 gap-10 lg:gap-20\">\n            <div\n              class=\"flex flex-col items-center lg:items-start space-y-4 text-center lg:text-left\"\n            >\n              <h2\n                class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl lg:text-left max-w-4xl\"\n              >\n                Frequently asked Questions\n              </h2>\n              <p\n                class=\"max-w-prose md:text-lg text-muted-foreground text-balance\"\n              >\n                Answers to common questions about pricing and billing.\n              </p>\n            </div>\n\n            <div class=\"lg:col-span-2 divide-y divide-dashed\">\n              {PRICING_FAQS.map(faq => (\n              <AccordionItem name=\"details\" title={faq.question}>\n                <p class=\"text-sm faq-answer text-muted-foreground pb-2\" set:html={faq.answer} />\n              </AccordionItem>\n            ))}\n            </div>\n          </div>\n        </Container>\n      </div>\n    </section>\n  </div>\n</Layout>\n\n<script>\n  function initBillingToggle() {\n    const toggle = document.getElementById(\"billing-toggle\");\n    const thumb = document.getElementById(\"toggle-thumb\");\n    const priceElements = document.querySelectorAll(\"[data-plan-id]\");\n    const periodElements = document.querySelectorAll(\"[data-plan-period]\");\n\n    let isYearly = true;\n\n    toggle?.addEventListener(\"click\", () => {\n      isYearly = !isYearly;\n\n      // Update toggle appearance\n      toggle.setAttribute(\"aria-checked\", String(isYearly));\n      if (isYearly) {\n        thumb?.classList.add(\"translate-x-5\");\n        thumb?.classList.remove(\"translate-x-0\");\n        toggle.classList.add(\"bg-accent\");\n        toggle.classList.remove(\"bg-muted\");\n      } else {\n        thumb?.classList.remove(\"translate-x-5\");\n        thumb?.classList.add(\"translate-x-0\");\n        toggle.classList.remove(\"bg-accent\");\n        toggle.classList.add(\"bg-muted\");\n      }\n\n      // Update prices\n      for (const el of priceElements) {\n        const monthly = el.getAttribute(\"data-monthly\");\n        const yearly = el.getAttribute(\"data-yearly\");\n        el.textContent = isYearly ? yearly : monthly;\n      }\n\n      for (const el of periodElements) {\n        const monthly = el.getAttribute(\"data-monthly\");\n        const yearly = el.getAttribute(\"data-yearly\");\n        el.textContent = isYearly ? yearly : monthly;\n      }\n    });\n  }\n\n  // Initialize on first load\n  initBillingToggle();\n\n  // Re-initialize after page transitions\n  document.addEventListener(\"astro:after-swap\", initBillingToggle);\n</script>\n"
  },
  {
    "path": "apps/web/src/pages/privacy/index.astro",
    "content": "---\n  import { getEntry } from \"astro:content\";\n  import Container from \"@/components/Container.astro\";\n  import Prose from \"@/components/Prose.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n\n  const entry = await getEntry(\"page\", \"privacy\");\n\n  if (!entry) {\n    throw new Error(\"Page not found\");\n  }\n\n  const formattedDate = new Date(entry.data.updatedAt).toLocaleDateString(\n    \"en-US\",\n    {\n      year: \"numeric\",\n      month: \"long\",\n      day: \"numeric\",\n      timeZone: \"UTC\",\n    }\n  );\n---\n<Layout\n  title={`Privacy policy - ${SITE.TITLE}`}\n  description=\"Privacy policy for marble.\"\n>\n  <div class=\"border-y border-dashed\">\n    <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n      <Container class=\"pt-14 pb-32\">\n        <div class=\"max-w-2xl mx-auto mb-10 space-y-2\">\n          <h1 class=\"text-2xl sm:text-3xl md:text-4xl text-center\">\n            Privacy Policy.\n          </h1>\n          <p class=\"text-center text-muted-foreground\">\n            Last updated: {formattedDate}\n          </p>\n        </div>\n\n        <Prose>\n          <div set:html={entry.data.content}/>\n        </Prose>\n      </Container>\n    </div>\n  </div>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/rss.xml.ts",
    "content": "import { getCollection } from \"astro:content\";\nimport rss from \"@astrojs/rss\";\nimport type { APIContext } from \"astro\";\nimport { SITE } from \"@/lib/constants/site\";\n\nexport async function GET(context: APIContext) {\n  const posts = await getCollection(\"posts\");\n  const changelog = await getCollection(\"changelog\");\n\n  const blogItems = posts.map((post) => ({\n    title: post.data.title,\n    description: post.data.description,\n    pubDate: new Date(post.data.publishedAt),\n    link: `/blog/${post.data.slug}`,\n  }));\n\n  const changelogItems = changelog.map((entry) => ({\n    title: entry.data.title,\n    description: entry.data.description,\n    pubDate: new Date(entry.data.publishedAt),\n    link: `/changelog/${entry.data.slug}`,\n  }));\n\n  const allItems = [...blogItems, ...changelogItems].sort(\n    (a, b) => b.pubDate.valueOf() - a.pubDate.valueOf()\n  );\n\n  return rss({\n    title: SITE.TITLE,\n    description: SITE.DESCRIPTION,\n    site: context.site ?? SITE.URL,\n    items: allItems,\n  });\n}\n"
  },
  {
    "path": "apps/web/src/pages/sponsors/index.astro",
    "content": "---\n  import type { SvgComponent } from \"astro/types\";\n  import Container from \"@/components/Container.astro\";\n  import Neon from \"@/components/icons/sponsors/Neon.astro\";\n  import Upstash from \"@/components/icons/sponsors/Upstash.astro\";\n  import Vercel from \"@/components/icons/sponsors/Vercel.astro\";\n  import ButtonComponent from \"@/components/ui/Button.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n\n  interface Sponsor {\n    url: string;\n    name: string;\n    description: string;\n    icon: SvgComponent;\n  }\n\n  const sponsorsData: Sponsor[] = [\n    {\n      name: \"Upstash\",\n      url: \"https://upstash.com?utm_source=marble\",\n      icon: Upstash,\n      description:\n        \"Powers our caching, webhooks and rate limiting infrastructure.\",\n    },\n    {\n      name: \"Neon\",\n      url: \"https://neon.com?utm_source=marble\",\n      icon: Neon,\n      description: \"Powers our entire database, literally.\",\n    },\n    {\n      name: \"Vercel\",\n      url: \"https://vercel.com?utm_source=marble\",\n      icon: Vercel,\n      description: \"Powers our entire deployment and ai infrastructure.\",\n    },\n  ];\n---\n<Layout\n  title={`Our Sponsors & Supporters - ${SITE.TITLE}`}\n  description=\"Meet the amazing companies and individuals that support the development of Marble.\"\n>\n  <div class=\"divide-y divide-dashed\">\n    <section class=\"border-t border-dashed\">\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"space-y-10 lg:space-y-16 pt-12 pb-20 lg:py-24\">\n          <div\n            class=\"relative flex flex-col items-center space-y-6 text-center\"\n          >\n            <h1\n              class=\"relative text-2xl sm:text-3xl md:text-4xl max-w-[25ch] mx-auto\"\n            >\n              Our Sponsors & Supporters\n            </h1>\n            <p class=\"mx-auto max-w-[60ch] md:text-lg text-muted-foreground\">\n              We're grateful to the amazing companies and organizations that\n              support the development of Marble.\n            </p>\n          </div>\n\n          <ul\n            class=\"grid gap-8 md:gap-10 grid-cols-1 md:grid-cols-2 max-w-4xl mx-auto\"\n          >\n            {sponsorsData.map((sponsor) => (\n              <li class=\"bg-white p-8 rounded-xl flex flex-col justify-between gap-8 border border-dashed\">\n                <div class=\"space-y-6 text-center\">\n                  <div class=\"flex items-center justify-center\">\n                    <a \n                      href={sponsor.url}\n                      target=\"_blank\" \n                      rel=\"noopener noreferrer\"\n                      class=\"flex items-center gap-3 group hover:scale-105 transition-transform duration-200\"\n                    >\n                      <sponsor.icon class=\"h-8 w-8\" />\n                      <span class=\"text-2xl font-medium text-gray-900 group-hover:text-primary-background/70 transition-colors\">{sponsor.name}</span>\n                    </a>\n                  </div>\n                  <p class=\"text-muted-foreground text-sm lg:text-base leading-relaxed\">\n                    {sponsor.description}\n                  </p>\n                </div>\n                <div class=\"pt-6 border-t border-dashed text-center\">\n                  <a\n                    href={sponsor.url}\n                    target=\"_blank\" \n                    rel=\"noopener noreferrer\"\n                    class=\"inline-flex items-center text-sm text-primary-background/70 font-medium hover:underline transition-all duration-300 ease-in-out\"\n                  >\n                    Learn more about {sponsor.name}\n                    <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" class=\"w-4 h-4 ml-1\">\n                      <title>External link</title>\n                      <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25\" />\n                    </svg>\n                  </a>\n                </div>\n              </li>\n            ))}\n          </ul>\n        </Container>\n      </div>\n    </section>\n\n    <!-- CTA -->\n    <section class=\"border-b border-dashed\">\n      <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n        <Container class=\"py-12 md:py-20\">\n          <div class=\"flex flex-col items-center space-y-6\">\n            <div class=\"space-y-4 text-center\">\n              <h2\n                class=\"relative text-center text-balance text-2xl tracking-tight md:text-3xl lg:text-4xl max-w-4xl\"\n              >\n                Want to support Marble?\n              </h2>\n              <p\n                class=\"mx-auto max-w-2xl text-muted-foreground md:text-lg text-balance\"\n              >\n                If you would like to support Marble, feel free to reach out.\n              </p>\n            </div>\n\n            <div\n              class=\"flex flex-col sm:flex-row w-full items-center justify-center gap-4\"\n            >\n              <ButtonComponent\n                href=\"https://twitter.com/usemarblecms\"\n                target=\"_blank\"\n                class=\"w-full sm:w-auto\"\n              >\n                Reach out on Twitter\n              </ButtonComponent>\n              <ButtonComponent\n                href={SITE.DISCORD_URL}\n                variant=\"secondary\"\n                class=\"w-full sm:w-auto\"\n              >\n                Join the Discord\n              </ButtonComponent>\n            </div>\n          </div>\n        </Container>\n      </div>\n    </section>\n  </div>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/pages/terms/index.astro",
    "content": "---\n  import { getEntry } from \"astro:content\";\n  import Container from \"@/components/Container.astro\";\n  import Prose from \"@/components/Prose.astro\";\n  import Layout from \"@/layouts/Layout.astro\";\n  import { SITE } from \"@/lib/constants/site\";\n\n  const entry = await getEntry(\"page\", \"terms\");\n\n  if (!entry) {\n    throw new Error(\"Page not found\");\n  }\n\n  const formattedDate = new Date(entry.data.updatedAt).toLocaleDateString(\n    \"en-US\",\n    {\n      year: \"numeric\",\n      month: \"long\",\n      day: \"numeric\",\n      timeZone: \"UTC\",\n    }\n  );\n---\n<Layout\n  title={`Terms of Service - ${SITE.TITLE}`}\n  description=\"Marble's terms of service.\"\n>\n  <div class=\"border-y border-dashed\">\n    <div class=\"lg:border-x border-dashed md:w-[calc(100%-140px)] mx-auto\">\n      <Container class=\"pt-14 pb-32\">\n        <div class=\"max-w-2xl mx-auto mb-10 space-y-2\">\n          <h1 class=\"text-2xl sm:text-3xl md:text-4xl text-center\">\n            Terms of service\n          </h1>\n          <p class=\"text-center text-muted-foreground\">\n            Last updated: {formattedDate}\n          </p>\n        </div>\n\n        <Prose>\n          <div set:html={entry.data.content}/>\n        </Prose>\n      </Container>\n    </div>\n  </div>\n</Layout>\n"
  },
  {
    "path": "apps/web/src/styles/globals.css",
    "content": "@import \"tailwindcss\";\n\n/* Plugins (and related config) */\n@plugin \"@tailwindcss/typography\";\n@config \"../../tailwind.config.ts\";\n\n:root {\n  --background: hsl(60 11% 98%);\n  --foreground: hsl(240 15% 14%);\n  --primary: hsl(240 10% 14%);\n  --primary-foreground: hsl(0 0% 98%);\n  --muted: hsl(0, 0%, 92%);\n  --muted-foreground: hsl(0, 0%, 25%);\n  --accent: hsl(244 100% 65%);\n  --accent-foreground: hsl(0 0% 9%);\n  --border: hsl(0 0% 86%);\n\n  /* radius */\n  --radius: 0.7rem;\n}\n\n@theme inline {\n  --font-sans:\n    var(--font-geist), ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\",\n    \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n  --font-serif:\n    var(--font-literata), ui-serif, Georgia, Cambria, \"Times New Roman\", Times,\n    serif;\n\n  /* border radius */\n  --radius-lg: var(--radius);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-sm: calc(var(--radius) - 4px);\n\n  /* Palette mapped to root design tokens */\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n\n  --color-border: var(--border);\n}\n\n/*\n  The default border color has changed to `currentcolor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentcolor);\n  }\n}\n\n@utility prose {\n  & li p {\n    margin: 0;\n  }\n}\n\n@layer base {\n  @media (prefers-reduced-motion: no-preference) {\n    html {\n      scroll-behavior: smooth;\n    }\n  }\n  html {\n    color: var(--foreground);\n    background: var(--background);\n    font-family: var(--font-geist), system-ui, sans-serif;\n  }\n  * {\n    @apply border-border selection:bg-border;\n  }\n  body {\n    @apply bg-background text-foreground antialiased;\n  }\n  .faq-answer a {\n    @apply text-foreground hover:text-primary underline font-medium;\n  }\n}\n"
  },
  {
    "path": "apps/web/tailwind.config.ts",
    "content": "import type { Config } from \"tailwindcss\";\nimport defaultTheme from \"tailwindcss/defaultTheme\";\n\nexport default {\n  theme: {\n    extend: {\n      fontFamily: {\n        sans: [\"var(--font-geist)\", ...defaultTheme.fontFamily.sans],\n        serif: [\"var(--font-literata)\", ...defaultTheme.fontFamily.serif],\n      },\n      typography: () => ({\n        marble: {\n          css: {\n            \"--tw-prose-bold\": \"var(--foreground)\",\n            \"--tw-prose-counters\": \"var(--foreground)\",\n            \"--tw-prose-bullets\": \"var(--muted-foreground)\",\n            \"--tw-prose-quotes\": \"var(--foreground)\",\n            \"--tw-prose-quote-borders\": \"var(--border)\",\n            \"--tw-prose-captions\": \"var(--muted-foreground)\",\n            \"--tw-prose-code\": \"var(--foreground)\",\n            \"--tw-prose-code-bg\": \"var(--muted)\",\n            \"--tw-prose-pre-code\": \"var(--color-zinc-100)\",\n            \"--tw-prose-pre-bg\": \"var(--color-zinc-800)\",\n            \"--tw-prose-th-borders\": \"var(--border)\",\n            \"--tw-prose-td-borders\": \"var(--border)\",\n            \"code:not(pre code)\": {\n              color: \"var(--tw-prose-code)\",\n              backgroundColor: \"var(--tw-prose-code-bg)\",\n              borderRadius: \"0.375rem\",\n              paddingInline: \"0.275rem\",\n              fontSize: \"0.875rem\",\n              fontWeight: \"600\",\n              display: \"inline-block\",\n            },\n          },\n        },\n        DEFAULT: {\n          css: {\n            a: {\n              \"&:hover\": {\n                color: \"var(--accent)\",\n              },\n            },\n          },\n        },\n      }),\n    },\n  },\n} satisfies Config;\n"
  },
  {
    "path": "apps/web/tsconfig.json",
    "content": "{\n  \"extends\": \"astro/tsconfigs/strict\",\n  \"include\": [\".astro/types.d.ts\", \"**/*\"],\n  \"exclude\": [\"dist\"],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "biome.jsonc",
    "content": "{\n  \"$schema\": \"./node_modules/@biomejs/biome/configuration_schema.json\",\n  \"extends\": [\n    \"ultracite/biome/core\",\n    \"ultracite/biome/react\",\n    \"ultracite/biome/next\",\n    \"ultracite/biome/astro\"\n  ],\n  \"html\": {\n    \"experimentalFullSupportEnabled\": true\n  },\n  \"files\": {\n    \"includes\": [\n      \"!packages/ui/src\",\n      \"packages/editor/src\",\n      \"!apps/cms/src/hooks/use-mobile.tsx\"\n    ]\n  },\n  \"linter\": {\n    \"rules\": {\n      \"suspicious\": {\n        /* Needs more work to fix */\n        \"noConsole\": \"off\",\n\n        /* Needs more work to fix */\n        \"useAwait\": \"off\",\n\n        /* Allowed for Tailwind */\n        \"noUnknownAtRules\": \"off\"\n      },\n      \"style\": {\n        /* Needs more work to fix */\n        \"noMagicNumbers\": \"off\",\n\n        /* Needs more work to fix */\n        \"noNestedTernary\": \"off\",\n\n        /* Doesn't work with Astro */\n        \"useFilenamingConvention\": \"off\"\n      },\n      \"complexity\": {\n        /* Has false positives */\n        \"useSimplifiedLogicExpression\": \"off\",\n\n        /* Needs more work to fix */\n        \"noExcessiveCognitiveComplexity\": \"off\"\n      },\n      \"nursery\": {\n        /* Needs more work to fix */\n        \"noShadow\": \"off\",\n\n        /* Has false positives */\n        \"noUnnecessaryConditions\": \"off\"\n      },\n      \"performance\": {\n        /* Needs more work to fix */\n        \"useTopLevelRegex\": \"off\",\n        \"noNamespaceImport\": \"warn\"\n      },\n      \"correctness\": {\n        /* Doesn't work with Astro */\n        \"noUnusedImports\": \"off\",\n\n        /* Needs more work to fix */\n        \"noUnusedVariables\": \"off\"\n      }\n    }\n  },\n  \"css\": {\n    \"parser\": {\n      \"tailwindDirectives\": true\n    }\n  },\n  \"overrides\": [\n    {\n      \"includes\": [\"apps/mcp/**/*.tsx\"],\n      \"linter\": {\n        \"rules\": {\n          \"style\": {\n            \"noHeadElement\": \"off\"\n          },\n          \"performance\": {\n            \"noImgElement\": \"off\"\n          }\n        }\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "commitlint.config.ts",
    "content": "export default {\n  extends: [\"@commitlint/config-conventional\"],\n  rules: {\n    \"type-enum\": [\n      2,\n      \"always\",\n      [\n        \"build\",\n        \"chore\",\n        \"ci\",\n        \"docs\",\n        \"feat\",\n        \"fix\",\n        \"perf\",\n        \"refactor\",\n        \"revert\",\n        \"style\",\n        \"test\",\n      ],\n    ],\n  },\n};\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  db:\n    image: postgres:15\n    environment:\n      POSTGRES_USER: usemarble\n      POSTGRES_PASSWORD: justusemarble\n      POSTGRES_DB: marble\n      TZ: UTC\n    ports:\n      - \"5432:5432\"\n    volumes:\n      - pgdata:/var/lib/postgresql/data\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U $${POSTGRES_USER}\"]\n      interval: 10s\n      timeout: 5s\n      retries: 5\n      start_period: 20s\n    restart: unless-stopped\n\n  redis:\n    image: redis\n    restart: always\n    ports:\n      - \"6379:6379\"\n\n  serverless-redis-http:\n    ports:\n      - \"8079:80\"\n    image: hiett/serverless-redis-http:latest\n    restart: always\n    environment:\n      SRH_MODE: env\n      SRH_TOKEN: justusemarble\n      SRH_CONNECTION_STRING: \"redis://redis:6379\" # Using `redis` hostname since they're in the same Docker network.\n\nvolumes:\n  pgdata:\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"marblecms\",\n  \"private\": true,\n  \"scripts\": {\n    \"docker:up\": \"docker compose up -d\",\n    \"docker:down\": \"docker compose down\",\n    \"docker:clean\": \"docker compose down -v\",\n    \"docker:logs\": \"docker compose logs -f\",\n    \"docker:ps\": \"docker compose ps\",\n    \"docker:restart\": \"docker compose restart\",\n    \"db:migrate\": \"pnpm --filter @marble/db db:migrate\",\n    \"db:deploy\": \"pnpm --filter @marble/db db:deploy\",\n    \"db:generate\": \"pnpm --filter @marble/db db:generate\",\n    \"db:studio\": \"pnpm --filter @marble/db db:studio\",\n    \"db:push\": \"pnpm --filter @marble/db db:push\",\n    \"build\": \"turbo run build\",\n    \"dev\": \"turbo run dev\",\n    \"lint\": \"pnpx ultracite@latest check\",\n    \"format\": \"pnpx ultracite@latest fix\",\n    \"web:dev\": \"turbo run dev --filter=web\",\n    \"cms:dev\": \"turbo run dev --filter=cms\",\n    \"api:dev\": \"turbo run dev --filter=api\",\n    \"docs:dev\": \"turbo run dev --filter=docs\",\n    \"mcp:dev\": \"turbo run dev --filter=mcp\",\n    \"test\": \"turbo run test\",\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"2.3.2\",\n    \"@commitlint/cli\": \"^19.8.1\",\n    \"@commitlint/config-conventional\": \"^19.8.1\",\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"husky\": \"^9.1.7\",\n    \"turbo\": \"^2.7.2\",\n    \"typescript\": \"^5.9.3\",\n    \"ultracite\": \"7.0.3\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"@types/react\": \"19.2.14\",\n      \"@types/react-dom\": \"19.2.3\"\n    }\n  },\n  \"packageManager\": \"pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264\"\n}\n"
  },
  {
    "path": "packages/db/.gitignore",
    "content": "node_modules\n# Keep environment variables out of version control\n.env\n\nscripts\n\n*.md"
  },
  {
    "path": "packages/db/package.json",
    "content": "{\n  \"name\": \"@marble/db\",\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./client\": \"./src/client.ts\",\n    \"./browser\": \"./src/browser.ts\",\n    \"./workers\": \"./src/workers.ts\",\n    \"./hyperdrive\": \"./src/hyperdrive.ts\"\n  },\n  \"scripts\": {\n    \"db:generate\": \"prisma generate\",\n    \"db:push\": \"prisma db push --skip-generate\",\n    \"db:studio\": \"prisma studio\",\n    \"postinstall\": \"prisma generate\",\n    \"db:migrate\": \"prisma migrate dev\",\n    \"db:deploy\": \"prisma migrate deploy\"\n  },\n  \"dependencies\": {\n    \"@neondatabase/serverless\": \"^1.0.1\",\n    \"@prisma/adapter-neon\": \"^7.0.0\",\n    \"@prisma/adapter-pg\": \"^7.0.0\",\n    \"@prisma/client\": \"^7.0.0\",\n    \"pg\": \"^8.18.0\",\n    \"ws\": \"^8.18.0\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"@types/node\": \"^22.9.0\",\n    \"@types/ws\": \"^8.5.13\",\n    \"bufferutil\": \"^4.0.9\",\n    \"dotenv\": \"^16.4.7\",\n    \"prisma\": \"^7.0.0\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/db/prisma/migrations/0_init/migration.sql",
    "content": "-- CreateSchema\nCREATE SCHEMA IF NOT EXISTS \"public\";\n\n-- CreateEnum\nCREATE TYPE \"public\".\"PostStatus\" AS ENUM ('published', 'draft');\n\n-- CreateEnum\nCREATE TYPE \"public\".\"PlanType\" AS ENUM ('team', 'pro');\n\n-- CreateEnum\nCREATE TYPE \"public\".\"SubscriptionStatus\" AS ENUM ('active', 'cancelled', 'expired', 'trialing', 'past_due');\n\n-- CreateEnum\nCREATE TYPE \"public\".\"WebhookEvent\" AS ENUM ('post_published', 'post_deleted', 'post_updated', 'category_created', 'category_updated', 'category_deleted', 'tag_created', 'tag_updated', 'tag_deleted', 'media_uploaded', 'media_deleted');\n\n-- CreateEnum\nCREATE TYPE \"public\".\"PayloadFormat\" AS ENUM ('json', 'discord');\n\n-- CreateEnum\nCREATE TYPE \"public\".\"MediaType\" AS ENUM ('image', 'video', 'audio', 'document');\n\n-- CreateTable\nCREATE TABLE \"public\".\"subscription\" (\n    \"id\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"plan\" \"public\".\"PlanType\" NOT NULL,\n    \"status\" \"public\".\"SubscriptionStatus\" NOT NULL DEFAULT 'active',\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"cancelAtPeriodEnd\" BOOLEAN NOT NULL DEFAULT false,\n    \"canceledAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"currentPeriodEnd\" TIMESTAMP(3) NOT NULL,\n    \"currentPeriodStart\" TIMESTAMP(3) NOT NULL,\n    \"endedAt\" TIMESTAMP(3),\n    \"endsAt\" TIMESTAMP(3),\n    \"polarId\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n\n    CONSTRAINT \"subscription_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"workspace\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"slug\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"metadata\" TEXT,\n    \"description\" TEXT,\n    \"subdomain\" TEXT,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"timezone\" TEXT NOT NULL DEFAULT 'Europe/London',\n\n    CONSTRAINT \"workspace_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"post\" (\n    \"id\" TEXT NOT NULL,\n    \"title\" TEXT NOT NULL,\n    \"content\" TEXT NOT NULL,\n    \"coverImage\" TEXT,\n    \"contentJson\" JSONB NOT NULL,\n    \"description\" TEXT NOT NULL,\n    \"views\" INTEGER NOT NULL DEFAULT 0,\n    \"workspaceId\" TEXT NOT NULL,\n    \"slug\" TEXT NOT NULL,\n    \"categoryId\" TEXT NOT NULL,\n    \"status\" \"public\".\"PostStatus\" NOT NULL DEFAULT 'draft',\n    \"featured\" BOOLEAN NOT NULL DEFAULT false,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"publishedAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"attribution\" JSONB,\n    \"primaryAuthorId\" TEXT NOT NULL,\n\n    CONSTRAINT \"post_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"tag\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"slug\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n\n    CONSTRAINT \"tag_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"media\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"url\" TEXT NOT NULL,\n    \"size\" INTEGER NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"type\" \"public\".\"MediaType\" NOT NULL DEFAULT 'image',\n\n    CONSTRAINT \"media_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"category\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"slug\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n\n    CONSTRAINT \"category_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"webhook\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"endpoint\" TEXT NOT NULL,\n    \"secret\" TEXT NOT NULL,\n    \"enabled\" BOOLEAN NOT NULL DEFAULT true,\n    \"workspaceId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"events\" \"public\".\"WebhookEvent\"[],\n    \"format\" \"public\".\"PayloadFormat\" NOT NULL DEFAULT 'json',\n\n    CONSTRAINT \"webhook_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"user\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"emailVerified\" BOOLEAN NOT NULL,\n    \"image\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"user_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"session\" (\n    \"id\" TEXT NOT NULL,\n    \"expiresAt\" TIMESTAMP(3) NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"ipAddress\" TEXT,\n    \"userAgent\" TEXT,\n    \"userId\" TEXT NOT NULL,\n    \"activeOrganizationId\" TEXT,\n\n    CONSTRAINT \"session_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"account\" (\n    \"id\" TEXT NOT NULL,\n    \"accountId\" TEXT NOT NULL,\n    \"providerId\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"accessToken\" TEXT,\n    \"refreshToken\" TEXT,\n    \"idToken\" TEXT,\n    \"accessTokenExpiresAt\" TIMESTAMP(3),\n    \"refreshTokenExpiresAt\" TIMESTAMP(3),\n    \"scope\" TEXT,\n    \"password\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"account_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"verification\" (\n    \"id\" TEXT NOT NULL,\n    \"identifier\" TEXT NOT NULL,\n    \"value\" TEXT NOT NULL,\n    \"expiresAt\" TIMESTAMP(3) NOT NULL,\n    \"createdAt\" TIMESTAMP(3),\n    \"updatedAt\" TIMESTAMP(3),\n\n    CONSTRAINT \"verification_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"member\" (\n    \"id\" TEXT NOT NULL,\n    \"organizationId\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"role\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"member_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"invitation\" (\n    \"id\" TEXT NOT NULL,\n    \"organizationId\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"role\" TEXT,\n    \"status\" TEXT NOT NULL,\n    \"expiresAt\" TIMESTAMP(3) NOT NULL,\n    \"inviterId\" TEXT NOT NULL,\n\n    CONSTRAINT \"invitation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"_PostToTag\" (\n    \"A\" TEXT NOT NULL,\n    \"B\" TEXT NOT NULL,\n\n    CONSTRAINT \"_PostToTag_AB_pkey\" PRIMARY KEY (\"A\",\"B\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"_PostToUser\" (\n    \"A\" TEXT NOT NULL,\n    \"B\" TEXT NOT NULL,\n\n    CONSTRAINT \"_PostToUser_AB_pkey\" PRIMARY KEY (\"A\",\"B\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"subscription_polarId_key\" ON \"public\".\"subscription\"(\"polarId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"subscription_workspaceId_key\" ON \"public\".\"subscription\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"workspace_slug_key\" ON \"public\".\"workspace\"(\"slug\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"workspace_subdomain_key\" ON \"public\".\"workspace\"(\"subdomain\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"post_workspaceId_slug_key\" ON \"public\".\"post\"(\"workspaceId\", \"slug\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"tag_workspaceId_slug_key\" ON \"public\".\"tag\"(\"workspaceId\", \"slug\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"category_workspaceId_slug_key\" ON \"public\".\"category\"(\"workspaceId\", \"slug\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_email_key\" ON \"public\".\"user\"(\"email\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"session_token_key\" ON \"public\".\"session\"(\"token\");\n\n-- CreateIndex\nCREATE INDEX \"_PostToTag_B_index\" ON \"public\".\"_PostToTag\"(\"B\");\n\n-- CreateIndex\nCREATE INDEX \"_PostToUser_B_index\" ON \"public\".\"_PostToUser\"(\"B\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"subscription\" ADD CONSTRAINT \"subscription_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"subscription\" ADD CONSTRAINT \"subscription_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_categoryId_fkey\" FOREIGN KEY (\"categoryId\") REFERENCES \"public\".\"category\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_primaryAuthorId_fkey\" FOREIGN KEY (\"primaryAuthorId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"tag\" ADD CONSTRAINT \"tag_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"media\" ADD CONSTRAINT \"media_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"category\" ADD CONSTRAINT \"category_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"webhook\" ADD CONSTRAINT \"webhook_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"session\" ADD CONSTRAINT \"session_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"account\" ADD CONSTRAINT \"account_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"member\" ADD CONSTRAINT \"member_organizationId_fkey\" FOREIGN KEY (\"organizationId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"member\" ADD CONSTRAINT \"member_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"invitation\" ADD CONSTRAINT \"invitation_inviterId_fkey\" FOREIGN KEY (\"inviterId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"invitation\" ADD CONSTRAINT \"invitation_organizationId_fkey\" FOREIGN KEY (\"organizationId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"_PostToTag\" ADD CONSTRAINT \"_PostToTag_A_fkey\" FOREIGN KEY (\"A\") REFERENCES \"public\".\"post\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"_PostToTag\" ADD CONSTRAINT \"_PostToTag_B_fkey\" FOREIGN KEY (\"B\") REFERENCES \"public\".\"tag\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"_PostToUser\" ADD CONSTRAINT \"_PostToUser_A_fkey\" FOREIGN KEY (\"A\") REFERENCES \"public\".\"post\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"_PostToUser\" ADD CONSTRAINT \"_PostToUser_B_fkey\" FOREIGN KEY (\"B\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250831193214_add_author_table/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"public\".\"post\" ADD COLUMN     \"newPrimaryAuthorId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"public\".\"author\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"email\" TEXT,\n    \"bio\" TEXT,\n    \"image\" TEXT,\n    \"role\" TEXT,\n    \"slug\" TEXT NOT NULL,\n    \"socials\" JSONB,\n    \"workspaceId\" TEXT NOT NULL,\n    \"userId\" TEXT,\n    \"isActive\" BOOLEAN NOT NULL DEFAULT true,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"author_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"_PostToAuthor\" (\n    \"A\" TEXT NOT NULL,\n    \"B\" TEXT NOT NULL,\n\n    CONSTRAINT \"_PostToAuthor_AB_pkey\" PRIMARY KEY (\"A\",\"B\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"author_workspaceId_userId_key\" ON \"public\".\"author\"(\"workspaceId\", \"userId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"author_workspaceId_slug_key\" ON \"public\".\"author\"(\"workspaceId\", \"slug\");\n\n-- CreateIndex\nCREATE INDEX \"_PostToAuthor_B_index\" ON \"public\".\"_PostToAuthor\"(\"B\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_newPrimaryAuthorId_fkey\" FOREIGN KEY (\"newPrimaryAuthorId\") REFERENCES \"public\".\"author\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"author\" ADD CONSTRAINT \"author_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"author\" ADD CONSTRAINT \"author_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"_PostToAuthor\" ADD CONSTRAINT \"_PostToAuthor_A_fkey\" FOREIGN KEY (\"A\") REFERENCES \"public\".\"author\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"_PostToAuthor\" ADD CONSTRAINT \"_PostToAuthor_B_fkey\" FOREIGN KEY (\"B\") REFERENCES \"public\".\"post\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250907120320_make_new_primary_author_required/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Made the column `newPrimaryAuthorId` on table `post` required. This step will fail if there are existing NULL values in that column.\n\n*/\n-- DropForeignKey\nALTER TABLE \"public\".\"post\" DROP CONSTRAINT \"post_newPrimaryAuthorId_fkey\";\n\n-- AlterTable\nALTER TABLE \"public\".\"post\" ALTER COLUMN \"newPrimaryAuthorId\" SET NOT NULL;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_newPrimaryAuthorId_fkey\" FOREIGN KEY (\"newPrimaryAuthorId\") REFERENCES \"public\".\"author\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250907125704_drop_legacy_user_author_fields/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `primaryAuthorId` on the `post` table. All the data in the column will be lost.\n  - You are about to drop the `_PostToUser` table. If the table is not empty, all the data it contains will be lost.\n\n*/\n-- DropForeignKey\nALTER TABLE \"public\".\"_PostToUser\" DROP CONSTRAINT \"_PostToUser_A_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"public\".\"_PostToUser\" DROP CONSTRAINT \"_PostToUser_B_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"public\".\"post\" DROP CONSTRAINT \"post_primaryAuthorId_fkey\";\n\n-- AlterTable\nALTER TABLE \"public\".\"post\" DROP COLUMN \"primaryAuthorId\";\n\n-- DropTable\nDROP TABLE \"public\".\"_PostToUser\";\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250907194746_rename_author_fields_to_final_names/migration.sql",
    "content": "/*\n  Rename author fields to final names:\n  - Rename newPrimaryAuthorId to primaryAuthorId\n  - Update foreign key constraint names\n*/\n\n-- Drop the old foreign key constraint\nALTER TABLE \"public\".\"post\" DROP CONSTRAINT \"post_newPrimaryAuthorId_fkey\";\n\n-- Rename the column\nALTER TABLE \"public\".\"post\" RENAME COLUMN \"newPrimaryAuthorId\" TO \"primaryAuthorId\";\n\n-- Add the new foreign key constraint with the renamed column\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_primaryAuthorId_fkey\" FOREIGN KEY (\"primaryAuthorId\") REFERENCES \"public\".\"author\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250908090455_make_primary_author_optional/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"public\".\"post\" DROP CONSTRAINT \"post_primaryAuthorId_fkey\";\n\n-- AlterTable\nALTER TABLE \"public\".\"post\" ALTER COLUMN \"primaryAuthorId\" DROP NOT NULL;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"post\" ADD CONSTRAINT \"post_primaryAuthorId_fkey\" FOREIGN KEY (\"primaryAuthorId\") REFERENCES \"public\".\"author\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250909162749_make_published_at_optional/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"public\".\"post\" ALTER COLUMN \"publishedAt\" DROP NOT NULL,\nALTER COLUMN \"publishedAt\" DROP DEFAULT;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250909171017_make_published_at_required/migration.sql",
    "content": "/*\n  Warnings:\n\n  - Made the column `publishedAt` on table `post` required. This step will fail if there are existing NULL values in that column.\n\n*/\n-- AlterTable\nALTER TABLE \"public\".\"post\" ALTER COLUMN \"publishedAt\" SET NOT NULL;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250911083948_add_slack_payload_format/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"public\".\"PayloadFormat\" ADD VALUE 'slack';\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250915114755_add_database_indices/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"account_userId_idx\" ON \"public\".\"account\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"account_providerId_accountId_idx\" ON \"public\".\"account\"(\"providerId\", \"accountId\");\n\n-- CreateIndex\nCREATE INDEX \"author_workspaceId_isActive_idx\" ON \"public\".\"author\"(\"workspaceId\", \"isActive\");\n\n-- CreateIndex\nCREATE INDEX \"author_userId_idx\" ON \"public\".\"author\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"category_workspaceId_idx\" ON \"public\".\"category\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_organizationId_idx\" ON \"public\".\"invitation\"(\"organizationId\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_email_idx\" ON \"public\".\"invitation\"(\"email\");\n\n-- CreateIndex\nCREATE INDEX \"invitation_inviterId_idx\" ON \"public\".\"invitation\"(\"inviterId\");\n\n-- CreateIndex\nCREATE INDEX \"media_workspaceId_createdAt_idx\" ON \"public\".\"media\"(\"workspaceId\", \"createdAt\");\n\n-- CreateIndex\nCREATE INDEX \"media_workspaceId_type_idx\" ON \"public\".\"media\"(\"workspaceId\", \"type\");\n\n-- CreateIndex\nCREATE INDEX \"member_userId_idx\" ON \"public\".\"member\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"member_organizationId_idx\" ON \"public\".\"member\"(\"organizationId\");\n\n-- CreateIndex\nCREATE INDEX \"member_organizationId_userId_idx\" ON \"public\".\"member\"(\"organizationId\", \"userId\");\n\n-- CreateIndex\nCREATE INDEX \"post_workspaceId_status_idx\" ON \"public\".\"post\"(\"workspaceId\", \"status\");\n\n-- CreateIndex\nCREATE INDEX \"post_workspaceId_createdAt_idx\" ON \"public\".\"post\"(\"workspaceId\", \"createdAt\");\n\n-- CreateIndex\nCREATE INDEX \"post_workspaceId_status_publishedAt_idx\" ON \"public\".\"post\"(\"workspaceId\", \"status\", \"publishedAt\");\n\n-- CreateIndex\nCREATE INDEX \"post_categoryId_idx\" ON \"public\".\"post\"(\"categoryId\");\n\n-- CreateIndex\nCREATE INDEX \"session_userId_idx\" ON \"public\".\"session\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"session_activeOrganizationId_idx\" ON \"public\".\"session\"(\"activeOrganizationId\");\n\n-- CreateIndex\nCREATE INDEX \"subscription_userId_idx\" ON \"public\".\"subscription\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"subscription_status_idx\" ON \"public\".\"subscription\"(\"status\");\n\n-- CreateIndex\nCREATE INDEX \"tag_workspaceId_idx\" ON \"public\".\"tag\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_workspaceId_idx\" ON \"public\".\"webhook\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_workspaceId_enabled_idx\" ON \"public\".\"webhook\"(\"workspaceId\", \"enabled\");\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250919210238_add_share_link_table/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"public\".\"ShareLink\" (\n    \"id\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"postId\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"password\" TEXT,\n    \"expiresAt\" TIMESTAMP(3) NOT NULL,\n    \"isActive\" BOOLEAN NOT NULL DEFAULT true,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"ShareLink_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ShareLink_token_key\" ON \"public\".\"ShareLink\"(\"token\");\n\n-- CreateIndex\nCREATE INDEX \"ShareLink_postId_idx\" ON \"public\".\"ShareLink\"(\"postId\");\n\n-- CreateIndex\nCREATE INDEX \"ShareLink_workspaceId_idx\" ON \"public\".\"ShareLink\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"ShareLink_expiresAt_idx\" ON \"public\".\"ShareLink\"(\"expiresAt\");\n\n-- CreateIndex\nCREATE INDEX \"ShareLink_isActive_idx\" ON \"public\".\"ShareLink\"(\"isActive\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"ShareLink\" ADD CONSTRAINT \"ShareLink_postId_fkey\" FOREIGN KEY (\"postId\") REFERENCES \"public\".\"post\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"ShareLink\" ADD CONSTRAINT \"ShareLink_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250923212858_add_ai_editor_preferences/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"public\".\"editor_preferences\" (\n    \"id\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n\n    CONSTRAINT \"editor_preferences_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"ai\" (\n    \"id\" TEXT NOT NULL,\n    \"enabled\" BOOLEAN NOT NULL DEFAULT false,\n    \"editorPreferencesId\" TEXT NOT NULL,\n\n    CONSTRAINT \"ai_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"editor_preferences_workspaceId_key\" ON \"public\".\"editor_preferences\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ai_editorPreferencesId_key\" ON \"public\".\"ai\"(\"editorPreferencesId\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"editor_preferences\" ADD CONSTRAINT \"editor_preferences_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"ai\" ADD CONSTRAINT \"ai_editorPreferencesId_fkey\" FOREIGN KEY (\"editorPreferencesId\") REFERENCES \"public\".\"editor_preferences\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250924180405_add_missing_better_auth_indices/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"session_token_idx\" ON \"public\".\"session\"(\"token\");\n\n-- CreateIndex\nCREATE INDEX \"verification_identifier_idx\" ON \"public\".\"verification\"(\"identifier\");\n"
  },
  {
    "path": "packages/db/prisma/migrations/20250927161627_add_author_social_links/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `socials` on the `author` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"public\".\"author\" DROP COLUMN \"socials\";\n\n-- CreateTable\nCREATE TABLE \"public\".\"author_social\" (\n    \"id\" TEXT NOT NULL,\n    \"authorId\" TEXT NOT NULL,\n    \"platform\" TEXT NOT NULL,\n    \"url\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"author_social_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"author_social_authorId_idx\" ON \"public\".\"author_social\"(\"authorId\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"author_social\" ADD CONSTRAINT \"author_social_authorId_fkey\" FOREIGN KEY (\"authorId\") REFERENCES \"public\".\"author\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20251114225009_add_usage_event_table/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"UsageEventType\" AS ENUM ('api_request', 'media_upload', 'webhook_delivery');\n\n-- CreateTable\nCREATE TABLE \"usage_event\" (\n    \"id\" TEXT NOT NULL,\n    \"type\" \"UsageEventType\" NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"endpoint\" TEXT,\n    \"size\" INTEGER,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"usage_event_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"usage_event_workspaceId_type_createdAt_idx\" ON \"usage_event\"(\"workspaceId\", \"type\", \"createdAt\");\n\n-- CreateIndex\nCREATE INDEX \"usage_event_workspaceId_createdAt_idx\" ON \"usage_event\"(\"workspaceId\", \"createdAt\");\n\n-- AddForeignKey\nALTER TABLE \"usage_event\" ADD CONSTRAINT \"usage_event_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20251116173412_new_media_enum_and_alt_text_column/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"WebhookEvent\" ADD VALUE 'media_updated';\n\n-- AlterTable\nALTER TABLE \"media\" ADD COLUMN     \"alt\" TEXT;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20251201001521_add_api_keys/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"public\".\"ApiKeyType\" AS ENUM ('public', 'private');\n\n-- CreateEnum\nCREATE TYPE \"public\".\"ApiScope\" AS ENUM ('posts_read', 'posts_write', 'authors_read', 'authors_write', 'categories_read', 'categories_write', 'tags_read', 'tags_write', 'media_read', 'media_write');\n\n-- CreateTable\nCREATE TABLE \"public\".\"api_key\" (\n    \"id\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"userId\" TEXT,\n    \"name\" TEXT NOT NULL,\n    \"prefix\" TEXT,\n    \"key\" TEXT NOT NULL,\n    \"preview\" TEXT NOT NULL,\n    \"type\" \"public\".\"ApiKeyType\" NOT NULL DEFAULT 'public',\n    \"scopes\" \"public\".\"ApiScope\"[] DEFAULT ARRAY[]::\"public\".\"ApiScope\"[],\n    \"requestCount\" INTEGER NOT NULL DEFAULT 0,\n    \"enabled\" BOOLEAN NOT NULL DEFAULT true,\n    \"rateLimitTimeWindow\" INTEGER,\n    \"rateLimitMax\" INTEGER,\n    \"lastRequest\" TIMESTAMP(3),\n    \"lastUsed\" TIMESTAMP(3),\n    \"expiresAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"api_key_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"api_key_key_key\" ON \"public\".\"api_key\"(\"key\");\n\n-- CreateIndex\nCREATE INDEX \"api_key_workspaceId_idx\" ON \"public\".\"api_key\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"api_key_workspaceId_createdAt_idx\" ON \"public\".\"api_key\"(\"workspaceId\", \"createdAt\");\n\n-- CreateIndex\nCREATE INDEX \"api_key_workspaceId_enabled_idx\" ON \"public\".\"api_key\"(\"workspaceId\", \"enabled\");\n\n-- CreateIndex\nCREATE INDEX \"api_key_workspaceId_type_idx\" ON \"public\".\"api_key\"(\"workspaceId\", \"type\");\n\n-- CreateIndex\nCREATE INDEX \"api_key_key_idx\" ON \"public\".\"api_key\"(\"key\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"api_key\" ADD CONSTRAINT \"api_key_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"api_key\" ADD CONSTRAINT \"api_key_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"public\".\"user\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20251210213108_subscription_history/migration.sql",
    "content": "/*\n  Warnings:\n\n  - The values [team] on the enum `PlanType` will be removed. If these variants are still used in the database, this will fail.\n  - The values [cancelled] on the enum `SubscriptionStatus` will be removed. If these variants are still used in the database, this will fail.\n\n*/\n-- CreateEnum\nCREATE TYPE \"public\".\"SubscriptionRecurringInterval\" AS ENUM ('day', 'week', 'month', 'year');\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"public\".\"PlanType_new\" AS ENUM ('hobby', 'pro');\nALTER TABLE \"public\".\"subscription\" ALTER COLUMN \"plan\" TYPE \"public\".\"PlanType_new\" USING (\"plan\"::text::\"public\".\"PlanType_new\");\nALTER TYPE \"public\".\"PlanType\" RENAME TO \"PlanType_old\";\nALTER TYPE \"public\".\"PlanType_new\" RENAME TO \"PlanType\";\nDROP TYPE \"public\".\"PlanType_old\";\nCOMMIT;\n\n-- AlterEnum\nBEGIN;\nCREATE TYPE \"public\".\"SubscriptionStatus_new\" AS ENUM ('active', 'expired', 'trialing', 'past_due', 'incomplete', 'incomplete_expired', 'unpaid', 'canceled');\nALTER TABLE \"public\".\"subscription\" ALTER COLUMN \"status\" DROP DEFAULT;\nALTER TABLE \"public\".\"subscription\" ALTER COLUMN \"status\" TYPE \"public\".\"SubscriptionStatus_new\" USING (\"status\"::text::\"public\".\"SubscriptionStatus_new\");\nALTER TYPE \"public\".\"SubscriptionStatus\" RENAME TO \"SubscriptionStatus_old\";\nALTER TYPE \"public\".\"SubscriptionStatus_new\" RENAME TO \"SubscriptionStatus\";\nDROP TYPE \"public\".\"SubscriptionStatus_old\";\nCOMMIT;\n\n-- DropIndex\nDROP INDEX \"public\".\"subscription_workspaceId_key\";\n\n-- AlterTable\nALTER TABLE \"public\".\"invitation\" ADD COLUMN     \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;\n\n-- AlterTable\nALTER TABLE \"public\".\"subscription\" ADD COLUMN     \"amount\" INTEGER NOT NULL DEFAULT 20,\nADD COLUMN     \"currency\" TEXT NOT NULL DEFAULT 'USD',\nADD COLUMN     \"discountId\" TEXT,\nADD COLUMN     \"productId\" TEXT,\nADD COLUMN     \"recurringInterval\" \"public\".\"SubscriptionRecurringInterval\" NOT NULL DEFAULT 'month',\nADD COLUMN     \"startedAt\" TIMESTAMP(3),\nALTER COLUMN \"status\" DROP DEFAULT,\nALTER COLUMN \"cancelAtPeriodEnd\" DROP DEFAULT;\n\n-- CreateIndex\nCREATE INDEX \"subscription_workspaceId_status_idx\" ON \"public\".\"subscription\"(\"workspaceId\", \"status\");\n"
  },
  {
    "path": "packages/db/prisma/migrations/20260331143009_add_fields/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[id,workspaceId]` on the table `post` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- CreateEnum\nCREATE TYPE \"public\".\"FieldType\" AS ENUM ('text', 'number', 'boolean', 'date', 'richtext', 'select', 'multiselect');\n\n-- CreateTable\nCREATE TABLE \"public\".\"field\" (\n    \"id\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"key\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"type\" \"public\".\"FieldType\" NOT NULL,\n    \"required\" BOOLEAN NOT NULL DEFAULT false,\n    \"position\" INTEGER NOT NULL DEFAULT 0,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"field_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"field_option\" (\n    \"id\" TEXT NOT NULL,\n    \"fieldId\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"value\" TEXT NOT NULL,\n    \"label\" TEXT NOT NULL,\n    \"position\" INTEGER NOT NULL DEFAULT 0,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"field_option_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"public\".\"field_value\" (\n    \"id\" TEXT NOT NULL,\n    \"postId\" TEXT NOT NULL,\n    \"fieldId\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"value\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"field_value_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"field_workspaceId_idx\" ON \"public\".\"field\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"field_workspaceId_key_key\" ON \"public\".\"field\"(\"workspaceId\", \"key\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"field_id_workspaceId_key\" ON \"public\".\"field\"(\"id\", \"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"field_option_fieldId_idx\" ON \"public\".\"field_option\"(\"fieldId\");\n\n-- CreateIndex\nCREATE INDEX \"field_option_workspaceId_idx\" ON \"public\".\"field_option\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"field_option_fieldId_position_idx\" ON \"public\".\"field_option\"(\"fieldId\", \"position\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"field_option_fieldId_value_key\" ON \"public\".\"field_option\"(\"fieldId\", \"value\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"field_option_id_workspaceId_key\" ON \"public\".\"field_option\"(\"id\", \"workspaceId\");\n\n-- CreateIndex\nCREATE INDEX \"field_value_postId_idx\" ON \"public\".\"field_value\"(\"postId\");\n\n-- CreateIndex\nCREATE INDEX \"field_value_fieldId_idx\" ON \"public\".\"field_value\"(\"fieldId\");\n\n-- CreateIndex\nCREATE INDEX \"field_value_workspaceId_idx\" ON \"public\".\"field_value\"(\"workspaceId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"field_value_postId_fieldId_key\" ON \"public\".\"field_value\"(\"postId\", \"fieldId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"post_id_workspaceId_key\" ON \"public\".\"post\"(\"id\", \"workspaceId\");\n\n-- AddForeignKey\nALTER TABLE \"public\".\"field\" ADD CONSTRAINT \"field_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"field_option\" ADD CONSTRAINT \"field_option_fieldId_workspaceId_fkey\" FOREIGN KEY (\"fieldId\", \"workspaceId\") REFERENCES \"public\".\"field\"(\"id\", \"workspaceId\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"field_option\" ADD CONSTRAINT \"field_option_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"field_value\" ADD CONSTRAINT \"field_value_postId_workspaceId_fkey\" FOREIGN KEY (\"postId\", \"workspaceId\") REFERENCES \"public\".\"post\"(\"id\", \"workspaceId\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"field_value\" ADD CONSTRAINT \"field_value_fieldId_workspaceId_fkey\" FOREIGN KEY (\"fieldId\", \"workspaceId\") REFERENCES \"public\".\"field\"(\"id\", \"workspaceId\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"public\".\"field_value\" ADD CONSTRAINT \"field_value_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"public\".\"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20260505135201_add_media_metadata/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"media\" ADD COLUMN     \"blurHash\" TEXT,\nADD COLUMN     \"duration\" INTEGER,\nADD COLUMN     \"height\" INTEGER,\nADD COLUMN     \"mimeType\" TEXT,\nADD COLUMN     \"width\" INTEGER;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20260508223056_add_notification_preferences/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the `ai` table. If the table is not empty, all the data it contains will be lost.\n  - You are about to drop the `editor_preferences` table. If the table is not empty, all the data it contains will be lost.\n\n*/\n-- DropForeignKey\nALTER TABLE \"ai\" DROP CONSTRAINT \"ai_editorPreferencesId_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"editor_preferences\" DROP CONSTRAINT \"editor_preferences_workspaceId_fkey\";\n\n-- DropTable\nDROP TABLE \"ai\";\n\n-- DropTable\nDROP TABLE \"editor_preferences\";\n\n-- CreateTable\nCREATE TABLE \"user_notification_preferences\" (\n    \"id\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"marketing\" BOOLEAN NOT NULL DEFAULT false,\n    \"product\" BOOLEAN NOT NULL DEFAULT true,\n    \"marketingConsentedAt\" TIMESTAMP(3),\n    \"marketingConsentSource\" TEXT,\n    \"marketingUnsubscribedAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"user_notification_preferences_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"workspace_notification_preferences\" (\n    \"id\" TEXT NOT NULL,\n    \"memberId\" TEXT NOT NULL,\n    \"usageAlerts\" BOOLEAN NOT NULL DEFAULT true,\n    \"subscriptions\" BOOLEAN NOT NULL DEFAULT true,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"workspace_notification_preferences_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"user_notification_preferences_userId_key\" ON \"user_notification_preferences\"(\"userId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"workspace_notification_preferences_memberId_key\" ON \"workspace_notification_preferences\"(\"memberId\");\n\n-- AddForeignKey\nALTER TABLE \"user_notification_preferences\" ADD CONSTRAINT \"user_notification_preferences_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"user\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"workspace_notification_preferences\" ADD CONSTRAINT \"workspace_notification_preferences_memberId_fkey\" FOREIGN KEY (\"memberId\") REFERENCES \"member\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "packages/db/prisma/migrations/20260511_rename_webhook_to_webhook_endpoint/migration.sql",
    "content": "-- Rename the enum type (no data change, all existing values are preserved)\nALTER TYPE \"WebhookEvent\" RENAME TO \"WorkspaceEventType\";\n\n-- Add the new post_created value to the enum\nALTER TYPE \"WorkspaceEventType\" ADD VALUE IF NOT EXISTS 'post_created';\n\n-- Rename the table\nALTER TABLE \"webhook\" RENAME TO \"webhook_endpoint\";\n\n-- Rename the endpoint column to url\nALTER TABLE \"webhook_endpoint\" RENAME COLUMN \"endpoint\" TO \"url\";\n"
  },
  {
    "path": "packages/db/prisma/migrations/20260513192507_add_workspace_events/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"WorkspaceEventSource\" AS ENUM ('dashboard', 'api', 'mcp', 'workflow', 'system');\n\n-- CreateEnum\nCREATE TYPE \"WorkspaceEventActorType\" AS ENUM ('user', 'api_key', 'mcp', 'system');\n\n-- CreateEnum\nCREATE TYPE \"WorkspaceEventResourceType\" AS ENUM ('post', 'category', 'tag', 'media', 'author', 'workspace');\n\n-- CreateEnum\nCREATE TYPE \"WebhookDeliveryStatus\" AS ENUM ('pending', 'sending', 'success', 'retrying', 'failed');\n\n-- CreateEnum\nCREATE TYPE \"UsageAlertKind\" AS ENUM ('warning', 'critical', 'exhausted');\n\n-- AlterEnum\n-- This migration adds more than one value to an enum.\n-- With PostgreSQL versions 11 and earlier, this is not possible\n-- in a single migration. This can be worked around by creating\n-- multiple migrations, each migration adding only one value to\n-- the enum.\n\n\nALTER TYPE \"WorkspaceEventType\" ADD VALUE 'post_unpublished';\nALTER TYPE \"WorkspaceEventType\" ADD VALUE 'author_created';\nALTER TYPE \"WorkspaceEventType\" ADD VALUE 'author_updated';\nALTER TYPE \"WorkspaceEventType\" ADD VALUE 'author_deleted';\n\n-- AlterTable\nALTER TABLE \"webhook_endpoint\" RENAME CONSTRAINT \"webhook_pkey\" TO \"webhook_endpoint_pkey\";\n\n-- CreateTable\nCREATE TABLE \"usage_alert\" (\n    \"id\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"type\" \"UsageEventType\" NOT NULL,\n    \"kind\" \"UsageAlertKind\" NOT NULL,\n    \"periodStart\" TIMESTAMP(3) NOT NULL,\n    \"periodEnd\" TIMESTAMP(3) NOT NULL,\n    \"emailSentTo\" TEXT NOT NULL,\n    \"sentAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"usage_alert_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"workspace_event\" (\n    \"id\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"type\" \"WorkspaceEventType\" NOT NULL,\n    \"source\" \"WorkspaceEventSource\" NOT NULL DEFAULT 'dashboard',\n    \"resourceType\" \"WorkspaceEventResourceType\",\n    \"resourceId\" TEXT,\n    \"actorType\" \"WorkspaceEventActorType\",\n    \"actorId\" TEXT,\n    \"payload\" JSONB NOT NULL DEFAULT '{}',\n    \"processedAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"workspace_event_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"webhook_delivery\" (\n    \"id\" TEXT NOT NULL,\n    \"eventId\" TEXT NOT NULL,\n    \"workspaceId\" TEXT NOT NULL,\n    \"webhookEndpointId\" TEXT NOT NULL,\n    \"url\" TEXT NOT NULL,\n    \"status\" \"WebhookDeliveryStatus\" NOT NULL DEFAULT 'pending',\n    \"isTest\" BOOLEAN NOT NULL DEFAULT false,\n    \"attemptCount\" INTEGER NOT NULL DEFAULT 0,\n    \"maxAttempts\" INTEGER NOT NULL DEFAULT 3,\n    \"nextRetryAt\" TIMESTAMP(3),\n    \"lastAttemptAt\" TIMESTAMP(3),\n    \"deliveredAt\" TIMESTAMP(3),\n    \"failedAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"webhook_delivery_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"webhook_delivery_attempt\" (\n    \"id\" TEXT NOT NULL,\n    \"deliveryId\" TEXT NOT NULL,\n    \"attemptNumber\" INTEGER NOT NULL,\n    \"success\" BOOLEAN NOT NULL DEFAULT false,\n    \"statusCode\" INTEGER,\n    \"responseBody\" TEXT,\n    \"errorMessage\" TEXT,\n    \"durationMs\" INTEGER,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"webhook_delivery_attempt_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"usage_alert_workspaceId_type_periodStart_periodEnd_idx\" ON \"usage_alert\"(\"workspaceId\", \"type\", \"periodStart\", \"periodEnd\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"usage_alert_workspaceId_type_kind_periodStart_periodEnd_key\" ON \"usage_alert\"(\"workspaceId\", \"type\", \"kind\", \"periodStart\", \"periodEnd\");\n\n-- CreateIndex\nCREATE INDEX \"workspace_event_workspaceId_createdAt_idx\" ON \"workspace_event\"(\"workspaceId\", \"createdAt\");\n\n-- CreateIndex\nCREATE INDEX \"workspace_event_workspaceId_type_idx\" ON \"workspace_event\"(\"workspaceId\", \"type\");\n\n-- CreateIndex\nCREATE INDEX \"workspace_event_workspaceId_resourceType_resourceId_idx\" ON \"workspace_event\"(\"workspaceId\", \"resourceType\", \"resourceId\");\n\n-- CreateIndex\nCREATE INDEX \"workspace_event_workspaceId_processedAt_idx\" ON \"workspace_event\"(\"workspaceId\", \"processedAt\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_delivery_eventId_idx\" ON \"webhook_delivery\"(\"eventId\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_delivery_workspaceId_status_idx\" ON \"webhook_delivery\"(\"workspaceId\", \"status\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_delivery_workspaceId_createdAt_idx\" ON \"webhook_delivery\"(\"workspaceId\", \"createdAt\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_delivery_webhookEndpointId_idx\" ON \"webhook_delivery\"(\"webhookEndpointId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"webhook_delivery_eventId_webhookEndpointId_key\" ON \"webhook_delivery\"(\"eventId\", \"webhookEndpointId\");\n\n-- CreateIndex\nCREATE INDEX \"webhook_delivery_attempt_deliveryId_idx\" ON \"webhook_delivery_attempt\"(\"deliveryId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"webhook_delivery_attempt_deliveryId_attemptNumber_key\" ON \"webhook_delivery_attempt\"(\"deliveryId\", \"attemptNumber\");\n\n-- RenameForeignKey\nALTER TABLE \"webhook_endpoint\" RENAME CONSTRAINT \"webhook_workspaceId_fkey\" TO \"webhook_endpoint_workspaceId_fkey\";\n\n-- AddForeignKey\nALTER TABLE \"usage_alert\" ADD CONSTRAINT \"usage_alert_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"workspace_event\" ADD CONSTRAINT \"workspace_event_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"webhook_delivery\" ADD CONSTRAINT \"webhook_delivery_eventId_fkey\" FOREIGN KEY (\"eventId\") REFERENCES \"workspace_event\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"webhook_delivery\" ADD CONSTRAINT \"webhook_delivery_workspaceId_fkey\" FOREIGN KEY (\"workspaceId\") REFERENCES \"workspace\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"webhook_delivery\" ADD CONSTRAINT \"webhook_delivery_webhookEndpointId_fkey\" FOREIGN KEY (\"webhookEndpointId\") REFERENCES \"webhook_endpoint\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"webhook_delivery_attempt\" ADD CONSTRAINT \"webhook_delivery_attempt_deliveryId_fkey\" FOREIGN KEY (\"deliveryId\") REFERENCES \"webhook_delivery\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- RenameIndex\nALTER INDEX \"webhook_workspaceId_enabled_idx\" RENAME TO \"webhook_endpoint_workspaceId_enabled_idx\";\n\n-- RenameIndex\nALTER INDEX \"webhook_workspaceId_idx\" RENAME TO \"webhook_endpoint_workspaceId_idx\";\n"
  },
  {
    "path": "packages/db/prisma/migrations/20260515000100_add_subscription_polar_event_ordering/migration.sql",
    "content": "-- Store the latest Polar webhook timestamp applied to a subscription so stale\n-- webhook deliveries cannot overwrite newer subscription state.\nALTER TABLE \"subscription\" ADD COLUMN \"lastPolarEventAt\" TIMESTAMP(3);\n"
  },
  {
    "path": "packages/db/prisma/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (e.g., Git)\nprovider = \"postgresql\"\n"
  },
  {
    "path": "packages/db/prisma/schema.prisma",
    "content": "generator client {\n  provider               = \"prisma-client\"\n  output                 = \"../src/generated/node\"\n  moduleFormat           = \"esm\"\n  generatedFileExtension = \"ts\"\n  importFileExtension    = \"ts\"\n}\n\ngenerator client_workers {\n  provider               = \"prisma-client\"\n  runtime                = \"workerd\"\n  output                 = \"../src/generated/workerd\"\n  moduleFormat           = \"esm\"\n  generatedFileExtension = \"ts\"\n  importFileExtension    = \"ts\"\n}\n\ndatasource db {\n  provider = \"postgresql\"\n}\n\nmodel Subscription {\n  id                 String                        @id @default(cuid())\n  userId             String\n  plan               PlanType\n  status             SubscriptionStatus\n  updatedAt          DateTime                      @updatedAt\n  cancelAtPeriodEnd  Boolean\n  canceledAt         DateTime?\n  createdAt          DateTime                      @default(now())\n  currentPeriodEnd   DateTime\n  currentPeriodStart DateTime\n  endedAt            DateTime?\n  endsAt             DateTime?\n  polarId            String                        @unique\n  workspaceId        String\n  startedAt          DateTime?\n  productId          String?\n  amount             Int                           @default(20)\n  currency           String                        @default(\"USD\")\n  discountId         String?\n  lastPolarEventAt   DateTime?\n  recurringInterval  SubscriptionRecurringInterval @default(month)\n  user               User                          @relation(fields: [userId], references: [id], onDelete: Cascade)\n  workspace          Organization                  @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@index([status])\n  @@index([workspaceId, status])\n  @@map(\"subscription\")\n}\n\nmodel Organization {\n  id                String            @id @default(cuid())\n  name              String\n  slug              String            @unique\n  logo              String?\n  metadata          String?\n  description       String?\n  subdomain         String?           @unique\n  updatedAt         DateTime          @updatedAt\n  createdAt         DateTime          @default(now())\n  timezone          String            @default(\"Europe/London\")\n  authors           Author[]\n  categories        Category[]\n  fields            Field[]\n  invitations       Invitation[]\n  media             Media[]\n  members           Member[]\n  posts             Post[]\n  subscriptions     Subscription[]\n  tags              Tag[]\n  fieldOptions      FieldOption[]\n  webhookEndpoints  WebhookEndpoint[]\n  ShareLink         ShareLink[]\n  usageEvents       UsageEvent[]\n  ApiToken          ApiKey[]\n  fieldValues       FieldValue[]\n  workspaceEvents   WorkspaceEvent[]\n  webhookDeliveries WebhookDelivery[]\n  usageAlerts       UsageAlert[]\n\n  @@map(\"workspace\")\n}\n\nmodel Post {\n  id          String     @id @default(cuid())\n  title       String\n  content     String\n  coverImage  String?\n  contentJson Json\n  description String\n  views       Int        @default(0)\n  workspaceId String\n  slug        String\n  categoryId  String\n  status      PostStatus @default(draft)\n  featured    Boolean    @default(false)\n  updatedAt   DateTime   @updatedAt\n  createdAt   DateTime   @default(now())\n  publishedAt DateTime\n  attribution Json?\n\n  primaryAuthorId String?\n  primaryAuthor   Author?  @relation(\"PrimaryAuthor\", fields: [primaryAuthorId], references: [id], onDelete: SetNull)\n  authors         Author[] @relation(\"PostToAuthor\")\n\n  category    Category     @relation(fields: [categoryId], references: [id])\n  workspace   Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  tags        Tag[]        @relation(\"PostToTag\")\n  shareLinks  ShareLink[]\n  fieldValues FieldValue[]\n\n  @@unique([workspaceId, slug])\n  @@unique([id, workspaceId])\n  @@index([workspaceId, status])\n  @@index([workspaceId, createdAt])\n  @@index([workspaceId, status, publishedAt])\n  @@index([categoryId])\n  @@map(\"post\")\n}\n\nmodel ShareLink {\n  id          String   @id @default(cuid())\n  token       String   @unique\n  postId      String\n  workspaceId String\n  password    String?\n  expiresAt   DateTime\n  isActive    Boolean  @default(true)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  post      Post         @relation(fields: [postId], references: [id], onDelete: Cascade)\n  workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@index([postId])\n  @@index([workspaceId])\n  @@index([expiresAt])\n  @@index([isActive])\n}\n\nmodel Tag {\n  id          String       @id @default(cuid())\n  name        String\n  description String?\n  slug        String\n  createdAt   DateTime     @default(now())\n  updatedAt   DateTime     @updatedAt\n  workspaceId String\n  workspace   Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  posts       Post[]       @relation(\"PostToTag\")\n\n  @@unique([workspaceId, slug])\n  @@index([workspaceId])\n  @@map(\"tag\")\n}\n\nmodel Media {\n  id          String       @id @default(cuid())\n  name        String\n  url         String\n  size        Int\n  alt         String?\n  mimeType    String?\n  width       Int?\n  height      Int?\n  duration    Int? // video length in milliseconds\n  blurHash    String?\n  createdAt   DateTime     @default(now())\n  updatedAt   DateTime     @updatedAt\n  workspaceId String\n  type        MediaType    @default(image)\n  workspace   Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@index([workspaceId, createdAt])\n  @@index([workspaceId, type])\n  @@map(\"media\")\n}\n\nmodel Category {\n  id          String       @id @default(cuid())\n  name        String\n  description String?\n  slug        String\n  createdAt   DateTime     @default(now())\n  updatedAt   DateTime     @updatedAt\n  workspaceId String\n  workspace   Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  posts       Post[]\n\n  @@unique([workspaceId, slug])\n  @@index([workspaceId])\n  @@map(\"category\")\n}\n\nmodel WebhookEndpoint {\n  id          String               @id @default(cuid())\n  name        String\n  url         String\n  secret      String\n  enabled     Boolean              @default(true)\n  workspaceId String\n  createdAt   DateTime             @default(now())\n  updatedAt   DateTime             @updatedAt\n  events      WorkspaceEventType[]\n  format      PayloadFormat        @default(json)\n  workspace   Organization         @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  deliveries  WebhookDelivery[]\n\n  @@index([workspaceId])\n  @@index([workspaceId, enabled])\n  @@map(\"webhook_endpoint\")\n}\n\nmodel User {\n  id                      String                       @id @default(cuid())\n  name                    String\n  email                   String                       @unique\n  emailVerified           Boolean\n  image                   String?\n  createdAt               DateTime\n  updatedAt               DateTime\n  accounts                Account[]\n  authors                 Author[]\n  invitations             Invitation[]\n  members                 Member[]\n  sessions                Session[]\n  subscriptions           Subscription[]\n  ApiKey                  ApiKey[]\n  notificationPreferences UserNotificationPreferences?\n\n  @@map(\"user\")\n}\n\nmodel UserNotificationPreferences {\n  id                      String    @id @default(cuid())\n  userId                  String    @unique\n  marketing               Boolean   @default(false)\n  product                 Boolean   @default(true)\n  marketingConsentedAt    DateTime?\n  marketingConsentSource  String?\n  marketingUnsubscribedAt DateTime?\n  createdAt               DateTime  @default(now())\n  updatedAt               DateTime  @updatedAt\n  user                    User      @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@map(\"user_notification_preferences\")\n}\n\nmodel Author {\n  id          String         @id @default(cuid())\n  name        String\n  email       String?\n  bio         String?\n  image       String?\n  role        String?\n  slug        String\n  socials     AuthorSocial[]\n  workspaceId String\n  userId      String?\n  isActive    Boolean        @default(true)\n  createdAt   DateTime       @default(now())\n  updatedAt   DateTime       @updatedAt\n\n  workspace       Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  user            User?        @relation(fields: [userId], references: [id], onDelete: SetNull)\n  primaryPosts    Post[]       @relation(\"PrimaryAuthor\")\n  coAuthoredPosts Post[]       @relation(\"PostToAuthor\")\n\n  @@unique([workspaceId, userId])\n  @@unique([workspaceId, slug])\n  @@index([workspaceId, isActive])\n  @@index([userId])\n  @@map(\"author\")\n}\n\nmodel AuthorSocial {\n  id        String   @id @default(cuid())\n  authorId  String\n  platform  String\n  url       String\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  author Author @relation(fields: [authorId], references: [id], onDelete: Cascade)\n\n  @@index([authorId])\n  @@map(\"author_social\")\n}\n\nmodel Session {\n  id                   String   @id @default(cuid())\n  expiresAt            DateTime\n  token                String   @unique\n  createdAt            DateTime\n  updatedAt            DateTime\n  ipAddress            String?\n  userAgent            String?\n  userId               String\n  activeOrganizationId String?\n  user                 User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@index([token])\n  @@index([activeOrganizationId])\n  @@map(\"session\")\n}\n\nmodel Account {\n  id                    String    @id @default(cuid())\n  accountId             String\n  providerId            String\n  userId                String\n  accessToken           String?\n  refreshToken          String?\n  idToken               String?\n  accessTokenExpiresAt  DateTime?\n  refreshTokenExpiresAt DateTime?\n  scope                 String?\n  password              String?\n  createdAt             DateTime\n  updatedAt             DateTime\n  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@index([providerId, accountId])\n  @@map(\"account\")\n}\n\nmodel Verification {\n  id         String    @id @default(cuid())\n  identifier String\n  value      String\n  expiresAt  DateTime\n  createdAt  DateTime?\n  updatedAt  DateTime?\n\n  @@index([identifier])\n  @@map(\"verification\")\n}\n\nmodel Member {\n  id                      String                            @id @default(cuid())\n  organizationId          String\n  userId                  String\n  role                    String?\n  createdAt               DateTime\n  notificationPreferences WorkspaceNotificationPreferences?\n  organization            Organization                      @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n  user                    User                              @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@index([userId])\n  @@index([organizationId])\n  @@index([organizationId, userId])\n  @@map(\"member\")\n}\n\nmodel WorkspaceNotificationPreferences {\n  id            String   @id @default(cuid())\n  memberId      String   @unique\n  usageAlerts   Boolean  @default(true)\n  subscriptions Boolean  @default(true)\n  createdAt     DateTime @default(now())\n  updatedAt     DateTime @updatedAt\n  member        Member   @relation(fields: [memberId], references: [id], onDelete: Cascade)\n\n  @@map(\"workspace_notification_preferences\")\n}\n\nmodel Invitation {\n  id             String       @id @default(cuid())\n  organizationId String\n  email          String\n  role           String?\n  status         String\n  expiresAt      DateTime\n  inviterId      String\n  user           User         @relation(fields: [inviterId], references: [id], onDelete: Cascade)\n  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n\n  @@index([organizationId])\n  @@index([email])\n  @@index([inviterId])\n  @@map(\"invitation\")\n}\n\nmodel UsageEvent {\n  id          String         @id @default(cuid())\n  type        UsageEventType\n  workspaceId String\n  endpoint    String?\n  size        Int?\n  createdAt   DateTime       @default(now())\n  workspace   Organization   @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@index([workspaceId, type, createdAt])\n  @@index([workspaceId, createdAt])\n  @@map(\"usage_event\")\n}\n\nmodel UsageAlert {\n  id          String         @id @default(cuid())\n  workspaceId String\n  type        UsageEventType\n  kind        UsageAlertKind\n  periodStart DateTime\n  periodEnd   DateTime\n  emailSentTo String\n  sentAt      DateTime       @default(now())\n  workspace   Organization   @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@unique([workspaceId, type, kind, periodStart, periodEnd])\n  @@index([workspaceId, type, periodStart, periodEnd])\n  @@map(\"usage_alert\")\n}\n\nenum UsageAlertKind {\n  warning\n  critical\n  exhausted\n}\n\nmodel ApiKey {\n  id           String     @id @default(cuid())\n  workspaceId  String\n  userId       String?\n  name         String\n  prefix       String?\n  key          String     @unique // SHA-256 hash of the API key\n  preview      String\n  type         ApiKeyType @default(public)\n  scopes       ApiScope[] @default([])\n  requestCount Int        @default(0)\n  enabled      Boolean    @default(true)\n\n  // Rate limiting fields\n  rateLimitTimeWindow Int? // milliseconds (e.g., 86400000 for 24 hours)\n  rateLimitMax        Int? // max requests per window\n  lastRequest         DateTime? // for rate limit window tracking\n\n  lastUsed  DateTime?\n  expiresAt DateTime?\n  createdAt DateTime  @default(now())\n  updatedAt DateTime  @updatedAt\n\n  workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  user      User?        @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n  @@index([workspaceId])\n  @@index([workspaceId, createdAt])\n  @@index([workspaceId, enabled])\n  @@index([workspaceId, type])\n  @@index([key])\n  @@map(\"api_key\")\n}\n\nenum PostStatus {\n  published\n  draft\n}\n\nenum PlanType {\n  hobby\n  pro\n}\n\nenum SubscriptionRecurringInterval {\n  day\n  week\n  month\n  year\n}\n\nenum SubscriptionStatus {\n  active // Active subscription\n  expired // End of period\n  trialing // Still in a trial period\n  past_due // Payment failed, unpaid, or incomplete\n  incomplete // Created, but with unpaid invoice\n  incomplete_expired // Created, but never made a payment and now expired\n  unpaid // Payment failed and is unpaid\n  canceled // Canceled (Polar uses American spelling)\n}\n\nenum WorkspaceEventType {\n  post_created\n  post_published\n  post_unpublished\n  post_updated\n  post_deleted\n\n  category_created\n  category_updated\n  category_deleted\n\n  tag_created\n  tag_updated\n  tag_deleted\n\n  media_uploaded\n  media_updated\n  media_deleted\n\n  author_created\n  author_updated\n  author_deleted\n}\n\nenum WorkspaceEventSource {\n  dashboard\n  api\n  mcp\n  workflow\n  system\n}\n\nenum WorkspaceEventActorType {\n  user\n  api_key\n  mcp\n  system\n}\n\nenum WorkspaceEventResourceType {\n  post\n  category\n  tag\n  media\n  author\n  workspace\n}\n\nenum PayloadFormat {\n  json\n  discord\n  slack\n}\n\nenum MediaType {\n  image\n  video\n  audio\n  document\n}\n\nenum UsageEventType {\n  api_request\n  media_upload\n  webhook_delivery\n}\n\nenum ApiKeyType {\n  public\n  private\n}\n\nenum ApiScope {\n  posts_read\n  posts_write\n  authors_read\n  authors_write\n  categories_read\n  categories_write\n  tags_read\n  tags_write\n  media_read\n  media_write\n}\n\nenum FieldType {\n  text\n  number\n  boolean\n  date\n  richtext\n  select\n  multiselect\n}\n\nmodel Field {\n  id          String        @id @default(cuid())\n  workspaceId String\n  key         String\n  name        String\n  description String?\n  type        FieldType\n  required    Boolean       @default(false)\n  position    Int           @default(0)\n  createdAt   DateTime      @default(now())\n  updatedAt   DateTime      @updatedAt\n  workspace   Organization  @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  values      FieldValue[]\n  options     FieldOption[]\n\n  @@unique([workspaceId, key])\n  @@unique([id, workspaceId])\n  @@index([workspaceId])\n  @@map(\"field\")\n}\n\nmodel FieldOption {\n  id          String       @id @default(cuid())\n  fieldId     String\n  workspaceId String\n  value       String\n  label       String\n  position    Int          @default(0)\n  createdAt   DateTime     @default(now())\n  updatedAt   DateTime     @updatedAt\n  field       Field        @relation(fields: [fieldId, workspaceId], references: [id, workspaceId], onDelete: Cascade)\n  workspace   Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@unique([fieldId, value])\n  @@unique([id, workspaceId])\n  @@index([fieldId])\n  @@index([workspaceId])\n  @@index([fieldId, position])\n  @@map(\"field_option\")\n}\n\nmodel FieldValue {\n  id          String       @id @default(cuid())\n  postId      String\n  fieldId     String\n  workspaceId String\n  value       String\n  createdAt   DateTime     @default(now())\n  updatedAt   DateTime     @updatedAt\n  post        Post         @relation(fields: [postId, workspaceId], references: [id, workspaceId], onDelete: Cascade)\n  field       Field        @relation(fields: [fieldId, workspaceId], references: [id, workspaceId], onDelete: Cascade)\n  workspace   Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n\n  @@unique([postId, fieldId])\n  @@index([postId])\n  @@index([fieldId])\n  @@index([workspaceId])\n  @@map(\"field_value\")\n}\n\nmodel WorkspaceEvent {\n  id          String               @id @default(cuid())\n  workspaceId String\n  type        WorkspaceEventType\n  source      WorkspaceEventSource @default(dashboard)\n\n  resourceType WorkspaceEventResourceType?\n  resourceId   String?\n\n  actorType WorkspaceEventActorType?\n  actorId   String?\n\n  payload     Json      @default(\"{}\")\n  processedAt DateTime?\n\n  createdAt DateTime @default(now())\n\n  workspace  Organization      @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  deliveries WebhookDelivery[]\n\n  @@index([workspaceId, createdAt])\n  @@index([workspaceId, type])\n  @@index([workspaceId, resourceType, resourceId])\n  @@index([workspaceId, processedAt])\n  @@map(\"workspace_event\")\n}\n\nmodel WebhookDelivery {\n  id                String @id @default(cuid())\n  eventId           String\n  workspaceId       String\n  webhookEndpointId String\n\n  url    String\n  status WebhookDeliveryStatus @default(pending)\n  isTest Boolean               @default(false)\n\n  attemptCount Int @default(0)\n  maxAttempts  Int @default(3)\n\n  nextRetryAt   DateTime?\n  lastAttemptAt DateTime?\n  deliveredAt   DateTime?\n  failedAt      DateTime?\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  event           WorkspaceEvent           @relation(fields: [eventId], references: [id], onDelete: Cascade)\n  workspace       Organization             @relation(fields: [workspaceId], references: [id], onDelete: Cascade)\n  webhookEndpoint WebhookEndpoint          @relation(fields: [webhookEndpointId], references: [id], onDelete: Cascade)\n  attempts        WebhookDeliveryAttempt[]\n\n  @@index([eventId])\n  @@index([workspaceId, status])\n  @@index([workspaceId, createdAt])\n  @@index([webhookEndpointId])\n  @@unique([eventId, webhookEndpointId])\n  @@map(\"webhook_delivery\")\n}\n\nmodel WebhookDeliveryAttempt {\n  id            String   @id @default(cuid())\n  deliveryId    String\n  attemptNumber Int\n  success       Boolean  @default(false)\n  statusCode    Int?\n  responseBody  String?\n  errorMessage  String?\n  durationMs    Int?\n  createdAt     DateTime @default(now())\n\n  delivery WebhookDelivery @relation(fields: [deliveryId], references: [id], onDelete: Cascade)\n\n  @@unique([deliveryId, attemptNumber])\n  @@index([deliveryId])\n  @@map(\"webhook_delivery_attempt\")\n}\n\nenum WebhookDeliveryStatus {\n  pending\n  sending\n  success\n  retrying\n  failed\n}\n"
  },
  {
    "path": "packages/db/prisma.config.ts",
    "content": "import \"dotenv/config\";\nimport { defineConfig } from \"prisma/config\";\n\nexport default defineConfig({\n  schema: \"prisma/schema.prisma\",\n  migrations: {\n    path: \"prisma/migrations\",\n  },\n  datasource: {\n    // Prefer a direct URL for CLI operations when available, but keep\n    // DATABASE_URL as a fallback so local generate/build flows stay simple.\n    url: process.env.DIRECT_URL ?? process.env.DATABASE_URL ?? \"\",\n  },\n});\n"
  },
  {
    "path": "packages/db/src/browser.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: \"required\" */\nexport * from \"./generated/node/browser\";\n"
  },
  {
    "path": "packages/db/src/client.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: \"required\" */\nexport * from \"./generated/node/client\";\n"
  },
  {
    "path": "packages/db/src/hyperdrive.ts",
    "content": "import { PrismaPg } from \"@prisma/adapter-pg\";\nimport { PrismaClient } from \"./generated/workerd/client\";\n\n/**\n * Create a Prisma client for Hyperdrive.\n *\n * Uses the pg-worker adapter (standard PostgreSQL protocol) instead of the Neon\n * serverless driver. Compatible with Cloudflare Hyperdrive, which requires\n * direct TCP Postgres connections per CF docs.\n *\n * Pass env.HYPERDRIVE.connectionString from your Worker. Same Prisma Client\n * API for all queries — no schema changes needed.\n */\nconst createClient = (connectionString: string) => {\n  const url =\n    typeof connectionString === \"string\"\n      ? connectionString.trim()\n      : String(connectionString || \"\").trim();\n\n  if (!url) {\n    throw new Error(\"Connection string is required and must be non-empty\");\n  }\n\n  const adapter = new PrismaPg({ connectionString: url });\n  return new PrismaClient({ adapter });\n};\n\nexport { createClient };\n"
  },
  {
    "path": "packages/db/src/index.ts",
    "content": "import { neonConfig } from \"@neondatabase/serverless\";\nimport { PrismaNeon } from \"@prisma/adapter-neon\";\nimport ws from \"ws\";\nimport { PrismaClient } from \"./generated/node/client\";\n\nneonConfig.webSocketConstructor = ws;\n\nconst createClient = () => {\n  const connectionString = process.env.DATABASE_URL;\n  if (!connectionString || typeof connectionString !== \"string\") {\n    throw new Error(\"DATABASE_URL is not set\");\n  }\n\n  const adapter = new PrismaNeon({ connectionString });\n  return new PrismaClient({ adapter });\n};\n\ndeclare global {\n  var prisma: PrismaClient | undefined;\n}\n\nlet db: PrismaClient;\n\nif (process.env.NODE_ENV === \"production\") {\n  db = createClient();\n} else {\n  if (!global.prisma) {\n    global.prisma = createClient();\n  }\n  db = global.prisma;\n}\n\nexport { db };\n"
  },
  {
    "path": "packages/db/src/workers.ts",
    "content": "import { PrismaNeon } from \"@prisma/adapter-neon\";\nimport { PrismaClient } from \"./generated/workerd/client\";\n\nconst createClient = (url: string) => {\n  const connectionString =\n    typeof url === \"string\" ? url.trim() : String(url || \"\").trim();\n\n  if (!connectionString) {\n    throw new Error(\"DATABASE_URL is required and must be a non-empty string\");\n  }\n\n  const adapter = new PrismaNeon({ connectionString });\n  return new PrismaClient({ adapter });\n};\nexport { createClient };\n"
  },
  {
    "path": "packages/db/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/base.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"target\": \"ES2023\"\n  },\n  \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/demo-markdown.md",
    "content": "# Markdown Demo File\n\nThis is a demo markdown file to test markdown paste functionality in the editor.\n\n## Images with Alt Text\n\nHere's a simple image with alt text:\n\n![A beautiful landscape with mountains and a lake](https://images.unsplash.com/photo-1764377975933-2ffbd913f3c2?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)\n\n## Linked Image\n\nHere's an image that's also a link:\n\n[![Clickable image with alt text](https://images.unsplash.com/photo-1761839271800-f44070ff0eb9?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)](https://taqib.dev)\n\n## Multiple Images\n\n![First image](https://images.unsplash.com/photo-1764312349609-41297ede0e57?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)\n\n![Second image with descriptive alt text](https://images.unsplash.com/photo-1764489307253-da5c9c311fa2?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)\n\n![Third image](https://images.unsplash.com/photo-1764813824530-eb9e431ea89d?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)\n\n## Text Formatting\n\nThis paragraph has **bold text**, _italic text_, and `inline code`.\n\n## Lists\n\n### Unordered List\n\n- Item one\n- Item two\n- Item three\n\n### Ordered List\n\n1. First item\n2. Second item\n3. Third item\n\n## Blockquote\n\n> This is a blockquote with an image below it.\n\n## Code Block\n\n```javascript\nfunction hello() {\n  console.log(\"Hello, World!\");\n}\n```\n\n## Links\n\nHere's a [regular link](https://images.unsplash.com/photo-1764760764956-fcb78be107a5?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) and an image link:\n\n![Image link](https://images.unsplash.com/photo-1764377725021-33bba9d00944?q=80&w=1980&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)\n\n## Table\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Data 1   | Data 2   | Data 3   |\n| Data 4   | Data 5   | Data 6   |\n\n## Horizontal Rule\n\n---\n\n## Final Image\n\n![Final test image with detailed alt text describing the content](https://images.unsplash.com/photo-1764526612515-6b4ab2868a6e?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)\n\nEnd of demo file.\n"
  },
  {
    "path": "packages/editor/package.json",
    "content": "{\n  \"name\": \"@marble/editor\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"exports\": {\n    \".\": \"./src/index.ts\",\n    \"./components/*\": \"./src/components/*.tsx\",\n    \"./extensions/*\": \"./src/extensions/*\"\n  },\n  \"scripts\": {\n    \"lint\": \"biome check .\",\n    \"format\": \"biome --write .\"\n  },\n  \"dependencies\": {\n    \"@floating-ui/dom\": \"^1.7.6\",\n    \"@hugeicons/core-free-icons\": \"^3.3.0\",\n    \"@hugeicons/react\": \"^1.1.6\",\n    \"@marble/ui\": \"workspace:*\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@tiptap/core\": \"3.22.3\",\n    \"@tiptap/extension-code-block-lowlight\": \"3.22.3\",\n    \"@tiptap/extension-document\": \"3.22.3\",\n    \"@tiptap/extension-drag-handle\": \"3.22.3\",\n    \"@tiptap/extension-drag-handle-react\": \"3.22.3\",\n    \"@tiptap/extension-file-handler\": \"3.22.3\",\n    \"@tiptap/extension-highlight\": \"3.22.3\",\n    \"@tiptap/extension-image\": \"3.22.3\",\n    \"@tiptap/extension-list\": \"3.22.3\",\n    \"@tiptap/extension-subscript\": \"3.22.3\",\n    \"@tiptap/extension-superscript\": \"3.22.3\",\n    \"@tiptap/extension-table\": \"3.22.3\",\n    \"@tiptap/extension-text-align\": \"3.22.3\",\n    \"@tiptap/extension-text-style\": \"3.22.3\",\n    \"@tiptap/extension-twitch\": \"3.22.3\",\n    \"@tiptap/extension-typography\": \"3.22.3\",\n    \"@tiptap/extension-youtube\": \"3.22.3\",\n    \"@tiptap/extensions\": \"3.22.3\",\n    \"@tiptap/markdown\": \"3.22.3\",\n    \"@tiptap/pm\": \"3.22.3\",\n    \"@tiptap/react\": \"3.22.3\",\n    \"@tiptap/starter-kit\": \"3.22.3\",\n    \"@tiptap/suggestion\": \"3.22.3\",\n    \"fuse.js\": \"^7.1.0\",\n    \"lowlight\": \"^3.3.0\",\n    \"react-colorful\": \"^5.6.1\",\n    \"react-tweet\": \"^3.3.0\",\n    \"tippy.js\": \"^6.3.7\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"@types/node\": \"^22.9.0\",\n    \"@types/react\": \"19.2.14\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/components/color-picker.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { ArrowUUpLeft } from \"@phosphor-icons/react\";\nimport { useCallback, useState } from \"react\";\nimport { HexColorPicker } from \"react-colorful\";\nimport \"../styles/color-picker.css\";\n\nconst PRESET_COLORS = [\n  \"#fb7185\", // Rose\n  \"#fdba74\", // Orange\n  \"#d9f99d\", // Lime\n  \"#a7f3d0\", // Emerald\n  \"#a5f3fc\", // Cyan\n  \"#a5b4fc\", // Indigo\n];\n\nexport const ColorPicker = ({\n  color,\n  onChange,\n  onClear,\n}: {\n  color?: string;\n  onChange: (color: string) => void;\n  onClear: () => void;\n}) => {\n  const [hexInput, setHexInput] = useState(color || \"\");\n  const [prevColor, setPrevColor] = useState(color);\n\n  if (color !== prevColor) {\n    setPrevColor(color);\n    setHexInput(color || \"\");\n  }\n\n  const handleHexInputChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const value = e.target.value;\n      setHexInput(value);\n\n      // Validate hex color format\n      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {\n        onChange(value);\n      }\n    },\n    [onChange]\n  );\n\n  const handleColorChange = useCallback(\n    (newColor: string) => {\n      setHexInput(newColor);\n      onChange(newColor);\n    },\n    [onChange]\n  );\n\n  return (\n    <div className=\"color-picker flex flex-col gap-3 p-2\">\n      <div className=\"w-full\">\n        <HexColorPicker\n          color={color || \"#000000\"}\n          onChange={handleColorChange}\n        />\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        <Input\n          className=\"h-8 font-mono text-xs\"\n          onChange={handleHexInputChange}\n          placeholder=\"#000000\"\n          value={hexInput}\n        />\n      </div>\n\n      <div className=\"flex items-center gap-2\">\n        {PRESET_COLORS.map((presetColor) => (\n          <button\n            className=\"size-6 rounded border border-border transition-transform hover:scale-110\"\n            key={presetColor}\n            onClick={() => handleColorChange(presetColor)}\n            style={{ backgroundColor: presetColor }}\n            title={presetColor}\n            type=\"button\"\n          />\n        ))}\n        <Button\n          className=\"size-8 shrink-0\"\n          onClick={onClear}\n          size=\"icon\"\n          title=\"Reset color\"\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          <ArrowUUpLeft className=\"size-4\" />\n        </Button>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/editor-character-count.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface EditorCharacterCountProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Character Count Component\n *\n * Displays character or word count statistics for the editor content.\n * Provides two variants: Characters and Words.\n *\n * @example\n * ```tsx\n * <EditorCharacterCount.Words>Words: </EditorCharacterCount.Words>\n * <EditorCharacterCount.Characters>Characters: </EditorCharacterCount.Characters>\n * ```\n */\nexport const EditorCharacterCount = {\n  Characters({ children, className }: EditorCharacterCountProps) {\n    const { editor } = useCurrentEditor();\n\n    if (!editor) {\n      return null;\n    }\n\n    return (\n      <div\n        className={cn(\n          \"absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow\",\n          className\n        )}\n      >\n        {children}\n        {editor.storage.characterCount.characters()}\n      </div>\n    );\n  },\n\n  Words({ children, className }: EditorCharacterCountProps) {\n    const { editor } = useCurrentEditor();\n\n    if (!editor) {\n      return null;\n    }\n\n    return (\n      <div\n        className={cn(\n          \"absolute right-4 bottom-4 rounded-md border bg-background p-2 text-muted-foreground text-sm shadow\",\n          className\n        )}\n      >\n        {children}\n        {editor.storage.characterCount.words()}\n      </div>\n    );\n  },\n};\n"
  },
  {
    "path": "packages/editor/src/components/editor-content.tsx",
    "content": "import {\n  EditorContent as TiptapEditorContent,\n  useCurrentEditor,\n} from \"@tiptap/react\";\n\n/**\n * EditorContent Component\n *\n * Component that renders the actual editor content area.\n * This is the EditorContent component from @tiptap/react - the main editable area\n * where users type and edit content.\n *\n */\nexport function EditorContent() {\n  const { editor } = useCurrentEditor();\n  if (!editor) {\n    return null;\n  }\n  return <TiptapEditorContent editor={editor} />;\n}\n"
  },
  {
    "path": "packages/editor/src/components/editor-provider.tsx",
    "content": "/** biome-ignore-all lint/suspicious/noExplicitAny: <> */\nimport type { AnyExtension } from \"@tiptap/core\";\nimport {\n  EditorProvider as TiptapEditorProvider,\n  type EditorProviderProps as TiptapEditorProviderProps,\n  type UseEditorOptions,\n  useEditor,\n} from \"@tiptap/react\";\nimport { ExtensionKit } from \"../extensions/extension-kit\";\nimport { handleCommandNavigation } from \"../extensions/slash-command\";\n\nfunction deduplicateExtensions(\n  defaults: AnyExtension[],\n  overrides: AnyExtension[]\n): AnyExtension[] {\n  const overrideNames = new Set(overrides.map((ext) => ext.name));\n  return [\n    ...defaults.filter((ext) => !overrideNames.has(ext.name)),\n    ...overrides,\n  ];\n}\n\nexport type EditorProviderProps = Omit<\n  TiptapEditorProviderProps,\n  \"extensions\"\n> & {\n  limit?: number;\n  placeholder?: string;\n  extensions?: any[];\n};\n\n/**\n * Editor Provider Component\n *\n * The root component that wraps the Tiptap editor with default extensions and configuration.\n * Provides the editor context to all child components. Use this as the wrapper for your\n * editor content and menus.\n *\n *\n * @example\n * ```tsx\n * <EditorProvider\n *   className=\"border rounded-lg p-4\"\n *   content={content}\n *   onUpdate={handleUpdate}\n *   placeholder=\"Start typing...\"\n * >\n *   <EditorBubbleMenu>...</EditorBubbleMenu>\n *   <EditorContent editor={editor} />\n * </EditorProvider>\n * ```\n */\nexport const EditorProvider = ({\n  extensions,\n  limit,\n  placeholder,\n  onUpdate,\n  ...props\n}: EditorProviderProps) => {\n  const defaultExtensions = ExtensionKit({ limit, placeholder });\n\n  return (\n    <TiptapEditorProvider\n      editorProps={{\n        handleKeyDown: (_view, event) => {\n          handleCommandNavigation(event);\n        },\n      }}\n      extensions={deduplicateExtensions(defaultExtensions, extensions ?? [])}\n      immediatelyRender={false}\n      onUpdate={onUpdate}\n      {...props}\n    />\n  );\n};\n\n// biome-ignore lint/performance/noBarrelFile: Re-exporting TipTap hooks for convenience\nexport { EditorContext, useCurrentEditor, useEditor } from \"@tiptap/react\";\n\n/**\n * Hook to create a Marble editor instance with default extensions and configuration.\n * This is a convenience hook that sets up the editor with ExtensionKit and handleCommandNavigation.\n *\n * Use this with EditorContext.Provider to avoid layout issues:\n *\n * @example\n * ```tsx\n * const editor = useMarbleEditor({\n *   content: \"<p>Hello</p>\",\n *   placeholder: \"Start typing...\",\n *   onUpdate: ({ editor }) => {\n *     console.log(editor.getHTML());\n *   },\n * });\n *\n * return (\n *   <EditorContext.Provider value={{ editor }}>\n *     <EditorContent />\n *     <EditorSidebar />\n *   </EditorContext.Provider>\n * );\n * ```\n */\nexport function useMarbleEditor(options: UseMarbleEditorOptions) {\n  const { limit, placeholder, extensions = [], ...restOptions } = options;\n  const defaultExtensions = ExtensionKit({ limit, placeholder });\n\n  return useEditor({\n    immediatelyRender: false,\n    editorProps: {\n      handleKeyDown: (_view, event) => {\n        handleCommandNavigation(event);\n      },\n      ...restOptions.editorProps,\n    },\n    extensions: deduplicateExtensions(defaultExtensions, extensions),\n    ...restOptions,\n  });\n}\n\nexport type UseMarbleEditorOptions = Omit<UseEditorOptions, \"extensions\"> & {\n  limit?: number;\n  placeholder?: string;\n  extensions?: any[];\n};\n"
  },
  {
    "path": "packages/editor/src/components/editor-table-menus.tsx",
    "content": "import { useCurrentEditor } from \"@tiptap/react\";\nimport { TableColumnMenu, TableRowMenu } from \"../extensions/table\";\n\n/**\n * EditorTableMenus Component\n *\n * Component that renders table row and column menus.\n * These menus appear when clicking on table grip handles (row grips on the left,\n * column grips on the top) and allow users to add/remove rows and columns.\n *\n * The menus are automatically shown/hidden based on which grip handle is selected.\n * This component handles the editor instance check internally.\n *\n * @example\n * ```tsx\n * <EditorProvider>\n *   <EditorContent />\n *   <EditorTableMenus />\n * </EditorProvider>\n * ```\n */\nexport function EditorTableMenus() {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <>\n      <TableRowMenu editor={editor} />\n      <TableColumnMenu editor={editor} />\n    </>\n  );\n}\n"
  },
  {
    "path": "packages/editor/src/components/icons/twitter.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nconst Twitter = (props: SVGProps<SVGSVGElement>) => (\n  <svg {...props} preserveAspectRatio=\"xMidYMid\" viewBox=\"0 0 256 209\">\n    <title>Twitter Icon</title>\n    <path\n      d=\"M256 25.45c-9.42 4.177-19.542 7-30.166 8.27 10.845-6.5 19.172-16.793 23.093-29.057a105.183 105.183 0 0 1-33.351 12.745C205.995 7.201 192.346.822 177.239.822c-29.006 0-52.523 23.516-52.523 52.52 0 4.117.465 8.125 1.36 11.97-43.65-2.191-82.35-23.1-108.255-54.876-4.52 7.757-7.11 16.78-7.11 26.404 0 18.222 9.273 34.297 23.365 43.716a52.312 52.312 0 0 1-23.79-6.57c-.003.22-.003.44-.003.661 0 25.447 18.104 46.675 42.13 51.5a52.592 52.592 0 0 1-23.718.9c6.683 20.866 26.08 36.05 49.062 36.475-17.975 14.086-40.622 22.483-65.228 22.483-4.24 0-8.42-.249-12.529-.734 23.243 14.902 50.85 23.597 80.51 23.597 96.607 0 149.434-80.031 149.434-149.435 0-2.278-.05-4.543-.152-6.795A106.748 106.748 0 0 0 256 25.45\"\n      fill=\"#55acee\"\n    />\n  </svg>\n);\n\nexport { Twitter };\n"
  },
  {
    "path": "packages/editor/src/components/icons/youtube.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nconst YouTubeIcon = (props: SVGProps<SVGSVGElement>) => (\n  <svg {...props} preserveAspectRatio=\"xMidYMid\" viewBox=\"0 0 256 180\">\n    <title>YouTube Icon</title>\n    <path\n      d=\"M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134Z\"\n      fill=\"red\"\n    />\n    <path d=\"m102.421 128.06 66.328-38.418-66.328-38.418z\" fill=\"#FFF\" />\n  </svg>\n);\n\nexport { YouTubeIcon };\n"
  },
  {
    "path": "packages/editor/src/components/index.ts",
    "content": "// Components\n/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\n// Utility Components\nexport {\n  EditorCharacterCount,\n  type EditorCharacterCountProps,\n} from \"./editor-character-count\";\nexport { EditorContent } from \"./editor-content\";\nexport {\n  EditorContext,\n  EditorProvider,\n  type EditorProviderProps,\n  type UseMarbleEditorOptions,\n  useCurrentEditor,\n  useEditor,\n  useMarbleEditor,\n} from \"./editor-provider\";\nexport { EditorTableMenus } from \"./editor-table-menus\";\n// Mark Components\nexport * from \"./marks\";\nexport {\n  EditorBlockHandleMenu,\n  type EditorBlockHandleMenuProps,\n  EditorBubbleMenu,\n  type EditorBubbleMenuProps,\n  EditorFloatingMenu,\n  type EditorFloatingMenuProps,\n} from \"./menus\";\n// Node Components\nexport * from \"./nodes\";\nexport {\n  FieldRichTextEditor,\n  type FieldRichTextEditorProps,\n} from \"./rich-text-field\";\nexport * from \"./ui\";\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-clear-formatting.tsx",
    "content": "import { TextTSlashIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui\";\n\n/**\n * Clear Formatting Button\n *\n * Button that removes all formatting (marks and node styles) from the selected text.\n * Resets the selection to plain text/paragraph format.\n *\n * @example\n * ```tsx\n * <EditorClearFormatting />\n * <EditorClearFormatting hideName />\n * ```\n */\nexport type EditorClearFormattingProps = Pick<EditorButtonProps, \"hideName\">;\n\nexport const EditorClearFormatting = ({\n  hideName = true,\n}: EditorClearFormattingProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}\n      hideName={hideName}\n      icon={TextTSlashIcon}\n      isActive={() => false}\n      name=\"Clear Formatting\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-link-selector.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { Separator } from \"@marble/ui/components/separator\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  ArrowSquareOutIcon,\n  ArrowsInSimpleIcon,\n  ArrowsOutSimpleIcon,\n  CheckIcon,\n  LinkIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { FormEventHandler } from \"react\";\nimport { useEffect, useRef, useState } from \"react\";\n\nexport interface EditorLinkSelectorProps {\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\n/**\n * Link Selector Component\n *\n * A popover component for adding, editing, or removing links from selected text.\n * Opens a popover with an input field to enter a URL. If text is already linked,\n * shows a delete button to remove the link.\n *\n * @example\n * ```tsx\n * <EditorLinkSelector />\n * <EditorLinkSelector open={isOpen} onOpenChange={setIsOpen} />\n * ```\n */\nexport const EditorLinkSelector = ({\n  open: controlledOpen,\n  onOpenChange: controlledOnOpenChange,\n}: EditorLinkSelectorProps) => {\n  const { editor } = useCurrentEditor();\n  const [internalOpen, setInternalOpen] = useState(false);\n  const [url, setUrl] = useState<string>(\"\");\n  const [openInNewTab, setOpenInNewTab] = useState(true);\n  const inputReference = useRef<HTMLInputElement>(null);\n\n  const isOpen = controlledOpen ?? internalOpen;\n  const setIsOpen = controlledOnOpenChange ?? setInternalOpen;\n\n  const isValidUrl = (text: string): boolean => {\n    try {\n      new URL(text);\n      return true;\n    } catch {\n      return false;\n    }\n  };\n\n  const getUrlFromString = (text: string): string | null => {\n    if (isValidUrl(text)) {\n      return text;\n    }\n    try {\n      if (text.includes(\".\") && !text.includes(\" \")) {\n        return new URL(`https://${text}`).toString();\n      }\n\n      return null;\n    } catch {\n      return null;\n    }\n  };\n\n  useEffect(() => {\n    if (isOpen) {\n      const linkAttributes = editor?.getAttributes(\"link\") ?? {};\n      const href = linkAttributes.href ?? \"\";\n      const target = linkAttributes.target ?? \"_blank\";\n      setUrl(href);\n      setOpenInNewTab(target === \"_blank\");\n      setTimeout(() => inputReference.current?.focus(), 0);\n    } else {\n      setUrl(\"\");\n    }\n  }, [isOpen, editor]);\n\n  if (!editor) {\n    return null;\n  }\n\n  const applyLink = () => {\n    const href = getUrlFromString(url);\n\n    if (href) {\n      editor\n        .chain()\n        .focus()\n        .setLink({\n          href,\n          target: openInNewTab ? \"_blank\" : \"_self\",\n        })\n        .run();\n      setUrl(\"\");\n      setIsOpen(false);\n    }\n  };\n\n  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {\n    event.preventDefault();\n    event.stopPropagation();\n    applyLink();\n  };\n\n  return (\n    <Popover modal onOpenChange={setIsOpen} open={isOpen}>\n      <PopoverTrigger\n        render={\n          <Button\n            className={cn(\"gap-2 rounded-none border-none\", {\n              \"text-primary\": editor.isActive(\"link\"),\n            })}\n            size=\"sm\"\n            variant=\"ghost\"\n          >\n            <LinkIcon size={12} />\n          </Button>\n        }\n      />\n      <PopoverContent align=\"start\" className=\"w-fit p-0\" sideOffset={10}>\n        <form className=\"flex items-center gap-0.5 p-1\" onSubmit={handleSubmit}>\n          <input\n            aria-label=\"Link URL\"\n            className=\"min-w-[200px] flex-1 bg-background px-2 py-1 text-sm outline-none\"\n            onChange={(event) => setUrl(event.target.value)}\n            placeholder=\"Paste or type link\"\n            ref={inputReference}\n            type=\"text\"\n            value={url}\n          />\n          <Button\n            className=\"h-8\"\n            disabled={!url || !getUrlFromString(url)}\n            onClick={applyLink}\n            size=\"icon\"\n            type=\"button\"\n            variant=\"secondary\"\n          >\n            <CheckIcon size={12} />\n          </Button>\n          <Separator\n            className=\"mx-1 h-full min-h-[1.5rem] w-[1px]\"\n            orientation=\"vertical\"\n          />\n          <Tooltip>\n            <TooltipTrigger\n              delay={400}\n              render={\n                <Button\n                  className=\"h-8 rounded-sm\"\n                  onClick={() => setOpenInNewTab(!openInNewTab)}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  {openInNewTab ? (\n                    <ArrowsOutSimpleIcon size={12} />\n                  ) : (\n                    <ArrowsInSimpleIcon size={12} />\n                  )}\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>{openInNewTab ? \"Opens in new tab\" : \"Opens in same tab\"}</p>\n            </TooltipContent>\n          </Tooltip>\n          <Tooltip>\n            <TooltipTrigger\n              delay={400}\n              render={\n                <Button\n                  className=\"h-8 rounded-sm\"\n                  disabled={!editor.getAttributes(\"link\").href}\n                  onClick={() => {\n                    editor.chain().focus().unsetLink().run();\n                    setUrl(\"\");\n                  }}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  <TrashIcon size={12} />\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>Remove link</p>\n            </TooltipContent>\n          </Tooltip>\n          <Tooltip>\n            <TooltipTrigger\n              delay={400}\n              render={\n                <Button\n                  className=\"h-8 rounded-sm\"\n                  disabled={!url || !getUrlFromString(url)}\n                  onClick={() => {\n                    const href =\n                      getUrlFromString(url) ||\n                      editor.getAttributes(\"link\").href;\n                    if (href) {\n                      window.open(href, \"_blank\", \"noopener,noreferrer\");\n                    }\n                  }}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"ghost\"\n                >\n                  <ArrowSquareOutIcon size={12} />\n                </Button>\n              }\n            />\n            <TooltipContent>\n              <p>Open link</p>\n            </TooltipContent>\n          </Tooltip>\n        </form>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-bold.tsx",
    "content": "import { TextBIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkBoldProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Bold Mark Button\n *\n * Button to toggle bold formatting on the selected text.\n * Active when the selection has bold formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkBold />\n * <EditorMarkBold hideName />\n * ```\n */\nexport const EditorMarkBold = ({ hideName = false }: EditorMarkBoldProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleBold().run()}\n      hideName={hideName}\n      icon={TextBIcon}\n      isActive={() => editor.isActive(\"bold\") ?? false}\n      name=\"Bold\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-code.tsx",
    "content": "import { CodeIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkCodeProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Inline Code Mark Button\n *\n * Button to toggle inline code formatting on the selected text (monospace font).\n * Active when the selection has inline code formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkCode />\n * <EditorMarkCode hideName />\n * ```\n */\nexport const EditorMarkCode = ({ hideName = false }: EditorMarkCodeProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleCode().run()}\n      hideName={hideName}\n      icon={CodeIcon}\n      isActive={() => editor.isActive(\"code\") ?? false}\n      name=\"Code\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-highlight.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { HighlighterIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor, useEditorState } from \"@tiptap/react\";\nimport { useCallback } from \"react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { ColorPicker } from \"../color-picker\";\n\nexport type EditorMarkHighlightProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Highlight Mark Button\n *\n * Button that opens a color picker to highlight the selected text.\n * Uses a Popover to display the ColorPicker component.\n * Active when the selection has a highlight color applied.\n *\n * @example\n * ```tsx\n * <EditorMarkHighlight />\n * <EditorMarkHighlight hideName />\n * ```\n */\nexport const EditorMarkHighlight = ({\n  hideName = true,\n}: EditorMarkHighlightProps) => {\n  const { editor } = useCurrentEditor();\n\n  const currentHighlight = useEditorState({\n    editor,\n    selector: (ctx) =>\n      ctx.editor?.getAttributes(\"highlight\")?.color || undefined,\n  });\n\n  const isActive = Boolean(currentHighlight);\n\n  const handleColorChange = useCallback(\n    (color: string) => {\n      if (!editor) {\n        return;\n      }\n      editor.chain().focus().setHighlight({ color }).run();\n    },\n    [editor]\n  );\n\n  const handleClearHighlight = useCallback(() => {\n    if (!editor) {\n      return;\n    }\n    editor.chain().focus().unsetHighlight().run();\n  }, [editor]);\n\n  if (!editor) {\n    return null;\n  }\n\n  // Check if Highlight extension is available\n  const hasHighlightExtension = editor.can().setHighlight({ color: \"#000000\" });\n\n  if (!hasHighlightExtension) {\n    return null;\n  }\n\n  return (\n    <Popover modal>\n      <PopoverTrigger\n        render={\n          <Button\n            className={cn(\n              hideName ? \"\" : \"w-full\",\n              isActive &&\n                \"bg-primary/20 text-primary hover:bg-primary/30 hover:text-primary\"\n            )}\n            size=\"sm\"\n            type=\"button\"\n            variant=\"ghost\"\n          >\n            <HighlighterIcon\n              className={cn(\"shrink-0\", isActive && \"text-primary\")}\n              size={12}\n            />\n            {!hideName && <span className=\"flex-1 text-left\">Highlight</span>}\n          </Button>\n        }\n      />\n      <PopoverContent align=\"start\" className=\"w-auto p-0\" side=\"top\">\n        <ColorPicker\n          color={currentHighlight}\n          onChange={handleColorChange}\n          onClear={handleClearHighlight}\n        />\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-italic.tsx",
    "content": "import { TextItalicIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkItalicProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Italic Mark Button\n *\n * Button to toggle italic formatting on the selected text.\n * Active when the selection has italic formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkItalic />\n * <EditorMarkItalic hideName />\n * ```\n */\nexport const EditorMarkItalic = ({\n  hideName = false,\n}: EditorMarkItalicProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleItalic().run()}\n      hideName={hideName}\n      icon={TextItalicIcon}\n      isActive={() => editor.isActive(\"italic\") ?? false}\n      name=\"Italic\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-strike.tsx",
    "content": "import { TextStrikethroughIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkStrikeProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Strikethrough Mark Button\n *\n * Button to toggle strikethrough formatting on the selected text.\n * Active when the selection has strikethrough formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkStrike />\n * <EditorMarkStrike hideName />\n * ```\n */\nexport const EditorMarkStrike = ({\n  hideName = false,\n}: EditorMarkStrikeProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleStrike().run()}\n      hideName={hideName}\n      icon={TextStrikethroughIcon}\n      isActive={() => editor.isActive(\"strike\") ?? false}\n      name=\"Strikethrough\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-subscript.tsx",
    "content": "import { TextSubscriptIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkSubscriptProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Subscript Mark Button\n *\n * Button to toggle subscript formatting on the selected text.\n * Active when the selection has subscript formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkSubscript />\n * <EditorMarkSubscript hideName />\n * ```\n */\nexport const EditorMarkSubscript = ({\n  hideName = false,\n}: EditorMarkSubscriptProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleSubscript().run()}\n      hideName={hideName}\n      icon={TextSubscriptIcon}\n      isActive={() => editor.isActive(\"subscript\") ?? false}\n      name=\"Subscript\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-superscript.tsx",
    "content": "import { TextSuperscriptIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkSuperscriptProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Superscript Mark Button\n *\n * Button to toggle superscript formatting on the selected text.\n * Active when the selection has superscript formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkSuperscript />\n * <EditorMarkSuperscript hideName />\n * ```\n */\nexport const EditorMarkSuperscript = ({\n  hideName = false,\n}: EditorMarkSuperscriptProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleSuperscript().run()}\n      hideName={hideName}\n      icon={TextSuperscriptIcon}\n      isActive={() => editor.isActive(\"superscript\") ?? false}\n      name=\"Superscript\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-text-color.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { PaletteIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor, useEditorState } from \"@tiptap/react\";\nimport { useCallback } from \"react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { ColorPicker } from \"../color-picker\";\n\nexport type EditorMarkTextColorProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Text Color Mark Button\n *\n * Button that opens a color picker to change the text color of the selected text.\n * Uses a Popover to display the ColorPicker component.\n * Active when the selection has a text color applied.\n *\n * @example\n * ```tsx\n * <EditorMarkTextColor />\n * <EditorMarkTextColor hideName />\n * ```\n */\nexport const EditorMarkTextColor = ({\n  hideName = true,\n}: EditorMarkTextColorProps) => {\n  const { editor } = useCurrentEditor();\n\n  const currentColor = useEditorState({\n    editor,\n    selector: (ctx) =>\n      ctx.editor?.getAttributes(\"textStyle\")?.color || undefined,\n  });\n\n  const isActive = Boolean(currentColor);\n\n  const handleColorChange = useCallback(\n    (color: string) => {\n      if (!editor) {\n        return;\n      }\n      editor.chain().focus().setColor(color).run();\n    },\n    [editor]\n  );\n\n  const handleClearColor = useCallback(() => {\n    if (!editor) {\n      return;\n    }\n    editor.chain().focus().unsetColor().run();\n  }, [editor]);\n\n  if (!editor) {\n    return null;\n  }\n\n  // Check if Color extension is available\n  const hasColorExtension = editor.can().setColor(\"#000000\");\n\n  if (!hasColorExtension) {\n    return null;\n  }\n\n  return (\n    <Popover modal>\n      <PopoverTrigger\n        render={\n          <Button\n            className={cn(\n              hideName ? \"\" : \"w-full\",\n              isActive &&\n                \"bg-primary/20 text-primary hover:bg-primary/30 hover:text-primary\"\n            )}\n            size=\"sm\"\n            type=\"button\"\n            variant=\"ghost\"\n          >\n            <PaletteIcon\n              className={cn(\"shrink-0\", isActive && \"text-primary\")}\n              size={12}\n            />\n            {!hideName && <span className=\"flex-1 text-left\">Text Color</span>}\n          </Button>\n        }\n      />\n      <PopoverContent align=\"start\" className=\"w-auto p-0\" side=\"top\">\n        <ColorPicker\n          color={currentColor}\n          onChange={handleColorChange}\n          onClear={handleClearColor}\n        />\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/editor-mark-underline.tsx",
    "content": "import { TextUnderlineIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorMarkUnderlineProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Underline Mark Button\n *\n * Button to toggle underline formatting on the selected text.\n * Active when the selection has underline formatting applied.\n *\n * @example\n * ```tsx\n * <EditorMarkUnderline />\n * <EditorMarkUnderline hideName />\n * ```\n */\nexport const EditorMarkUnderline = ({\n  hideName = false,\n}: EditorMarkUnderlineProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleUnderline().run()}\n      hideName={hideName}\n      icon={TextUnderlineIcon}\n      isActive={() => editor.isActive(\"underline\") ?? false}\n      name=\"Underline\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/marks/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\nexport {\n  EditorClearFormatting,\n  type EditorClearFormattingProps,\n} from \"./editor-clear-formatting\";\nexport {\n  EditorLinkSelector,\n  type EditorLinkSelectorProps,\n} from \"./editor-link-selector\";\nexport { EditorMarkBold, type EditorMarkBoldProps } from \"./editor-mark-bold\";\nexport { EditorMarkCode, type EditorMarkCodeProps } from \"./editor-mark-code\";\nexport {\n  EditorMarkHighlight,\n  type EditorMarkHighlightProps,\n} from \"./editor-mark-highlight\";\nexport {\n  EditorMarkItalic,\n  type EditorMarkItalicProps,\n} from \"./editor-mark-italic\";\nexport {\n  EditorMarkStrike,\n  type EditorMarkStrikeProps,\n} from \"./editor-mark-strike\";\nexport {\n  EditorMarkSubscript,\n  type EditorMarkSubscriptProps,\n} from \"./editor-mark-subscript\";\nexport {\n  EditorMarkSuperscript,\n  type EditorMarkSuperscriptProps,\n} from \"./editor-mark-superscript\";\nexport {\n  EditorMarkTextColor,\n  type EditorMarkTextColorProps,\n} from \"./editor-mark-text-color\";\nexport {\n  EditorMarkUnderline,\n  type EditorMarkUnderlineProps,\n} from \"./editor-mark-underline\";\n"
  },
  {
    "path": "packages/editor/src/components/menus/block-handle-menu.tsx",
    "content": "\"use client\";\n\nimport { offset } from \"@floating-ui/dom\";\nimport { PlusSignIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport {\n  createDropdownMenuHandle,\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"@marble/ui/components/dropdown-menu\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CheckSquareIcon,\n  CodeIcon,\n  CopyIcon,\n  ListBulletsIcon,\n  ListNumbersIcon,\n  QuotesIcon,\n  TextAlignLeftIcon,\n  TextHOneIcon,\n  TextHThreeIcon,\n  TextHTwoIcon,\n  TextTSlashIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport DragHandle from \"@tiptap/extension-drag-handle-react\";\nimport {\n  DOMSerializer,\n  Fragment,\n  type Node as ProseMirrorNode,\n} from \"@tiptap/pm/model\";\nimport { NodeSelection } from \"@tiptap/pm/state\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport {\n  type ComponentType,\n  type SVGProps,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\ninterface TargetBlock {\n  node: ProseMirrorNode;\n  pos: number;\n}\n\ninterface TransformOption {\n  icon: ComponentType<SVGProps<SVGSVGElement>>;\n  isActive: (node: ProseMirrorNode) => boolean;\n  label: string;\n  run: (focusPos: number) => void;\n}\n\nexport interface EditorBlockHandleMenuProps {\n  className?: string;\n}\n\nconst HANDLE_PLUGIN_KEY = \"marble-block-handle\";\n\nconst SUPPORTED_NODE_TYPES = new Set([\n  \"paragraph\",\n  \"heading\",\n  \"blockquote\",\n  \"codeBlock\",\n  \"bulletList\",\n  \"orderedList\",\n  \"taskList\",\n  \"figure\",\n  \"image\",\n  \"imageUpload\",\n  \"video\",\n  \"videoUpload\",\n  \"twitter\",\n  \"twitterUpload\",\n  \"youtube\",\n  \"youtubeUpload\",\n  \"horizontalRule\",\n]);\n\nconst TURN_INTO_SOURCE_TYPES = new Set([\n  \"paragraph\",\n  \"heading\",\n  \"blockquote\",\n  \"codeBlock\",\n]);\n\nconst CLEAR_FORMATTING_TYPES = new Set([\n  \"paragraph\",\n  \"heading\",\n  \"blockquote\",\n  \"codeBlock\",\n]);\n\nconst HANDLE_CONTROL_CLASSNAME =\n  \"flex size-6.5 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\";\n\nfunction getFocusPos(target: TargetBlock) {\n  return target.node.isTextblock ? target.pos + 1 : target.pos;\n}\n\nfunction isSupportedNode(\n  node: ProseMirrorNode | null\n): node is ProseMirrorNode {\n  return node !== null && SUPPORTED_NODE_TYPES.has(node.type.name);\n}\n\nfunction canTurnInto(node: ProseMirrorNode) {\n  return TURN_INTO_SOURCE_TYPES.has(node.type.name);\n}\n\nfunction canClearFormatting(node: ProseMirrorNode) {\n  return CLEAR_FORMATTING_TYPES.has(node.type.name);\n}\n\nfunction getScrollParent(node: HTMLElement | null) {\n  if (!node) {\n    return null;\n  }\n\n  let current: HTMLElement | null = node.parentElement;\n\n  while (current) {\n    const { overflowY } = window.getComputedStyle(current);\n\n    if (overflowY === \"auto\" || overflowY === \"scroll\") {\n      return current;\n    }\n\n    current = current.parentElement;\n  }\n\n  return null;\n}\n\nfunction serializeNodeToClipboardData(\n  node: ProseMirrorNode,\n  schema: Parameters<typeof DOMSerializer.fromSchema>[0],\n  ownerDocument: Document\n) {\n  const serializer = DOMSerializer.fromSchema(schema);\n  const fragment = serializer.serializeFragment(Fragment.from(node), {\n    document: ownerDocument,\n  });\n  const container = ownerDocument.createElement(\"div\");\n\n  container.appendChild(fragment);\n\n  return {\n    html: container.innerHTML,\n    text: node.textContent || container.textContent || \"\",\n  };\n}\n\nexport function EditorBlockHandleMenu({\n  className,\n}: EditorBlockHandleMenuProps = {}) {\n  const { editor } = useCurrentEditor();\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [target, setTarget] = useState<TargetBlock | null>(null);\n  const menuHandle = useMemo(() => createDropdownMenuHandle(), []);\n  const menuTriggerRef = useRef<HTMLButtonElement | null>(null);\n\n  useEffect(() => {\n    if (!editor) {\n      return;\n    }\n\n    const transaction = editor.state.tr.setMeta(\"lockDragHandle\", menuOpen);\n    editor.view.dispatch(transaction);\n  }, [editor, menuOpen]);\n\n  useEffect(() => {\n    if (!editor) {\n      return;\n    }\n\n    const hideHandle = () => {\n      setMenuOpen(false);\n      setTarget(null);\n      editor.view.dispatch(editor.state.tr.setMeta(\"hideDragHandle\", true));\n    };\n\n    const scrollParent = getScrollParent(editor.view.dom as HTMLElement);\n\n    scrollParent?.addEventListener(\"scroll\", hideHandle, { passive: true });\n    window.addEventListener(\"scroll\", hideHandle, { passive: true });\n\n    return () => {\n      scrollParent?.removeEventListener(\"scroll\", hideHandle);\n      window.removeEventListener(\"scroll\", hideHandle);\n    };\n  }, [editor]);\n\n  const handleNodeChange = useCallback(\n    ({ node, pos }: { node: ProseMirrorNode | null; pos: number }) => {\n      if (!editor || !editor.isEditable || !isSupportedNode(node)) {\n        if (!menuOpen) {\n          setTarget(null);\n        }\n        return;\n      }\n\n      setTarget({ node, pos });\n    },\n    [editor, menuOpen]\n  );\n\n  const selectTargetNode = useCallback(() => {\n    if (!editor || !target) {\n      return null;\n    }\n\n    const nextSelection = NodeSelection.create(editor.state.doc, target.pos);\n    editor.view.dispatch(editor.state.tr.setSelection(nextSelection));\n\n    return editor.state.doc.nodeAt(target.pos);\n  }, [editor, target]);\n\n  const handleAdd = useCallback(() => {\n    if (!editor || !target) {\n      return;\n    }\n\n    const currentNode = editor.state.doc.nodeAt(target.pos);\n\n    if (!currentNode) {\n      return;\n    }\n\n    const currentNodeIsEmptyParagraph =\n      currentNode.type.name === \"paragraph\" && currentNode.content.size === 0;\n    const insertPos = target.pos + currentNode.nodeSize;\n    const focusPos = currentNodeIsEmptyParagraph\n      ? target.pos + 2\n      : insertPos + 2;\n\n    editor\n      .chain()\n      .command(({ dispatch, state, tr }) => {\n        if (!dispatch) {\n          return true;\n        }\n\n        if (currentNodeIsEmptyParagraph) {\n          tr.insertText(\"/\", target.pos + 1);\n          dispatch(tr);\n          return true;\n        }\n\n        const paragraphNodeType = state.schema.nodes.paragraph;\n\n        if (!paragraphNodeType) {\n          return false;\n        }\n\n        const slashParagraph = paragraphNodeType.create(\n          null,\n          state.schema.text(\"/\")\n        );\n\n        tr.insert(insertPos, slashParagraph);\n        dispatch(tr);\n        return true;\n      })\n      .focus(focusPos)\n      .run();\n  }, [editor, target]);\n\n  const handleDuplicate = useCallback(() => {\n    if (!editor || !target) {\n      return;\n    }\n\n    const currentNode = editor.state.doc.nodeAt(target.pos);\n\n    if (!currentNode) {\n      return;\n    }\n\n    editor\n      .chain()\n      .focus()\n      .insertContentAt(target.pos + currentNode.nodeSize, currentNode.toJSON())\n      .run();\n  }, [editor, target]);\n\n  const handleDelete = useCallback(() => {\n    if (!editor || !target) {\n      return;\n    }\n\n    editor.chain().focus().setNodeSelection(target.pos).deleteSelection().run();\n  }, [editor, target]);\n\n  const handleCopy = useCallback(async () => {\n    if (!editor || !target) {\n      return;\n    }\n\n    const currentNode =\n      editor.state.doc.nodeAt(target.pos) ?? selectTargetNode();\n\n    if (!currentNode) {\n      return;\n    }\n\n    const ownerDocument = editor.view.dom.ownerDocument;\n    const { html, text } = serializeNodeToClipboardData(\n      currentNode,\n      editor.schema,\n      ownerDocument\n    );\n\n    try {\n      if (\n        typeof window !== \"undefined\" &&\n        \"ClipboardItem\" in window &&\n        html.trim().length > 0\n      ) {\n        const clipboardItem = new ClipboardItem({\n          \"text/html\": new Blob([html], { type: \"text/html\" }),\n          \"text/plain\": new Blob([text || html], { type: \"text/plain\" }),\n        });\n\n        await navigator.clipboard.write([clipboardItem]);\n        return;\n      }\n\n      await navigator.clipboard.writeText(text || html);\n    } catch (error) {\n      console.error(\"Failed to copy block content:\", error);\n    }\n  }, [editor, selectTargetNode, target]);\n\n  const handleClearFormatting = useCallback(() => {\n    if (!editor || !target || !canClearFormatting(target.node)) {\n      return;\n    }\n\n    const focusPos = getFocusPos(target);\n    const chain = editor.chain().focus(focusPos).unsetAllMarks();\n\n    if (target.node.type.name !== \"paragraph\") {\n      chain.clearNodes();\n    }\n\n    chain.run();\n  }, [editor, target]);\n\n  const transformOptions = useMemo<TransformOption[]>(() => {\n    if (!editor) {\n      return [];\n    }\n\n    return [\n      {\n        icon: TextAlignLeftIcon,\n        isActive: (node) => node.type.name === \"paragraph\",\n        label: \"Text\",\n        run: (focusPos) => {\n          editor.chain().focus(focusPos).clearNodes().run();\n        },\n      },\n      {\n        icon: TextHOneIcon,\n        isActive: (node) =>\n          node.type.name === \"heading\" && node.attrs.level === 1,\n        label: \"Heading 1\",\n        run: (focusPos) => {\n          editor\n            .chain()\n            .focus(focusPos)\n            .clearNodes()\n            .setNode(\"heading\", { level: 1 })\n            .run();\n        },\n      },\n      {\n        icon: TextHTwoIcon,\n        isActive: (node) =>\n          node.type.name === \"heading\" && node.attrs.level === 2,\n        label: \"Heading 2\",\n        run: (focusPos) => {\n          editor\n            .chain()\n            .focus(focusPos)\n            .clearNodes()\n            .setNode(\"heading\", { level: 2 })\n            .run();\n        },\n      },\n      {\n        icon: TextHThreeIcon,\n        isActive: (node) =>\n          node.type.name === \"heading\" && node.attrs.level === 3,\n        label: \"Heading 3\",\n        run: (focusPos) => {\n          editor\n            .chain()\n            .focus(focusPos)\n            .clearNodes()\n            .setNode(\"heading\", { level: 3 })\n            .run();\n        },\n      },\n      {\n        icon: ListBulletsIcon,\n        isActive: (node) => node.type.name === \"bulletList\",\n        label: \"Bullet List\",\n        run: (focusPos) => {\n          editor.chain().focus(focusPos).clearNodes().toggleBulletList().run();\n        },\n      },\n      {\n        icon: ListNumbersIcon,\n        isActive: (node) => node.type.name === \"orderedList\",\n        label: \"Numbered List\",\n        run: (focusPos) => {\n          editor.chain().focus(focusPos).clearNodes().toggleOrderedList().run();\n        },\n      },\n      {\n        icon: CheckSquareIcon,\n        isActive: (node) => node.type.name === \"taskList\",\n        label: \"Task List\",\n        run: (focusPos) => {\n          editor\n            .chain()\n            .focus(focusPos)\n            .clearNodes()\n            .toggleList(\"taskList\", \"taskItem\")\n            .run();\n        },\n      },\n      {\n        icon: QuotesIcon,\n        isActive: (node) => node.type.name === \"blockquote\",\n        label: \"Quote\",\n        run: (focusPos) => {\n          editor.chain().focus(focusPos).clearNodes().toggleBlockquote().run();\n        },\n      },\n      {\n        icon: CodeIcon,\n        isActive: (node) => node.type.name === \"codeBlock\",\n        label: \"Code\",\n        run: (focusPos) => {\n          editor.chain().focus(focusPos).clearNodes().toggleCodeBlock().run();\n        },\n      },\n    ];\n  }, [editor]);\n\n  if (!editor) {\n    return null;\n  }\n\n  const canShowMenu = !!target && editor.isEditable;\n  const canTransformTarget = !!target && canTurnInto(target.node);\n  const canClearTarget = !!target && canClearFormatting(target.node);\n\n  return (\n    <DragHandle\n      className={cn(\"z-40\", className)}\n      computePositionConfig={{\n        middleware: [offset(12)],\n        placement: \"left-start\",\n      }}\n      editor={editor}\n      onElementDragStart={() => {\n        setMenuOpen(false);\n      }}\n      onNodeChange={handleNodeChange}\n      pluginKey={HANDLE_PLUGIN_KEY}\n    >\n      <div\n        aria-hidden={!canShowMenu}\n        className={cn(\n          \"flex w-[4.5rem] items-center gap-1 text-muted-foreground transition-opacity\",\n          canShowMenu\n            ? \"pointer-events-auto opacity-100\"\n            : \"pointer-events-none opacity-0\"\n        )}\n      >\n        <Tooltip>\n          <TooltipTrigger\n            delay={300}\n            render={\n              <Button\n                className=\"size-6.5 rounded-md bg-transparent p-0 text-muted-foreground shadow-none hover:bg-accent/60 hover:text-foreground\"\n                onClick={handleAdd}\n                size=\"icon-xs\"\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <HugeiconsIcon\n                  className=\"size-4\"\n                  icon={PlusSignIcon}\n                  strokeWidth={2}\n                />\n                <span className=\"sr-only\">Insert block below</span>\n              </Button>\n            }\n          />\n          <TooltipContent side=\"top\">\n            <p>Click to insert block below</p>\n          </TooltipContent>\n        </Tooltip>\n\n        <DropdownMenu\n          handle={menuHandle}\n          onOpenChange={setMenuOpen}\n          open={menuOpen}\n        >\n          <div className=\"relative size-6.5\">\n            <Tooltip>\n              <TooltipTrigger\n                delay={300}\n                render={\n                  <button\n                    aria-expanded={menuOpen}\n                    aria-haspopup=\"menu\"\n                    aria-label=\"Open block actions\"\n                    className={cn(\n                      HANDLE_CONTROL_CLASSNAME,\n                      \"cursor-grab active:cursor-grabbing\"\n                    )}\n                    onClick={() => {\n                      if (menuOpen) {\n                        menuHandle.close();\n                        return;\n                      }\n\n                      menuTriggerRef.current?.click();\n                    }}\n                    type=\"button\"\n                  >\n                    <svg\n                      className=\"size-4\"\n                      fill=\"currentColor\"\n                      height=\"24\"\n                      viewBox=\"0 0 24 24\"\n                      width=\"24\"\n                      xmlns=\"http://www.w3.org/2000/svg\"\n                    >\n                      <title>Open block actions</title>\n                      <path\n                        d=\"M9 3C7.89543 3 7 3.89543 7 5C7 6.10457 7.89543 7 9 7C10.1046 7 11 6.10457 11 5C11 3.89543 10.1046 3 9 3Z\"\n                        fill=\"currentColor\"\n                      />\n                      <path\n                        d=\"M9 10C7.89543 10 7 10.8954 7 12C7 13.1046 7.89543 14 9 14C10.1046 14 11 13.1046 11 12C11 10.8954 10.1046 10 9 10Z\"\n                        fill=\"currentColor\"\n                      />\n                      <path\n                        d=\"M7 19C7 17.8954 7.89543 17 9 17C10.1046 17 11 17.8954 11 19C11 20.1046 10.1046 21 9 21C7.89543 21 7 20.1046 7 19Z\"\n                        fill=\"currentColor\"\n                      />\n                      <path\n                        d=\"M15 10C13.8954 10 13 10.8954 13 12C13 13.1046 13.8954 14 15 14C16.1046 14 17 13.1046 17 12C17 10.8954 16.1046 10 15 10Z\"\n                        fill=\"currentColor\"\n                      />\n                      <path\n                        d=\"M13 5C13 3.89543 13.8954 3 15 3C16.1046 3 17 3.89543 17 5C17 6.10457 16.1046 7 15 7C13.8954 7 13 6.10457 13 5Z\"\n                        fill=\"currentColor\"\n                      />\n                      <path\n                        d=\"M15 17C13.8954 17 13 17.8954 13 19C13 20.1046 13.8954 21 15 21C16.1046 21 17 20.1046 17 19C17 17.8954 16.1046 17 15 17Z\"\n                        fill=\"currentColor\"\n                      />\n                    </svg>\n                    <span className=\"sr-only\">Open block actions</span>\n                  </button>\n                }\n              />\n              <TooltipContent side=\"top\">\n                <p>Drag to move, click to open menu</p>\n              </TooltipContent>\n            </Tooltip>\n\n            <DropdownMenuTrigger\n              handle={menuHandle}\n              render={\n                <button\n                  aria-hidden=\"true\"\n                  className={cn(\n                    HANDLE_CONTROL_CLASSNAME,\n                    \"pointer-events-none invisible absolute inset-0\"\n                  )}\n                  ref={menuTriggerRef}\n                  tabIndex={-1}\n                  type=\"button\"\n                />\n              }\n            />\n          </div>\n\n          <DropdownMenuContent align=\"start\" className=\"w-56\" sideOffset={8}>\n            {canTransformTarget ? (\n              <DropdownMenuSub>\n                <DropdownMenuSubTrigger>\n                  <TextAlignLeftIcon className=\"size-4\" />\n                  <span>Turn into</span>\n                </DropdownMenuSubTrigger>\n                <DropdownMenuSubContent className=\"w-52\">\n                  {transformOptions.map((option) => {\n                    const Icon = option.icon;\n                    const isActive = target\n                      ? option.isActive(target.node)\n                      : false;\n\n                    return (\n                      <DropdownMenuItem\n                        disabled={isActive}\n                        key={option.label}\n                        onClick={() => {\n                          if (!target) {\n                            return;\n                          }\n\n                          option.run(getFocusPos(target));\n                        }}\n                      >\n                        <Icon className=\"size-4\" />\n                        <span>{option.label}</span>\n                      </DropdownMenuItem>\n                    );\n                  })}\n                </DropdownMenuSubContent>\n              </DropdownMenuSub>\n            ) : null}\n\n            {canTransformTarget && canClearTarget ? (\n              <DropdownMenuSeparator />\n            ) : null}\n\n            {canClearTarget ? (\n              <DropdownMenuItem onClick={handleClearFormatting}>\n                <TextTSlashIcon className=\"size-4\" />\n                <span>Clear formatting</span>\n              </DropdownMenuItem>\n            ) : null}\n\n            {canTransformTarget || canClearTarget ? (\n              <DropdownMenuSeparator />\n            ) : null}\n\n            <DropdownMenuItem onClick={handleDuplicate}>\n              <CopyIcon className=\"size-4\" />\n              <span>Duplicate</span>\n            </DropdownMenuItem>\n\n            <DropdownMenuItem onClick={handleCopy}>\n              <CopyIcon className=\"size-4\" />\n              <span>Copy</span>\n            </DropdownMenuItem>\n\n            <DropdownMenuSeparator />\n\n            <DropdownMenuItem onClick={handleDelete} variant=\"destructive\">\n              <TrashIcon className=\"size-4\" />\n              <span>Delete</span>\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </DragHandle>\n  );\n}\n"
  },
  {
    "path": "packages/editor/src/components/menus/bubble-menu.tsx",
    "content": "/** biome-ignore-all lint/suspicious/noArrayIndexKey: <> */\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport {\n  BubbleMenu as TiptapBubbleMenu,\n  type BubbleMenuProps as TiptapBubbleMenuProps,\n} from \"@tiptap/react/menus\";\nimport { useCallback } from \"react\";\nimport { isCustomNodeSelected, isTextSelected } from \"../../lib\";\n\nexport type EditorBubbleMenuProps = Omit<TiptapBubbleMenuProps, \"editor\">;\n\n/**\n * Bubble Menu Component\n *\n * A floating menu that appears when text is selected in the editor.\n * Displays formatting options like text styles, marks, and other tools.\n * Automatically positions itself above the selected text using Floating UI.\n *\n * The menu will not appear when custom nodes (like YouTube embeds, code blocks, etc.) are selected.\n *\n * @example\n * ```tsx\n * <EditorBubbleMenu>\n *   <EditorSelector title=\"Text\">\n *     <EditorNodeHeading1 />\n *   </EditorSelector>\n *   <EditorMarkBold />\n * </EditorBubbleMenu>\n * ```\n */\nexport const EditorBubbleMenu = ({\n  className,\n  children,\n  shouldShow: customShouldShow,\n  ...props\n}: EditorBubbleMenuProps) => {\n  const { editor } = useCurrentEditor();\n\n  const shouldShow = useCallback(\n    (\n      props: Parameters<NonNullable<TiptapBubbleMenuProps[\"shouldShow\"]>>[0]\n    ) => {\n      if (!editor || !props.view || editor.view.dragging) {\n        return false;\n      }\n\n      // If a custom shouldShow is provided, check it first\n      if (customShouldShow) {\n        const customResult = customShouldShow(props);\n        if (!customResult) {\n          return false;\n        }\n      }\n\n      const fromPos = props.from ?? 0;\n      const domAtPos = props.view.domAtPos(fromPos).node as HTMLElement | null;\n      const nodeDOM = props.view.nodeDOM(fromPos) as HTMLElement | null;\n      const node = nodeDOM ?? domAtPos;\n\n      // Don't show bubble menu if a custom node is selected\n      if (isCustomNodeSelected(editor, node)) {\n        return false;\n      }\n\n      // Only show if text is actually selected\n      return isTextSelected({ editor });\n    },\n    [editor, customShouldShow]\n  );\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <TiptapBubbleMenu\n      className={cn(\n        \"flex rounded-xl border bg-background p-1 shadow\",\n        \"[&>*:first-child]:rounded-l-[9px]\",\n        \"[&>*:last-child]:rounded-r-[9px]\",\n        className\n      )}\n      editor={editor}\n      shouldShow={shouldShow}\n      {...props}\n    >\n      {children}\n    </TiptapBubbleMenu>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/menus/floating-menu.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport {\n  FloatingMenu as TiptapFloatingMenu,\n  type FloatingMenuProps as TiptapFloatingMenuProps,\n} from \"@tiptap/react/menus\";\n\nexport type EditorFloatingMenuProps = Omit<TiptapFloatingMenuProps, \"editor\">;\n\n/**\n * Floating Menu Component\n * Shows formatting options on empty lines\n * Updated for Tiptap v3 with Floating UI\n */\nexport const EditorFloatingMenu = ({\n  className,\n  ...props\n}: EditorFloatingMenuProps) => {\n  const { editor } = useCurrentEditor();\n\n  return (\n    <TiptapFloatingMenu\n      className={cn(\"flex items-center bg-secondary\", className)}\n      editor={editor ?? null}\n      {...props}\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/menus/index.ts",
    "content": "/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */\nexport {\n  EditorBlockHandleMenu,\n  type EditorBlockHandleMenuProps,\n} from \"./block-handle-menu\";\nexport { EditorBubbleMenu, type EditorBubbleMenuProps } from \"./bubble-menu\";\nexport {\n  EditorFloatingMenu,\n  type EditorFloatingMenuProps,\n} from \"./floating-menu\";\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-align-selector.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  TextAlignCenter,\n  TextAlignJustify,\n  TextAlignLeft,\n  TextAlignRight,\n} from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport { useState } from \"react\";\n\nexport interface EditorAlignSelectorProps {\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\ntype Alignment = \"left\" | \"center\" | \"right\" | \"justify\";\n\nconst alignments: {\n  value: Alignment;\n  icon: typeof TextAlignLeft;\n  label: string;\n}[] = [\n  { value: \"left\", icon: TextAlignLeft, label: \"Align Left\" },\n  { value: \"center\", icon: TextAlignCenter, label: \"Align Center\" },\n  { value: \"right\", icon: TextAlignRight, label: \"Align Right\" },\n  { value: \"justify\", icon: TextAlignJustify, label: \"Justify\" },\n];\n\n/**\n * Align Selector Component\n *\n * A popover component for setting text alignment.\n * Shows alignment options when clicked.\n *\n * @example\n * ```tsx\n * <EditorAlignSelector />\n * <EditorAlignSelector open={isOpen} onOpenChange={setIsOpen} />\n * ```\n */\nexport const EditorAlignSelector = ({\n  open: controlledOpen,\n  onOpenChange: controlledOnOpenChange,\n}: EditorAlignSelectorProps) => {\n  const { editor } = useCurrentEditor();\n  const [internalOpen, setInternalOpen] = useState(false);\n\n  const isOpen = controlledOpen ?? internalOpen;\n  const setIsOpen = controlledOnOpenChange ?? setInternalOpen;\n\n  if (!editor) {\n    return null;\n  }\n\n  const getCurrentAlignment = (): Alignment => {\n    for (const alignment of alignments) {\n      if (editor.isActive({ textAlign: alignment.value })) {\n        return alignment.value;\n      }\n    }\n    return \"left\";\n  };\n\n  const currentAlignment = getCurrentAlignment();\n  const CurrentIcon =\n    alignments.find((a) => a.value === currentAlignment)?.icon ?? TextAlignLeft;\n\n  const handleAlignmentChange = (alignment: Alignment) => {\n    editor.chain().focus().setTextAlign(alignment).run();\n    setIsOpen(false);\n  };\n\n  return (\n    <Popover modal onOpenChange={setIsOpen} open={isOpen}>\n      <PopoverTrigger\n        render={\n          <Button\n            className={cn(\"gap-2 rounded-none border-none\")}\n            size=\"sm\"\n            variant=\"ghost\"\n          >\n            <CurrentIcon size={12} />\n          </Button>\n        }\n      />\n      <PopoverContent align=\"start\" className=\"w-fit p-1\" sideOffset={10}>\n        <div className=\"flex items-center gap-0.5\">\n          {alignments.map(({ value, icon: Icon, label }) => (\n            <Tooltip key={value}>\n              <TooltipTrigger\n                delay={400}\n                render={\n                  <Button\n                    className={cn(\n                      \"h-8 w-8\",\n                      currentAlignment === value && \"text-primary\"\n                    )}\n                    onClick={() => handleAlignmentChange(value)}\n                    size=\"icon\"\n                    type=\"button\"\n                    variant=\"ghost\"\n                  >\n                    <Icon size={14} />\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                <p>{label}</p>\n              </TooltipContent>\n            </Tooltip>\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-align.tsx",
    "content": "import {\n  TextAlignCenter,\n  TextAlignJustify,\n  TextAlignLeft,\n  TextAlignRight,\n} from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\n/**\n * Align Left Button\n *\n * Button that aligns text to the left.\n *\n * @example\n * ```tsx\n * <EditorAlignLeft />\n * <EditorAlignLeft hideName />\n * ```\n */\nexport type EditorAlignProps = Pick<EditorButtonProps, \"hideName\">;\n\nexport const EditorAlignLeft = ({ hideName = true }: EditorAlignProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().setTextAlign(\"left\").run()}\n      hideName={hideName}\n      icon={TextAlignLeft}\n      isActive={() => editor.isActive({ textAlign: \"left\" }) ?? false}\n      name=\"Align Left\"\n    />\n  );\n};\n\n/**\n * Align Center Button\n *\n * Button that centers text.\n *\n * @example\n * ```tsx\n * <EditorAlignCenter />\n * <EditorAlignCenter hideName />\n * ```\n */\nexport const EditorAlignCenter = ({ hideName = true }: EditorAlignProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().setTextAlign(\"center\").run()}\n      hideName={hideName}\n      icon={TextAlignCenter}\n      isActive={() => editor.isActive({ textAlign: \"center\" }) ?? false}\n      name=\"Align Center\"\n    />\n  );\n};\n\n/**\n * Align Right Button\n *\n * Button that aligns text to the right.\n *\n * @example\n * ```tsx\n * <EditorAlignRight />\n * <EditorAlignRight hideName />\n * ```\n */\nexport const EditorAlignRight = ({ hideName = true }: EditorAlignProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().setTextAlign(\"right\").run()}\n      hideName={hideName}\n      icon={TextAlignRight}\n      isActive={() => editor.isActive({ textAlign: \"right\" }) ?? false}\n      name=\"Align Right\"\n    />\n  );\n};\n\n/**\n * Justify Button\n *\n * Button that justifies text.\n *\n * @example\n * ```tsx\n * <EditorAlignJustify />\n * <EditorAlignJustify hideName />\n * ```\n */\nexport const EditorAlignJustify = ({ hideName = true }: EditorAlignProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().setTextAlign(\"justify\").run()}\n      hideName={hideName}\n      icon={TextAlignJustify}\n      isActive={() => editor.isActive({ textAlign: \"justify\" }) ?? false}\n      name=\"Justify\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-bullet-list.tsx",
    "content": "import { ListBulletsIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeBulletListProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Bullet List Node Button\n *\n * Button to toggle the current selection to a bullet list (unordered list).\n * Active when the selection is within a bullet list.\n *\n * @example\n * ```tsx\n * <EditorNodeBulletList />\n * <EditorNodeBulletList hideName />\n * ```\n */\nexport const EditorNodeBulletList = ({\n  hideName = false,\n}: EditorNodeBulletListProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleBulletList().run()}\n      hideName={hideName}\n      icon={ListBulletsIcon}\n      isActive={() => editor.isActive(\"bulletList\") ?? false}\n      name=\"Bullet List\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-code.tsx",
    "content": "import { CodeIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeCodeProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Code Block Node Button\n *\n * Button to toggle the current selection to a code block (syntax-highlighted code).\n * Active when the selection is within a code block.\n *\n * @example\n * ```tsx\n * <EditorNodeCode />\n * <EditorNodeCode hideName />\n * ```\n */\nexport const EditorNodeCode = ({ hideName = false }: EditorNodeCodeProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleCodeBlock().run()}\n      hideName={hideName}\n      icon={CodeIcon}\n      isActive={() => editor.isActive(\"codeBlock\") ?? false}\n      name=\"Code\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-heading1.tsx",
    "content": "import { TextHOneIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeHeading1Props = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Heading 1 Node Button\n *\n * Button to toggle the current selection to Heading 1 (largest heading).\n * Active when the selection is a heading with level 1.\n *\n * @example\n * ```tsx\n * <EditorNodeHeading1 />\n * <EditorNodeHeading1 hideName />\n * ```\n */\nexport const EditorNodeHeading1 = ({\n  hideName = false,\n}: EditorNodeHeading1Props) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}\n      hideName={hideName}\n      icon={TextHOneIcon}\n      isActive={() => editor.isActive(\"heading\", { level: 1 }) ?? false}\n      name=\"Heading 1\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-heading2.tsx",
    "content": "import { TextHTwoIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeHeading2Props = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Heading 2 Node Button\n *\n * Button to toggle the current selection to Heading 2 (medium heading).\n * Active when the selection is a heading with level 2.\n *\n * @example\n * ```tsx\n * <EditorNodeHeading2 />\n * <EditorNodeHeading2 hideName />\n * ```\n */\nexport const EditorNodeHeading2 = ({\n  hideName = false,\n}: EditorNodeHeading2Props) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}\n      hideName={hideName}\n      icon={TextHTwoIcon}\n      isActive={() => editor.isActive(\"heading\", { level: 2 }) ?? false}\n      name=\"Heading 2\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-heading3.tsx",
    "content": "import { TextHThreeIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeHeading3Props = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Heading 3 Node Button\n *\n * Button to toggle the current selection to Heading 3 (small heading).\n * Active when the selection is a heading with level 3.\n *\n * @example\n * ```tsx\n * <EditorNodeHeading3 />\n * <EditorNodeHeading3 hideName />\n * ```\n */\nexport const EditorNodeHeading3 = ({\n  hideName = false,\n}: EditorNodeHeading3Props) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}\n      hideName={hideName}\n      icon={TextHThreeIcon}\n      isActive={() => editor.isActive(\"heading\", { level: 3 }) ?? false}\n      name=\"Heading 3\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-ordered-list.tsx",
    "content": "import { ListNumbersIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeOrderedListProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Ordered List Node Button\n *\n * Button to toggle the current selection to an ordered list (numbered list).\n * Active when the selection is within an ordered list.\n *\n * @example\n * ```tsx\n * <EditorNodeOrderedList />\n * <EditorNodeOrderedList hideName />\n * ```\n */\nexport const EditorNodeOrderedList = ({\n  hideName = false,\n}: EditorNodeOrderedListProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() => editor.chain().focus().toggleOrderedList().run()}\n      hideName={hideName}\n      icon={ListNumbersIcon}\n      isActive={() => editor.isActive(\"orderedList\") ?? false}\n      name=\"Numbered List\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-quote.tsx",
    "content": "import { QuotesIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeQuoteProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Quote Node Button\n *\n * Button to toggle the current selection to a blockquote (quote block).\n * Active when the selection is within a blockquote.\n *\n * @example\n * ```tsx\n * <EditorNodeQuote />\n * <EditorNodeQuote hideName />\n * ```\n */\nexport const EditorNodeQuote = ({ hideName = false }: EditorNodeQuoteProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() =>\n        editor\n          .chain()\n          .focus()\n          .toggleNode(\"paragraph\", \"paragraph\")\n          .toggleBlockquote()\n          .run()\n      }\n      hideName={hideName}\n      icon={QuotesIcon}\n      isActive={() => editor.isActive(\"blockquote\") ?? false}\n      name=\"Quote\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-table.tsx",
    "content": "import { TableIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeTableProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Table Node Button\n *\n * Button to insert a new table (3x3 with header row) at the current position.\n * Active when the cursor is inside a table.\n *\n * @example\n * ```tsx\n * <EditorNodeTable />\n * <EditorNodeTable hideName />\n * ```\n */\nexport const EditorNodeTable = ({ hideName = false }: EditorNodeTableProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() =>\n        editor\n          .chain()\n          .focus()\n          .insertTable({ rows: 3, cols: 3, withHeaderRow: true })\n          .run()\n      }\n      hideName={hideName}\n      icon={TableIcon}\n      isActive={() => editor.isActive(\"table\") ?? false}\n      name=\"Table\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-task-list.tsx",
    "content": "import { CheckSquareIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeTaskListProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Task List Node Button\n *\n * Button to toggle the current selection to a task list (to-do list with checkboxes).\n * Active when the selection is within a task list item.\n *\n * @example\n * ```tsx\n * <EditorNodeTaskList />\n * <EditorNodeTaskList hideName />\n * ```\n */\nexport const EditorNodeTaskList = ({\n  hideName = false,\n}: EditorNodeTaskListProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() =>\n        editor.chain().focus().toggleList(\"taskList\", \"taskItem\").run()\n      }\n      hideName={hideName}\n      icon={CheckSquareIcon}\n      isActive={() => editor.isActive(\"taskItem\") ?? false}\n      name=\"To-do List\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/editor-node-text.tsx",
    "content": "import { TextAlignLeftIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport type { EditorButtonProps } from \"../../types\";\nimport { BubbleMenuButton } from \"../ui/editor-button\";\n\nexport type EditorNodeTextProps = Pick<EditorButtonProps, \"hideName\">;\n\n/**\n * Text Node Button\n *\n * Button to toggle the current selection to plain text (paragraph) format.\n * Active when the selection is not a heading, list, or other block node.\n *\n * @example\n * ```tsx\n * <EditorNodeText />\n * <EditorNodeText hideName />\n * ```\n */\nexport const EditorNodeText = ({ hideName = false }: EditorNodeTextProps) => {\n  const { editor } = useCurrentEditor();\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <BubbleMenuButton\n      command={() =>\n        editor.chain().focus().toggleNode(\"paragraph\", \"paragraph\").run()\n      }\n      hideName={hideName}\n      icon={TextAlignLeftIcon}\n      isActive={() =>\n        (editor &&\n          !editor.isActive(\"paragraph\") &&\n          !editor.isActive(\"bulletList\") &&\n          !editor.isActive(\"orderedList\")) ??\n        false\n      }\n      name=\"Text\"\n    />\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/nodes/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\nexport {\n  EditorAlignCenter,\n  EditorAlignJustify,\n  EditorAlignLeft,\n  type EditorAlignProps,\n  EditorAlignRight,\n} from \"./editor-align\";\nexport {\n  EditorAlignSelector,\n  type EditorAlignSelectorProps,\n} from \"./editor-align-selector\";\nexport {\n  EditorNodeBulletList,\n  type EditorNodeBulletListProps,\n} from \"./editor-node-bullet-list\";\nexport { EditorNodeCode, type EditorNodeCodeProps } from \"./editor-node-code\";\nexport {\n  EditorNodeHeading1,\n  type EditorNodeHeading1Props,\n} from \"./editor-node-heading1\";\nexport {\n  EditorNodeHeading2,\n  type EditorNodeHeading2Props,\n} from \"./editor-node-heading2\";\nexport {\n  EditorNodeHeading3,\n  type EditorNodeHeading3Props,\n} from \"./editor-node-heading3\";\nexport {\n  EditorNodeOrderedList,\n  type EditorNodeOrderedListProps,\n} from \"./editor-node-ordered-list\";\nexport {\n  EditorNodeQuote,\n  type EditorNodeQuoteProps,\n} from \"./editor-node-quote\";\nexport {\n  EditorNodeTable,\n  type EditorNodeTableProps,\n} from \"./editor-node-table\";\nexport {\n  EditorNodeTaskList,\n  type EditorNodeTaskListProps,\n} from \"./editor-node-task-list\";\nexport { EditorNodeText, type EditorNodeTextProps } from \"./editor-node-text\";\n"
  },
  {
    "path": "packages/editor/src/components/rich-text-field.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  ListBulletsIcon,\n  ListNumbersIcon,\n  TextBIcon,\n  TextItalicIcon,\n  TextUnderlineIcon,\n} from \"@phosphor-icons/react\";\nimport { TextStyleKit } from \"@tiptap/extension-text-style\";\nimport { Placeholder } from \"@tiptap/extensions\";\nimport { EditorContent, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { useEffect } from \"react\";\n\nexport interface FieldRichTextEditorProps {\n  disabled?: boolean;\n  id?: string;\n  labelId?: string;\n  onBlur?: () => void;\n  onChange: (value: string) => void;\n  placeholder?: string;\n  value: string;\n}\n\nfunction ToolbarButton({\n  active,\n  disabled,\n  icon: Icon,\n  onClick,\n}: {\n  active?: boolean;\n  disabled?: boolean;\n  icon: React.ComponentType<{ className?: string }>;\n  onClick: () => void;\n}) {\n  return (\n    <Button\n      className={cn(\n        \"h-8 w-8 p-0 text-muted-foreground shadow-none\",\n        active && \"bg-muted text-foreground\"\n      )}\n      disabled={disabled}\n      onClick={onClick}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n    >\n      <Icon className=\"size-4\" />\n    </Button>\n  );\n}\n\nexport function FieldRichTextEditor({\n  disabled,\n  id,\n  labelId,\n  onBlur,\n  onChange,\n  placeholder = \"Write something...\",\n  value,\n}: FieldRichTextEditorProps) {\n  const editor = useEditor({\n    immediatelyRender: false,\n    editable: !disabled,\n    content: value || \"<p></p>\",\n    extensions: [\n      StarterKit.configure({\n        blockquote: false,\n        codeBlock: false,\n        dropcursor: false,\n        gapcursor: false,\n        heading: false,\n        horizontalRule: false,\n      }),\n      TextStyleKit,\n      Placeholder.configure({\n        placeholder: ({ node }) => {\n          if (\n            node.type.name === \"bulletList\" ||\n            node.type.name === \"orderedList\" ||\n            node.type.name === \"listItem\"\n          ) {\n            return \"\";\n          }\n\n          return placeholder;\n        },\n        emptyEditorClass:\n          \"field-rich-text-placeholder before:content-[attr(data-placeholder)]\",\n        emptyNodeClass:\n          \"field-rich-text-placeholder before:content-[attr(data-placeholder)]\",\n      }),\n    ],\n    editorProps: {\n      attributes: {\n        class:\n          \"min-h-[120px] px-3 py-3 text-sm leading-6 text-foreground caret-foreground focus:outline-hidden [&_.field-rich-text-placeholder::before]:pointer-events-none [&_.field-rich-text-placeholder::before]:float-left [&_.field-rich-text-placeholder::before]:h-0 [&_.field-rich-text-placeholder::before]:text-muted-foreground [&_.field-rich-text-placeholder::before]:leading-6 [&_li_.field-rich-text-placeholder::before]:content-none [&_ol]:my-0 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:m-0 [&_strong]:text-foreground [&_u]:text-foreground [&_ul]:my-0 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:my-1 [&_li_p]:m-0\",\n        ...(id ? { id } : {}),\n        ...(labelId ? { \"aria-labelledby\": labelId } : {}),\n      },\n    },\n    onBlur: () => {\n      onBlur?.();\n    },\n    onUpdate: ({ editor: nextEditor }) => {\n      onChange(nextEditor.getHTML());\n    },\n  });\n\n  useEffect(() => {\n    if (!editor) {\n      return;\n    }\n\n    const nextValue = value || \"<p></p>\";\n    if (editor.getHTML() === nextValue) {\n      return;\n    }\n\n    editor.commands.setContent(nextValue, {\n      emitUpdate: false,\n    });\n  }, [editor, value]);\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <div className=\"overflow-hidden rounded-md border bg-editor-field\">\n      <div className=\"flex items-center gap-1 border-b px-2 py-1\">\n        <ToolbarButton\n          active={editor.isActive(\"bold\")}\n          disabled={disabled}\n          icon={TextBIcon}\n          onClick={() => editor.chain().focus().toggleBold().run()}\n        />\n        <ToolbarButton\n          active={editor.isActive(\"italic\")}\n          disabled={disabled}\n          icon={TextItalicIcon}\n          onClick={() => editor.chain().focus().toggleItalic().run()}\n        />\n        <ToolbarButton\n          active={editor.isActive(\"underline\")}\n          disabled={disabled}\n          icon={TextUnderlineIcon}\n          onClick={() => editor.chain().focus().toggleUnderline().run()}\n        />\n        <div className=\"mx-1 h-5 w-px bg-border\" />\n        <ToolbarButton\n          active={editor.isActive(\"bulletList\")}\n          disabled={disabled}\n          icon={ListBulletsIcon}\n          onClick={() => editor.chain().focus().toggleBulletList().run()}\n        />\n        <ToolbarButton\n          active={editor.isActive(\"orderedList\")}\n          disabled={disabled}\n          icon={ListNumbersIcon}\n          onClick={() => editor.chain().focus().toggleOrderedList().run()}\n        />\n      </div>\n      <div className=\"relative\">\n        <EditorContent editor={editor} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "packages/editor/src/components/ui/editor-button.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CheckIcon } from \"@phosphor-icons/react\";\nimport type { EditorButtonProps } from \"../../types\";\n\n/**\n * Base Button Component for Editor Toolbar\n * Used in BubbleMenu and other UI components\n */\nexport const BubbleMenuButton = ({\n  name,\n  isActive,\n  command,\n  icon: Icon,\n  hideName,\n}: EditorButtonProps) => (\n  <Button\n    className={cn(\"flex gap-4\", hideName ? \"\" : \"w-full\")}\n    onClick={() => command()}\n    size=\"sm\"\n    variant=\"ghost\"\n  >\n    <Icon className={cn(\"shrink-0\", isActive() && \"text-primary\")} size={12} />\n    {!hideName && <span className=\"flex-1 text-left\">{name}</span>}\n    {!hideName && isActive() ? (\n      <CheckIcon className=\"shrink-0\" size={12} />\n    ) : null}\n  </Button>\n);\n"
  },
  {
    "path": "packages/editor/src/components/ui/editor-selector.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CaretDownIcon } from \"@phosphor-icons/react\";\nimport { useCurrentEditor } from \"@tiptap/react\";\nimport { type HTMLAttributes, type ReactNode, useState } from \"react\";\n\nexport type EditorSelectorProps = HTMLAttributes<HTMLDivElement> & {\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  title: string;\n  children?: ReactNode;\n};\n\n/**\n * Editor Selector Component\n *\n * A popover-based selector that groups related editor buttons together.\n * Displays a button with a title and dropdown arrow that opens a popover\n * containing child components (typically editor node or mark buttons).\n *\n * @example\n * ```tsx\n * <EditorSelector title=\"Text\">\n *   <EditorNodeHeading1 />\n *   <EditorNodeHeading2 />\n *   <EditorNodeHeading3 />\n * </EditorSelector>\n * ```\n */\nexport const EditorSelector = ({\n  open,\n  onOpenChange,\n  title,\n  className,\n  children,\n  ...props\n}: EditorSelectorProps) => {\n  const { editor } = useCurrentEditor();\n  const [internalOpen, setInternalOpen] = useState(false);\n\n  if (!editor) {\n    return null;\n  }\n\n  const isControlled = open !== undefined;\n  const currentOpen = isControlled ? open : internalOpen;\n\n  const handleOpenChange = (newOpen: boolean) => {\n    if (!isControlled) {\n      setInternalOpen(newOpen);\n    }\n    onOpenChange?.(newOpen);\n  };\n\n  return (\n    <Popover onOpenChange={handleOpenChange} open={currentOpen}>\n      <PopoverTrigger\n        render={\n          <Button\n            className=\"gap-2 rounded-none border-none\"\n            size=\"sm\"\n            variant=\"ghost\"\n          >\n            <span className=\"whitespace-nowrap font-normal text-xs\">\n              {title}\n            </span>\n            <CaretDownIcon size={12} />\n          </Button>\n        }\n      />\n      <PopoverContent\n        align=\"start\"\n        className={cn(\"w-48 p-1\", className)}\n        onClick={() => handleOpenChange(false)}\n        sideOffset={5}\n        {...props}\n      >\n        {children}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/components/ui/index.ts",
    "content": "/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */\nexport { BubbleMenuButton } from \"./editor-button\";\nexport { EditorSelector, type EditorSelectorProps } from \"./editor-selector\";\n"
  },
  {
    "path": "packages/editor/src/extensions/code-block/code-block-comp.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport { Card, CardContent } from \"@marble/ui/components/card\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@marble/ui/components/command\";\nimport { Popover, PopoverContent } from \"@marble/ui/components/popover\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CaretUpDownIcon,\n  CheckIcon,\n  CopySimpleIcon,\n} from \"@phosphor-icons/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\n\n/**\n * Supported languages for the code block language selector.\n */\nconst LANGUAGES = [\n  { value: \"text\", label: \"Text\" },\n  { value: \"javascript\", label: \"JavaScript\" },\n  { value: \"typescript\", label: \"TypeScript\" },\n  { value: \"python\", label: \"Python\" },\n  { value: \"html\", label: \"HTML\" },\n  { value: \"css\", label: \"CSS\" },\n  { value: \"json\", label: \"JSON\" },\n  { value: \"bash\", label: \"Bash\" },\n  { value: \"sql\", label: \"SQL\" },\n  { value: \"go\", label: \"Go\" },\n  { value: \"rust\", label: \"Rust\" },\n] as const;\n\n/** Common aliases that map to a supported language value. */\nconst LANGUAGE_ALIASES: Record<string, string> = {\n  js: \"javascript\",\n  jsx: \"javascript\",\n  ts: \"typescript\",\n  tsx: \"typescript\",\n  py: \"python\",\n  sh: \"bash\",\n  shell: \"bash\",\n  zsh: \"bash\",\n  htm: \"html\",\n  golang: \"go\",\n  rs: \"rust\",\n  plaintext: \"text\",\n  plain: \"text\",\n  txt: \"text\",\n};\n\nconst LANGUAGE_VALUES: Set<string> = new Set(LANGUAGES.map((l) => l.value));\n\n/**\n * Resolve a raw language string (from markdown fences, pasted content, etc.)\n * to a known language value. Unrecognised strings fall back to \"text\".\n */\nexport const resolveLanguage = (raw: string): string => {\n  const lower = raw.toLowerCase().trim();\n  if (LANGUAGE_VALUES.has(lower)) {\n    return lower;\n  }\n  return LANGUAGE_ALIASES[lower] ?? \"text\";\n};\n\ninterface CodeBlockCompProps {\n  /** The currently selected language */\n  language: string;\n  /** Callback when the language changes */\n  onLanguageChange: (language: string) => void;\n  /** Callback to copy the code block content */\n  onCopy: () => void;\n  /** Whether the content was recently copied */\n  copied: boolean;\n  /** The editable code content (NodeViewContent) */\n  children: ReactNode;\n}\n\n/**\n * Code Block UI Component\n *\n * Card-based layout with a searchable language selector and copy button\n * in the header, and the editable code content in the body.\n */\nexport const CodeBlockComp = ({\n  language,\n  onLanguageChange,\n  onCopy,\n  copied,\n  children,\n}: CodeBlockCompProps) => {\n  const [open, setOpen] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n\n  const selectedLabel =\n    LANGUAGES.find((lang) => lang.value === language)?.label ?? language;\n\n  const handleSelect = useCallback(\n    (value: string) => {\n      onLanguageChange(value);\n      setOpen(false);\n    },\n    [onLanguageChange]\n  );\n\n  return (\n    <Card className=\"col-span-full gap-0 rounded-[20px] border-none bg-surface p-2 pt-0\">\n      {/* Header with language selector and copy button */}\n      {/* biome-ignore lint/a11y/useKeyWithClickEvents: ProseMirror event isolation */}\n      {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: ProseMirror event isolation */}\n      {/* biome-ignore lint/a11y/noStaticElementInteractions: ProseMirror event isolation */}\n      <div\n        className=\"flex select-none items-center justify-between gap-2 p-1.5\"\n        contentEditable={false}\n        data-drag-handle\n        onClick={(e) => e.stopPropagation()}\n        onMouseDown={(e) => e.stopPropagation()}\n      >\n        {/*\n         * we are using a regular Button instead of PopoverTrigger to avoid\n         * Base UI's internal pointer-event handling conflicting with\n         * ProseMirror's contentEditable. The popover is fully controlled\n         * via open/onOpenChange and anchored to this button via ref.\n         */}\n        <Popover onOpenChange={setOpen} open={open}>\n          <Button\n            className=\"h-7 gap-1.5 px-2 font-normal text-muted-foreground text-xs shadow-none hover:bg-background active:scale-100\"\n            onClick={() => setOpen(!open)}\n            ref={triggerRef}\n            size=\"sm\"\n            type=\"button\"\n            variant=\"ghost\"\n          >\n            {selectedLabel}\n            <CaretUpDownIcon className=\"size-3 opacity-50\" />\n          </Button>\n          <PopoverContent\n            align=\"start\"\n            anchor={triggerRef}\n            className=\"w-[200px] p-0\"\n            side=\"bottom\"\n            sideOffset={4}\n          >\n            <Command>\n              <CommandInput placeholder=\"Search language...\" />\n              <CommandList>\n                <CommandEmpty>No language found.</CommandEmpty>\n                <CommandGroup>\n                  {LANGUAGES.map((lang) => (\n                    <CommandItem\n                      key={lang.value}\n                      onSelect={() => handleSelect(lang.value)}\n                      value={lang.value}\n                    >\n                      {lang.label}\n                      <CheckIcon\n                        className={cn(\n                          \"ml-auto size-3.5\",\n                          language === lang.value ? \"opacity-100\" : \"opacity-0\"\n                        )}\n                      />\n                    </CommandItem>\n                  ))}\n                </CommandGroup>\n              </CommandList>\n            </Command>\n          </PopoverContent>\n        </Popover>\n\n        <Button\n          className=\"h-7 w-7 text-muted-foreground shadow-none hover:bg-background\"\n          onClick={onCopy}\n          size=\"icon\"\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          {copied ? (\n            <CheckIcon className=\"size-3.5\" />\n          ) : (\n            <CopySimpleIcon className=\"size-3.5\" />\n          )}\n        </Button>\n      </div>\n\n      {/* Code content area with syntax highlighting */}\n      <CardContent\n        className={cn(\n          \"overflow-hidden rounded-[12px] bg-background p-0 shadow-xs\",\n          \"font-mono text-foreground text-sm\",\n          // hljs syntax highlighting — light mode\n          \"[&_.hljs-doctag]:text-[#d73a49] [&_.hljs-keyword]:text-[#d73a49] [&_.hljs-meta_.hljs-keyword]:text-[#d73a49] [&_.hljs-template-tag]:text-[#d73a49] [&_.hljs-template-variable]:text-[#d73a49] [&_.hljs-type]:text-[#d73a49] [&_.hljs-variable.language_]:text-[#d73a49]\",\n          \"[&_.hljs-title.class_.inherited__]:text-[#6f42c1] [&_.hljs-title.class_]:text-[#6f42c1] [&_.hljs-title.function_]:text-[#6f42c1] [&_.hljs-title]:text-[#6f42c1]\",\n          \"[&_.hljs-attr]:text-[#005cc5] [&_.hljs-attribute]:text-[#005cc5] [&_.hljs-literal]:text-[#005cc5] [&_.hljs-meta]:text-[#005cc5] [&_.hljs-number]:text-[#005cc5] [&_.hljs-operator]:text-[#005cc5] [&_.hljs-selector-attr]:text-[#005cc5] [&_.hljs-selector-class]:text-[#005cc5] [&_.hljs-selector-id]:text-[#005cc5] [&_.hljs-variable]:text-[#005cc5]\",\n          \"[&_.hljs-meta_.hljs-string]:text-[#032f62] [&_.hljs-regexp]:text-[#032f62] [&_.hljs-string]:text-[#032f62]\",\n          \"[&_.hljs-built_in]:text-[#e36209] [&_.hljs-symbol]:text-[#e36209]\",\n          \"[&_.hljs-code]:text-[#6a737d] [&_.hljs-comment]:text-[#6a737d] [&_.hljs-formula]:text-[#6a737d]\",\n          \"[&_.hljs-name]:text-[#22863a] [&_.hljs-quote]:text-[#22863a] [&_.hljs-selector-pseudo]:text-[#22863a] [&_.hljs-selector-tag]:text-[#22863a]\",\n          \"[&_.hljs-subst]:text-[#24292e]\",\n          \"[&_.hljs-section]:font-bold [&_.hljs-section]:text-[#005cc5]\",\n          \"[&_.hljs-bullet]:text-[#735c0f]\",\n          \"[&_.hljs-emphasis]:text-[#24292e] [&_.hljs-emphasis]:italic\",\n          \"[&_.hljs-strong]:font-bold [&_.hljs-strong]:text-[#24292e]\",\n          \"[&_.hljs-addition]:bg-[#f0fff4] [&_.hljs-addition]:text-[#22863a]\",\n          \"[&_.hljs-deletion]:bg-[#ffeef0] [&_.hljs-deletion]:text-[#b31d28]\",\n          // hljs syntax highlighting — dark mode overrides\n          \"dark:[&_.hljs-doctag]:text-[#ff7b72] dark:[&_.hljs-keyword]:text-[#ff7b72] dark:[&_.hljs-meta_.hljs-keyword]:text-[#ff7b72] dark:[&_.hljs-template-tag]:text-[#ff7b72] dark:[&_.hljs-template-variable]:text-[#ff7b72] dark:[&_.hljs-type]:text-[#ff7b72] dark:[&_.hljs-variable.language_]:text-[#ff7b72]\",\n          \"dark:[&_.hljs-title.class_.inherited__]:text-[#d2a8ff] dark:[&_.hljs-title.class_]:text-[#d2a8ff] dark:[&_.hljs-title.function_]:text-[#d2a8ff] dark:[&_.hljs-title]:text-[#d2a8ff]\",\n          \"dark:[&_.hljs-attr]:text-[#79c0ff] dark:[&_.hljs-attribute]:text-[#79c0ff] dark:[&_.hljs-literal]:text-[#79c0ff] dark:[&_.hljs-meta]:text-[#79c0ff] dark:[&_.hljs-number]:text-[#79c0ff] dark:[&_.hljs-operator]:text-[#79c0ff] dark:[&_.hljs-selector-attr]:text-[#79c0ff] dark:[&_.hljs-selector-class]:text-[#79c0ff] dark:[&_.hljs-selector-id]:text-[#79c0ff] dark:[&_.hljs-variable]:text-[#79c0ff]\",\n          \"dark:[&_.hljs-meta_.hljs-string]:text-[#a5d6ff] dark:[&_.hljs-regexp]:text-[#a5d6ff] dark:[&_.hljs-string]:text-[#a5d6ff]\",\n          \"dark:[&_.hljs-built_in]:text-[#ffa657] dark:[&_.hljs-symbol]:text-[#ffa657]\",\n          \"dark:[&_.hljs-code]:text-[#8b949e] dark:[&_.hljs-comment]:text-[#8b949e] dark:[&_.hljs-formula]:text-[#8b949e]\",\n          \"dark:[&_.hljs-name]:text-[#7ee787] dark:[&_.hljs-quote]:text-[#7ee787] dark:[&_.hljs-selector-pseudo]:text-[#7ee787] dark:[&_.hljs-selector-tag]:text-[#7ee787]\",\n          \"dark:[&_.hljs-subst]:text-[#c9d1d9]\",\n          \"dark:[&_.hljs-section]:text-[#79c0ff]\",\n          \"dark:[&_.hljs-bullet]:text-[#f2cc60]\",\n          \"dark:[&_.hljs-emphasis]:text-[#c9d1d9]\",\n          \"dark:[&_.hljs-strong]:text-[#c9d1d9]\",\n          \"dark:[&_.hljs-addition]:bg-[#0d2117] dark:[&_.hljs-addition]:text-[#7ee787]\",\n          \"dark:[&_.hljs-deletion]:bg-[#28060a] dark:[&_.hljs-deletion]:text-[#ff7b72]\"\n        )}\n      >\n        {children}\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/code-block/code-block-view.tsx",
    "content": "import type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewContent, NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback, useState } from \"react\";\nimport { CodeBlockComp, resolveLanguage } from \"./code-block-comp\";\n\nexport const CodeBlockView = ({ node, updateAttributes }: NodeViewProps) => {\n  const rawLanguage = (node.attrs.language as string) || \"text\";\n  const language = resolveLanguage(rawLanguage);\n  const [copied, setCopied] = useState(false);\n\n  const onLanguageChange = useCallback(\n    (lang: string) => {\n      updateAttributes({ language: lang });\n    },\n    [updateAttributes]\n  );\n\n  const onCopy = useCallback(() => {\n    const text = node.textContent;\n    navigator.clipboard\n      .writeText(text)\n      .then(() => {\n        setCopied(true);\n        setTimeout(() => {\n          setCopied(false);\n        }, 2000);\n      })\n      .catch((error: unknown) => {\n        console.error(\"Failed to copy code block content:\", error);\n      });\n  }, [node]);\n\n  return (\n    <NodeViewWrapper className=\"my-5\">\n      <CodeBlockComp\n        copied={copied}\n        language={language}\n        onCopy={onCopy}\n        onLanguageChange={onLanguageChange}\n      >\n        <pre className=\"!m-0 !bg-transparent !p-0 !text-inherit overflow-x-auto\">\n          <NodeViewContent className=\"!outline-none p-4 font-mono text-sm\" />\n        </pre>\n      </CodeBlockComp>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/code-block/code-block.ts",
    "content": "import { textblockTypeInputRule } from \"@tiptap/core\";\nimport CodeBlockLowlight from \"@tiptap/extension-code-block-lowlight\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport { lowlight } from \"../../lib/lowlight\";\nimport { CodeBlockView } from \"./code-block-view\";\n\n/**\n * Code Block extension with syntax highlighting and custom UI.\n *\n * Extends CodeBlockLowlight with a React NodeView that renders a card-style\n * wrapper with a searchable language selector and copy button.\n * Lowlight decorations (syntax highlighting) still apply through ProseMirror.\n *\n * Input rules are overridden so that triple backticks (or tildes) followed by\n * space/enter immediately insert a code block with language \"text\", without\n * allowing a language string after the backticks (Notion-style behaviour).\n * Language selection happens exclusively via the dropdown in the UI.\n */\nexport const CodeBlock = CodeBlockLowlight.extend({\n  addNodeView() {\n    return ReactNodeViewRenderer(CodeBlockView);\n  },\n\n  addInputRules() {\n    return [\n      textblockTypeInputRule({\n        find: /^```[\\s\\n]$/,\n        type: this.type,\n        getAttributes: () => ({ language: \"text\" }),\n      }),\n      textblockTypeInputRule({\n        find: /^~~~[\\s\\n]$/,\n        type: this.type,\n        getAttributes: () => ({ language: \"text\" }),\n      }),\n    ];\n  },\n}).configure({\n  lowlight,\n  defaultLanguage: \"text\",\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/code-block/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\nexport { CodeBlock } from \"./code-block\";\n"
  },
  {
    "path": "packages/editor/src/extensions/extension-kit.ts",
    "content": "import { cn } from \"@marble/ui/lib/utils\";\nimport { FileHandler } from \"@tiptap/extension-file-handler\";\nimport { Highlight } from \"@tiptap/extension-highlight\";\nimport { Image } from \"@tiptap/extension-image\";\nimport { TaskItem, TaskList } from \"@tiptap/extension-list\";\nimport { Subscript } from \"@tiptap/extension-subscript\";\nimport { Superscript } from \"@tiptap/extension-superscript\";\nimport { TextAlign } from \"@tiptap/extension-text-align\";\nimport { TextStyleKit } from \"@tiptap/extension-text-style\";\nimport Typography from \"@tiptap/extension-typography\";\nimport { Youtube } from \"@tiptap/extension-youtube\";\nimport { CharacterCount, Placeholder } from \"@tiptap/extensions\";\nimport { Markdown } from \"@tiptap/markdown\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport { CodeBlock } from \"./code-block\";\nimport { Figure } from \"./figure\";\nimport { ImageUpload } from \"./image-upload\";\nimport { MarkdownInput } from \"./markdown-input\";\nimport { configureSlashCommand } from \"./slash-command\";\nimport { Table, TableCell, TableHeader, TableRow } from \"./table\";\nimport { Twitter } from \"./twitter/index\";\nimport { TwitterUpload } from \"./twitter/twitter-upload\";\nimport { Video } from \"./video\";\nimport { VideoUpload } from \"./video-upload\";\nimport { YouTubeUpload } from \"./youtube/youtube-upload\";\nimport \"../styles/task-list.css\";\n\n/**\n * Extension kit configuration options\n */\nexport interface ExtensionKitOptions {\n  /** Character limit for the editor */\n  limit?: number;\n  /** Placeholder text for empty editor */\n  placeholder?: string;\n}\n\n/**\n * Extension Kit\n * Bundles all editor extensions with default configurations\n */\nexport const ExtensionKit = ({\n  limit,\n  placeholder,\n}: ExtensionKitOptions = {}) => [\n  Markdown,\n  StarterKit.configure({\n    codeBlock: false, // Using custom CodeBlock with syntax highlighting\n    bulletList: {\n      HTMLAttributes: {\n        class: cn(\"list-outside list-disc pl-4\"),\n      },\n    },\n    link: {\n      openOnClick: false,\n    },\n    orderedList: {\n      HTMLAttributes: {\n        class: cn(\"list-outside list-decimal pl-4\"),\n      },\n    },\n    listItem: {\n      HTMLAttributes: {\n        class: cn(\"leading-normal\"),\n      },\n    },\n    blockquote: {\n      HTMLAttributes: {\n        class: cn(\"border-l border-l-2 pl-2\"),\n      },\n    },\n    code: {\n      HTMLAttributes: {\n        class: cn(\"rounded-md bg-muted px-1.5 py-1 font-medium font-mono\"),\n        spellcheck: \"false\",\n      },\n    },\n    horizontalRule: {\n      HTMLAttributes: {\n        class: cn(\"mt-4 mb-6 border-muted-foreground border-t\"),\n      },\n    },\n    dropcursor: {\n      color: \"var(--border)\",\n      width: 4,\n    },\n  }),\n\n  // Typography for smart quotes, dashes, etc.\n  Typography,\n\n  Placeholder.configure({\n    placeholder: ({ editor }) => {\n      if (!editor) {\n        return placeholder ?? \"\";\n      }\n\n      // Hide placeholder inside tables, blockquotes, code blocks, and lists\n      if (\n        editor.isActive(\"table\") ||\n        editor.isActive(\"tableCell\") ||\n        editor.isActive(\"tableHeader\") ||\n        editor.isActive(\"blockquote\") ||\n        editor.isActive(\"codeBlock\") ||\n        editor.isActive(\"bulletList\") ||\n        editor.isActive(\"orderedList\") ||\n        editor.isActive(\"taskList\") ||\n        editor.isActive(\"listItem\") ||\n        editor.isActive(\"taskItem\")\n      ) {\n        return \"\";\n      }\n\n      return placeholder ?? \"\";\n    },\n    emptyEditorClass:\n      \"before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none\",\n    emptyNodeClass:\n      \"before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none\",\n  }),\n\n  // Character count\n  CharacterCount.configure({\n    limit,\n  }),\n\n  // Code block with syntax highlighting\n  CodeBlock,\n\n  // Subscript and superscript\n  Superscript,\n  Subscript,\n\n  // Slash command\n  configureSlashCommand(),\n\n  // Table extensions\n  Table,\n  TableRow,\n  TableCell,\n  TableHeader,\n\n  // YouTube\n  Youtube.configure({\n    controls: true,\n    nocookie: false,\n  }),\n\n  // YouTube Upload (placeholder node for YouTube upload component)\n  YouTubeUpload,\n\n  // Twitter\n  Twitter.configure({\n    addPasteHandler: true,\n    inline: false,\n  }),\n\n  // Twitter Upload (placeholder node for Twitter upload component)\n  TwitterUpload,\n\n  // Image extension for backward compatibility with older posts\n  Image.configure({\n    inline: false,\n    allowBase64: false,\n  }),\n\n  // Figure (image with caption support)\n  Figure,\n\n  // Image Upload (placeholder node for image upload component)\n  // Note: Will be unconfigured by default, CMS app should pass configured version\n  ImageUpload,\n\n  // Video (self-hosted video with caption support)\n  Video,\n\n  // Video Upload (placeholder node for video upload component)\n  // Note: Will be unconfigured by default, CMS app should pass configured version\n  VideoUpload,\n\n  // File Handler for drag-and-drop and paste image/video uploads\n  FileHandler.configure({\n    allowedMimeTypes: [\n      \"image/png\",\n      \"image/jpeg\",\n      \"image/gif\",\n      \"image/webp\",\n      \"video/mp4\",\n      \"video/webm\",\n      \"video/ogg\",\n      \"video/quicktime\",\n    ],\n    onDrop: (currentEditor, files, _pos) => {\n      for (const file of files) {\n        if (file.type.startsWith(\"video/\")) {\n          currentEditor.chain().focus().setVideoUpload({ file }).run();\n        } else {\n          currentEditor.chain().focus().setImageUpload({ file }).run();\n        }\n      }\n    },\n    onPaste: (currentEditor, files) => {\n      for (const file of files) {\n        if (file.type.startsWith(\"video/\")) {\n          currentEditor.chain().focus().setVideoUpload({ file }).run();\n        } else {\n          currentEditor.chain().focus().setImageUpload({ file }).run();\n        }\n      }\n    },\n  }),\n\n  // Task list\n  TaskList.configure({\n    HTMLAttributes: {\n      class: \"list-none p-0\",\n    },\n  }),\n  TaskItem.configure({\n    nested: true,\n    HTMLAttributes: {\n      class: \"flex\",\n    },\n  }),\n\n  // Text styling kit (includes Color, BackgroundColor, FontFamily, FontSize, LineHeight, TextStyle)\n  TextStyleKit,\n\n  // Highlight extension for text highlighting\n  Highlight.configure({ multicolor: true }),\n\n  // Text alignment\n  TextAlign.configure({\n    types: [\"heading\", \"paragraph\"],\n    alignments: [\"left\", \"center\", \"right\", \"justify\"],\n  }),\n\n  // Markdown input handling (paste and file drop)\n  MarkdownInput,\n];\n\nexport default ExtensionKit;\n"
  },
  {
    "path": "packages/editor/src/extensions/figure/figure-view.tsx",
    "content": "/** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: <> */\n/** biome-ignore-all lint/a11y/useKeyWithClickEvents: <> */\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  FadersHorizontalIcon,\n  TextAlignCenterIcon,\n  TextAlignLeftIcon,\n  TextAlignRightIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\n\nexport const FigureView = ({\n  node,\n  updateAttributes,\n  selected,\n}: NodeViewProps) => {\n  const { src, alt, caption, width, align } = node.attrs as {\n    src: string;\n    alt: string;\n    caption: string;\n    width: string;\n    align: \"left\" | \"center\" | \"right\";\n  };\n\n  const [altValue, setAltValue] = useState(alt || \"\");\n  const [captionValue, setCaptionValue] = useState(caption || \"\");\n  const [widthValue, setWidthValue] = useState(width || \"100\");\n  const [alignValue, setAlignValue] = useState<\"left\" | \"center\" | \"right\">(\n    align || \"center\"\n  );\n  const [isResizing, setIsResizing] = useState(false);\n  const [isHovered, setIsHovered] = useState(false);\n  const [showSettings, setShowSettings] = useState(false);\n  const figureRef = useRef<HTMLElement>(null);\n  const settingsPanelRef = useRef<HTMLDivElement>(null);\n  const startXRef = useRef(0);\n  const startWidthRef = useRef(0);\n  const resizeSideRef = useRef<\"left\" | \"right\">(\"right\");\n\n  const altId = useId();\n  const captionId = useId();\n\n  const [prevAttrs, setPrevAttrs] = useState({ alt, caption, width, align });\n\n  if (\n    alt !== prevAttrs.alt ||\n    caption !== prevAttrs.caption ||\n    width !== prevAttrs.width ||\n    align !== prevAttrs.align\n  ) {\n    setPrevAttrs({ alt, caption, width, align });\n    setAltValue(alt || \"\");\n    setCaptionValue(caption || \"\");\n    setWidthValue(width || \"100\");\n    setAlignValue(align || \"center\");\n  }\n\n  // Handle click outside settings panel\n  useEffect(() => {\n    if (!showSettings) {\n      return;\n    }\n\n    const handleClickOutside = (e: MouseEvent) => {\n      if (\n        settingsPanelRef.current &&\n        !settingsPanelRef.current.contains(e.target as Node)\n      ) {\n        // Check if click was on the settings button\n        const target = e.target as HTMLElement;\n        if (!target.closest(\"[data-settings-trigger]\")) {\n          setShowSettings(false);\n        }\n      }\n    };\n\n    // Use timeout to avoid the click that opened the panel from closing it\n    const timeoutId = setTimeout(() => {\n      document.addEventListener(\"mousedown\", handleClickOutside);\n    }, 0);\n\n    return () => {\n      clearTimeout(timeoutId);\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, [showSettings]);\n\n  const handleAltChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const newAlt = e.target.value;\n      setAltValue(newAlt);\n      updateAttributes({ alt: newAlt });\n    },\n    [updateAttributes]\n  );\n\n  const handleCaptionChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const newCaption = e.target.value;\n      setCaptionValue(newCaption);\n      updateAttributes({ caption: newCaption });\n    },\n    [updateAttributes]\n  );\n\n  const handleAlignChange = useCallback(\n    (newAlign: \"left\" | \"center\" | \"right\") => {\n      setAlignValue(newAlign);\n      setTimeout(() => {\n        updateAttributes({ align: newAlign });\n      }, 0);\n    },\n    [updateAttributes]\n  );\n\n  const handleResizeStart = useCallback(\n    (side: \"left\" | \"right\") => (e: React.MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setIsResizing(true);\n      startXRef.current = e.clientX;\n      resizeSideRef.current = side;\n      const currentWidth = Number.parseInt(widthValue, 10) || 100;\n      startWidthRef.current = currentWidth;\n    },\n    [widthValue]\n  );\n\n  useEffect(() => {\n    if (!isResizing) {\n      return;\n    }\n\n    const handleMouseMove = (e: MouseEvent) => {\n      const deltaX = e.clientX - startXRef.current;\n      const containerWidth =\n        figureRef.current?.parentElement?.clientWidth || 800;\n\n      const effectiveDelta =\n        resizeSideRef.current === \"left\" ? -deltaX : deltaX;\n      const deltaPercent = (effectiveDelta / containerWidth) * 100;\n      const newWidth = Math.max(\n        10,\n        Math.min(100, startWidthRef.current + deltaPercent)\n      );\n\n      const roundedWidth = Math.round(newWidth);\n      setWidthValue(String(roundedWidth));\n      updateAttributes({ width: String(roundedWidth) });\n    };\n\n    const handleMouseUp = () => {\n      setIsResizing(false);\n    };\n\n    document.addEventListener(\"mousemove\", handleMouseMove);\n    document.addEventListener(\"mouseup\", handleMouseUp);\n\n    return () => {\n      document.removeEventListener(\"mousemove\", handleMouseMove);\n      document.removeEventListener(\"mouseup\", handleMouseUp);\n    };\n  }, [isResizing, updateAttributes]);\n\n  const alignmentStyles: React.CSSProperties = {\n    width: `${widthValue}%`,\n    marginLeft: alignValue === \"left\" ? 0 : \"auto\",\n    marginRight: alignValue === \"right\" ? 0 : \"auto\",\n  };\n\n  const showToolbar = selected || isHovered || showSettings;\n\n  const handleSettingsClick = useCallback(\n    (e: React.MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setShowSettings(!showSettings);\n    },\n    [showSettings]\n  );\n\n  return (\n    <NodeViewWrapper className=\"my-5\" data-drag-handle>\n      <figure\n        aria-label=\"Image figure\"\n        className={cn(\n          \"relative\",\n          selected && \"outline-2 outline-primary outline-offset-2\"\n        )}\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => setIsHovered(false)}\n        ref={figureRef}\n        style={alignmentStyles}\n      >\n        {/* biome-ignore lint: Tiptap NodeView requires standard img element */}\n        <img\n          alt={altValue}\n          className=\"h-auto w-full rounded-md border border-muted\"\n          src={src}\n        />\n\n        {showToolbar && (\n          <div className=\"absolute top-2 right-2 z-30 flex items-center gap-0.5 rounded-lg border bg-background p-1 shadow\">\n            <Button\n              className={cn(\n                \"size-7 p-0\",\n                alignValue === \"left\" && \"bg-accent text-accent-foreground\"\n              )}\n              onClick={() => handleAlignChange(\"left\")}\n              size=\"icon\"\n              title=\"Align left\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <TextAlignLeftIcon className=\"size-3.5\" />\n            </Button>\n            <Button\n              className={cn(\n                \"size-7 p-0\",\n                alignValue === \"center\" && \"bg-accent text-accent-foreground\"\n              )}\n              onClick={() => handleAlignChange(\"center\")}\n              size=\"icon\"\n              title=\"Align center\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <TextAlignCenterIcon className=\"size-3.5\" />\n            </Button>\n            <Button\n              className={cn(\n                \"size-7 p-0\",\n                alignValue === \"right\" && \"bg-accent text-accent-foreground\"\n              )}\n              onClick={() => handleAlignChange(\"right\")}\n              size=\"icon\"\n              title=\"Align right\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <TextAlignRightIcon className=\"size-3.5\" />\n            </Button>\n\n            {/* Divider */}\n            <div className=\"mx-0.5 h-5 w-px bg-border\" />\n\n            <button\n              className={cn(\n                \"flex size-7 items-center justify-center rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground\",\n                showSettings && \"bg-accent text-accent-foreground\"\n              )}\n              data-settings-trigger\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                setShowSettings((prev) => !prev);\n              }}\n              onMouseDown={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n              }}\n              title=\"Image settings\"\n              type=\"button\"\n            >\n              <FadersHorizontalIcon className=\"size-3.5\" />\n            </button>\n          </div>\n        )}\n\n        {showSettings && (\n          <div\n            className=\"absolute top-14 right-2 z-40 flex w-72 flex-col gap-3 rounded-md border bg-popover p-3 text-popover-foreground shadow-md\"\n            ref={settingsPanelRef}\n          >\n            {/* Alt Text */}\n            <div className=\"space-y-1.5\">\n              <Label className=\"font-medium text-xs\" htmlFor={altId}>\n                Alt Text\n              </Label>\n              <Input\n                className=\"h-8 text-sm\"\n                id={altId}\n                onChange={handleAltChange}\n                placeholder=\"Describe the image...\"\n                type=\"text\"\n                value={altValue}\n              />\n            </div>\n\n            {/* Caption */}\n            <div className=\"space-y-1.5\">\n              <Label className=\"font-medium text-xs\" htmlFor={captionId}>\n                Caption\n              </Label>\n              <Input\n                className=\"h-8 text-sm\"\n                id={captionId}\n                onChange={handleCaptionChange}\n                placeholder=\"Add a caption...\"\n                type=\"text\"\n                value={captionValue}\n              />\n            </div>\n          </div>\n        )}\n\n        {showToolbar && (\n          <>\n            <button\n              className=\"-translate-y-1/2 absolute top-1/2 left-2 z-20 h-8 w-1 cursor-ew-resize rounded-full border border-foreground border-white bg-background transition-all\"\n              onMouseDown={handleResizeStart(\"left\")}\n              title=\"Drag to resize\"\n              type=\"button\"\n            />\n            <button\n              className=\"-translate-y-1/2 absolute top-1/2 right-2 z-20 h-8 w-1 cursor-ew-resize rounded-full border border-foreground border-white bg-background transition-all\"\n              onMouseDown={handleResizeStart(\"right\")}\n              title=\"Drag to resize\"\n              type=\"button\"\n            />\n          </>\n        )}\n\n        {captionValue && (\n          <figcaption className=\"mt-2 text-center text-muted-foreground text-sm italic\">\n            <p>{captionValue}</p>\n          </figcaption>\n        )}\n      </figure>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/figure/index.ts",
    "content": "import type { CommandProps } from \"@tiptap/core\";\nimport { mergeAttributes, Node } from \"@tiptap/core\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport { FigureView } from \"./figure-view\";\n\ndeclare module \"@tiptap/core\" {\n  interface Commands<ReturnType> {\n    figure: {\n      setFigure: (options: {\n        src: string;\n        alt?: string;\n        caption?: string;\n        href?: string;\n        width?: string;\n        align?: \"left\" | \"center\" | \"right\";\n      }) => ReturnType;\n      updateFigure: (attrs: {\n        alt?: string;\n        caption?: string;\n        href?: string;\n        width?: string;\n        align?: \"left\" | \"center\" | \"right\";\n      }) => ReturnType;\n    };\n  }\n}\n\nexport const Figure = Node.create({\n  name: \"figure\",\n  group: \"block\",\n  content: \"\",\n  draggable: true,\n  selectable: true,\n  isolating: true,\n\n  addAttributes() {\n    return {\n      src: {\n        default: null,\n        parseHTML: (element) =>\n          element.querySelector(\"img\")?.getAttribute(\"src\") ||\n          element.querySelector(\"a img\")?.getAttribute(\"src\"),\n        renderHTML: (attributes) => {\n          // Return attribute to make it available in HTMLAttributes\n          // Main renderHTML will apply it to img element\n          return { src: attributes.src };\n        },\n      },\n      alt: {\n        default: \"\",\n        parseHTML: (element) =>\n          element.querySelector(\"img\")?.getAttribute(\"alt\") ||\n          element.querySelector(\"a img\")?.getAttribute(\"alt\") ||\n          \"\",\n        renderHTML: (attributes) => {\n          // Return attribute to make it available in HTMLAttributes\n          // Main renderHTML will apply it to img element\n          return { alt: attributes.alt };\n        },\n      },\n      caption: {\n        default: \"\",\n        parseHTML: (element) =>\n          element.querySelector(\"figcaption\")?.textContent || \"\",\n        renderHTML: (attributes) => {\n          // Return attribute to make it available in HTMLAttributes\n          // Main renderHTML will use it for figcaption content\n          return { caption: attributes.caption };\n        },\n      },\n      href: {\n        default: null,\n        parseHTML: (element) =>\n          element.querySelector(\"a\")?.getAttribute(\"href\") || null,\n        renderHTML: (attributes) => {\n          // Return attribute to make it available in HTMLAttributes\n          // Main renderHTML will apply it to anchor element\n          return { href: attributes.href };\n        },\n      },\n      width: {\n        default: \"100\",\n        parseHTML: (element) => element.getAttribute(\"data-width\") || \"100\",\n        renderHTML: (attributes) => ({\n          \"data-width\": attributes.width,\n        }),\n      },\n      align: {\n        default: \"center\",\n        parseHTML: (element) => element.getAttribute(\"data-align\") || \"center\",\n        renderHTML: (attributes) => ({\n          \"data-align\": attributes.align,\n        }),\n      },\n    };\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: \"figure\",\n        getAttrs: (element) => {\n          if (typeof element === \"string\") {\n            return false;\n          }\n          const img = element.querySelector(\"img\");\n          return img ? {} : false;\n        },\n      },\n    ];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    const { src, alt, href, caption, ...figureAttrs } = HTMLAttributes;\n\n    // Prepare img attributes\n    const imgAttrs: Record<string, string> = {};\n    if (src) {\n      imgAttrs.src = src;\n    }\n    if (alt) {\n      imgAttrs.alt = alt;\n    }\n\n    // Prepare figcaption content\n    const figcaptionContent = caption || \"\";\n\n    // If href exists, wrap img in anchor tag\n    if (href) {\n      return [\n        \"figure\",\n        mergeAttributes(figureAttrs),\n        [\"a\", { href }, [\"img\", imgAttrs]],\n        [\"figcaption\", {}, figcaptionContent],\n      ];\n    }\n\n    // Otherwise, render img directly\n    return [\n      \"figure\",\n      mergeAttributes(figureAttrs),\n      [\"img\", imgAttrs],\n      [\"figcaption\", {}, figcaptionContent],\n    ];\n  },\n\n  addCommands() {\n    return {\n      setFigure:\n        (options) =>\n        ({ commands }: CommandProps) =>\n          commands.insertContent({\n            type: this.name,\n            attrs: options,\n          }),\n      updateFigure:\n        (attrs) =>\n        ({ commands, tr, state }: CommandProps) => {\n          const { selection } = state;\n          const node = tr.doc.nodeAt(selection.from);\n\n          if (node?.type.name === this.name) {\n            return commands.updateAttributes(this.name, attrs);\n          }\n\n          return false;\n        },\n    };\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(FigureView);\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/image-upload/hooks.ts",
    "content": "import { toast } from \"@marble/ui/components/sonner\";\nimport type { DragEvent } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\n\nexport const useFileUpload = () => {\n  const fileInput = useRef<HTMLInputElement>(null);\n\n  const handleUploadClick = useCallback(() => {\n    fileInput.current?.click();\n  }, []);\n\n  return { ref: fileInput, handleUploadClick };\n};\n\nexport const useUploader = ({\n  onUpload,\n  upload,\n  onError,\n}: {\n  onUpload: (url: string) => void;\n  upload: (file: File) => Promise<string>;\n  onError?: (error: Error) => void;\n}) => {\n  const [loading, setLoading] = useState(false);\n\n  const uploadImage = useCallback(\n    async (file: File) => {\n      setLoading(true);\n      try {\n        const url = await upload(file);\n        if (url) {\n          onUpload(url);\n        } else {\n          const error = new Error(\n            \"Upload failed: Invalid response from server.\"\n          );\n          if (onError) {\n            onError(error);\n          } else {\n            toast.error(error.message);\n          }\n        }\n      } catch (error) {\n        const errorMessage =\n          error instanceof Error ? error.message : \"Failed to upload image\";\n        const uploadError = new Error(errorMessage);\n        if (onError) {\n          onError(uploadError);\n        } else {\n          toast.error(errorMessage);\n        }\n      }\n      setLoading(false);\n    },\n    [onUpload, upload, onError]\n  );\n\n  return { loading, uploadImage };\n};\n\nexport const useDropZone = ({\n  uploader,\n}: {\n  uploader: (file: File) => void;\n}) => {\n  const [draggedInside, setDraggedInside] = useState<boolean>(false);\n\n  const onDrop = useCallback(\n    (e: DragEvent<HTMLDivElement>) => {\n      setDraggedInside(false);\n      e.preventDefault();\n      e.stopPropagation();\n\n      const fileList = e.dataTransfer.files;\n      const files: File[] = [];\n\n      for (let i = 0; i < fileList.length; i += 1) {\n        const item = fileList.item(i);\n        if (item) {\n          files.push(item);\n        }\n      }\n\n      // Validate only image files\n      if (files.some((file) => !file.type.startsWith(\"image/\"))) {\n        toast.error(\"Only image files are allowed\");\n        return;\n      }\n\n      const filteredFiles = files.filter((f) => f.type.startsWith(\"image/\"));\n      const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;\n\n      if (file) {\n        uploader(file);\n      }\n    },\n    [uploader]\n  );\n\n  const onDragEnter = useCallback(() => {\n    setDraggedInside(true);\n  }, []);\n\n  const onDragLeave = useCallback(() => {\n    setDraggedInside(false);\n  }, []);\n\n  const onDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    e.stopPropagation();\n  }, []);\n\n  return { draggedInside, onDragEnter, onDragLeave, onDrop, onDragOver };\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/image-upload/image-upload-comp.tsx",
    "content": "import { Album02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Card, CardContent, CardFooter } from \"@marble/ui/components/card\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CheckIcon,\n  ImagesIcon,\n  SpinnerIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport type { ChangeEvent } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { MediaItem, MediaPage } from \"../../types\";\nimport { useDropZone, useFileUpload, useUploader } from \"./hooks\";\n\n// Simple URL validation\nconst isValidUrl = (url: string): boolean => {\n  try {\n    new URL(url);\n    return true;\n  } catch {\n    return false;\n  }\n};\n\nexport interface ImageUploadCompProps {\n  initialFile?: File;\n  onUpload: (url: string) => void;\n  onCancel: () => void;\n  upload: (file: File) => Promise<string>;\n  media?: MediaItem[];\n  fetchMediaPage?: (cursor?: string) => Promise<MediaPage>;\n  onError?: (error: Error) => void;\n}\n\nexport const ImageUploadComp = ({\n  initialFile,\n  onUpload,\n  onCancel,\n  upload,\n  media: providedMedia,\n  fetchMediaPage,\n  onError,\n}: ImageUploadCompProps) => {\n  const [showEmbedInput, setShowEmbedInput] = useState(false);\n  const [embedUrl, setEmbedUrl] = useState(\"\");\n  const [urlError, setUrlError] = useState<string | null>(null);\n  const [isValidatingUrl, setIsValidatingUrl] = useState(false);\n  const [isGalleryOpen, setIsGalleryOpen] = useState(false);\n  const [media, setMedia] = useState<MediaItem[] | undefined>(providedMedia);\n  const [isLoadingMedia, setIsLoadingMedia] = useState(false);\n  const [nextCursor, setNextCursor] = useState<string | undefined>(undefined);\n  const [isLoadingMore, setIsLoadingMore] = useState(false);\n\n  const { loading, uploadImage } = useUploader({ onUpload, upload, onError });\n  const { handleUploadClick, ref } = useFileUpload();\n  const { draggedInside, onDrop, onDragEnter, onDragLeave, onDragOver } =\n    useDropZone({\n      uploader: uploadImage,\n    });\n\n  // Fetch initial media page if fetchMediaPage function is provided.\n  // Uses an `active` flag so stale responses from a previous render are ignored.\n  useEffect(() => {\n    if (!fetchMediaPage || providedMedia) {\n      return;\n    }\n\n    let active = true;\n    setIsLoadingMedia(true);\n\n    fetchMediaPage()\n      .then((page) => {\n        if (active) {\n          setMedia(page.media);\n          setNextCursor(page.nextCursor);\n        }\n      })\n      .catch(() => {\n        if (active) {\n          setMedia([]);\n        }\n      })\n      .finally(() => {\n        if (active) {\n          setIsLoadingMedia(false);\n        }\n      });\n\n    return () => {\n      active = false;\n    };\n  }, [fetchMediaPage, providedMedia]);\n\n  // Load more media handler\n  const handleLoadMore = useCallback(async () => {\n    if (!fetchMediaPage || !nextCursor || isLoadingMore) {\n      return;\n    }\n    setIsLoadingMore(true);\n    try {\n      const page = await fetchMediaPage(nextCursor);\n      setMedia((prev) => [...(prev || []), ...page.media]);\n      setNextCursor(page.nextCursor);\n    } catch {\n      // Ignore errors on load more\n    } finally {\n      setIsLoadingMore(false);\n    }\n  }, [fetchMediaPage, nextCursor, isLoadingMore]);\n\n  // Update media when providedMedia changes\n  useEffect(() => {\n    if (providedMedia) {\n      setMedia(providedMedia);\n    }\n  }, [providedMedia]);\n\n  const initialUploadedRef = useRef(false);\n\n  useEffect(() => {\n    if (initialFile && !initialUploadedRef.current) {\n      initialUploadedRef.current = true;\n      uploadImage(initialFile);\n    }\n  }, [initialFile, uploadImage]);\n\n  const onFileChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      const file = e.target.files?.[0];\n      if (file) {\n        uploadImage(file);\n      }\n    },\n    [uploadImage]\n  );\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent<HTMLDivElement>) => {\n      onDrop(e);\n    },\n    [onDrop]\n  );\n\n  const handleEmbedUrl = useCallback(\n    async (url: string) => {\n      if (!url) {\n        return;\n      }\n\n      setIsValidatingUrl(true);\n      setUrlError(null);\n\n      if (!isValidUrl(url)) {\n        setUrlError(\"Please enter a valid URL\");\n        setIsValidatingUrl(false);\n        return;\n      }\n\n      const img = new Image();\n      img.onload = () => {\n        onUpload(url);\n        setEmbedUrl(\"\");\n        setShowEmbedInput(false);\n        setIsValidatingUrl(false);\n      };\n      img.onerror = () => {\n        setUrlError(\"Invalid image URL\");\n        setIsValidatingUrl(false);\n      };\n      img.src = url;\n    },\n    [onUpload]\n  );\n\n  const handleMediaSelect = useCallback(\n    (url: string) => {\n      onUpload(url);\n      setIsGalleryOpen(false);\n    },\n    [onUpload]\n  );\n\n  const handleDropzoneClick = useCallback(() => {\n    handleUploadClick();\n  }, [handleUploadClick]);\n\n  const handleDropzoneKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        handleUploadClick();\n      }\n    },\n    [handleUploadClick]\n  );\n\n  // Get dropzone text based on drag state\n  const getDropzoneText = () => {\n    if (draggedInside) {\n      return \"Drop image here\";\n    }\n    return \"Drag and drop or click to upload\";\n  };\n\n  return (\n    <>\n      <Card className=\"col-span-full gap-0 rounded-[20px] border-none bg-surface p-2 pt-0\">\n        <div className=\"flex h-10 select-none items-center justify-between gap-2 p-1.5\">\n          <HugeiconsIcon\n            className=\"shrink-0 text-muted-foreground\"\n            icon={Album02Icon}\n            size={18}\n          />\n          <span className=\"font-normal text-muted-foreground text-xs\">\n            Upload or embed an image\n          </span>\n        </div>\n        <CardContent className=\"rounded-[12px] bg-background p-4 shadow-xs\">\n          {/* Dropzone or Uploading state */}\n          {loading ? (\n            <div className=\"flex min-h-[260px] flex-1 flex-col items-center justify-center\">\n              <p className=\"text-muted-foreground text-sm\">\n                Uploading image...\n              </p>\n            </div>\n          ) : (\n            // biome-ignore lint/a11y/useSemanticElements: Dropzone requires div for drag-and-drop functionality\n            <div\n              aria-label=\"Upload image by clicking or dragging and dropping\"\n              className={cn(\n                \"flex min-h-[260px] flex-1 cursor-pointer flex-col items-center justify-center gap-2\",\n                draggedInside\n                  ? \"border-primary bg-primary/5\"\n                  : \"border-muted bg-background\"\n              )}\n              onClick={handleDropzoneClick}\n              onDragEnter={onDragEnter}\n              onDragLeave={onDragLeave}\n              onDragOver={onDragOver}\n              onDrop={handleDrop}\n              onKeyDown={handleDropzoneKeyDown}\n              role=\"button\"\n              tabIndex={0}\n            >\n              <p\n                className={cn(\n                  \"text-center font-medium text-sm\",\n                  draggedInside ? \"text-primary\" : \"text-muted-foreground\"\n                )}\n              >\n                {getDropzoneText()}\n              </p>\n              <input\n                accept=\"image/*\"\n                aria-label=\"Upload image\"\n                className=\"sr-only size-0 overflow-hidden opacity-0\"\n                onChange={onFileChange}\n                ref={ref}\n                type=\"file\"\n              />\n            </div>\n          )}\n        </CardContent>\n        <CardFooter className=\"mt-2 flex items-center justify-between gap-10 rounded-[12px] bg-background p-3 shadow-xs\">\n          {showEmbedInput ? (\n            <div className=\"flex flex-1 flex-col gap-2\">\n              <div className=\"flex items-center gap-2\">\n                <Input\n                  className={cn(\n                    \"h-8 flex-1 bg-background\",\n                    urlError && \"border-destructive\"\n                  )}\n                  disabled={isValidatingUrl || loading}\n                  onChange={({ target }) => {\n                    setEmbedUrl(target.value);\n                    setUrlError(null);\n                  }}\n                  onKeyDown={(e) => {\n                    if (\n                      e.key === \"Enter\" &&\n                      embedUrl &&\n                      !isValidatingUrl &&\n                      !loading\n                    ) {\n                      handleEmbedUrl(embedUrl);\n                    }\n                  }}\n                  placeholder=\"Paste image URL\"\n                  value={embedUrl}\n                />\n                <Button\n                  className=\"size-8 shrink-0 shadow-none\"\n                  disabled={!embedUrl || isValidatingUrl || loading}\n                  onClick={() => handleEmbedUrl(embedUrl)}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  {isValidatingUrl ? (\n                    <SpinnerIcon className=\"size-4 animate-spin\" />\n                  ) : (\n                    <CheckIcon className=\"size-4\" />\n                  )}\n                </Button>\n                <Button\n                  className=\"size-8 shrink-0 shadow-none\"\n                  disabled={loading}\n                  onClick={() => {\n                    setShowEmbedInput(false);\n                    setEmbedUrl(\"\");\n                    setUrlError(null);\n                  }}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <XIcon className=\"size-4\" />\n                </Button>\n              </div>\n              {urlError && (\n                <p className=\"text-destructive text-xs\">{urlError}</p>\n              )}\n            </div>\n          ) : (\n            // Media and Embed URL buttons - shown by default\n            <div className=\"flex items-center gap-2\">\n              {(media !== undefined || fetchMediaPage) && (\n                <Button\n                  className=\"shrink-0 text-muted-foreground shadow-none\"\n                  disabled={loading}\n                  onClick={() => setIsGalleryOpen(true)}\n                  size=\"sm\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  View Gallery\n                </Button>\n              )}\n              <Button\n                className=\"shrink-0 text-muted-foreground shadow-none\"\n                disabled={loading}\n                onClick={() => setShowEmbedInput(true)}\n                size=\"sm\"\n                type=\"button\"\n                variant=\"outline\"\n              >\n                Embed URL\n              </Button>\n            </div>\n          )}\n          <Button\n            className=\"shrink-0 shadow-none\"\n            disabled={loading}\n            onClick={onCancel}\n            size=\"sm\"\n            type=\"button\"\n          >\n            Cancel\n          </Button>\n        </CardFooter>\n      </Card>\n\n      {/* Media Gallery Dialog */}\n      {(media !== undefined || fetchMediaPage) && (\n        <Dialog onOpenChange={setIsGalleryOpen} open={isGalleryOpen}>\n          <DialogContent\n            className=\"flex max-h-[800px] flex-col overflow-hidden text-clip sm:max-w-4xl\"\n            variant=\"card\"\n          >\n            <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n              <div className=\"flex flex-1 items-center gap-2\">\n                <HugeiconsIcon\n                  className=\"text-muted-foreground\"\n                  icon={Album02Icon}\n                  size={20}\n                />\n                <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n                  Media Gallery\n                </DialogTitle>\n              </div>\n              <DialogX />\n            </DialogHeader>\n            <DialogBody className=\"min-h-[400px] p-4\">\n              {isLoadingMedia ? (\n                <div className=\"flex min-h-[360px] items-center justify-center\">\n                  <div className=\"flex flex-col items-center justify-center gap-2 text-muted-foreground\">\n                    <SpinnerIcon className=\"size-6 animate-spin\" />\n                    <p className=\"font-medium text-sm\">Loading media...</p>\n                  </div>\n                </div>\n              ) : media && media.length > 0 ? (\n                <div className=\"flex max-h-[500px] flex-col gap-4 overflow-y-auto\">\n                  <ul className=\"m-0 grid w-full list-none grid-cols-[repeat(auto-fill,minmax(8.125rem,1fr))] gap-2.5 p-0\">\n                    {media\n                      ?.filter((item) => item.type === \"image\")\n                      .map((item) => (\n                        <li\n                          className=\"group relative size-[8.125rem]\"\n                          key={item.id}\n                        >\n                          <button\n                            className=\"flex h-full w-full items-center justify-center rounded-lg border border-border bg-background p-1 transition-opacity hover:opacity-80\"\n                            onClick={() => handleMediaSelect(item.url)}\n                            type=\"button\"\n                          >\n                            <div className=\"flex h-full w-full items-center justify-center overflow-hidden rounded-md border border-border\">\n                              {/* biome-ignore lint: Preview image in dialog */}\n                              <img\n                                alt={item.name}\n                                className=\"h-full w-full object-contain\"\n                                src={item.url}\n                              />\n                            </div>\n                          </button>\n                        </li>\n                      ))}\n                  </ul>\n                  {nextCursor && (\n                    <div className=\"flex justify-center py-2\">\n                      <Button\n                        disabled={isLoadingMore}\n                        onClick={handleLoadMore}\n                        type=\"button\"\n                        variant=\"outline\"\n                      >\n                        {isLoadingMore ? (\n                          <>\n                            <SpinnerIcon className=\"mr-2 size-4 animate-spin\" />\n                            Loading...\n                          </>\n                        ) : (\n                          \"Load More\"\n                        )}\n                      </Button>\n                    </div>\n                  )}\n                </div>\n              ) : (\n                <div className=\"flex min-h-[360px] items-center justify-center\">\n                  <div className=\"flex flex-col items-center justify-center gap-2 text-muted-foreground\">\n                    <ImagesIcon className=\"size-8\" />\n                    <p className=\"font-medium text-sm\">\n                      Your gallery is empty. Upload some media to get started.\n                    </p>\n                  </div>\n                </div>\n              )}\n            </DialogBody>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/image-upload/image-upload-view.tsx",
    "content": "import type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport { ImageUploadComp } from \"./image-upload-comp\";\nimport type { ImageUploadStorage } from \"./index\";\n\nexport const ImageUploadView = ({\n  getPos,\n  editor,\n  node,\n  extension,\n}: NodeViewProps) => {\n  const storage = extension.storage as ImageUploadStorage;\n  const pendingUploads = storage.pendingUploads;\n\n  // Get fileId from node attributes\n  const fileId = node.attrs.fileId as string | null;\n  const initialFile = fileId ? pendingUploads.get(fileId) : undefined;\n\n  // Get extension options from storage\n  const { options } = storage;\n\n  // Track whether the upload was consumed (success or cancel) so the\n  // unmount cleanup knows whether it still needs to release the entry.\n  const consumedRef = useRef(false);\n\n  // Clean up the pending upload entry when this view unmounts (e.g. the\n  // node is deleted while an upload is still in progress).\n  useEffect(() => {\n    return () => {\n      if (fileId && !consumedRef.current) {\n        pendingUploads.delete(fileId);\n      }\n    };\n  }, [fileId, pendingUploads]);\n\n  const onUpload = useCallback(\n    (url: string) => {\n      if (url && typeof getPos === \"function\") {\n        const pos = getPos();\n        if (typeof pos === \"number\") {\n          consumedRef.current = true;\n          if (fileId) {\n            pendingUploads.delete(fileId);\n          }\n\n          editor\n            .chain()\n            .focus()\n            .deleteRange({ from: pos, to: pos + 1 })\n            .setFigure({ src: url, alt: \"\", caption: \"\" })\n            .run();\n        }\n      }\n    },\n    [getPos, editor, fileId, pendingUploads]\n  );\n\n  const onCancel = useCallback(() => {\n    if (typeof getPos === \"function\") {\n      const pos = getPos();\n      if (typeof pos === \"number\") {\n        consumedRef.current = true;\n        if (fileId) {\n          pendingUploads.delete(fileId);\n        }\n\n        editor\n          .chain()\n          .focus()\n          .deleteRange({ from: pos, to: pos + 1 })\n          .run();\n      }\n    }\n  }, [getPos, editor, fileId, pendingUploads]);\n\n  // Only render if upload handler is configured\n  if (!options.upload) {\n    return (\n      <NodeViewWrapper className=\"my-5\">\n        <div className=\"flex items-center justify-center rounded-md border border-muted bg-muted/50 p-8\">\n          <p className=\"text-muted-foreground text-sm\">\n            Image upload is not configured. Please configure the ImageUpload\n            extension with an upload handler.\n          </p>\n        </div>\n      </NodeViewWrapper>\n    );\n  }\n\n  return (\n    <NodeViewWrapper className=\"my-5\">\n      <div className=\"m-0 p-0\" data-drag-handle>\n        <ImageUploadComp\n          fetchMediaPage={options.fetchMediaPage}\n          initialFile={initialFile}\n          media={options.media}\n          onCancel={onCancel}\n          onError={options.onError}\n          onUpload={onUpload}\n          upload={options.upload}\n        />\n      </div>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/image-upload/index.ts",
    "content": "/** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */\nimport type { CommandProps } from \"@tiptap/core\";\nimport { Node } from \"@tiptap/core\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport type { ImageUploadOptions } from \"../../types\";\nimport { ImageUploadView } from \"./image-upload-view\";\n\ndeclare module \"@tiptap/core\" {\n  interface Commands<ReturnType> {\n    imageUpload: {\n      setImageUpload: (options?: { file?: File }) => ReturnType;\n    };\n  }\n}\n\nexport const ImageUpload = Node.create<ImageUploadOptions>({\n  name: \"imageUpload\",\n  isolating: true,\n  defining: true,\n  group: \"block\",\n  draggable: true,\n  selectable: true,\n  inline: false,\n\n  addOptions() {\n    return {\n      upload: undefined,\n      accept: \"image/*\",\n      maxSize: undefined,\n      limit: undefined,\n      onError: undefined,\n      media: undefined,\n      fetchMediaPage: undefined,\n    };\n  },\n\n  addAttributes() {\n    return {\n      fileId: {\n        default: null,\n        parseHTML: (element) => element.getAttribute(\"data-file-id\"),\n        renderHTML: (attributes) => {\n          if (!attributes.fileId) {\n            return {};\n          }\n          return {\n            \"data-file-id\": attributes.fileId,\n          };\n        },\n      },\n    };\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: `div[data-type=\"${this.name}\"]`,\n      },\n    ];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return [\"div\", { \"data-type\": this.name, ...HTMLAttributes }];\n  },\n\n  addCommands() {\n    const extensionStorage = this.storage as ImageUploadStorage;\n    return {\n      setImageUpload:\n        (options) =>\n        ({ commands }: CommandProps) => {\n          const { file } = options || {};\n\n          if (file) {\n            const fileId = `upload-${Date.now()}-${Math.random()}`;\n            extensionStorage.pendingUploads.set(fileId, file);\n\n            return commands.insertContent({\n              type: this.name,\n              attrs: { fileId },\n            });\n          }\n\n          return commands.insertContent({\n            type: this.name,\n          });\n        },\n    };\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(ImageUploadView, {\n      as: \"div\",\n    });\n  },\n\n  addStorage() {\n    return {\n      pendingUploads: new Map<string, File>(),\n      options: this.options,\n    };\n  },\n\n  onDestroy() {\n    const storage = this.storage as ImageUploadStorage;\n    storage.pendingUploads.clear();\n  },\n});\n\nexport interface ImageUploadStorage {\n  pendingUploads: Map<string, File>;\n  options: ImageUploadOptions;\n}\n"
  },
  {
    "path": "packages/editor/src/extensions/index.ts",
    "content": "// Extensions\n/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\nexport type {\n  ImageUploadOptions,\n  MediaItem,\n  VideoUploadOptions,\n} from \"../types\";\nexport { CodeBlock } from \"./code-block\";\n// Extension Kit\nexport {\n  default,\n  ExtensionKit,\n  type ExtensionKitOptions,\n} from \"./extension-kit\";\nexport { Figure } from \"./figure\";\nexport { ImageUpload } from \"./image-upload\";\nexport { MarkdownInput } from \"./markdown-input\";\nexport {\n  configureSlashCommand,\n  handleCommandNavigation,\n  SlashCommand,\n} from \"./slash-command\";\nexport {\n  Table,\n  TableCell,\n  TableColumnMenu,\n  TableHeader,\n  TableRow,\n  TableRowMenu,\n} from \"./table\";\nexport { TwitterUpload } from \"./twitter/twitter-upload\";\nexport { Video } from \"./video\";\nexport { VideoUpload } from \"./video-upload\";\nexport { YouTubeUpload } from \"./youtube/youtube-upload\";\n"
  },
  {
    "path": "packages/editor/src/extensions/markdown-input/index.ts",
    "content": "import { Extension } from \"@tiptap/core\";\nimport { Plugin, PluginKey } from \"@tiptap/pm/state\";\nimport type { EditorView } from \"@tiptap/pm/view\";\nimport { looksLikeMarkdown, transformContent } from \"./utils\";\n\n/**\n * Unified extension for handling markdown input via paste and file drop\n * Handles three scenarios:\n * 1. Text paste: Detects and parses markdown text from clipboard\n * 2. File drop: Handles dropped markdown files\n * 3. File paste: Handles pasted markdown files from clipboard\n */\nexport const MarkdownInput = Extension.create({\n  name: \"markdownInput\",\n\n  addProseMirrorPlugins() {\n    return [\n      new Plugin({\n        key: new PluginKey(\"markdownInput\"),\n        props: {\n          handlePaste: (_view: EditorView, event: ClipboardEvent) => {\n            const { editor } = this;\n\n            // First, check for markdown files in clipboard\n            const files = Array.from(event.clipboardData?.files || []);\n            const markdownFiles = files.filter(\n              (file) =>\n                file.name.endsWith(\".md\") ||\n                file.name.endsWith(\".markdown\") ||\n                file.type === \"text/markdown\"\n            );\n\n            if (markdownFiles.length > 0) {\n              // Handle pasted markdown files\n              event.preventDefault();\n\n              for (const file of markdownFiles) {\n                const reader = new FileReader();\n                reader.onload = (e) => {\n                  const text = e.target?.result as string;\n                  if (text) {\n                    try {\n                      const json = editor?.markdown?.parse(text);\n                      if (json) {\n                        const transformedContent = transformContent(json);\n                        editor.commands.insertContent(transformedContent);\n                      }\n                    } catch (error) {\n                      console.error(\"Failed to parse markdown file:\", error);\n                    }\n                  }\n                };\n                reader.readAsText(file);\n              }\n\n              return true;\n            }\n\n            // If HTML is available, let the normal HTML paste pipeline handle it\n            const html = event.clipboardData?.getData(\"text/html\");\n            if (html) {\n              return false;\n            }\n\n            // If no HTML, check if clipboard text looks like markdown\n            const text = event.clipboardData?.getData(\"text/plain\");\n\n            if (!text) {\n              return false;\n            }\n\n            if (!looksLikeMarkdown(text)) {\n              return false;\n            }\n\n            // Prevent default paste behavior\n            event.preventDefault();\n\n            try {\n              // Parse markdown to JSON using Tiptap's markdown extension\n              const json = editor?.markdown?.parse(text) ?? {\n                type: \"doc\",\n                content: [],\n              };\n\n              // Transform Image nodes to Figure nodes\n              const transformedContent = transformContent(json);\n\n              // Insert the parsed and transformed content\n              editor.commands.insertContent(transformedContent);\n\n              return true;\n            } catch (error) {\n              console.error(\"Failed to parse markdown:\", error);\n              // Fall back to default paste behavior\n              return false;\n            }\n          },\n\n          handleDrop: (_view: EditorView, event: DragEvent, _slice, moved) => {\n            // Don't handle if this is a move within the editor\n            if (moved) {\n              return false;\n            }\n\n            const { editor } = this;\n            const files = Array.from(event.dataTransfer?.files || []);\n\n            // Check if any files are markdown files\n            const markdownFiles = files.filter(\n              (file) =>\n                file.name.endsWith(\".md\") ||\n                file.name.endsWith(\".markdown\") ||\n                file.type === \"text/markdown\"\n            );\n\n            if (markdownFiles.length === 0) {\n              // Let other plugins handle this\n              return false;\n            }\n\n            // Prevent default browser behavior\n            event.preventDefault();\n\n            // Process all markdown files\n            for (const file of markdownFiles) {\n              const reader = new FileReader();\n              reader.onload = (e) => {\n                const text = e.target?.result as string;\n                if (text) {\n                  try {\n                    // Parse markdown to JSON\n                    const json = editor?.markdown?.parse(text);\n                    if (json) {\n                      // Transform Image nodes to Figure nodes\n                      const transformedContent = transformContent(json);\n                      // Insert at drop position\n                      editor.commands.insertContent(transformedContent);\n                    }\n                  } catch (error) {\n                    console.error(\"Failed to parse markdown file:\", error);\n                  }\n                }\n              };\n              reader.readAsText(file);\n            }\n\n            // Return true to indicate we handled this event\n            return true;\n          },\n        },\n      }),\n    ];\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/markdown-input/utils.ts",
    "content": "import type { JSONContent } from \"@tiptap/core\";\n\n/**\n * Checks if text looks like markdown by detecting common markdown patterns\n */\nexport function looksLikeMarkdown(text: string): boolean {\n  if (!text || text.trim().length === 0) {\n    return false;\n  }\n\n  const markdownPatterns = [\n    /^#{1,6}\\s+.+/m, // Headings\n    /\\*\\*[^*]+\\*\\*/m, // Bold with **\n    /__[^_]+__/m, // Bold with __\n    /\\*[^*]+\\*/m, // Italic with *\n    /_[^_]+_/m, // Italic with _\n    /\\[.+\\]\\(.+\\)/m, // Links [text](url)\n    /^\\s*[-*+]\\s+/m, // Unordered lists\n    /^\\s*\\d+\\.\\s+/m, // Ordered lists\n    /```[\\s\\S]*?```/m, // Code blocks\n    /`[^`]+`/m, // Inline code\n    /^\\s*>\\s+/m, // Blockquotes\n    /!\\[.*\\]\\(.*\\)/m, // Images\n    /^\\s*[-*_]{3,}\\s*$/m, // Horizontal rules\n    /^\\|.+\\|$/m, // Tables\n  ];\n\n  // Check if at least 2 patterns match for better accuracy\n  const matchCount = markdownPatterns.filter((pattern) =>\n    pattern.test(text)\n  ).length;\n  return matchCount >= 2 || /^#{1,6}\\s+.+/m.test(text); // Or single heading pattern\n}\n\n/**\n * Check if a node type is an inline context where block-level figures can't exist\n */\nfunction isInlineContext(nodeType?: string): boolean {\n  const inlineTypes = [\"text\", \"strong\", \"em\", \"code\", \"strike\", \"underline\"];\n  return nodeType ? inlineTypes.includes(nodeType) : false;\n}\n\n/**\n * Recursively transforms Image nodes to Figure nodes in parsed JSON\n * Converts markdown image syntax ![caption](url) to Figure nodes with captions\n * Handles linked images [![alt](img)](href) by extracting the href\n */\nexport function transformImageToFigure(\n  content: JSONContent,\n  parentType?: string\n): JSONContent {\n  if (!content) {\n    return content;\n  }\n\n  // Handle link nodes that contain a single image (linked images)\n  // Transform: link > image -> figure with href\n  if (content.type === \"link\") {\n    const hasOnlyImage =\n      content.content &&\n      content.content.length === 1 &&\n      content.content[0]?.type === \"image\";\n\n    if (hasOnlyImage && content.content) {\n      const image = content.content[0];\n      const href = content.attrs?.href;\n\n      // Transform to figure with href\n      return {\n        type: \"figure\",\n        attrs: {\n          src: image?.attrs?.src || \"\",\n          alt: image?.attrs?.alt || \"\",\n          caption: image?.attrs?.alt || \"\",\n          href: href || null,\n        },\n      };\n    }\n  }\n\n  // Transform the current node if it's an image\n  if (content.type === \"image\") {\n    // Don't transform images in inline contexts (e.g., inside text, strong, etc.)\n    if (isInlineContext(parentType)) {\n      return content; // Keep as image\n    }\n\n    // Transform to figure (without href)\n    return {\n      type: \"figure\",\n      attrs: {\n        src: content.attrs?.src || \"\",\n        alt: content.attrs?.alt || \"\",\n        caption: content.attrs?.alt || \"\", // Use alt text as caption\n        href: null,\n      },\n    };\n  }\n\n  // Recursively transform children, passing current node type as parent\n  if (content.content && Array.isArray(content.content)) {\n    return {\n      ...content,\n      content: content.content.map((child) =>\n        transformImageToFigure(child, content.type)\n      ),\n    };\n  }\n\n  return content;\n}\n\n/**\n * Lifts figures out of paragraphs where they're the only child\n * Markdown parsers often wrap standalone images in paragraphs, which becomes\n * invalid when the image is transformed to a figure (block-level element)\n */\nfunction liftFiguresFromParagraphs(content: JSONContent): JSONContent {\n  if (!content) {\n    return content;\n  }\n\n  // If this is a paragraph with a single figure child, replace the paragraph with the figure\n  if (\n    content.type === \"paragraph\" &&\n    content.content &&\n    content.content.length === 1 &&\n    content.content[0]?.type === \"figure\"\n  ) {\n    return content.content[0]; // Replace paragraph with the figure\n  }\n\n  // Recursively process children\n  if (content.content && Array.isArray(content.content)) {\n    return {\n      ...content,\n      content: content.content.map((child) => liftFiguresFromParagraphs(child)),\n    };\n  }\n\n  return content;\n}\n\n/**\n * Transforms an array of JSON content, converting images to figures\n * and lifting figures out of invalid contexts\n */\nexport function transformContent(\n  json: JSONContent | JSONContent[]\n): JSONContent | JSONContent[] {\n  if (Array.isArray(json)) {\n    // First transform images to figures\n    const transformed = json.map((item) => transformImageToFigure(item));\n    // Then lift figures out of paragraphs\n    return transformed.map((item) => liftFiguresFromParagraphs(item));\n  }\n  // First transform images to figures\n  const transformed = transformImageToFigure(json);\n  // Then lift figures out of paragraphs\n  return liftFiguresFromParagraphs(transformed);\n}\n"
  },
  {
    "path": "packages/editor/src/extensions/slash-command/groups.ts",
    "content": "import {\n  CheckSquareIcon,\n  CodeIcon,\n  ImageIcon,\n  ListBulletsIcon,\n  ListNumbersIcon,\n  QuotesIcon,\n  TableIcon,\n  TextAlignLeftIcon,\n  TextHOneIcon,\n  TextHThreeIcon,\n  TextHTwoIcon,\n  VideoCameraIcon,\n} from \"@phosphor-icons/react\";\nimport type { SuggestionOptions } from \"@tiptap/suggestion\";\nimport { Twitter } from \"../../components/icons/twitter\";\nimport { YouTubeIcon } from \"../../components/icons/youtube\";\nimport type { SuggestionItem } from \"../../types\";\n\n/**\n * Default slash command suggestions\n * These are the commands that appear when typing \"/\" in the editor\n */\nexport const defaultSlashSuggestions: SuggestionOptions<SuggestionItem>[\"items\"] =\n  () => [\n    {\n      title: \"Text\",\n      description: \"Just start typing with plain text.\",\n      searchTerms: [\"p\", \"paragraph\"],\n      icon: TextAlignLeftIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .toggleNode(\"paragraph\", \"paragraph\")\n          .run();\n      },\n    },\n    {\n      title: \"Heading 1\",\n      description: \"Use for main page title.\",\n      searchTerms: [\"title\", \"big\", \"large\"],\n      icon: TextHOneIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .setNode(\"heading\", { level: 1 })\n          .run();\n      },\n    },\n    {\n      title: \"Heading 2\",\n      description: \"Use for section headings.\",\n      searchTerms: [\"subtitle\", \"medium\"],\n      icon: TextHTwoIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .setNode(\"heading\", { level: 2 })\n          .run();\n      },\n    },\n    {\n      title: \"Heading 3\",\n      description: \"Use for sub-section headings.\",\n      searchTerms: [\"subtitle\", \"small\"],\n      icon: TextHThreeIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .setNode(\"heading\", { level: 3 })\n          .run();\n      },\n    },\n    {\n      title: \"Bullet List\",\n      description: \"Create a simple bullet list.\",\n      searchTerms: [\"unordered\", \"point\"],\n      icon: ListBulletsIcon,\n      command: ({ editor, range }) => {\n        editor.chain().focus().deleteRange(range).toggleBulletList().run();\n      },\n    },\n    {\n      title: \"Numbered List\",\n      description: \"Create a list with numbering.\",\n      searchTerms: [\"ordered\"],\n      icon: ListNumbersIcon,\n      command: ({ editor, range }) => {\n        editor.chain().focus().deleteRange(range).toggleOrderedList().run();\n      },\n    },\n    {\n      title: \"To-do List\",\n      description: \"Track tasks with a to-do list.\",\n      searchTerms: [\"todo\", \"task\", \"list\", \"check\", \"checkbox\"],\n      icon: CheckSquareIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .toggleList(\"taskList\", \"taskItem\")\n          .run();\n      },\n    },\n    {\n      title: \"Quote\",\n      description: \"Capture a quote.\",\n      searchTerms: [\"blockquote\"],\n      icon: QuotesIcon,\n      command: ({ editor, range }) =>\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .toggleNode(\"paragraph\", \"paragraph\")\n          .toggleBlockquote()\n          .run(),\n    },\n    {\n      title: \"Code\",\n      description: \"Capture a code snippet.\",\n      searchTerms: [\"codeblock\"],\n      icon: CodeIcon,\n      command: ({ editor, range }) =>\n        editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),\n    },\n    {\n      title: \"Table\",\n      description: \"Add a table view to organize data.\",\n      searchTerms: [\"table\"],\n      icon: TableIcon,\n      command: ({ editor, range }) =>\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .insertTable({ rows: 3, cols: 3, withHeaderRow: true })\n          .run(),\n    },\n    {\n      title: \"YouTube\",\n      description: \"Embed a YouTube video.\",\n      searchTerms: [\"youtube\", \"video\", \"embed\"],\n      icon: YouTubeIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .insertContent({\n            type: \"youtubeUpload\",\n          })\n          .run();\n      },\n    },\n    {\n      title: \"Twitter\",\n      description: \"Embed a Tweet.\",\n      searchTerms: [\"twitter\", \"tweet\", \"x\"],\n      icon: Twitter,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .insertContent({\n            type: \"twitterUpload\",\n          })\n          .run();\n      },\n    },\n    {\n      title: \"Image\",\n      description: \"Upload or embed an image.\",\n      searchTerms: [\"image\", \"picture\", \"photo\", \"img\"],\n      icon: ImageIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .insertContent({\n            type: \"imageUpload\",\n          })\n          .run();\n      },\n    },\n    {\n      title: \"Video\",\n      description: \"Upload or embed a video.\",\n      searchTerms: [\"video\", \"mp4\", \"clip\", \"media\"],\n      icon: VideoCameraIcon,\n      command: ({ editor, range }) => {\n        editor\n          .chain()\n          .focus()\n          .deleteRange(range)\n          .insertContent({\n            type: \"videoUpload\",\n          })\n          .run();\n      },\n    },\n  ];\n"
  },
  {
    "path": "packages/editor/src/extensions/slash-command/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\nexport type { EditorSlashMenuProps, SlashNodeAttrs } from \"../../types\";\nexport { defaultSlashSuggestions } from \"./groups\";\nexport { EditorSlashMenu, handleCommandNavigation } from \"./menu-list\";\nexport type { SlashOptions } from \"./slash-command\";\nexport { configureSlashCommand, SlashCommand } from \"./slash-command\";\n"
  },
  {
    "path": "packages/editor/src/extensions/slash-command/menu-list.tsx",
    "content": "import {\n  Command,\n  CommandEmpty,\n  CommandItem,\n  CommandList,\n} from \"@marble/ui/components/command\";\nimport { useRef } from \"react\";\nimport type { EditorSlashMenuProps } from \"../../types\";\n\n/**\n * Menu list component for slash commands\n * Displays available commands in a dropdown menu\n * Uses cmdk's built-in keyboard navigation (ArrowUp, ArrowDown, Enter)\n */\nexport const EditorSlashMenu = ({\n  items,\n  editor,\n  range,\n}: EditorSlashMenuProps) => {\n  const commandRef = useRef<HTMLDivElement>(null);\n\n  const selectItem = (index: number) => {\n    const item = items.at(index);\n    if (item) {\n      item.command({ editor, range });\n    }\n  };\n\n  return (\n    <Command\n      className=\"border shadow\"\n      id=\"slash-command\"\n      loop\n      ref={commandRef}\n      shouldFilter={false}\n    >\n      <CommandEmpty className=\"flex w-full items-center justify-center p-4 text-muted-foreground text-sm\">\n        <p>No results</p>\n      </CommandEmpty>\n      <CommandList className=\"p-1\">\n        {items.map((item, index) => (\n          <CommandItem\n            className=\"flex items-center gap-3 pr-3\"\n            key={item.title}\n            onSelect={() => selectItem(index)}\n            value={item.title}\n          >\n            <div className=\"flex size-9 shrink-0 items-center justify-center rounded border bg-secondary\">\n              <item.icon className=\"text-muted-foreground\" size={16} />\n            </div>\n            <div className=\"flex flex-col\">\n              <span className=\"font-medium text-sm\">{item.title}</span>\n              <span className=\"text-muted-foreground text-xs\">\n                {item.description}\n              </span>\n            </div>\n          </CommandItem>\n        ))}\n      </CommandList>\n    </Command>\n  );\n};\n\n/**\n * Handle keyboard navigation for slash command menu\n */\nexport const handleCommandNavigation = (event: KeyboardEvent) => {\n  if ([\"ArrowUp\", \"ArrowDown\", \"Enter\"].includes(event.key)) {\n    const slashCommand = document.querySelector<HTMLElement>(\"#slash-command\");\n\n    if (slashCommand) {\n      // For Enter key, find and trigger the selected item directly\n      if (event.key === \"Enter\") {\n        const selectedItem = slashCommand.querySelector<HTMLElement>(\n          '[data-selected=\"true\"], [cmdk-item][aria-selected=\"true\"], [cmdk-item][data-state=\"selected\"]'\n        );\n\n        if (selectedItem) {\n          event.preventDefault();\n          event.stopPropagation();\n          selectedItem.click();\n          return true;\n        }\n\n        // If no item is selected, select the first item\n        const firstItem =\n          slashCommand.querySelector<HTMLElement>(\"[cmdk-item]\");\n        if (firstItem) {\n          event.preventDefault();\n          event.stopPropagation();\n          firstItem.click();\n          return true;\n        }\n      }\n\n      // For ArrowUp/ArrowDown, dispatch the event to cmdk\n      const keyboardEvent = new KeyboardEvent(\"keydown\", {\n        key: event.key,\n        cancelable: true,\n        bubbles: true,\n      });\n\n      slashCommand.dispatchEvent(keyboardEvent);\n      event.preventDefault();\n      event.stopPropagation();\n\n      return true;\n    }\n  }\n\n  return false;\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/slash-command/slash-command.ts",
    "content": "/** biome-ignore-all lint/suspicious/noExplicitAny: <> */\nimport {\n  autoUpdate,\n  computePosition,\n  flip,\n  offset,\n  shift,\n} from \"@floating-ui/dom\";\nimport { mergeAttributes, Node } from \"@tiptap/core\";\nimport type { DOMOutputSpec, Node as ProseMirrorNode } from \"@tiptap/pm/model\";\nimport { PluginKey } from \"@tiptap/pm/state\";\nimport { ReactRenderer } from \"@tiptap/react\";\nimport Suggestion, { type SuggestionOptions } from \"@tiptap/suggestion\";\nimport Fuse from \"fuse.js\";\nimport type { EditorSlashMenuProps, SlashNodeAttrs } from \"../../types\";\nimport { defaultSlashSuggestions } from \"./groups\";\nimport { EditorSlashMenu, handleCommandNavigation } from \"./menu-list\";\n\nconst SlashPluginKey = new PluginKey(\"slash\");\n\n/**\n * Slash command options type\n */\nexport interface SlashOptions<\n  SlashOptionSuggestionItem = unknown,\n  Attrs = SlashNodeAttrs,\n> {\n  HTMLAttributes: Record<string, unknown>;\n  renderText: (props: {\n    options: SlashOptions<SlashOptionSuggestionItem, Attrs>;\n    node: ProseMirrorNode;\n  }) => string;\n  renderHTML: (props: {\n    options: SlashOptions<SlashOptionSuggestionItem, Attrs>;\n    node: ProseMirrorNode;\n  }) => DOMOutputSpec;\n  deleteTriggerWithBackspace: boolean;\n  suggestion: Omit<\n    SuggestionOptions<SlashOptionSuggestionItem, Attrs>,\n    \"editor\"\n  >;\n}\n\n/**\n * Slash Command Extension\n * Allows users to type \"/\" to open a command menu with formatting options\n */\nexport const SlashCommand = Node.create<SlashOptions>({\n  name: \"slash\",\n  priority: 101,\n\n  addOptions() {\n    return {\n      HTMLAttributes: {},\n      renderText({ options, node }) {\n        return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;\n      },\n      deleteTriggerWithBackspace: false,\n      renderHTML({ options, node }) {\n        return [\n          \"span\",\n          mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),\n          `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,\n        ];\n      },\n      suggestion: {\n        char: \"/\",\n        pluginKey: SlashPluginKey,\n        command: ({ editor, range, props }) => {\n          // increase range.to by one when the next node is of type \"text\"\n          // and starts with a space character\n          const nodeAfter = editor.view.state.selection.$to.nodeAfter;\n          const overrideSpace = nodeAfter?.text?.startsWith(\" \");\n\n          if (overrideSpace) {\n            range.to += 1;\n          }\n\n          editor\n            .chain()\n            .focus()\n            .insertContentAt(range, [\n              {\n                type: this.name,\n                attrs: props,\n              },\n              {\n                type: \"text\",\n                text: \" \",\n              },\n            ])\n            .run();\n\n          // get reference to `window` object from editor element, to support cross-frame JS usage\n          editor.view.dom.ownerDocument.defaultView\n            ?.getSelection()\n            ?.collapseToEnd();\n        },\n        allow: ({ state, range }) => {\n          const $from = state.doc.resolve(range.from);\n\n          // Check if we're inside a table by looking at ancestor nodes\n          let isInTable = false;\n          for (let depth = $from.depth; depth > 0; depth -= 1) {\n            const node = $from.node(depth);\n            if (\n              node.type.name === \"table\" ||\n              node.type.name === \"tableRow\" ||\n              node.type.name === \"tableCell\" ||\n              node.type.name === \"tableHeader\"\n            ) {\n              isInTable = true;\n              break;\n            }\n          }\n\n          // Don't allow slash commands inside tables\n          if (isInTable) {\n            return false;\n          }\n\n          const isRootDepth = $from.depth === 1;\n          const isParagraph = $from.parent.type.name === \"paragraph\";\n          const isStartOfNode = $from.parent.textContent?.charAt(0) === \"/\";\n\n          // Check if we're in a column (for column layouts) by checking ancestor nodes\n          let isInColumn = false;\n          for (let depth = $from.depth; depth > 0; depth -= 1) {\n            const node = $from.node(depth);\n            if (node.type.name === \"column\") {\n              isInColumn = true;\n              break;\n            }\n          }\n\n          // Check if content after '/' is valid (not ending with double space)\n          const afterContent = $from.parent.textContent?.substring(\n            $from.parent.textContent?.indexOf(\"/\") ?? 0\n          );\n          const isValidAfterContent = !afterContent?.endsWith(\"  \");\n\n          // Only allow slash commands at root depth or in columns, and only in paragraphs at the start\n          return (\n            ((isRootDepth && isParagraph && isStartOfNode) ||\n              (isInColumn && isParagraph && isStartOfNode)) &&\n            isValidAfterContent\n          );\n        },\n      },\n    };\n  },\n\n  group: \"inline\",\n\n  inline: true,\n\n  selectable: false,\n\n  atom: true,\n\n  addAttributes() {\n    return {\n      id: {\n        default: null,\n        parseHTML: (element) => element.getAttribute(\"data-id\"),\n        renderHTML: (attributes) => {\n          if (!attributes.id) {\n            return {};\n          }\n\n          return {\n            \"data-id\": attributes.id,\n          };\n        },\n      },\n\n      label: {\n        default: null,\n        parseHTML: (element) => element.getAttribute(\"data-label\"),\n        renderHTML: (attributes) => {\n          if (!attributes.label) {\n            return {};\n          }\n\n          return {\n            \"data-label\": attributes.label,\n          };\n        },\n      },\n    };\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: `span[data-type=\"${this.name}\"]`,\n      },\n    ];\n  },\n\n  renderHTML({ node, HTMLAttributes }) {\n    const mergedOptions = { ...this.options };\n\n    mergedOptions.HTMLAttributes = mergeAttributes(\n      { \"data-type\": this.name },\n      this.options.HTMLAttributes,\n      HTMLAttributes\n    );\n    const html = this.options.renderHTML({\n      options: mergedOptions,\n      node,\n    });\n\n    if (typeof html === \"string\") {\n      return [\n        \"span\",\n        mergeAttributes(\n          { \"data-type\": this.name },\n          this.options.HTMLAttributes,\n          HTMLAttributes\n        ),\n        html,\n      ];\n    }\n    return html;\n  },\n\n  renderText({ node }) {\n    return this.options.renderText({\n      options: this.options,\n      node,\n    });\n  },\n\n  addKeyboardShortcuts() {\n    return {\n      Backspace: () =>\n        this.editor.commands.command(({ tr, state }) => {\n          let isMention = false;\n          const { selection } = state;\n          const { empty, anchor } = selection;\n\n          if (!empty) {\n            return false;\n          }\n\n          state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {\n            if (node.type.name === this.name) {\n              isMention = true;\n              tr.insertText(\n                this.options.deleteTriggerWithBackspace\n                  ? \"\"\n                  : this.options.suggestion.char || \"\",\n                pos,\n                pos + node.nodeSize\n              );\n\n              return false;\n            }\n          });\n\n          return isMention;\n        }),\n    };\n  },\n\n  addProseMirrorPlugins() {\n    return [\n      Suggestion({\n        editor: this.editor,\n        ...this.options.suggestion,\n      }),\n    ];\n  },\n});\n\n/**\n * Configure slash command with default suggestions and Floating UI renderer\n */\nexport const configureSlashCommand = () =>\n  SlashCommand.configure({\n    suggestion: {\n      items: async ({ editor, query }) => {\n        if (!defaultSlashSuggestions) {\n          return [];\n        }\n        const items = await defaultSlashSuggestions({ editor, query });\n\n        if (!query) {\n          return items;\n        }\n\n        const slashFuse = new Fuse(items, {\n          keys: [\"title\", \"description\", \"searchTerms\"],\n          threshold: 0.2,\n          minMatchCharLength: 1,\n        });\n\n        const results = slashFuse.search(query);\n\n        return results.map((result) => result.item);\n      },\n      char: \"/\",\n      render: () => {\n        let component: ReactRenderer<EditorSlashMenuProps>;\n        let cleanup: (() => void) | undefined;\n\n        return {\n          onStart: (onStartProps) => {\n            // Clean up any existing component first (prevents double rendering in Strict Mode)\n            if (component) {\n              if (cleanup) {\n                cleanup();\n              }\n              if (component.element.parentNode) {\n                component.element.parentNode.removeChild(component.element);\n              }\n              component.destroy();\n            }\n\n            component = new ReactRenderer(EditorSlashMenu, {\n              props: onStartProps,\n              editor: onStartProps.editor,\n            });\n\n            const referenceElement = {\n              getBoundingClientRect: () =>\n                onStartProps.clientRect?.() || new DOMRect(),\n            };\n\n            // Use Floating UI for positioning (Tiptap v3)\n            cleanup = autoUpdate(\n              referenceElement as any,\n              component.element,\n              () => {\n                computePosition(referenceElement as any, component.element, {\n                  placement: \"bottom-start\",\n                  middleware: [offset(6), flip(), shift({ padding: 8 })],\n                }).then(({ x, y }) => {\n                  Object.assign(component.element.style, {\n                    left: `${x}px`,\n                    top: `${y}px`,\n                    position: \"absolute\",\n                  });\n                });\n              }\n            );\n\n            // Only append if not already in DOM (prevents duplicates)\n            if (!component.element.parentNode) {\n              document.body.appendChild(component.element);\n            }\n          },\n\n          onUpdate(onUpdateProps) {\n            component.updateProps(onUpdateProps);\n          },\n\n          onKeyDown(onKeyDownProps) {\n            if (onKeyDownProps.event.key === \"Escape\") {\n              if (cleanup) {\n                cleanup();\n              }\n              if (component.element.parentNode) {\n                component.element.parentNode.removeChild(component.element);\n              }\n              component.destroy();\n\n              return true;\n            }\n\n            return handleCommandNavigation(onKeyDownProps.event) ?? false;\n          },\n\n          onExit() {\n            if (cleanup) {\n              cleanup();\n            }\n            if (component.element.parentNode) {\n              component.element.parentNode.removeChild(component.element);\n            }\n            component.destroy();\n          },\n        };\n      },\n    },\n  });\n"
  },
  {
    "path": "packages/editor/src/extensions/table/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\nexport { TableColumnMenu } from \"./menus/table-column\";\nexport { TableRowMenu } from \"./menus/table-row\";\nexport { Table } from \"./table\";\nexport { TableCell } from \"./table-cell\";\nexport { TableHeader } from \"./table-header\";\nexport { TableRow } from \"./table-row\";\n"
  },
  {
    "path": "packages/editor/src/extensions/table/menus/table-column/index.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  ArrowLeftIcon,\n  ArrowRightIcon,\n  TrashIcon,\n} from \"@phosphor-icons/react\";\nimport type { Editor } from \"@tiptap/react\";\nimport { BubbleMenu as TiptapBubbleMenu } from \"@tiptap/react/menus\";\nimport { type JSX, memo, useCallback } from \"react\";\nimport { isColumnGripSelected } from \"./utils\";\n\ninterface MenuProps {\n  editor: Editor;\n  appendTo?: React.RefObject<HTMLElement>;\n}\n\ninterface ShouldShowProps {\n  view: unknown;\n  state: unknown;\n  from: number;\n}\n\nfunction TableColumnMenuComponent({\n  editor,\n  appendTo,\n}: MenuProps): JSX.Element {\n  const shouldShow = useCallback(\n    ({ view, state, from }: ShouldShowProps) => {\n      if (!state) {\n        return false;\n      }\n\n      return isColumnGripSelected({\n        editor,\n        view,\n        state,\n        from: from || 0,\n      } as Parameters<typeof isColumnGripSelected>[0]);\n    },\n    [editor]\n  );\n\n  const onAddColumnBefore = useCallback(() => {\n    editor.chain().focus().addColumnBefore().run();\n  }, [editor]);\n\n  const onAddColumnAfter = useCallback(() => {\n    editor.chain().focus().addColumnAfter().run();\n  }, [editor]);\n\n  const onDeleteColumn = useCallback(() => {\n    editor.chain().focus().deleteColumn().run();\n  }, [editor]);\n\n  return (\n    <TiptapBubbleMenu\n      appendTo={() => appendTo?.current ?? document.body}\n      className=\"flex flex-col items-center gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm\"\n      editor={editor}\n      options={{\n        placement: \"top\",\n        offset: { mainAxis: 24, crossAxis: 0 },\n      }}\n      pluginKey=\"tableColumnMenu\"\n      shouldShow={shouldShow}\n      updateDelay={0}\n    >\n      <Button\n        className=\"w-full justify-start gap-2\"\n        onClick={onAddColumnBefore}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <ArrowLeftIcon className=\"size-4\" />\n        <span>Add column before</span>\n      </Button>\n\n      <Button\n        className=\"w-full justify-start gap-2\"\n        onClick={onAddColumnAfter}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <ArrowRightIcon className=\"size-4\" />\n        <span>Add column after</span>\n      </Button>\n\n      <Button\n        className=\"w-full justify-start gap-2\"\n        onClick={onDeleteColumn}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <TrashIcon className=\"size-4\" />\n        <span>Delete column</span>\n      </Button>\n    </TiptapBubbleMenu>\n  );\n}\n\nexport const TableColumnMenu = memo(TableColumnMenuComponent);\nTableColumnMenu.displayName = \"TableColumnMenu\";\n\nexport default TableColumnMenu;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/menus/table-column/utils.ts",
    "content": "import type { EditorState } from \"@tiptap/pm/state\";\nimport type { EditorView } from \"@tiptap/pm/view\";\nimport type { Editor } from \"@tiptap/react\";\nimport { Table } from \"../..\";\nimport { isTableSelected } from \"../../utils\";\n\nexport const isColumnGripSelected = ({\n  editor,\n  view,\n  state,\n  from,\n}: {\n  editor: Editor;\n  view: EditorView;\n  state: EditorState;\n  from: number;\n}) => {\n  const domAtPos = view.domAtPos(from).node as HTMLElement;\n  const nodeDOM = view.nodeDOM(from) as HTMLElement;\n  const node = nodeDOM || domAtPos;\n\n  if (\n    !editor.isActive(Table.name) ||\n    !node ||\n    isTableSelected(state.selection)\n  ) {\n    return false;\n  }\n\n  // Find the owning table cell (TD/TH)\n  const element: Element | null =\n    node.nodeType === Node.ELEMENT_NODE\n      ? (node as Element)\n      : node.parentElement;\n  const cell = element?.closest?.(\"td, th\") ?? null;\n\n  const gripColumn = cell?.querySelector?.(\"a.grip-column.selected\");\n\n  return !!gripColumn;\n};\n\nexport default isColumnGripSelected;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/menus/table-row/index.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport { ArrowDownIcon, ArrowUpIcon, TrashIcon } from \"@phosphor-icons/react\";\nimport type { EditorState } from \"@tiptap/pm/state\";\nimport type { EditorView } from \"@tiptap/pm/view\";\nimport type { Editor } from \"@tiptap/react\";\nimport { BubbleMenu as TiptapBubbleMenu } from \"@tiptap/react/menus\";\nimport { type JSX, memo, useCallback } from \"react\";\nimport { isRowGripSelected } from \"./utils\";\n\ninterface MenuProps {\n  editor: Editor;\n  appendTo?: React.RefObject<HTMLElement>;\n}\n\ninterface ShouldShowProps {\n  view: EditorView;\n  state: EditorState;\n  from: number;\n}\n\nfunction TableRowMenuComponent({ editor, appendTo }: MenuProps): JSX.Element {\n  const shouldShow = useCallback(\n    ({ view, state, from }: ShouldShowProps) => {\n      if (!state || !from) {\n        return false;\n      }\n\n      return isRowGripSelected({ editor, view, state, from } as Parameters<\n        typeof isRowGripSelected\n      >[0]);\n    },\n    [editor]\n  );\n\n  const onAddRowBefore = useCallback(() => {\n    editor.chain().focus().addRowBefore().run();\n  }, [editor]);\n\n  const onAddRowAfter = useCallback(() => {\n    editor.chain().focus().addRowAfter().run();\n  }, [editor]);\n\n  const onDeleteRow = useCallback(() => {\n    editor.chain().focus().deleteRow().run();\n  }, [editor]);\n\n  return (\n    <TiptapBubbleMenu\n      appendTo={() => appendTo?.current ?? document.body}\n      className=\"flex flex-col gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm\"\n      editor={editor}\n      options={{\n        placement: \"left\",\n        offset: { mainAxis: 24, crossAxis: 0 },\n      }}\n      pluginKey=\"tableRowMenu\"\n      shouldShow={shouldShow}\n      updateDelay={0}\n    >\n      <Button\n        className=\"justify-start gap-2\"\n        onClick={onAddRowBefore}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <ArrowUpIcon className=\"size-4\" />\n        <span>Add row before</span>\n      </Button>\n\n      <Button\n        className=\"justify-start gap-2\"\n        onClick={onAddRowAfter}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <ArrowDownIcon className=\"size-4\" />\n        <span>Add row after</span>\n      </Button>\n\n      <Button\n        className=\"justify-start gap-2\"\n        onClick={onDeleteRow}\n        size=\"sm\"\n        type=\"button\"\n        variant=\"ghost\"\n      >\n        <TrashIcon className=\"size-4\" />\n        <span>Delete row</span>\n      </Button>\n    </TiptapBubbleMenu>\n  );\n}\n\nexport const TableRowMenu = memo(TableRowMenuComponent);\nTableRowMenu.displayName = \"TableRowMenu\";\n\nexport default TableRowMenu;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/menus/table-row/utils.ts",
    "content": "import type { EditorState } from \"@tiptap/pm/state\";\nimport type { EditorView } from \"@tiptap/pm/view\";\nimport type { Editor } from \"@tiptap/react\";\nimport { Table } from \"../..\";\nimport { isTableSelected } from \"../../utils\";\n\nexport const isRowGripSelected = ({\n  editor,\n  view,\n  state,\n  from,\n}: {\n  editor: Editor;\n  view: EditorView;\n  state: EditorState;\n  from: number;\n}) => {\n  const domAtPos = view.domAtPos(from).node as HTMLElement;\n  const nodeDOM = view.nodeDOM(from) as HTMLElement;\n  const node = nodeDOM || domAtPos;\n\n  if (\n    !editor.isActive(Table.name) ||\n    !node ||\n    isTableSelected(state.selection)\n  ) {\n    return false;\n  }\n\n  const element: Element | null =\n    node.nodeType === Node.ELEMENT_NODE\n      ? (node as Element)\n      : node.parentElement;\n  const cell = element?.closest?.(\"td, th\") ?? null;\n\n  const gripRow = cell?.querySelector?.(\"a.grip-row.selected\");\n\n  return !!gripRow;\n};\n\nexport default isRowGripSelected;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/table-cell.ts",
    "content": "import { mergeAttributes, Node } from \"@tiptap/core\";\nimport { Plugin } from \"@tiptap/pm/state\";\nimport { Decoration, DecorationSet } from \"@tiptap/pm/view\";\n\nimport { getCellsInColumn, isRowSelected, selectRow } from \"./utils\";\n\nexport interface TableCellOptions {\n  HTMLAttributes: Record<string, unknown>;\n}\n\nexport const TableCell = Node.create<TableCellOptions>({\n  name: \"tableCell\",\n\n  content: \"block+\",\n\n  tableRole: \"cell\",\n\n  isolating: true,\n\n  addOptions() {\n    return {\n      HTMLAttributes: {},\n    };\n  },\n\n  parseHTML() {\n    return [{ tag: \"td\" }];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return [\n      \"td\",\n      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),\n      0,\n    ];\n  },\n\n  addAttributes() {\n    return {\n      colspan: {\n        default: 1,\n        parseHTML: (element) => {\n          const colspan = element.getAttribute(\"colspan\");\n          const value = colspan ? Number.parseInt(colspan, 10) : 1;\n\n          return value;\n        },\n      },\n      rowspan: {\n        default: 1,\n        parseHTML: (element) => {\n          const rowspan = element.getAttribute(\"rowspan\");\n          const value = rowspan ? Number.parseInt(rowspan, 10) : 1;\n\n          return value;\n        },\n      },\n      colwidth: {\n        default: null,\n        parseHTML: (element) => {\n          const colwidth = element.getAttribute(\"colwidth\");\n          const value = colwidth ? [Number.parseInt(colwidth, 10)] : null;\n\n          return value;\n        },\n      },\n      style: {\n        default: null,\n      },\n    };\n  },\n\n  addProseMirrorPlugins() {\n    return [\n      new Plugin({\n        props: {\n          decorations: (state) => {\n            const { isEditable } = this.editor;\n\n            if (!isEditable) {\n              return DecorationSet.empty;\n            }\n\n            const { doc, selection } = state;\n            const decorations: Decoration[] = [];\n            const cells = getCellsInColumn(0)(selection);\n\n            if (cells) {\n              let index = 0;\n              for (const { pos } of cells) {\n                const currentIndex = index;\n                decorations.push(\n                  Decoration.widget(pos + 1, () => {\n                    const rowSelected = isRowSelected(currentIndex)(selection);\n                    let className = \"grip-row\";\n\n                    if (rowSelected) {\n                      className += \" selected\";\n                    }\n\n                    if (currentIndex === 0) {\n                      className += \" first\";\n                    }\n\n                    if (currentIndex === cells.length - 1) {\n                      className += \" last\";\n                    }\n\n                    const grip = document.createElement(\"a\");\n\n                    grip.className = className;\n                    grip.addEventListener(\"mousedown\", (event) => {\n                      event.preventDefault();\n                      event.stopImmediatePropagation();\n\n                      this.editor.view.dispatch(\n                        selectRow(currentIndex)(this.editor.state.tr)\n                      );\n                    });\n\n                    return grip;\n                  })\n                );\n                index += 1;\n              }\n            }\n\n            return DecorationSet.create(doc, decorations);\n          },\n        },\n      }),\n    ];\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/table/table-header.ts",
    "content": "import { TableHeader as TiptapTableHeader } from \"@tiptap/extension-table\";\nimport { Plugin } from \"@tiptap/pm/state\";\nimport { Decoration, DecorationSet } from \"@tiptap/pm/view\";\n\nimport { getCellsInRow, isColumnSelected, selectColumn } from \"./utils\";\n\nexport const TableHeader = TiptapTableHeader.extend({\n  addAttributes() {\n    return {\n      colspan: {\n        default: 1,\n      },\n      rowspan: {\n        default: 1,\n      },\n      colwidth: {\n        default: null,\n        parseHTML: (element: HTMLElement) => {\n          const colwidth = element.getAttribute(\"colwidth\");\n          const value = colwidth\n            ? colwidth\n                .split(\",\")\n                .map((item: string) => Number.parseInt(item, 10))\n            : null;\n\n          return value;\n        },\n      },\n      style: {\n        default: null,\n      },\n    };\n  },\n\n  addProseMirrorPlugins() {\n    return [\n      new Plugin({\n        props: {\n          decorations: (state) => {\n            const { isEditable } = this.editor;\n\n            if (!isEditable) {\n              return DecorationSet.empty;\n            }\n\n            const { doc, selection } = state;\n            const decorations: Decoration[] = [];\n            const cells = getCellsInRow(0)(selection);\n\n            if (cells) {\n              let index = 0;\n              for (const { pos } of cells) {\n                const currentIndex = index;\n                decorations.push(\n                  Decoration.widget(pos + 1, () => {\n                    const colSelected =\n                      isColumnSelected(currentIndex)(selection);\n                    let className = \"grip-column\";\n\n                    if (colSelected) {\n                      className += \" selected\";\n                    }\n\n                    if (currentIndex === 0) {\n                      className += \" first\";\n                    }\n\n                    if (currentIndex === cells.length - 1) {\n                      className += \" last\";\n                    }\n\n                    const grip = document.createElement(\"a\");\n\n                    grip.className = className;\n                    grip.addEventListener(\"mousedown\", (event) => {\n                      event.preventDefault();\n                      event.stopImmediatePropagation();\n\n                      this.editor.view.dispatch(\n                        selectColumn(currentIndex)(this.editor.state.tr)\n                      );\n                    });\n\n                    return grip;\n                  })\n                );\n                index += 1;\n              }\n            }\n\n            return DecorationSet.create(doc, decorations);\n          },\n        },\n      }),\n    ];\n  },\n});\n\nexport default TableHeader;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/table-row.ts",
    "content": "import { TableRow as TiptapTableRow } from \"@tiptap/extension-table\";\n\nexport const TableRow = TiptapTableRow.extend({\n  allowGapCursor: false,\n  content: \"(tableCell | tableHeader)*\",\n});\n\nexport default TableRow;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/table.ts",
    "content": "import { Table as TiptapTable } from \"@tiptap/extension-table\";\nimport \"../../styles/table.css\";\n\nexport const Table = TiptapTable.configure({\n  resizable: true,\n  lastColumnResizable: false,\n});\n\nexport default Table;\n"
  },
  {
    "path": "packages/editor/src/extensions/table/utils.ts",
    "content": "import { findParentNode } from \"@tiptap/core\";\nimport type { Node, ResolvedPos } from \"@tiptap/pm/model\";\nimport type { Selection, Transaction } from \"@tiptap/pm/state\";\nimport { CellSelection, type Rect, TableMap } from \"@tiptap/pm/tables\";\n\nexport const isRectSelected = (rect: Rect) => (selection: CellSelection) => {\n  const map = TableMap.get(selection.$anchorCell.node(-1));\n  const start = selection.$anchorCell.start(-1);\n  const cells = map.cellsInRect(rect);\n  const selectedCells = map.cellsInRect(\n    map.rectBetween(\n      selection.$anchorCell.pos - start,\n      selection.$headCell.pos - start\n    )\n  );\n\n  for (let i = 0, count = cells.length; i < count; i += 1) {\n    const cell = cells[i];\n    if (cell !== undefined && selectedCells.indexOf(cell) === -1) {\n      return false;\n    }\n  }\n\n  return true;\n};\n\nexport const findTable = (selection: Selection) =>\n  findParentNode(\n    (node) => node.type.spec.tableRole && node.type.spec.tableRole === \"table\"\n  )(selection);\n\nexport const isCellSelection = (\n  selection: Selection\n): selection is CellSelection => selection instanceof CellSelection;\n\nexport const isColumnSelected =\n  (columnIndex: number) => (selection: Selection) => {\n    if (isCellSelection(selection)) {\n      const map = TableMap.get(selection.$anchorCell.node(-1));\n\n      return isRectSelected({\n        left: columnIndex,\n        right: columnIndex + 1,\n        top: 0,\n        bottom: map.height,\n      })(selection);\n    }\n\n    return false;\n  };\n\nexport const isRowSelected = (rowIndex: number) => (selection: Selection) => {\n  if (isCellSelection(selection)) {\n    const map = TableMap.get(selection.$anchorCell.node(-1));\n\n    return isRectSelected({\n      left: 0,\n      right: map.width,\n      top: rowIndex,\n      bottom: rowIndex + 1,\n    })(selection);\n  }\n\n  return false;\n};\n\nexport const isTableSelected = (selection: Selection) => {\n  if (isCellSelection(selection)) {\n    const map = TableMap.get(selection.$anchorCell.node(-1));\n\n    return isRectSelected({\n      left: 0,\n      right: map.width,\n      top: 0,\n      bottom: map.height,\n    })(selection);\n  }\n\n  return false;\n};\n\nexport const getCellsInColumn =\n  (columnIndex: number | number[]) => (selection: Selection) => {\n    const table = findTable(selection);\n    if (table) {\n      const map = TableMap.get(table.node);\n      const indexes = Array.isArray(columnIndex)\n        ? columnIndex\n        : Array.from([columnIndex]);\n\n      return indexes.reduce(\n        (acc, index) => {\n          if (index >= 0 && index <= map.width - 1) {\n            const cells = map.cellsInRect({\n              left: index,\n              right: index + 1,\n              top: 0,\n              bottom: map.height,\n            });\n\n            return acc.concat(\n              cells.map((nodePos) => {\n                const node = table.node.nodeAt(nodePos);\n                const pos = nodePos + table.start;\n\n                return { pos, start: pos + 1, node };\n              })\n            );\n          }\n\n          return acc;\n        },\n        [] as { pos: number; start: number; node: Node | null | undefined }[]\n      );\n    }\n    return null;\n  };\n\nexport const getCellsInRow =\n  (rowIndex: number | number[]) => (selection: Selection) => {\n    const table = findTable(selection);\n\n    if (table) {\n      const map = TableMap.get(table.node);\n      const indexes = Array.isArray(rowIndex)\n        ? rowIndex\n        : Array.from([rowIndex]);\n\n      return indexes.reduce(\n        (acc, index) => {\n          if (index >= 0 && index <= map.height - 1) {\n            const cells = map.cellsInRect({\n              left: 0,\n              right: map.width,\n              top: index,\n              bottom: index + 1,\n            });\n\n            return acc.concat(\n              cells.map((nodePos) => {\n                const node = table.node.nodeAt(nodePos);\n                const pos = nodePos + table.start;\n                return { pos, start: pos + 1, node };\n              })\n            );\n          }\n\n          return acc;\n        },\n        [] as { pos: number; start: number; node: Node | null | undefined }[]\n      );\n    }\n\n    return null;\n  };\n\nexport const getCellsInTable = (selection: Selection) => {\n  const table = findTable(selection);\n\n  if (table) {\n    const map = TableMap.get(table.node);\n    const cells = map.cellsInRect({\n      left: 0,\n      right: map.width,\n      top: 0,\n      bottom: map.height,\n    });\n\n    return cells.map((nodePos) => {\n      const node = table.node.nodeAt(nodePos);\n      const pos = nodePos + table.start;\n\n      return { pos, start: pos + 1, node };\n    });\n  }\n\n  return null;\n};\n\nexport const findParentNodeClosestToPos = (\n  $pos: ResolvedPos,\n  predicate: (node: Node) => boolean\n) => {\n  for (let i = $pos.depth; i > 0; i -= 1) {\n    const node = $pos.node(i);\n\n    if (predicate(node)) {\n      return {\n        pos: i > 0 ? $pos.before(i) : 0,\n        start: $pos.start(i),\n        depth: i,\n        node,\n      };\n    }\n  }\n\n  return null;\n};\n\nexport const findCellClosestToPos = ($pos: ResolvedPos) => {\n  const predicate = (node: Node) =>\n    node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);\n\n  return findParentNodeClosestToPos($pos, predicate);\n};\n\nconst select =\n  (type: \"row\" | \"column\") => (index: number) => (tr: Transaction) => {\n    const table = findTable(tr.selection);\n    const isRowSelection = type === \"row\";\n\n    if (table) {\n      const map = TableMap.get(table.node);\n\n      // Check if the index is valid\n      if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {\n        const left = isRowSelection ? 0 : index;\n        const top = isRowSelection ? index : 0;\n        const right = isRowSelection ? map.width : index + 1;\n        const bottom = isRowSelection ? index + 1 : map.height;\n\n        const cellsInFirstRow = map.cellsInRect({\n          left,\n          top,\n          right: isRowSelection ? right : left + 1,\n          bottom: isRowSelection ? top + 1 : bottom,\n        });\n\n        const cellsInLastRow =\n          bottom - top === 1\n            ? cellsInFirstRow\n            : map.cellsInRect({\n                left: isRowSelection ? left : right - 1,\n                top: isRowSelection ? bottom - 1 : top,\n                right,\n                bottom,\n              });\n\n        const head = table.start + (cellsInFirstRow[0] ?? 0);\n        const anchor = table.start + (cellsInLastRow.at(-1) ?? 0);\n        const $head = tr.doc.resolve(head);\n        const $anchor = tr.doc.resolve(anchor);\n\n        return tr.setSelection(new CellSelection($anchor, $head));\n      }\n    }\n    return tr;\n  };\n\nexport const selectColumn = select(\"column\");\n\nexport const selectRow = select(\"row\");\n\nexport const selectTable = (tr: Transaction) => {\n  const table = findTable(tr.selection);\n\n  if (table) {\n    const { map } = TableMap.get(table.node);\n\n    if (map?.length) {\n      const head = table.start + (map[0] ?? 0);\n      const anchor = table.start + (map.at(-1) ?? 0);\n      const $head = tr.doc.resolve(head);\n      const $anchor = tr.doc.resolve(anchor);\n\n      return tr.setSelection(new CellSelection($anchor, $head));\n    }\n  }\n\n  return tr;\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/twitter/index.tsx",
    "content": "/** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */\nimport { mergeAttributes, Node, nodePasteRule } from \"@tiptap/core\";\nimport {\n  NodeViewWrapper,\n  ReactNodeViewRenderer,\n  type ReactNodeViewRendererOptions,\n} from \"@tiptap/react\";\nimport { Tweet } from \"react-tweet\";\nexport const TWITTER_REGEX_GLOBAL =\n  /(https?:\\/\\/)?(www\\.)?x\\.com\\/([a-zA-Z0-9_]{1,15})(\\/status\\/(\\d+))?(\\/\\S*)?/g;\nexport const TWITTER_REGEX =\n  /^https?:\\/\\/(www\\.)?x\\.com\\/([a-zA-Z0-9_]{1,15})(\\/status\\/(\\d+))?(\\/\\S*)?$/;\n\nexport const isValidTwitterUrl = (url: string) => url.match(TWITTER_REGEX);\n\nconst TweetComponent = ({\n  node,\n}: {\n  node: Partial<ReactNodeViewRendererOptions>;\n}) => {\n  const url = (node?.attrs as Record<string, string>)?.src;\n  const tweetId = url?.split(\"/\").pop();\n\n  if (!tweetId) {\n    return null;\n  }\n\n  return (\n    <NodeViewWrapper className=\"my-5\">\n      <div data-twitter=\"\">\n        <Tweet id={tweetId} />\n      </div>\n    </NodeViewWrapper>\n  );\n};\n\nexport interface TwitterOptions {\n  /**\n   * Controls if the paste handler for tweets should be added.\n   * @default true\n   * @example false\n   */\n  addPasteHandler: boolean;\n\n  // biome-ignore lint/suspicious/noExplicitAny: <>\n  HTMLAttributes: Record<string, any>;\n\n  /**\n   * Controls if the twitter node should be inline or not.\n   * @default false\n   * @example true\n   */\n  inline: boolean;\n\n  /**\n   * The origin of the tweet.\n   * @default ''\n   * @example 'https://tiptap.dev'\n   */\n  origin: string;\n}\n\n/**\n * The options for setting a tweet.\n */\ntype SetTweetOptions = { src: string };\n\ndeclare module \"@tiptap/core\" {\n  interface Commands<ReturnType> {\n    twitter: {\n      /**\n       * Insert a tweet\n       * @param options The tweet attributes\n       * @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' })\n       */\n      setTweet: (options: SetTweetOptions) => ReturnType;\n    };\n  }\n}\n\n/**\n * This extension adds support for tweets.\n */\nexport const Twitter = Node.create<TwitterOptions>({\n  name: \"twitter\",\n\n  addOptions() {\n    return {\n      addPasteHandler: true,\n      HTMLAttributes: {},\n      inline: false,\n      origin: \"\",\n    };\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(TweetComponent, {\n      attrs: this.options.HTMLAttributes,\n    });\n  },\n\n  inline() {\n    return this.options.inline;\n  },\n\n  group() {\n    return this.options.inline ? \"inline\" : \"block\";\n  },\n\n  draggable: true,\n\n  addAttributes() {\n    return {\n      src: {\n        default: null,\n        parseHTML: (element) => element.getAttribute(\"data-src\"),\n        renderHTML: (attributes) => {\n          if (!attributes.src) {\n            return {};\n          }\n          return {\n            \"data-src\": attributes.src,\n          };\n        },\n      },\n    };\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: \"div[data-twitter]\",\n      },\n    ];\n  },\n\n  addCommands() {\n    return {\n      setTweet:\n        (options: SetTweetOptions) =>\n        ({ commands }) => {\n          if (!isValidTwitterUrl(options.src)) {\n            return false;\n          }\n\n          return commands.insertContent({\n            type: this.name,\n            attrs: options,\n          });\n        },\n    };\n  },\n\n  addPasteRules() {\n    if (!this.options.addPasteHandler) {\n      return [];\n    }\n\n    return [\n      nodePasteRule({\n        find: TWITTER_REGEX_GLOBAL,\n        type: this.type,\n        getAttributes: (match) => ({ src: match.input }),\n      }),\n    ];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return [\"div\", mergeAttributes({ \"data-twitter\": \"\" }, HTMLAttributes)];\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/twitter/twitter-comp.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type { ChangeEvent, KeyboardEvent } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Twitter } from \"../../components/icons/twitter\";\n\n// Validate Twitter/X.com URL\nconst TWITTER_REGEX =\n  /^https?:\\/\\/(www\\.)?x\\.com\\/([a-zA-Z0-9_]{1,15})(\\/status\\/(\\d+))?(\\/\\S*)?$/;\n\nfunction isValidTwitterUrl(url: string): boolean {\n  if (!url) {\n    return false;\n  }\n  return TWITTER_REGEX.test(url);\n}\n\nexport const TwitterComp = ({\n  onSubmit,\n  onCancel,\n}: {\n  onSubmit: (url: string) => void;\n  onCancel: () => void;\n}) => {\n  const [url, setUrl] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n\n  useEffect(() => {\n    // Use requestAnimationFrame to ensure the element is rendered\n    const frame = requestAnimationFrame(() => {\n      inputRef.current?.focus();\n    });\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const validateAndSubmit = useCallback(() => {\n    if (!isValidTwitterUrl(url)) {\n      setError(\"Invalid Tweet link\");\n      return;\n    }\n\n    onSubmit(url);\n  }, [url, onSubmit]);\n\n  const handleInputChange = useCallback(\n    (e: ChangeEvent<HTMLTextAreaElement>) => {\n      setUrl(e.target.value);\n      setError(null);\n    },\n    []\n  );\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        validateAndSubmit();\n      } else if (e.key === \"Escape\") {\n        e.preventDefault();\n        onCancel();\n      }\n    },\n    [validateAndSubmit, onCancel]\n  );\n\n  const isValidUrl = isValidTwitterUrl(url);\n\n  return (\n    <Card className=\"col-span-full gap-4 rounded-[20px] border-none bg-surface p-2\">\n      <CardHeader className=\"gap-0 px-4 pt-2\">\n        <div className=\"flex items-center justify-between gap-2\">\n          <Twitter className=\"size-5\" />\n          <CardTitle className=\"font-normal text-sm\">\n            Paste a Tweet link\n          </CardTitle>\n        </div>\n      </CardHeader>\n      <CardContent className=\"rounded-[12px] bg-background p-4 shadow-xs\">\n        <div className=\"flex flex-col gap-2\">\n          <Textarea\n            className={cn(\n              \"resize-none\",\n              error && \"border-destructive focus-visible:ring-destructive\"\n            )}\n            onChange={handleInputChange}\n            onKeyDown={handleKeyDown}\n            placeholder=\"https://x.com/username/status/...\"\n            ref={inputRef}\n            value={url}\n          />\n          {error && <p className=\"text-destructive text-xs\">{error}</p>}\n        </div>\n\n        <CardFooter className=\"flex items-center gap-2 px-0 pt-4\">\n          <Button\n            disabled={!url || !isValidUrl}\n            onClick={validateAndSubmit}\n            size=\"sm\"\n            type=\"button\"\n          >\n            Embed Tweet\n          </Button>\n          <Button onClick={onCancel} size=\"sm\" type=\"button\" variant=\"ghost\">\n            Cancel\n          </Button>\n        </CardFooter>\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/twitter/twitter-upload.ts",
    "content": "import { Node } from \"@tiptap/core\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport { TwitterUploadView } from \"./twitter-view\";\n\n/**\n * Twitter Upload Node Extension\n * Creates a placeholder node that renders the Twitter upload component\n * When a URL is submitted, it replaces itself with an actual Twitter embed\n */\nexport const TwitterUpload = Node.create({\n  name: \"twitterUpload\",\n\n  group: \"block\",\n\n  atom: true,\n\n  addNodeView() {\n    return ReactNodeViewRenderer(TwitterUploadView);\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: 'div[data-type=\"twitter-upload\"]',\n      },\n    ];\n  },\n\n  renderHTML() {\n    return [\"div\", { \"data-type\": \"twitter-upload\" }];\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/twitter/twitter-view.tsx",
    "content": "import type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback } from \"react\";\nimport { TwitterComp } from \"./twitter-comp\";\n\nexport const TwitterUploadView = ({ getPos, editor }: NodeViewProps) => {\n  const onSubmit = useCallback(\n    (url: string) => {\n      if (url && typeof getPos === \"function\") {\n        const pos = getPos();\n        if (typeof pos === \"number\") {\n          // Replace the twitterUpload node with an actual Twitter embed\n          editor\n            .chain()\n            .focus()\n            .deleteRange({ from: pos, to: pos + 1 })\n            .setTweet({ src: url })\n            .run();\n        }\n      }\n    },\n    [getPos, editor]\n  );\n\n  const onCancel = useCallback(() => {\n    if (typeof getPos === \"function\") {\n      const pos = getPos();\n      if (typeof pos === \"number\") {\n        // Remove the placeholder node\n        editor\n          .chain()\n          .focus()\n          .deleteRange({ from: pos, to: pos + 1 })\n          .run();\n      }\n    }\n  }, [getPos, editor]);\n\n  return (\n    <NodeViewWrapper className=\"my-5\">\n      <div className=\"m-0 p-0\" data-drag-handle>\n        <TwitterComp onCancel={onCancel} onSubmit={onSubmit} />\n      </div>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/video/index.ts",
    "content": "import type { CommandProps } from \"@tiptap/core\";\nimport { mergeAttributes, Node } from \"@tiptap/core\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport { VideoView } from \"./video-view\";\n\ndeclare module \"@tiptap/core\" {\n  interface Commands<ReturnType> {\n    video: {\n      setVideo: (options: {\n        src: string;\n        caption?: string;\n        width?: string;\n        align?: \"left\" | \"center\" | \"right\";\n      }) => ReturnType;\n      updateVideo: (attrs: {\n        caption?: string;\n        width?: string;\n        align?: \"left\" | \"center\" | \"right\";\n      }) => ReturnType;\n    };\n  }\n}\n\nexport const Video = Node.create({\n  name: \"video\",\n  group: \"block\",\n  content: \"\",\n  draggable: true,\n  selectable: true,\n  isolating: true,\n\n  addAttributes() {\n    return {\n      src: {\n        default: null,\n        parseHTML: (element) =>\n          element.querySelector(\"video\")?.getAttribute(\"src\") ||\n          element.querySelector(\"video source\")?.getAttribute(\"src\"),\n        renderHTML: (attributes) => ({\n          src: attributes.src,\n        }),\n      },\n      caption: {\n        default: \"\",\n        parseHTML: (element) =>\n          element.querySelector(\"figcaption\")?.textContent || \"\",\n        renderHTML: (attributes) => ({\n          caption: attributes.caption,\n        }),\n      },\n      width: {\n        default: \"100\",\n        parseHTML: (element) => element.getAttribute(\"data-width\") || \"100\",\n        renderHTML: (attributes) => ({\n          \"data-width\": attributes.width,\n        }),\n      },\n      align: {\n        default: \"center\",\n        parseHTML: (element) => element.getAttribute(\"data-align\") || \"center\",\n        renderHTML: (attributes) => ({\n          \"data-align\": attributes.align,\n        }),\n      },\n    };\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: \"figure\",\n        getAttrs: (element) => {\n          if (typeof element === \"string\") {\n            return false;\n          }\n          const video = element.querySelector(\"video\");\n          return video ? {} : false;\n        },\n      },\n      {\n        tag: \"video\",\n        getAttrs: (element) => {\n          if (typeof element === \"string\") {\n            return false;\n          }\n          return {\n            src: element.getAttribute(\"src\"),\n          };\n        },\n      },\n    ];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    const { src, caption, ...figureAttrs } = HTMLAttributes;\n\n    const videoAttrs: Record<string, string> = { controls: \"true\" };\n    if (src) {\n      videoAttrs.src = src;\n    }\n\n    const figcaptionContent = caption || \"\";\n\n    return [\n      \"figure\",\n      mergeAttributes({ \"data-type\": \"video\" }, figureAttrs),\n      [\"video\", videoAttrs],\n      [\"figcaption\", {}, figcaptionContent],\n    ];\n  },\n\n  addCommands() {\n    return {\n      setVideo:\n        (options) =>\n        ({ commands }: CommandProps) =>\n          commands.insertContent({\n            type: this.name,\n            attrs: options,\n          }),\n      updateVideo:\n        (attrs) =>\n        ({ commands, tr, state }: CommandProps) => {\n          const { selection } = state;\n          const node = tr.doc.nodeAt(selection.from);\n\n          if (node?.type.name === this.name) {\n            return commands.updateAttributes(this.name, attrs);\n          }\n\n          return false;\n        },\n    };\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(VideoView);\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/video/video-view.tsx",
    "content": "/** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: <> */\n/** biome-ignore-all lint/a11y/useKeyWithClickEvents: <> */\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Label } from \"@marble/ui/components/label\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  FadersHorizontalIcon,\n  TextAlignCenterIcon,\n  TextAlignLeftIcon,\n  TextAlignRightIcon,\n} from \"@phosphor-icons/react\";\nimport type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\n\nexport const VideoView = ({\n  node,\n  updateAttributes,\n  selected,\n}: NodeViewProps) => {\n  const { src, caption, width, align } = node.attrs as {\n    src: string;\n    caption: string;\n    width: string;\n    align: \"left\" | \"center\" | \"right\";\n  };\n\n  const [captionValue, setCaptionValue] = useState(caption || \"\");\n  const [widthValue, setWidthValue] = useState(width || \"100\");\n  const [alignValue, setAlignValue] = useState<\"left\" | \"center\" | \"right\">(\n    align || \"center\"\n  );\n  const [isResizing, setIsResizing] = useState(false);\n  const [isHovered, setIsHovered] = useState(false);\n  const [showSettings, setShowSettings] = useState(false);\n  const figureRef = useRef<HTMLElement>(null);\n  const settingsPanelRef = useRef<HTMLDivElement>(null);\n  const startXRef = useRef(0);\n  const startWidthRef = useRef(0);\n  const resizeSideRef = useRef<\"left\" | \"right\">(\"right\");\n\n  const captionId = useId();\n\n  const [prevAttrs, setPrevAttrs] = useState({ caption, width, align });\n\n  if (\n    caption !== prevAttrs.caption ||\n    width !== prevAttrs.width ||\n    align !== prevAttrs.align\n  ) {\n    setPrevAttrs({ caption, width, align });\n    setCaptionValue(caption || \"\");\n    setWidthValue(width || \"100\");\n    setAlignValue(align || \"center\");\n  }\n\n  // Handle click outside settings panel\n  useEffect(() => {\n    if (!showSettings) {\n      return;\n    }\n\n    const handleClickOutside = (e: MouseEvent) => {\n      if (\n        settingsPanelRef.current &&\n        !settingsPanelRef.current.contains(e.target as Node)\n      ) {\n        const target = e.target as HTMLElement;\n        if (!target.closest(\"[data-settings-trigger]\")) {\n          setShowSettings(false);\n        }\n      }\n    };\n\n    const timeoutId = setTimeout(() => {\n      document.addEventListener(\"mousedown\", handleClickOutside);\n    }, 0);\n\n    return () => {\n      clearTimeout(timeoutId);\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, [showSettings]);\n\n  const handleCaptionChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const newCaption = e.target.value;\n      setCaptionValue(newCaption);\n      updateAttributes({ caption: newCaption });\n    },\n    [updateAttributes]\n  );\n\n  const handleAlignChange = useCallback(\n    (newAlign: \"left\" | \"center\" | \"right\") => {\n      setAlignValue(newAlign);\n      setTimeout(() => {\n        updateAttributes({ align: newAlign });\n      }, 0);\n    },\n    [updateAttributes]\n  );\n\n  const handleResizeStart = useCallback(\n    (side: \"left\" | \"right\") => (e: React.MouseEvent) => {\n      e.preventDefault();\n      e.stopPropagation();\n      setIsResizing(true);\n      startXRef.current = e.clientX;\n      resizeSideRef.current = side;\n      const currentWidth = Number.parseInt(widthValue, 10) || 100;\n      startWidthRef.current = currentWidth;\n    },\n    [widthValue]\n  );\n\n  useEffect(() => {\n    if (!isResizing) {\n      return;\n    }\n\n    const handleMouseMove = (e: MouseEvent) => {\n      const deltaX = e.clientX - startXRef.current;\n      const containerWidth =\n        figureRef.current?.parentElement?.clientWidth || 800;\n\n      const effectiveDelta =\n        resizeSideRef.current === \"left\" ? -deltaX : deltaX;\n      const deltaPercent = (effectiveDelta / containerWidth) * 100;\n      const newWidth = Math.max(\n        10,\n        Math.min(100, startWidthRef.current + deltaPercent)\n      );\n\n      const roundedWidth = Math.round(newWidth);\n      setWidthValue(String(roundedWidth));\n      updateAttributes({ width: String(roundedWidth) });\n    };\n\n    const handleMouseUp = () => {\n      setIsResizing(false);\n    };\n\n    document.addEventListener(\"mousemove\", handleMouseMove);\n    document.addEventListener(\"mouseup\", handleMouseUp);\n\n    return () => {\n      document.removeEventListener(\"mousemove\", handleMouseMove);\n      document.removeEventListener(\"mouseup\", handleMouseUp);\n    };\n  }, [isResizing, updateAttributes]);\n\n  const alignmentStyles: React.CSSProperties = {\n    width: `${widthValue}%`,\n    marginLeft: alignValue === \"left\" ? 0 : \"auto\",\n    marginRight: alignValue === \"right\" ? 0 : \"auto\",\n  };\n\n  const showToolbar = selected || isHovered || showSettings;\n\n  return (\n    <NodeViewWrapper className=\"my-5\" data-drag-handle>\n      <figure\n        aria-label=\"Video figure\"\n        className={cn(\n          \"relative\",\n          selected && \"outline-2 outline-primary outline-offset-2\"\n        )}\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => setIsHovered(false)}\n        ref={figureRef}\n        style={alignmentStyles}\n      >\n        <video\n          className=\"h-auto w-full rounded-md border border-muted\"\n          controls\n          preload=\"metadata\"\n          src={src}\n        >\n          <track kind=\"captions\" />\n        </video>\n\n        {showToolbar && (\n          <div className=\"absolute top-2 right-2 z-30 flex items-center gap-0.5 rounded-lg border bg-background p-1 shadow\">\n            <Button\n              className={cn(\n                \"size-7 p-0\",\n                alignValue === \"left\" && \"bg-accent text-accent-foreground\"\n              )}\n              onClick={() => handleAlignChange(\"left\")}\n              size=\"icon\"\n              title=\"Align left\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <TextAlignLeftIcon className=\"size-3.5\" />\n            </Button>\n            <Button\n              className={cn(\n                \"size-7 p-0\",\n                alignValue === \"center\" && \"bg-accent text-accent-foreground\"\n              )}\n              onClick={() => handleAlignChange(\"center\")}\n              size=\"icon\"\n              title=\"Align center\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <TextAlignCenterIcon className=\"size-3.5\" />\n            </Button>\n            <Button\n              className={cn(\n                \"size-7 p-0\",\n                alignValue === \"right\" && \"bg-accent text-accent-foreground\"\n              )}\n              onClick={() => handleAlignChange(\"right\")}\n              size=\"icon\"\n              title=\"Align right\"\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <TextAlignRightIcon className=\"size-3.5\" />\n            </Button>\n\n            {/* Divider */}\n            <div className=\"mx-0.5 h-5 w-px bg-border\" />\n\n            <button\n              className={cn(\n                \"flex size-7 items-center justify-center rounded-lg transition-colors hover:bg-accent hover:text-accent-foreground\",\n                showSettings && \"bg-accent text-accent-foreground\"\n              )}\n              data-settings-trigger\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                setShowSettings((prev) => !prev);\n              }}\n              onMouseDown={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n              }}\n              title=\"Video settings\"\n              type=\"button\"\n            >\n              <FadersHorizontalIcon className=\"size-3.5\" />\n            </button>\n          </div>\n        )}\n\n        {showSettings && (\n          <div\n            className=\"absolute top-14 right-2 z-40 flex w-72 flex-col gap-3 rounded-md border bg-popover p-3 text-popover-foreground shadow-md\"\n            ref={settingsPanelRef}\n          >\n            {/* Caption */}\n            <div className=\"space-y-1.5\">\n              <Label className=\"font-medium text-xs\" htmlFor={captionId}>\n                Caption\n              </Label>\n              <Input\n                className=\"h-8 text-sm\"\n                id={captionId}\n                onChange={handleCaptionChange}\n                placeholder=\"Add a caption...\"\n                type=\"text\"\n                value={captionValue}\n              />\n            </div>\n          </div>\n        )}\n\n        {showToolbar && (\n          <>\n            <button\n              className=\"-translate-y-1/2 absolute top-1/2 left-2 z-20 h-8 w-1 cursor-ew-resize rounded-full border border-white bg-background transition-all\"\n              onMouseDown={handleResizeStart(\"left\")}\n              title=\"Drag to resize\"\n              type=\"button\"\n            />\n            <button\n              className=\"-translate-y-1/2 absolute top-1/2 right-2 z-20 h-8 w-1 cursor-ew-resize rounded-full border border-white bg-background transition-all\"\n              onMouseDown={handleResizeStart(\"right\")}\n              title=\"Drag to resize\"\n              type=\"button\"\n            />\n          </>\n        )}\n\n        {captionValue && (\n          <figcaption className=\"mt-2 text-center text-muted-foreground text-sm italic\">\n            <p>{captionValue}</p>\n          </figcaption>\n        )}\n      </figure>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/video-upload/hooks.ts",
    "content": "import { toast } from \"@marble/ui/components/sonner\";\nimport type { DragEvent } from \"react\";\nimport { useCallback, useRef, useState } from \"react\";\n\nexport const useFileUpload = () => {\n  const fileInput = useRef<HTMLInputElement>(null);\n\n  const handleUploadClick = useCallback(() => {\n    fileInput.current?.click();\n  }, []);\n\n  return { ref: fileInput, handleUploadClick };\n};\n\nexport const useUploader = ({\n  onUpload,\n  upload,\n  onError,\n}: {\n  onUpload: (url: string) => void;\n  upload: (file: File) => Promise<string>;\n  onError?: (error: Error) => void;\n}) => {\n  const [loading, setLoading] = useState(false);\n\n  const uploadVideo = useCallback(\n    async (file: File) => {\n      setLoading(true);\n      try {\n        const url = await upload(file);\n        if (url) {\n          onUpload(url);\n        } else {\n          const error = new Error(\n            \"Upload failed: Invalid response from server.\"\n          );\n          if (onError) {\n            onError(error);\n          } else {\n            toast.error(error.message);\n          }\n        }\n      } catch (error) {\n        const errorMessage =\n          error instanceof Error ? error.message : \"Failed to upload video\";\n        const uploadError = new Error(errorMessage);\n        if (onError) {\n          onError(uploadError);\n        } else {\n          toast.error(errorMessage);\n        }\n      }\n      setLoading(false);\n    },\n    [onUpload, upload, onError]\n  );\n\n  return { loading, uploadVideo };\n};\n\nexport const useDropZone = ({\n  uploader,\n}: {\n  uploader: (file: File) => void;\n}) => {\n  const [draggedInside, setDraggedInside] = useState<boolean>(false);\n\n  const onDrop = useCallback(\n    (e: DragEvent<HTMLDivElement>) => {\n      setDraggedInside(false);\n      e.preventDefault();\n      e.stopPropagation();\n\n      const fileList = e.dataTransfer.files;\n      const files: File[] = [];\n\n      for (let i = 0; i < fileList.length; i += 1) {\n        const item = fileList.item(i);\n        if (item) {\n          files.push(item);\n        }\n      }\n\n      // Validate only video files\n      if (files.some((file) => !file.type.startsWith(\"video/\"))) {\n        toast.error(\"Only video files are allowed\");\n        return;\n      }\n\n      const filteredFiles = files.filter((f) => f.type.startsWith(\"video/\"));\n      const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;\n\n      if (file) {\n        uploader(file);\n      }\n    },\n    [uploader]\n  );\n\n  const onDragEnter = useCallback(() => {\n    setDraggedInside(true);\n  }, []);\n\n  const onDragLeave = useCallback(() => {\n    setDraggedInside(false);\n  }, []);\n\n  const onDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {\n    e.preventDefault();\n    e.stopPropagation();\n  }, []);\n\n  return { draggedInside, onDragEnter, onDragLeave, onDrop, onDragOver };\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/video-upload/index.ts",
    "content": "/** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */\nimport type { CommandProps } from \"@tiptap/core\";\nimport { Node } from \"@tiptap/core\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport type { VideoUploadOptions } from \"../../types\";\nimport { VideoUploadView } from \"./video-upload-view\";\n\ndeclare module \"@tiptap/core\" {\n  interface Commands<ReturnType> {\n    videoUpload: {\n      setVideoUpload: (options?: { file?: File }) => ReturnType;\n    };\n  }\n}\n\nexport const VideoUpload = Node.create<VideoUploadOptions>({\n  name: \"videoUpload\",\n  isolating: true,\n  defining: true,\n  group: \"block\",\n  draggable: true,\n  selectable: true,\n  inline: false,\n\n  addOptions() {\n    return {\n      upload: undefined,\n      accept: \"video/*\",\n      maxSize: undefined,\n      limit: undefined,\n      onError: undefined,\n      media: undefined,\n      fetchMediaPage: undefined,\n    };\n  },\n\n  addAttributes() {\n    return {\n      fileId: {\n        default: null,\n        parseHTML: (element) => element.getAttribute(\"data-file-id\"),\n        renderHTML: (attributes) => {\n          if (!attributes.fileId) {\n            return {};\n          }\n          return {\n            \"data-file-id\": attributes.fileId,\n          };\n        },\n      },\n    };\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: `div[data-type=\"${this.name}\"]`,\n      },\n    ];\n  },\n\n  renderHTML({ HTMLAttributes }) {\n    return [\"div\", { \"data-type\": this.name, ...HTMLAttributes }];\n  },\n\n  addCommands() {\n    const extensionStorage = this.storage as VideoUploadStorage;\n    return {\n      setVideoUpload:\n        (options) =>\n        ({ commands }: CommandProps) => {\n          const { file } = options || {};\n\n          if (file) {\n            const fileId = `upload-${Date.now()}-${Math.random()}`;\n            extensionStorage.pendingUploads.set(fileId, file);\n\n            return commands.insertContent({\n              type: this.name,\n              attrs: { fileId },\n            });\n          }\n\n          return commands.insertContent({\n            type: this.name,\n          });\n        },\n    };\n  },\n\n  addNodeView() {\n    return ReactNodeViewRenderer(VideoUploadView, {\n      as: \"div\",\n    });\n  },\n\n  addStorage() {\n    return {\n      pendingUploads: new Map<string, File>(),\n      options: this.options,\n    };\n  },\n\n  onDestroy() {\n    const storage = this.storage as VideoUploadStorage;\n    storage.pendingUploads.clear();\n  },\n});\n\nexport interface VideoUploadStorage {\n  pendingUploads: Map<string, File>;\n  options: VideoUploadOptions;\n}\n"
  },
  {
    "path": "packages/editor/src/extensions/video-upload/video-upload-comp.tsx",
    "content": "import { Video02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Card, CardContent, CardFooter } from \"@marble/ui/components/card\";\nimport {\n  Dialog,\n  DialogBody,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n  DialogX,\n} from \"@marble/ui/components/dialog\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  CheckIcon,\n  SpinnerIcon,\n  VideoCameraIcon,\n  XIcon,\n} from \"@phosphor-icons/react\";\nimport type { ChangeEvent } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport type { MediaItem, MediaPage } from \"../../types\";\nimport { useDropZone, useFileUpload, useUploader } from \"./hooks\";\n\n// Simple URL validation\nconst isValidUrl = (url: string): boolean => {\n  try {\n    new URL(url);\n    return true;\n  } catch {\n    return false;\n  }\n};\n\nexport interface VideoUploadCompProps {\n  initialFile?: File;\n  onUpload: (url: string) => void;\n  onCancel: () => void;\n  upload: (file: File) => Promise<string>;\n  media?: MediaItem[];\n  fetchMediaPage?: (cursor?: string) => Promise<MediaPage>;\n  onError?: (error: Error) => void;\n}\n\nexport const VideoUploadComp = ({\n  initialFile,\n  onUpload,\n  onCancel,\n  upload,\n  media: providedMedia,\n  fetchMediaPage,\n  onError,\n}: VideoUploadCompProps) => {\n  const [showEmbedInput, setShowEmbedInput] = useState(false);\n  const [embedUrl, setEmbedUrl] = useState(\"\");\n  const [urlError, setUrlError] = useState<string | null>(null);\n  const [isGalleryOpen, setIsGalleryOpen] = useState(false);\n  const [media, setMedia] = useState<MediaItem[] | undefined>(providedMedia);\n  const [isLoadingMedia, setIsLoadingMedia] = useState(false);\n  const [nextCursor, setNextCursor] = useState<string | undefined>(undefined);\n  const [isLoadingMore, setIsLoadingMore] = useState(false);\n\n  const { loading, uploadVideo } = useUploader({ onUpload, upload, onError });\n  const { handleUploadClick, ref } = useFileUpload();\n  const { draggedInside, onDrop, onDragEnter, onDragLeave, onDragOver } =\n    useDropZone({\n      uploader: uploadVideo,\n    });\n\n  useEffect(() => {\n    if (!fetchMediaPage || providedMedia) {\n      return;\n    }\n\n    let active = true;\n    setIsLoadingMedia(true);\n\n    fetchMediaPage()\n      .then((page) => {\n        if (active) {\n          setMedia(page.media);\n          setNextCursor(page.nextCursor);\n        }\n      })\n      .catch(() => {\n        if (active) {\n          setMedia([]);\n        }\n      })\n      .finally(() => {\n        if (active) {\n          setIsLoadingMedia(false);\n        }\n      });\n\n    return () => {\n      active = false;\n    };\n  }, [fetchMediaPage, providedMedia]);\n\n  // Load more media handler\n  const handleLoadMore = useCallback(async () => {\n    if (!fetchMediaPage || !nextCursor || isLoadingMore) {\n      return;\n    }\n    setIsLoadingMore(true);\n    try {\n      const page = await fetchMediaPage(nextCursor);\n      setMedia((prev) => [...(prev || []), ...page.media]);\n      setNextCursor(page.nextCursor);\n    } catch {\n      // Ignore errors on load more\n    } finally {\n      setIsLoadingMore(false);\n    }\n  }, [fetchMediaPage, nextCursor, isLoadingMore]);\n\n  // Update media when providedMedia changes\n  useEffect(() => {\n    if (providedMedia) {\n      setMedia(providedMedia);\n    }\n  }, [providedMedia]);\n\n  // Auto-upload if initialFile is provided\n  useEffect(() => {\n    if (initialFile) {\n      uploadVideo(initialFile);\n    }\n  }, [initialFile, uploadVideo]);\n\n  const onFileChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      const file = e.target.files?.[0];\n      if (file) {\n        uploadVideo(file);\n      }\n    },\n    [uploadVideo]\n  );\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent<HTMLDivElement>) => {\n      onDrop(e);\n    },\n    [onDrop]\n  );\n\n  const handleEmbedUrl = useCallback(\n    (url: string) => {\n      if (!url) {\n        return;\n      }\n\n      setUrlError(null);\n\n      if (!isValidUrl(url)) {\n        setUrlError(\"Please enter a valid URL\");\n        return;\n      }\n\n      onUpload(url);\n      setEmbedUrl(\"\");\n      setShowEmbedInput(false);\n    },\n    [onUpload]\n  );\n\n  const handleMediaSelect = useCallback(\n    (url: string) => {\n      onUpload(url);\n      setIsGalleryOpen(false);\n    },\n    [onUpload]\n  );\n\n  const handleDropzoneClick = useCallback(() => {\n    handleUploadClick();\n  }, [handleUploadClick]);\n\n  const handleDropzoneKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        handleUploadClick();\n      }\n    },\n    [handleUploadClick]\n  );\n\n  // Get dropzone text based on drag state\n  const getDropzoneText = () => {\n    if (draggedInside) {\n      return \"Drop video here\";\n    }\n    return \"Drag and drop or click to upload\";\n  };\n\n  return (\n    <>\n      <Card className=\"col-span-full gap-0 rounded-[20px] border-none bg-surface p-2 pt-0\">\n        <div className=\"flex h-10 select-none items-center justify-between gap-2 p-1.5\">\n          <HugeiconsIcon\n            className=\"shrink-0 text-muted-foreground\"\n            icon={Video02Icon}\n            size={18}\n          />\n          <span className=\"font-normal text-muted-foreground text-xs\">\n            Upload or embed a video\n          </span>\n        </div>\n        <CardContent className=\"rounded-[12px] bg-background p-4 shadow-xs\">\n          {/* Dropzone or Uploading state */}\n          {loading ? (\n            <div className=\"flex min-h-[260px] flex-1 flex-col items-center justify-center\">\n              <p className=\"text-muted-foreground text-sm\">\n                Uploading video...\n              </p>\n            </div>\n          ) : (\n            // biome-ignore lint/a11y/useSemanticElements: Dropzone requires div for drag-and-drop functionality\n            <div\n              aria-label=\"Upload video by clicking or dragging and dropping\"\n              className={cn(\n                \"flex min-h-[260px] flex-1 cursor-pointer flex-col items-center justify-center gap-2\",\n                draggedInside\n                  ? \"border-primary bg-primary/5\"\n                  : \"border-muted bg-background\"\n              )}\n              onClick={handleDropzoneClick}\n              onDragEnter={onDragEnter}\n              onDragLeave={onDragLeave}\n              onDragOver={onDragOver}\n              onDrop={handleDrop}\n              onKeyDown={handleDropzoneKeyDown}\n              role=\"button\"\n              tabIndex={0}\n            >\n              <p\n                className={cn(\n                  \"text-center font-medium text-sm\",\n                  draggedInside ? \"text-primary\" : \"text-muted-foreground\"\n                )}\n              >\n                {getDropzoneText()}\n              </p>\n              <input\n                accept=\"video/*\"\n                aria-label=\"Upload video\"\n                className=\"sr-only size-0 overflow-hidden opacity-0\"\n                onChange={onFileChange}\n                ref={ref}\n                type=\"file\"\n              />\n            </div>\n          )}\n        </CardContent>\n        <CardFooter className=\"mt-2 flex items-center justify-between gap-10 rounded-[12px] bg-background p-3 shadow-xs\">\n          {showEmbedInput ? (\n            <div className=\"flex flex-1 flex-col gap-2\">\n              <div className=\"flex items-center gap-2\">\n                <Input\n                  className={cn(\n                    \"h-8 flex-1 bg-background\",\n                    urlError && \"border-destructive\"\n                  )}\n                  disabled={loading}\n                  onChange={({ target }) => {\n                    setEmbedUrl(target.value);\n                    setUrlError(null);\n                  }}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\" && embedUrl && !loading) {\n                      handleEmbedUrl(embedUrl);\n                    }\n                  }}\n                  placeholder=\"Paste video URL\"\n                  value={embedUrl}\n                />\n                <Button\n                  className=\"size-8 shrink-0 shadow-none\"\n                  disabled={!embedUrl || loading}\n                  onClick={() => handleEmbedUrl(embedUrl)}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <CheckIcon className=\"size-4\" />\n                </Button>\n                <Button\n                  className=\"size-8 shrink-0 shadow-none\"\n                  disabled={loading}\n                  onClick={() => {\n                    setShowEmbedInput(false);\n                    setEmbedUrl(\"\");\n                    setUrlError(null);\n                  }}\n                  size=\"icon\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  <XIcon className=\"size-4\" />\n                </Button>\n              </div>\n              {urlError && (\n                <p className=\"text-destructive text-xs\">{urlError}</p>\n              )}\n            </div>\n          ) : (\n            // Media and Embed URL buttons - shown by default\n            <div className=\"flex items-center gap-2\">\n              {(media !== undefined || fetchMediaPage) && (\n                <Button\n                  className=\"shrink-0 text-muted-foreground shadow-none\"\n                  disabled={loading}\n                  onClick={() => setIsGalleryOpen(true)}\n                  size=\"sm\"\n                  type=\"button\"\n                  variant=\"outline\"\n                >\n                  View Gallery\n                </Button>\n              )}\n              <Button\n                className=\"shrink-0 text-muted-foreground shadow-none\"\n                disabled={loading}\n                onClick={() => setShowEmbedInput(true)}\n                size=\"sm\"\n                type=\"button\"\n                variant=\"outline\"\n              >\n                Embed URL\n              </Button>\n            </div>\n          )}\n          <Button\n            className=\"shrink-0 shadow-none\"\n            disabled={loading}\n            onClick={onCancel}\n            size=\"sm\"\n            type=\"button\"\n          >\n            Cancel\n          </Button>\n        </CardFooter>\n      </Card>\n\n      {/* Media Gallery Dialog */}\n      {(media !== undefined || fetchMediaPage) && (\n        <Dialog onOpenChange={setIsGalleryOpen} open={isGalleryOpen}>\n          <DialogContent\n            className=\"flex max-h-[800px] flex-col overflow-hidden text-clip sm:max-w-4xl\"\n            variant=\"card\"\n          >\n            <DialogHeader className=\"flex-row items-center justify-between px-4 py-2\">\n              <div className=\"flex flex-1 items-center gap-2\">\n                <HugeiconsIcon\n                  className=\"text-muted-foreground\"\n                  icon={Video02Icon}\n                  size={20}\n                />\n                <DialogTitle className=\"font-medium text-muted-foreground text-sm\">\n                  Video Gallery\n                </DialogTitle>\n              </div>\n              <DialogX />\n            </DialogHeader>\n            <DialogBody className=\"min-h-[400px] p-4\">\n              {isLoadingMedia ? (\n                <div className=\"flex min-h-[360px] items-center justify-center\">\n                  <div className=\"flex flex-col items-center justify-center gap-2 text-muted-foreground\">\n                    <SpinnerIcon className=\"size-6 animate-spin\" />\n                    <p className=\"font-medium text-sm\">Loading media...</p>\n                  </div>\n                </div>\n              ) : media && media.length > 0 ? (\n                <div className=\"flex max-h-[500px] flex-col gap-4 overflow-y-auto\">\n                  <ul className=\"m-0 grid w-full list-none grid-cols-[repeat(auto-fill,minmax(8.125rem,1fr))] gap-2.5 p-0\">\n                    {media\n                      ?.filter((item) => item.type === \"video\")\n                      .map((item) => (\n                        <li\n                          className=\"group relative size-[8.125rem]\"\n                          key={item.id}\n                        >\n                          <button\n                            className=\"flex h-full w-full items-center justify-center rounded-lg border border-border bg-background p-1 transition-opacity hover:opacity-80\"\n                            onClick={() => handleMediaSelect(item.url)}\n                            type=\"button\"\n                          >\n                            <div className=\"flex h-full w-full flex-col items-center justify-center gap-1 overflow-hidden rounded-md border border-border\">\n                              <video\n                                className=\"h-full w-full object-cover\"\n                                muted\n                                preload=\"metadata\"\n                                src={`${item.url}#t=0.5`}\n                              >\n                                <track kind=\"captions\" />\n                              </video>\n                            </div>\n                          </button>\n                        </li>\n                      ))}\n                  </ul>\n                  {nextCursor && (\n                    <div className=\"flex justify-center py-2\">\n                      <Button\n                        disabled={isLoadingMore}\n                        onClick={handleLoadMore}\n                        type=\"button\"\n                        variant=\"outline\"\n                      >\n                        {isLoadingMore ? (\n                          <>\n                            <SpinnerIcon className=\"mr-2 size-4 animate-spin\" />\n                            Loading...\n                          </>\n                        ) : (\n                          \"Load More\"\n                        )}\n                      </Button>\n                    </div>\n                  )}\n                </div>\n              ) : (\n                <div className=\"flex min-h-[360px] items-center justify-center\">\n                  <div className=\"flex flex-col items-center justify-center gap-2 text-muted-foreground\">\n                    <VideoCameraIcon className=\"size-8\" />\n                    <p className=\"font-medium text-sm\">\n                      Your gallery has no videos. Upload some media to get\n                      started.\n                    </p>\n                  </div>\n                </div>\n              )}\n            </DialogBody>\n          </DialogContent>\n        </Dialog>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/video-upload/video-upload-view.tsx",
    "content": "import type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { VideoUploadStorage } from \"./index\";\nimport { VideoUploadComp } from \"./video-upload-comp\";\n\nexport const VideoUploadView = ({\n  getPos,\n  editor,\n  node,\n  extension,\n}: NodeViewProps) => {\n  const storage = extension.storage as VideoUploadStorage;\n  const pendingUploads = storage.pendingUploads;\n\n  // Get fileId from node attributes\n  const fileId = node.attrs.fileId as string | null;\n  const initialFile = fileId ? pendingUploads.get(fileId) : undefined;\n\n  // Get extension options from storage\n  const { options } = storage;\n\n  // Track whether the upload was consumed (success or cancel) so the\n  // unmount cleanup knows whether it still needs to release the entry.\n  const consumedRef = useRef(false);\n\n  // Clean up the pending upload entry when this view unmounts (e.g. the\n  // node is deleted while an upload is still in progress).\n  useEffect(() => {\n    return () => {\n      if (fileId && !consumedRef.current) {\n        pendingUploads.delete(fileId);\n      }\n    };\n  }, [fileId, pendingUploads]);\n\n  const onUpload = useCallback(\n    (url: string) => {\n      if (url && typeof getPos === \"function\") {\n        const pos = getPos();\n        if (typeof pos === \"number\") {\n          consumedRef.current = true;\n          if (fileId) {\n            pendingUploads.delete(fileId);\n          }\n\n          editor\n            .chain()\n            .focus()\n            .deleteRange({ from: pos, to: pos + 1 })\n            .setVideo({ src: url, caption: \"\" })\n            .run();\n        }\n      }\n    },\n    [getPos, editor, fileId, pendingUploads]\n  );\n\n  const onCancel = useCallback(() => {\n    if (typeof getPos === \"function\") {\n      const pos = getPos();\n      if (typeof pos === \"number\") {\n        consumedRef.current = true;\n        if (fileId) {\n          pendingUploads.delete(fileId);\n        }\n\n        editor\n          .chain()\n          .focus()\n          .deleteRange({ from: pos, to: pos + 1 })\n          .run();\n      }\n    }\n  }, [getPos, editor, fileId, pendingUploads]);\n\n  // Only render if upload handler is configured\n  if (!options.upload) {\n    return (\n      <NodeViewWrapper className=\"my-5\">\n        <div className=\"flex items-center justify-center rounded-md border border-muted bg-muted/50 p-8\">\n          <p className=\"text-muted-foreground text-sm\">\n            Video upload is not configured. Please configure the VideoUpload\n            extension with an upload handler.\n          </p>\n        </div>\n      </NodeViewWrapper>\n    );\n  }\n\n  return (\n    <NodeViewWrapper className=\"my-5\">\n      <div className=\"m-0 p-0\" data-drag-handle>\n        <VideoUploadComp\n          fetchMediaPage={options.fetchMediaPage}\n          initialFile={initialFile}\n          media={options.media}\n          onCancel={onCancel}\n          onError={options.onError}\n          onUpload={onUpload}\n          upload={options.upload}\n        />\n      </div>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/youtube/youtube-comp.tsx",
    "content": "import { Button } from \"@marble/ui/components/button\";\nimport {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@marble/ui/components/card\";\nimport { Textarea } from \"@marble/ui/components/textarea\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type { ChangeEvent, KeyboardEvent } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { YouTubeIcon } from \"../../components/icons/youtube\";\n\n// Extract YouTube video ID from various URL formats\nfunction extractYouTubeVideoId(url: string): string | null {\n  if (!url) {\n    return null;\n  }\n\n  const patterns = [\n    /(?:youtube\\.com\\/watch\\?v=|youtu\\.be\\/|youtube\\.com\\/embed\\/)([^&\\n?#]+)/,\n    /^([a-zA-Z0-9_-]{11})$/, // Direct video ID\n  ];\n\n  for (const pattern of patterns) {\n    const match = url.match(pattern);\n    if (match?.[1]) {\n      return match[1];\n    }\n  }\n\n  return null;\n}\n\nexport const YouTubeComp = ({\n  onSubmit,\n  onCancel,\n}: {\n  onSubmit: (url: string) => void;\n  onCancel: () => void;\n}) => {\n  const [url, setUrl] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n\n  useEffect(() => {\n    // Use requestAnimationFrame to ensure the element is rendered\n    const frame = requestAnimationFrame(() => {\n      inputRef.current?.focus();\n    });\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const validateAndSubmit = useCallback(() => {\n    const videoId = extractYouTubeVideoId(url);\n    if (!videoId) {\n      setError(\"Invalid YouTube URL\");\n      return;\n    }\n\n    // Construct a clean YouTube URL\n    const cleanUrl = `https://www.youtube.com/watch?v=${videoId}`;\n    onSubmit(cleanUrl);\n  }, [url, onSubmit]);\n\n  const handleInputChange = useCallback(\n    (e: ChangeEvent<HTMLTextAreaElement>) => {\n      setUrl(e.target.value);\n      setError(null);\n    },\n    []\n  );\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        validateAndSubmit();\n      } else if (e.key === \"Escape\") {\n        e.preventDefault();\n        onCancel();\n      }\n    },\n    [validateAndSubmit, onCancel]\n  );\n\n  const isValidUrl = extractYouTubeVideoId(url) !== null;\n\n  return (\n    <Card className=\"col-span-full gap-4 rounded-[20px] border-none bg-surface p-2\">\n      <CardHeader className=\"gap-0 px-4 pt-2\">\n        <div className=\"flex items-center justify-between gap-2\">\n          <YouTubeIcon className=\"size-5\" />\n          <CardTitle className=\"font-normal text-sm\">\n            Paste a YouTube URL\n          </CardTitle>\n        </div>\n      </CardHeader>\n      <CardContent className=\"rounded-[12px] bg-background p-4 shadow-xs\">\n        <div className=\"flex flex-col gap-2\">\n          <Textarea\n            className={cn(\n              \"resize-none\",\n              error && \"border-destructive focus-visible:ring-destructive\"\n            )}\n            onChange={handleInputChange}\n            onKeyDown={handleKeyDown}\n            placeholder=\"https://www.youtube.com/watch?v=...\"\n            ref={inputRef}\n            value={url}\n          />\n          {error && <p className=\"text-destructive text-xs\">{error}</p>}\n        </div>\n\n        <CardFooter className=\"flex items-center gap-2 px-0 pt-4\">\n          <Button\n            disabled={!url || !isValidUrl}\n            onClick={validateAndSubmit}\n            size=\"sm\"\n            type=\"button\"\n          >\n            Embed Video\n          </Button>\n          <Button onClick={onCancel} size=\"sm\" type=\"button\" variant=\"ghost\">\n            Cancel\n          </Button>\n        </CardFooter>\n      </CardContent>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/extensions/youtube/youtube-upload.ts",
    "content": "import { Node } from \"@tiptap/core\";\nimport { ReactNodeViewRenderer } from \"@tiptap/react\";\nimport { YouTubeUploadView } from \"./youtube-view\";\n\n/**\n * YouTube Upload Node Extension\n * Creates a placeholder node that renders the YouTube upload component\n * When a URL is submitted, it replaces itself with an actual YouTube embed\n */\nexport const YouTubeUpload = Node.create({\n  name: \"youtubeUpload\",\n\n  group: \"block\",\n\n  atom: true,\n\n  addNodeView() {\n    return ReactNodeViewRenderer(YouTubeUploadView);\n  },\n\n  parseHTML() {\n    return [\n      {\n        tag: 'div[data-type=\"youtube-upload\"]',\n      },\n    ];\n  },\n\n  renderHTML() {\n    return [\"div\", { \"data-type\": \"youtube-upload\" }];\n  },\n});\n"
  },
  {
    "path": "packages/editor/src/extensions/youtube/youtube-view.tsx",
    "content": "import type { NodeViewProps } from \"@tiptap/core\";\nimport { NodeViewWrapper } from \"@tiptap/react\";\nimport { useCallback } from \"react\";\nimport { YouTubeComp } from \"./youtube-comp\";\n\nexport const YouTubeUploadView = ({ getPos, editor }: NodeViewProps) => {\n  const onSubmit = useCallback(\n    (url: string) => {\n      if (url && typeof getPos === \"function\") {\n        const pos = getPos();\n        if (typeof pos === \"number\") {\n          // Replace the youtubeUpload node with an actual YouTube embed\n          editor\n            .chain()\n            .focus()\n            .deleteRange({ from: pos, to: pos + 1 })\n            .setYoutubeVideo({ src: url })\n            .run();\n        }\n      }\n    },\n    [getPos, editor]\n  );\n\n  const onCancel = useCallback(() => {\n    if (typeof getPos === \"function\") {\n      const pos = getPos();\n      if (typeof pos === \"number\") {\n        // Remove the placeholder node\n        editor\n          .chain()\n          .focus()\n          .deleteRange({ from: pos, to: pos + 1 })\n          .run();\n      }\n    }\n  }, [getPos, editor]);\n\n  return (\n    <NodeViewWrapper className=\"my-5\">\n      <div className=\"m-0 p-0\" data-drag-handle>\n        <YouTubeComp onCancel={onCancel} onSubmit={onSubmit} />\n      </div>\n    </NodeViewWrapper>\n  );\n};\n"
  },
  {
    "path": "packages/editor/src/index.ts",
    "content": "// Components\n/** biome-ignore-all lint/performance/noBarrelFile: <> */\n\n// Types\nexport type { Editor, JSONContent } from \"@tiptap/react\";\nexport type {\n  EditorBlockHandleMenuProps,\n  EditorBubbleMenuProps,\n  EditorCharacterCountProps,\n  EditorFloatingMenuProps,\n  EditorLinkSelectorProps,\n  // Mark Component Types\n  EditorMarkBoldProps,\n  EditorMarkCodeProps,\n  EditorMarkHighlightProps,\n  EditorMarkItalicProps,\n  EditorMarkStrikeProps,\n  EditorMarkSubscriptProps,\n  EditorMarkSuperscriptProps,\n  EditorMarkTextColorProps,\n  EditorMarkUnderlineProps,\n  EditorNodeBulletListProps,\n  EditorNodeCodeProps,\n  EditorNodeHeading1Props,\n  EditorNodeHeading2Props,\n  EditorNodeHeading3Props,\n  EditorNodeOrderedListProps,\n  EditorNodeQuoteProps,\n  EditorNodeTableProps,\n  EditorNodeTaskListProps,\n  // Node Component Types\n  EditorNodeTextProps,\n  EditorProviderProps,\n  // Utility Component Types\n  EditorSelectorProps,\n  FieldRichTextEditorProps,\n  UseMarbleEditorOptions,\n} from \"./components\";\nexport {\n  // Alignment Components\n  EditorAlignCenter,\n  EditorAlignJustify,\n  EditorAlignLeft,\n  EditorAlignRight,\n  EditorAlignSelector,\n  EditorBlockHandleMenu,\n  EditorBubbleMenu,\n  EditorCharacterCount,\n  EditorClearFormatting,\n  EditorContent,\n  EditorContext,\n  EditorFloatingMenu,\n  EditorLinkSelector,\n  // Mark Components\n  EditorMarkBold,\n  EditorMarkCode,\n  EditorMarkHighlight,\n  EditorMarkItalic,\n  EditorMarkStrike,\n  EditorMarkSubscript,\n  EditorMarkSuperscript,\n  EditorMarkTextColor,\n  EditorMarkUnderline,\n  EditorNodeBulletList,\n  EditorNodeCode,\n  EditorNodeHeading1,\n  EditorNodeHeading2,\n  EditorNodeHeading3,\n  EditorNodeOrderedList,\n  EditorNodeQuote,\n  EditorNodeTable,\n  EditorNodeTaskList,\n  // Node Components\n  EditorNodeText,\n  EditorProvider,\n  // Utility Components\n  EditorSelector,\n  EditorTableMenus,\n  FieldRichTextEditor,\n  useCurrentEditor,\n  useEditor,\n  useMarbleEditor,\n} from \"./components\";\nexport * from \"./components/ui\";\nexport {\n  CodeBlock,\n  configureSlashCommand,\n  Figure,\n  handleCommandNavigation,\n  ImageUpload,\n  SlashCommand,\n  Table,\n  TableCell,\n  TableColumnMenu,\n  TableHeader,\n  TableRow,\n  TableRowMenu,\n  Video,\n  VideoUpload,\n} from \"./extensions\";\nexport type { ExtensionKitOptions } from \"./extensions/extension-kit\";\n// Extensions\nexport { ExtensionKit } from \"./extensions/extension-kit\";\n// Lib\nexport { lowlight } from \"./lib\";\nexport type {\n  EditorButtonProps,\n  EditorIcon,\n  EditorSlashMenuProps,\n  ImageUploadOptions,\n  MediaItem,\n  MediaPage,\n  SlashNodeAttrs,\n  SuggestionItem,\n  VideoUploadOptions,\n} from \"./types\";\n"
  },
  {
    "path": "packages/editor/src/lib/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\nexport { lowlight } from \"./lowlight\";\nexport { isCustomNodeSelected, isTextSelected } from \"./utils\";\n"
  },
  {
    "path": "packages/editor/src/lib/lowlight.ts",
    "content": "import { all, createLowlight } from \"lowlight\";\n\n/**\n * Create a lowlight instance with all languages loaded\n * Used for syntax highlighting in code blocks\n */\nexport const lowlight = createLowlight(all);\n"
  },
  {
    "path": "packages/editor/src/lib/utils.ts",
    "content": "import type { Editor } from \"@tiptap/core\";\nimport { isTextSelection } from \"@tiptap/core\";\n\n/**\n * Check if a table grip is selected\n */\nfunction isTableGripSelected(node: HTMLElement): boolean {\n  let container: HTMLElement | null = node;\n\n  while (container && ![\"TD\", \"TH\"].includes(container.tagName)) {\n    container = container.parentElement;\n  }\n\n  if (!container) {\n    return false;\n  }\n\n  const gripColumn = container.querySelector?.(\"a.grip-column.selected\");\n  const gripRow = container.querySelector?.(\"a.grip-row.selected\");\n\n  return !!(gripColumn || gripRow);\n}\n\n/**\n * Check if a custom node is currently selected\n * Custom nodes are block-level nodes that shouldn't show the bubble menu\n * (e.g., YouTube embeds, code blocks, horizontal rules, etc.)\n */\nexport function isCustomNodeSelected(\n  editor: Editor | null,\n  node: HTMLElement | null\n): boolean {\n  if (!editor || !node) {\n    return false;\n  }\n\n  const customNodes = [\n    \"youtube\",\n    \"youtubeUpload\",\n    \"twitter\",\n    \"twitterUpload\",\n    \"codeBlock\",\n    \"horizontalRule\",\n    \"imageUpload\",\n    \"figure\",\n    \"image\",\n    \"video\",\n    \"videoUpload\",\n  ];\n\n  const isCustomNodeActive = customNodes.some((type) => editor.isActive(type));\n\n  return isCustomNodeActive || isTableGripSelected(node);\n}\n\n/**\n * Check if text is currently selected in the editor\n * Returns false if selection is empty or if the editor is not editable\n */\nexport function isTextSelected({ editor }: { editor: Editor | null }): boolean {\n  if (!editor) {\n    return false;\n  }\n\n  const {\n    state: {\n      doc,\n      selection,\n      selection: { empty, from, to },\n    },\n  } = editor;\n\n  // Sometimes check for `empty` is not enough.\n  // Double-click an empty paragraph returns a node size of 2.\n  // So we check also for an empty text size.\n  const isEmptyTextBlock =\n    !doc.textBetween(from, to).length && isTextSelection(selection);\n\n  if (empty || isEmptyTextBlock || !editor.isEditable) {\n    return false;\n  }\n\n  return true;\n}\n"
  },
  {
    "path": "packages/editor/src/styles/color-picker.css",
    "content": ".color-picker .react-colorful {\n  width: 100%;\n  height: 200px;\n}\n\n.color-picker .react-colorful__saturation {\n  border-radius: 4px;\n  margin-bottom: 6px;\n}\n\n.color-picker .react-colorful__hue {\n  border-radius: 4px;\n}\n\n.color-picker .react-colorful__pointer {\n  width: 28px;\n  height: 28px;\n}\n"
  },
  {
    "path": "packages/editor/src/styles/table.css",
    "content": "/* Light mode styles */\n.ProseMirror {\n  .tableWrapper {\n    margin-top: 3rem;\n    margin-bottom: 3rem;\n  }\n\n  table {\n    border-collapse: collapse;\n    border-color: rgba(0, 0, 0, 0.1);\n    border-radius: 0.25rem;\n    box-sizing: border-box;\n    width: 100%;\n  }\n\n  table td,\n  table th {\n    border: 1px solid rgba(0, 0, 0, 0.1);\n    min-width: 100px;\n    padding: 0.5rem;\n    position: relative;\n    text-align: left;\n    vertical-align: top;\n  }\n\n  table td:not(:last-child)::after,\n  table th:not(:last-child)::after {\n    content: \"\";\n    position: absolute;\n    top: 0;\n    right: -0.5rem;\n    bottom: 0;\n    width: 1rem;\n    cursor: col-resize;\n    z-index: 1;\n  }\n\n  table td:first-of-type:not(a),\n  table th:first-of-type:not(a) {\n    margin-top: 0;\n  }\n\n  table td p,\n  table th p {\n    margin: 0;\n  }\n\n  table td p + p,\n  table th p + p {\n    margin-top: 0.75rem;\n  }\n\n  table th {\n    font-weight: 700;\n  }\n\n  table .column-resize-handle {\n    bottom: -2px;\n    display: flex;\n    pointer-events: none;\n    position: absolute;\n    right: -0.25rem;\n    top: 0;\n    width: 0.5rem;\n  }\n\n  table .column-resize-handle::before {\n    background-color: rgba(0, 0, 0, 0.2);\n    height: 100%;\n    width: 1px;\n    margin-left: 0.5rem;\n    content: \"\";\n  }\n\n  table .selectedCell {\n    background-color: rgba(0, 0, 0, 0.05);\n    border-color: rgba(0, 0, 0, 0.2);\n    border-style: double;\n  }\n\n  table .grip-column,\n  table .grip-row {\n    align-items: center;\n    background-color: rgba(0, 0, 0, 0.05);\n    cursor: pointer;\n    display: flex;\n    justify-content: center;\n    position: absolute;\n    z-index: 10;\n  }\n\n  table .grip-column {\n    width: calc(100% + 1px);\n    border-left: 1px solid rgba(0, 0, 0, 0.2);\n    height: 0.75rem;\n    left: 0;\n    margin-left: -1px;\n    top: -0.75rem;\n  }\n\n  table .grip-column:hover::before,\n  table .grip-column.selected::before {\n    content: \"\";\n    width: 0.625rem;\n  }\n\n  table .grip-column:hover {\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n\n  table .grip-column:hover::before {\n    border-bottom: 2px dotted rgba(0, 0, 0, 0.6);\n  }\n\n  table .grip-column.first {\n    border-color: transparent;\n    border-top-left-radius: 0.125rem;\n  }\n\n  table .grip-column.last {\n    border-top-right-radius: 0.125rem;\n  }\n\n  table .grip-column.selected {\n    background-color: rgba(0, 0, 0, 0.3);\n    border-color: rgba(0, 0, 0, 0.3);\n    box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n  }\n\n  table .grip-column.selected::before {\n    border-bottom: 2px dotted;\n  }\n\n  table .grip-row {\n    height: calc(100% + 1px);\n    border-top: 1px solid rgba(0, 0, 0, 0.2);\n    left: -0.75rem;\n    width: 0.75rem;\n    top: 0;\n    margin-top: -1px;\n  }\n\n  table .grip-row:hover::before,\n  table .grip-row.selected::before {\n    height: 0.625rem;\n    content: \"\";\n  }\n\n  table .grip-row:hover {\n    background-color: rgba(0, 0, 0, 0.1);\n  }\n\n  table .grip-row:hover::before {\n    border-left: 2px dotted rgba(0, 0, 0, 0.6);\n  }\n\n  table .grip-row.first {\n    border-color: transparent;\n    border-top-left-radius: 0.125rem;\n  }\n\n  table .grip-row.last {\n    border-bottom-left-radius: 0.125rem;\n  }\n\n  table .grip-row.selected {\n    background-color: rgba(0, 0, 0, 0.3);\n    border-color: rgba(0, 0, 0, 0.3);\n    box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);\n  }\n\n  table .grip-row.selected::before {\n    border-left: 2px dotted;\n  }\n}\n\n/* Dark mode styles - using :is(.dark *) to match when .dark is on any ancestor */\n:is(.dark *) .ProseMirror {\n  table {\n    border-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table td,\n  table th {\n    border-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .column-resize-handle::before {\n    background-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .selectedCell {\n    background-color: rgba(255, 255, 255, 0.1);\n    border-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .grip-column,\n  table .grip-row {\n    background-color: rgba(255, 255, 255, 0.1);\n  }\n\n  table .grip-column {\n    border-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .grip-column:hover {\n    background-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .grip-column:hover::before {\n    border-color: rgba(255, 255, 255, 0.6);\n  }\n\n  table .grip-column.selected {\n    background-color: rgba(255, 255, 255, 0.3);\n    border-color: rgba(255, 255, 255, 0.3);\n  }\n\n  table .grip-row {\n    border-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .grip-row:hover {\n    background-color: rgba(255, 255, 255, 0.2);\n  }\n\n  table .grip-row:hover::before {\n    border-color: rgba(255, 255, 255, 0.6);\n  }\n\n  table .grip-row.selected {\n    background-color: rgba(255, 255, 255, 0.3);\n    border-color: rgba(255, 255, 255, 0.3);\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/styles/task-list.css",
    "content": ".ProseMirror {\n  ul[data-type=\"taskList\"] {\n    list-style: none;\n    padding: 0;\n\n    p {\n      margin: 0;\n    }\n\n    li {\n      display: flex;\n\n      > label {\n        flex: 0 0 auto;\n        margin-right: 0.5rem;\n        user-select: none;\n      }\n\n      > div {\n        flex: 1 1 auto;\n      }\n\n      &[data-checked=\"true\"] {\n        text-decoration: line-through;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/editor/src/types/index.ts",
    "content": "/** biome-ignore-all lint/suspicious/noExplicitAny: <expanation> */\nimport type { Editor, Range } from \"@tiptap/core\";\nimport type { ComponentType, SVGProps } from \"react\";\n\n/**\n * Icon type that accepts Lucide icons, custom SVG components, or render functions\n */\nexport type EditorIcon =\n  | ComponentType<SVGProps<SVGSVGElement>>\n  | ComponentType<Record<string, unknown>>\n  | ((props: Record<string, unknown>) => React.ReactNode);\n\n/**\n * Suggestion item for slash command menu\n */\nexport interface SuggestionItem {\n  title: string;\n  description: string;\n  icon: EditorIcon;\n  searchTerms: string[];\n  command: (props: { editor: Editor; range: Range }) => void;\n}\n\n/**\n * Props for editor provider component\n */\nexport interface EditorProviderProps {\n  className?: string;\n  limit?: number;\n  placeholder?: string;\n  children?: React.ReactNode;\n  content?: string;\n  extensions?: any[];\n  editorProps?: Record<string, unknown>;\n  onUpdate?: (props: { editor: Editor }) => void;\n}\n\n/**\n * Props for editor button components\n */\nexport interface EditorButtonProps {\n  name: string;\n  isActive: () => boolean;\n  command: () => void;\n  icon: EditorIcon;\n  hideName?: boolean;\n}\n\n/**\n * Props for slash command menu component\n */\nexport interface EditorSlashMenuProps {\n  items: SuggestionItem[];\n  command: (item: SuggestionItem) => void;\n  editor: Editor;\n  range: Range;\n}\n\n/**\n * Slash node attributes type\n */\nexport interface SlashNodeAttrs {\n  id: string | null;\n  label?: string | null;\n}\n\n/**\n * Media item type for image upload extension\n */\nexport interface MediaItem {\n  id: string;\n  url: string;\n  name: string;\n  type: \"image\" | \"video\" | \"file\";\n}\n\n/**\n * Paginated media response\n */\nexport interface MediaPage {\n  media: MediaItem[];\n  nextCursor?: string;\n}\n\n/**\n * Image upload extension options\n */\nexport interface ImageUploadOptions {\n  /** Upload handler function - required for upload functionality */\n  upload?: (file: File) => Promise<string>;\n  /** File accept types (default: 'image/*') */\n  accept?: string;\n  /** Max file size in bytes */\n  maxSize?: number;\n  /** Max number of files */\n  limit?: number;\n  /** Error handler */\n  onError?: (error: Error) => void;\n  /** Pre-loaded media library items */\n  media?: MediaItem[];\n  /** Fetch media with pagination - takes optional cursor, returns page with media and next cursor */\n  fetchMediaPage?: (cursor?: string) => Promise<MediaPage>;\n}\n\n/**\n * Video upload extension options\n */\nexport interface VideoUploadOptions {\n  /** Upload handler function - required for upload functionality */\n  upload?: (file: File) => Promise<string>;\n  /** File accept types (default: 'video/*') */\n  accept?: string;\n  /** Max file size in bytes */\n  maxSize?: number;\n  /** Max number of files */\n  limit?: number;\n  /** Error handler */\n  onError?: (error: Error) => void;\n  /** Pre-loaded media library items */\n  media?: MediaItem[];\n  /** Fetch media with pagination - takes optional cursor, returns page with media and next cursor */\n  fetchMediaPage?: (cursor?: string) => Promise<MediaPage>;\n}\n"
  },
  {
    "path": "packages/editor/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/react-library.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"./dist\",\n    \"rootDir\": \"./src\",\n    \"baseUrl\": \".\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"paths\": {\n      \"@marble/*\": [\"../*\"],\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/email/package.json",
    "content": "{\n  \"name\": \"@marble/email\",\n  \"version\": \"0.0.0\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"scripts\": {\n    \"dev\": \"email dev --dir ./src/emails --port 3001\"\n  },\n  \"dependencies\": {\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-email\": \"^6.1.3\",\n    \"resend\": \"^6.12.3\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"@react-email/ui\": \"^6.1.3\",\n    \"@types/node\": \"^22.9.0\",\n    \"@types/react\": \"19.2.14\",\n    \"@types/react-dom\": \"19.2.3\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/email/src/components/button.tsx",
    "content": "import type { ComponentProps } from \"react\";\nimport { Button as ReactEmailButton } from \"react-email\";\n\ntype EmailButtonProps = ComponentProps<typeof ReactEmailButton>;\n\nexport const EmailButton = ({\n  children,\n  className,\n  ...props\n}: EmailButtonProps) => {\n  const baseClasses =\n    \"rounded-lg bg-[#766df8] px-6 py-3 font-semibold text-sm text-white\";\n  const combinedClassName = className\n    ? `${baseClasses} ${className}`\n    : baseClasses;\n\n  return (\n    <ReactEmailButton {...props} className={combinedClassName}>\n      {children}\n    </ReactEmailButton>\n  );\n};\n"
  },
  {
    "path": "packages/email/src/components/footer.tsx",
    "content": "import { Hr, Link, Section, Text } from \"react-email\";\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\nexport const EmailFooter = () => {\n  const currentYear = new Date().getFullYear();\n  const { physicalAddress } = EMAIL_CONFIG;\n\n  return (\n    <Section>\n      <Hr className=\"mx-0 mb-[26px] w-full border border-[#eaeaea] border-solid\" />\n      {physicalAddress.street && (\n        <Text className=\"m-0 text-center text-xs\" style={{ color: \"#717175\" }}>\n          {physicalAddress.name}\n          <br />\n          {physicalAddress.street}\n          <br />\n          {physicalAddress.city}, {physicalAddress.state} {physicalAddress.zip}\n          <br />\n          {physicalAddress.country}\n        </Text>\n      )}\n      <Text className=\"mt-4 text-center text-xs\" style={{ color: \"#717175\" }}>\n        © {currentYear} Marble. All rights reserved.\n      </Text>\n      <Text className=\"mt-4 text-center text-xs\" style={{ color: \"#717175\" }}>\n        <Link\n          href=\"https://marblecms.com\"\n          style={{ color: \"#717175\", textDecoration: \"underline\" }}\n        >\n          Website\n        </Link>\n        {\" · \"}\n        <Link\n          href=\"https://docs.marblecms.com\"\n          style={{ color: \"#717175\", textDecoration: \"underline\" }}\n        >\n          Documentation\n        </Link>\n        {\" · \"}\n        <Link\n          href={`mailto:${EMAIL_CONFIG.replyTo}`}\n          style={{ color: \"#717175\", textDecoration: \"underline\" }}\n        >\n          Support\n        </Link>\n      </Text>\n    </Section>\n  );\n};\n"
  },
  {
    "path": "packages/email/src/emails/founder.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Tailwind,\n  Text,\n} from \"react-email\";\n\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\ninterface FounderEmailProps {\n  userEmail: string;\n}\n\nexport const FounderEmail = ({ userEmail }: FounderEmailProps) => {\n  const previewText = \"A note from Marble\";\n  const twitterLink = EMAIL_CONFIG.twitterLink;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white px-2 font-sans\">\n          <Container className=\"mx-auto my-[40px] max-w-[465px] p-[20px]\">\n            <Text className=\"text-[#333] text-base leading-relaxed\">\n              Hello,\n            </Text>\n\n            <Text className=\"text-[#333] text-base leading-relaxed\">\n              Marble started as a simple idea: to make a CMS feel as simple as\n              possible.\n            </Text>\n\n            <Text className=\"text-[#333] text-base leading-relaxed\">\n              It’s still early, and you can shape where it goes next.\n            </Text>\n\n            <Text className=\"text-[#333] text-base leading-relaxed\">\n              If anything feels confusing, missing, or just off, feel free to\n              reply to this email. I read every message.\n            </Text>\n\n            <Text className=\"text-[#333] text-base leading-relaxed\">\n              You can also reach me on{\" \"}\n              <Link className=\"text-[#766df8] no-underline\" href={twitterLink}>\n                Twitter\n              </Link>{\" \"}\n              if that’s easier.\n            </Text>\n\n            <Text className=\"text-[#333] text-base leading-relaxed\">\n              Thanks,\n              <br />\n              Taqib\n            </Text>\n\n            <Hr />\n\n            <Text className=\"mt-8 text-[#999] text-xs\">\n              This email was sent to {userEmail} because you signed up for\n              Marble.\n            </Text>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default FounderEmail;\n"
  },
  {
    "path": "packages/email/src/emails/invite.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"react-email\";\n\nimport { EmailButton } from \"../components/button\";\nimport { EmailFooter } from \"../components/footer\";\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\ninterface InviteUserEmailProps {\n  inviteeEmail: string;\n  invitedByUsername: string;\n  invitedByEmail: string;\n  workspaceName: string;\n  inviteLink: string;\n}\n\nexport const InviteUserEmail = ({\n  inviteeEmail,\n  invitedByUsername,\n  workspaceName,\n  inviteLink,\n}: InviteUserEmailProps) => {\n  const previewText = `Join ${invitedByUsername} on Marble`;\n  const logoUrl = EMAIL_CONFIG.getLogoUrl();\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white px-2 font-sans\">\n          <Container className=\"mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]\">\n            <Section className=\"mt-[32px]\">\n              <Img\n                alt=\"Marble Logo\"\n                className=\"mx-auto\"\n                height=\"40\"\n                src={logoUrl}\n                width=\"40\"\n              />\n            </Section>\n\n            <Heading className=\"my-6 text-center font-medium text-2xl text-black\">\n              Join <strong>{workspaceName}</strong> on Marble\n            </Heading>\n\n            <Text className=\"text-center text-[#737373] text-base leading-relaxed\">\n              <strong>{invitedByUsername}</strong> has invited you to join the{\" \"}\n              <strong>{workspaceName}</strong> workspace on Marble.\n            </Text>\n\n            <Section className=\"my-8 text-center\">\n              <EmailButton href={inviteLink}>Join workspace</EmailButton>\n            </Section>\n\n            <Text className=\"text-[14px] text-black leading-[24px]\">\n              or copy and paste this URL into your browser:{\" \"}\n              <Link className=\"text-blue-600 no-underline\" href={inviteLink}>\n                {inviteLink}\n              </Link>\n            </Text>\n\n            <Hr className=\"mx-0 mt-[26px] w-full border border-[#eaeaea] border-solid\" />\n            <Text className=\"text-[#666666] text-[12px] leading-[24px]\">\n              This invitation was intended for{\" \"}\n              <span className=\"text-black\">{inviteeEmail}</span>. If you weren't\n              expecting this, you can safely ignore this email. Need help? Reach\n              us at {EMAIL_CONFIG.replyTo}.\n            </Text>\n            <EmailFooter />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default InviteUserEmail;\n"
  },
  {
    "path": "packages/email/src/emails/reset.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"react-email\";\n\nimport { EmailButton } from \"../components/button\";\nimport { EmailFooter } from \"../components/footer\";\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\ninterface ResetPasswordProps {\n  userEmail: string;\n  resetLink: string;\n  baseUrl?: string;\n}\n\nexport const ResetPasswordEmail = ({\n  userEmail,\n  resetLink,\n}: ResetPasswordProps) => {\n  const logoUrl = EMAIL_CONFIG.getLogoUrl();\n  return (\n    <Html>\n      <Head />\n      <Preview>Reset your password</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white px-2 font-sans\">\n          <Container className=\"mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]\">\n            <Section className=\"mt-[32px]\">\n              <Img\n                alt=\"Marble Logo\"\n                className=\"mx-auto\"\n                height=\"40\"\n                src={logoUrl}\n                width=\"40\"\n              />\n            </Section>\n\n            <Heading className=\"my-6 text-center font-medium text-2xl text-black\">\n              Reset your password\n            </Heading>\n\n            <Text className=\"text-center text-[#737373] text-base leading-relaxed\">\n              We received a request to reset the password for your account. To\n              proceed, click on the button below\n            </Text>\n\n            <Section className=\"my-8 text-center\">\n              <EmailButton href={resetLink}>Reset your password</EmailButton>\n            </Section>\n\n            <Hr className=\"mx-0 mt-[26px] w-full border border-[#eaeaea] border-solid\" />\n            <Text className=\"text-[#666666] text-[12px] leading-[24px]\">\n              This email was intended for{\" \"}\n              <span className=\"text-black\">{userEmail}</span>. If you didn't\n              request this, you can safely ignore this email. Need help? Reach\n              us at {EMAIL_CONFIG.replyTo}.\n            </Text>\n            <EmailFooter />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default ResetPasswordEmail;\n"
  },
  {
    "path": "packages/email/src/emails/usage-limit.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"react-email\";\n\nimport { EmailButton } from \"../components/button\";\nimport { EmailFooter } from \"../components/footer\";\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\ninterface UsageLimitEmailProps {\n  userName?: string;\n  featureName?: string;\n  usageAmount?: number;\n  limitAmount?: number;\n  workspaceId?: string;\n}\n\nfunction formatNumber(num: number): string {\n  if (num >= 1_000_000) {\n    return `${(num / 1_000_000).toFixed(1)}M`;\n  }\n  if (num >= 1000) {\n    return `${(num / 1000).toFixed(1)}K`;\n  }\n  return num.toLocaleString();\n}\n\nexport const UsageLimitEmail = ({\n  userName,\n  featureName = \"Webhooks\",\n  usageAmount = 75,\n  limitAmount = 100,\n}: UsageLimitEmailProps) => {\n  const previewText = `You're approaching your ${featureName} limit`;\n  const logoUrl = EMAIL_CONFIG.getLogoUrl();\n  const siteurl = EMAIL_CONFIG.getSiteUrl();\n  const billingUrl = `${siteurl}/pricing`;\n\n  const greeting = userName ? `Hi ${userName},` : \"Hi there,\";\n  const limitValid = Number.isFinite(limitAmount) && limitAmount > 0;\n\n  const usageFormatted = formatNumber(usageAmount);\n  const limitFormatted = limitValid ? formatNumber(limitAmount) : \"N/A\";\n\n  const percentage = limitValid\n    ? Math.round((usageAmount / limitAmount) * 100)\n    : 0;\n\n  const remaining = limitValid ? Math.max(0, limitAmount - usageAmount) : 0;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white px-2 font-sans\">\n          <Container className=\"mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]\">\n            <Section className=\"mt-[32px]\">\n              <Img\n                alt=\"Marble Logo\"\n                className=\"mx-auto\"\n                height=\"40\"\n                src={logoUrl}\n                width=\"40\"\n              />\n            </Section>\n\n            <Heading className=\"my-6 text-center font-medium text-2xl text-black\">\n              {featureName} Usage Alert\n            </Heading>\n\n            <Section className=\"mt-4\">\n              <Text className=\"m-0 mb-4 text-[#737373] text-base leading-relaxed\">\n                {greeting}\n              </Text>\n              <Text className=\"m-0 mb-4 text-[#737373] text-base leading-relaxed\">\n                You've used {percentage}% of your {featureName.toLowerCase()}{\" \"}\n                limit for this billing period. You currently have{\" \"}\n                <strong>{remaining.toLocaleString()}</strong> remaining out of{\" \"}\n                {limitFormatted} total.\n              </Text>\n            </Section>\n\n            <Section\n              className=\"my-6 rounded p-4\"\n              style={{\n                backgroundColor: \"#f9fafb\",\n                border: \"1px solid #eaeaea\",\n              }}\n            >\n              <Text\n                className=\"m-0 mb-1 text-center text-xs uppercase tracking-wider\"\n                style={{ color: \"#737373\" }}\n              >\n                Current Usage\n              </Text>\n              <Text className=\"m-0 text-center font-semibold text-2xl text-black\">\n                {usageFormatted}{\" \"}\n                <span className=\"font-normal text-[#737373]\">\n                  / {limitFormatted}\n                </span>\n              </Text>\n              <Text\n                className=\"m-0 mt-2 text-center text-xs\"\n                style={{ color: \"#737373\" }}\n              >\n                {percentage}% of limit used\n              </Text>\n            </Section>\n\n            <Section>\n              <Text className=\"m-0 mb-4 text-[#737373] text-base leading-relaxed\">\n                {percentage >= 100\n                  ? `You've reached your ${featureName.toLowerCase()} limit and requests are no longer being processed. They will resume once your usage resets at the start of your next billing period, or you upgrade your plan.`\n                  : \"To avoid any interruption to your service, consider upgrading your plan. You can also wait until your usage resets at the start of your next billing period.\"}\n              </Text>\n            </Section>\n\n            <Section className=\"my-8 text-center\">\n              <EmailButton href={billingUrl}>View Plans</EmailButton>\n            </Section>\n\n            <Hr className=\"mx-0 mt-[26px] w-full border border-[#eaeaea] border-solid\" />\n            <Text className=\"text-[#666666] text-[12px] leading-[24px]\">\n              Need help? Send us an email at{\" \"}\n              <Link\n                className=\"text-[#766df8] no-underline\"\n                href={`mailto:${EMAIL_CONFIG.replyTo}`}\n              >\n                {EMAIL_CONFIG.replyTo}\n              </Link>{\" \"}\n              or message us on our{\" \"}\n              <Link\n                className=\"text-[#766df8] no-underline\"\n                href=\"https://discord.marblecms.com\"\n              >\n                Discord server\n              </Link>\n              .\n            </Text>\n            <EmailFooter />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default UsageLimitEmail;\n"
  },
  {
    "path": "packages/email/src/emails/verify.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"react-email\";\n\nimport { EmailFooter } from \"../components/footer\";\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\ninterface VerifyUserEmailProps {\n  userEmail: string;\n  otp: string;\n  type: \"sign-in\" | \"email-verification\" | \"forget-password\" | \"change-email\";\n}\n\nexport const VerifyUserEmail = ({\n  userEmail,\n  otp,\n  type,\n}: VerifyUserEmailProps) => {\n  const logoUrl = EMAIL_CONFIG.getLogoUrl();\n  const previewText =\n    type === \"sign-in\"\n      ? \"Your verification code\"\n      : type === \"email-verification\"\n        ? \"Verify your email address\"\n        : type === \"change-email\"\n          ? \"Confirm your new email address\"\n          : \"Reset your password\";\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white px-2 font-sans\">\n          <Container className=\"mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]\">\n            <Section className=\"mt-[32px]\">\n              <Img\n                alt=\"Marble Logo\"\n                className=\"mx-auto\"\n                height=\"40\"\n                src={logoUrl}\n                width=\"40\"\n              />\n            </Section>\n\n            <Heading className=\"my-6 text-center font-medium text-2xl text-black\">\n              {previewText}\n            </Heading>\n\n            <Text className=\"text-center text-[#737373] text-base leading-relaxed\">\n              Use the verification code below to complete your verification\n              process. This code will expire in 5 minutes.\n            </Text>\n\n            <Section className=\"mt-[32px] mb-[32px] text-center\">\n              <Text className=\"font-mono font-semibold text-[28px] tracking-wide\">\n                {otp}\n              </Text>\n            </Section>\n\n            <Hr className=\"mx-0 mt-[26px] w-full border border-[#eaeaea] border-solid\" />\n            <Text className=\"text-[#666666] text-[12px] leading-[24px]\">\n              This email was intended for{\" \"}\n              <span className=\"text-black\">{userEmail}</span>. If you didn't\n              request this code, you can safely ignore this email. Need help?\n              Reach us at {EMAIL_CONFIG.replyTo}.\n            </Text>\n            <EmailFooter />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default VerifyUserEmail;\n"
  },
  {
    "path": "packages/email/src/emails/welcome.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"react-email\";\n\nimport { EmailButton } from \"../components/button\";\nimport { EmailFooter } from \"../components/footer\";\nimport { EMAIL_CONFIG } from \"../lib/config\";\n\ninterface WelcomeEmailProps {\n  userEmail: string;\n  baseUrl?: string;\n}\n\nexport const WelcomeEmail = ({\n  userEmail,\n  baseUrl = EMAIL_CONFIG.getAppUrl(),\n}: WelcomeEmailProps) => {\n  const previewText = \"Welcome to Marble, let's get started!\";\n  const logoUrl = EMAIL_CONFIG.getLogoUrl();\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white px-2 font-sans\">\n          <Container className=\"mx-auto my-[40px] max-w-[465px] rounded border border-[#eaeaea] border-solid p-[20px]\">\n            <Section className=\"mt-[32px]\">\n              <Img\n                alt=\"Marble Logo\"\n                className=\"mx-auto\"\n                height=\"40\"\n                src={logoUrl}\n                width=\"40\"\n              />\n            </Section>\n\n            <Heading className=\"my-6 text-center font-medium text-2xl text-black\">\n              Welcome aboard\n            </Heading>\n\n            <Text className=\"text-center text-[#737373] text-base leading-relaxed\">\n              Thanks for signing up! Here's how to get the most out of Marble:\n            </Text>\n\n            <Section className=\"my-8\">\n              <Text className=\"mb-4 font-medium text-black text-sm\">\n                Get started\n              </Text>\n              <Text className=\"mb-6 text-[#737373] text-sm leading-relaxed\">\n                Check out our{\" \"}\n                <Link\n                  className=\"text-[#766df8] no-underline\"\n                  href=\"https://docs.marblecms.com\"\n                >\n                  documentation\n                </Link>{\" \"}\n                to learn how to set up your workspace, how to use the API, and\n                learn more about the features.\n              </Text>\n\n              <Text className=\"mb-4 font-medium text-black text-sm\">\n                Join the community\n              </Text>\n              <Text className=\"mb-6 text-[#737373] text-sm leading-relaxed\">\n                Have questions? Join our{\" \"}\n                <Link\n                  className=\"text-[#766df8] no-underline\"\n                  href=\"https://discord.marblecms.com\"\n                >\n                  Discord\n                </Link>{\" \"}\n                to chat with other users and get help from the team.\n              </Text>\n\n              <Text className=\"mb-4 font-medium text-black text-sm\">\n                Stay updated\n              </Text>\n              <Text className=\"mb-6 text-[#737373] text-sm leading-relaxed\">\n                Follow us on{\" \"}\n                <Link\n                  className=\"text-[#766df8] no-underline\"\n                  href=\"https://x.com/usemarblecms\"\n                >\n                  Twitter\n                </Link>{\" \"}\n                for product updates, tips, and announcements.\n              </Text>\n            </Section>\n\n            <Section className=\"my-8 text-center\">\n              <EmailButton href={baseUrl}>Go to Dashboard</EmailButton>\n            </Section>\n\n            <Hr className=\"mx-0 mt-[26px] w-full border border-[#eaeaea] border-solid\" />\n            <Text className=\"text-[#666666] text-[12px] leading-[24px]\">\n              This email was intended for{\" \"}\n              <span className=\"text-black\">{userEmail}</span>. If you didn't\n              create an account, you can safely ignore this email. Need help?\n              Reach us at {EMAIL_CONFIG.replyTo}.\n            </Text>\n            <EmailFooter />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default WelcomeEmail;\n"
  },
  {
    "path": "packages/email/src/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\nexport * from \"./lib/dev\";\nexport * from \"./lib/send\";\n"
  },
  {
    "path": "packages/email/src/lib/config.ts",
    "content": "export const EMAIL_CONFIG = {\n  /**\n   * Site URL for marketing site (logo, assets, etc.)\n   * Falls back to production URL if not set\n   */\n  getSiteUrl(): string {\n    return process.env.NEXT_PUBLIC_SITE_URL || \"https://marblecms.com\";\n  },\n\n  /**\n   * App/Dashboard URL for the CMS application\n   * Falls back to production URL if not set\n   */\n  getAppUrl(): string {\n    return process.env.NEXT_PUBLIC_APP_URL || \"https://app.marblecms.com\";\n  },\n\n  /**\n   * Get logo URL with fallback (uses site URL)\n   */\n  getLogoUrl(): string {\n    const siteUrl = this.getSiteUrl();\n    return `${siteUrl}/logo.svg`;\n  },\n\n  /**\n   * Reply-to email address\n   */\n  replyTo: \"support@marblecms.com\",\n\n  /**\n   * From email address\n   */\n  from: \"Marble <notifications@marblecms.com>\",\n\n  /**\n   * Founder email configuration\n   */\n  founderFrom: \"Taqib <taqib@marblecms.com>\",\n  founderReplyTo: \"taqib@marblecms.com\",\n  calLink: \"https://cal.com/taqib\",\n  twitterLink: \"https://x.com/retaqib\",\n\n  /**\n   * Physical mailing address for CAN-SPAM compliance\n   */\n  physicalAddress: {\n    name: \"Marble\",\n    street: \"\",\n    city: \"\",\n    state: \"\",\n    zip: \"\",\n    country: \"Federal Republic of Nigeria\",\n  },\n} as const;\n"
  },
  {
    "path": "packages/email/src/lib/dev.ts",
    "content": "import type { CreateEmailOptions } from \"resend\";\n\ntype MockableEmailOptions = CreateEmailOptions & {\n  _mockContext?: {\n    type:\n      | \"invite\"\n      | \"verification\"\n      | \"reset\"\n      | \"welcome\"\n      | \"usage-limit\"\n      | \"founder\";\n    data: Record<string, unknown>;\n  };\n};\n\nexport async function sendDevEmail(options: MockableEmailOptions) {\n  console.log(\"--- MOCK EMAIL SENT (DEVELOPMENT MODE) ---\");\n  console.log(\"From:\", options.from);\n  console.log(\"To:\", options.to);\n  console.log(\"Subject:\", options.subject);\n\n  if (options._mockContext) {\n    const { type, data } = options._mockContext;\n    console.log(\"Email Type:\", type.toUpperCase());\n    console.log(\"Email Data:\");\n    for (const [key, value] of Object.entries(data)) {\n      console.log(`  ${key}:`, value);\n    }\n  } else {\n    console.log(\"React Component: Email component\");\n  }\n\n  console.log(\"----------------------------------------------\");\n\n  return { data: { id: \"mock-email-id\" }, error: null };\n}\n"
  },
  {
    "path": "packages/email/src/lib/send.ts",
    "content": "import type { Resend } from \"resend\";\nimport { FounderEmail } from \"../emails/founder\";\nimport { InviteUserEmail } from \"../emails/invite\";\nimport { ResetPasswordEmail } from \"../emails/reset\";\nimport { UsageLimitEmail } from \"../emails/usage-limit\";\nimport { VerifyUserEmail } from \"../emails/verify\";\nimport { WelcomeEmail } from \"../emails/welcome\";\nimport { EMAIL_CONFIG } from \"./config\";\n\ninterface SendInviteEmailProps {\n  inviteeEmail: string;\n  inviteeUsername?: string;\n  inviterName: string;\n  inviterEmail: string;\n  workspaceName: string;\n  inviteLink: string;\n}\n\nexport async function sendInviteEmail(\n  resend: Resend,\n  {\n    inviteeEmail,\n    inviterName,\n    inviterEmail,\n    workspaceName,\n    inviteLink,\n  }: SendInviteEmailProps\n) {\n  return await resend.emails.send({\n    from: EMAIL_CONFIG.from,\n    replyTo: EMAIL_CONFIG.replyTo,\n    to: inviteeEmail,\n    subject: `Join ${workspaceName} on Marble`,\n    react: InviteUserEmail({\n      inviteeEmail,\n      invitedByUsername: inviterName,\n      invitedByEmail: inviterEmail,\n      workspaceName,\n      inviteLink,\n    }),\n  });\n}\n\nexport async function sendVerificationEmail(\n  resend: Resend,\n  {\n    userEmail,\n    otp,\n    type,\n  }: {\n    userEmail: string;\n    otp: string;\n    type: \"sign-in\" | \"email-verification\" | \"forget-password\" | \"change-email\";\n  }\n) {\n  return await resend.emails.send({\n    from: EMAIL_CONFIG.from,\n    replyTo: EMAIL_CONFIG.replyTo,\n    to: userEmail,\n    subject: \"Verify your email address\",\n    react: VerifyUserEmail({\n      userEmail,\n      otp,\n      type,\n    }),\n  });\n}\n\nexport async function sendResetPassword(\n  resend: Resend,\n  {\n    userEmail,\n    resetLink,\n  }: {\n    userEmail: string;\n    resetLink: string;\n  }\n) {\n  return await resend.emails.send({\n    from: EMAIL_CONFIG.from,\n    replyTo: EMAIL_CONFIG.replyTo,\n    to: userEmail,\n    subject: \"Reset Your Password\",\n    react: ResetPasswordEmail({\n      userEmail,\n      resetLink,\n    }),\n  });\n}\n\nexport async function sendWelcomeEmail(\n  resend: Resend,\n  {\n    userEmail,\n  }: {\n    userEmail: string;\n  }\n) {\n  return await resend.emails.send({\n    from: EMAIL_CONFIG.from,\n    replyTo: EMAIL_CONFIG.replyTo,\n    to: userEmail,\n    subject: \"Welcome to Marble\",\n    react: WelcomeEmail({\n      userEmail,\n      baseUrl: EMAIL_CONFIG.getAppUrl(),\n    }),\n  });\n}\n\nexport async function sendUsageLimitEmail(\n  resend: Resend,\n  {\n    userEmail,\n    userName,\n    featureName = \"Webhooks\",\n    usageAmount,\n    limitAmount,\n    workspaceId,\n  }: {\n    userEmail: string;\n    userName?: string;\n    featureName?: string;\n    usageAmount: number;\n    limitAmount: number;\n    workspaceId?: string;\n  }\n) {\n  return await resend.emails.send({\n    from: EMAIL_CONFIG.from,\n    replyTo: EMAIL_CONFIG.replyTo,\n    to: userEmail,\n    subject: `You're approaching your ${featureName} limit`,\n    react: UsageLimitEmail({\n      userName,\n      featureName,\n      usageAmount,\n      limitAmount,\n      workspaceId,\n    }),\n  });\n}\n\nexport async function sendFounderEmail(\n  resend: Resend,\n  {\n    userEmail,\n    scheduledAt,\n  }: {\n    userEmail: string;\n    scheduledAt?: Date;\n  }\n) {\n  return await resend.emails.send({\n    from: EMAIL_CONFIG.founderFrom,\n    replyTo: EMAIL_CONFIG.founderReplyTo,\n    to: userEmail,\n    subject: \"Thanks for trying Marble\",\n    react: FounderEmail({\n      userEmail,\n    }),\n    ...(scheduledAt && { scheduledAt: scheduledAt.toISOString() }),\n  });\n}\n"
  },
  {
    "path": "packages/email/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/base.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "packages/events/package.json",
    "content": "{\n  \"name\": \"@marble/events\",\n  \"version\": \"0.0.0\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/events/src/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: package public API */\n\nexport * from \"./types\";\nexport * from \"./utils/demo\";\nexport * from \"./utils/envelope\";\nexport * from \"./utils/events\";\nexport * from \"./utils/resources\";\n"
  },
  {
    "path": "packages/events/src/types.ts",
    "content": "/** Workspace event names persisted internally and exposed as dotted webhook types. */\nexport const WORKSPACE_EVENT_TYPES = [\n  \"post_created\",\n  \"post_published\",\n  \"post_unpublished\",\n  \"post_updated\",\n  \"post_deleted\",\n  \"category_created\",\n  \"category_updated\",\n  \"category_deleted\",\n  \"tag_created\",\n  \"tag_updated\",\n  \"tag_deleted\",\n  \"media_uploaded\",\n  \"media_updated\",\n  \"media_deleted\",\n  \"author_created\",\n  \"author_updated\",\n  \"author_deleted\",\n] as const;\n\n/** Origin systems that can create workspace events. */\nexport const WORKSPACE_EVENT_SOURCES = [\n  \"dashboard\",\n  \"api\",\n  \"mcp\",\n  \"workflow\",\n  \"system\",\n] as const;\n\n/** Actor classes that can be attached to a workspace event. */\nexport const WORKSPACE_EVENT_ACTOR_TYPES = [\n  \"user\",\n  \"api_key\",\n  \"mcp\",\n  \"system\",\n] as const;\n\n/** Resource classes supported by workspace events and webhook envelopes. */\nexport const WORKSPACE_EVENT_RESOURCE_TYPES = [\n  \"post\",\n  \"category\",\n  \"tag\",\n  \"media\",\n  \"author\",\n  \"workspace\",\n] as const;\n\nexport type WorkspaceEventType = (typeof WORKSPACE_EVENT_TYPES)[number];\nexport type WorkspaceEventSource = (typeof WORKSPACE_EVENT_SOURCES)[number];\nexport type WorkspaceEventActorType =\n  (typeof WORKSPACE_EVENT_ACTOR_TYPES)[number];\nexport type WorkspaceEventResourceType =\n  (typeof WORKSPACE_EVENT_RESOURCE_TYPES)[number];\n\nexport type EventPayloadValue =\n  | string\n  | number\n  | boolean\n  | null\n  | EventPayload\n  | EventPayloadArray;\n\nexport interface EventPayloadArray extends Array<EventPayloadValue> {}\n\n/** JSON-compatible object used as the event-specific webhook data payload. */\nexport interface EventPayload {\n  [key: string]: EventPayloadValue;\n}\n\n/** Minimal event shape required to build the public webhook envelope. */\nexport interface WorkspaceEventLike {\n  id: string;\n  type: WorkspaceEventType | string;\n  createdAt: Date | string;\n  workspaceId: string;\n  resourceType?: WorkspaceEventResourceType | string | null;\n  resourceId?: string | null;\n  actorType?: WorkspaceEventActorType | string | null;\n  actorId?: string | null;\n  payload?: unknown;\n}\n\nexport type Dateish = Date | string | null | undefined;\n"
  },
  {
    "path": "packages/events/src/utils/demo.ts",
    "content": "import type { EventPayload } from \"../types\";\n\n/** Returns the fixed demo payload used by webhook test sends. */\nexport function getDemoPostPublishedPayload(): EventPayload {\n  const now = new Date().toISOString();\n\n  return {\n    id: \"test_post\",\n    test: true,\n    title: \"Test post\",\n    slug: \"test-post\",\n    description: \"This is a test webhook event from Marble.\",\n    coverImage: null,\n    status: \"published\",\n    featured: false,\n    categoryId: \"test_category\",\n    primaryAuthorId: \"test_author\",\n    publishedAt: now,\n    createdAt: now,\n    updatedAt: now,\n  };\n}\n"
  },
  {
    "path": "packages/events/src/utils/envelope.ts",
    "content": "import type { WorkspaceEventLike } from \"../types\";\nimport { serializeEventType } from \"./events\";\n\n/** Builds the stable JSON body sent to normal webhook endpoints. */\nexport function buildWebhookPayload(event: WorkspaceEventLike) {\n  const createdAt =\n    event.createdAt instanceof Date\n      ? event.createdAt.toISOString()\n      : event.createdAt;\n\n  return {\n    id: event.id,\n    type: serializeEventType(event.type),\n    createdAt,\n    workspaceId: event.workspaceId,\n    resource:\n      event.resourceType && event.resourceId\n        ? {\n            type: event.resourceType,\n            id: event.resourceId,\n          }\n        : null,\n    actor: event.actorType\n      ? {\n          type: event.actorType,\n          id: event.actorId ?? null,\n        }\n      : null,\n    data:\n      event.payload &&\n      typeof event.payload === \"object\" &&\n      !Array.isArray(event.payload)\n        ? event.payload\n        : {},\n  };\n}\n"
  },
  {
    "path": "packages/events/src/utils/events.ts",
    "content": "import type { WorkspaceEventResourceType, WorkspaceEventType } from \"../types\";\n\n/** Converts persisted enum names like `post_published` into public webhook names like `post.published`. */\nexport function serializeEventType(type: string) {\n  return type.replaceAll(\"_\", \".\");\n}\n\n/** Derives the affected resource type from a workspace event type. */\nexport function getResourceTypeForEvent(\n  type: WorkspaceEventType\n): WorkspaceEventResourceType {\n  const [resourceType] = type.split(\"_\");\n\n  if (\n    resourceType === \"post\" ||\n    resourceType === \"category\" ||\n    resourceType === \"tag\" ||\n    resourceType === \"media\" ||\n    resourceType === \"author\"\n  ) {\n    return resourceType;\n  }\n\n  return \"workspace\";\n}\n"
  },
  {
    "path": "packages/events/src/utils/resources.ts",
    "content": "import type { Dateish, EventPayload } from \"../types\";\n\n/** Serializes optional dates into the string/null shape used in webhook payloads. */\nfunction serializeDate(value: Dateish) {\n  if (!value) {\n    return null;\n  }\n  return value instanceof Date ? value.toISOString() : value;\n}\n\nexport interface AuthorInput {\n  id: string;\n  name: string;\n  slug: string;\n  bio?: string | null;\n  role?: string | null;\n  image?: string | null;\n  email?: string | null;\n  socials?: Array<{ platform: string; url: string }> | null;\n  createdAt?: Dateish;\n  updatedAt?: Dateish;\n}\n\n/** Converts an author record into Marble's public author webhook payload. */\nexport function toAuthorPayload(author: AuthorInput): EventPayload {\n  return {\n    id: author.id,\n    name: author.name,\n    slug: author.slug,\n    bio: author.bio ?? null,\n    role: author.role ?? null,\n    image: author.image ?? null,\n    email: author.email ?? null,\n    socials:\n      author.socials?.map((social) => ({\n        platform: social.platform,\n        url: social.url,\n      })) ?? [],\n    createdAt: serializeDate(author.createdAt),\n    updatedAt: serializeDate(author.updatedAt),\n  };\n}\n\nexport interface CategoryInput {\n  id: string;\n  name: string;\n  slug: string;\n  description?: string | null;\n  createdAt?: Dateish;\n  updatedAt?: Dateish;\n}\n\n/** Converts a category record into Marble's public category webhook payload. */\nexport function toCategoryPayload(category: CategoryInput): EventPayload {\n  return {\n    id: category.id,\n    name: category.name,\n    slug: category.slug,\n    description: category.description ?? null,\n    createdAt: serializeDate(category.createdAt),\n    updatedAt: serializeDate(category.updatedAt),\n  };\n}\n\nexport interface TagInput {\n  id: string;\n  name: string;\n  slug: string;\n  description?: string | null;\n  createdAt?: Dateish;\n  updatedAt?: Dateish;\n}\n\n/** Converts a tag record into Marble's public tag webhook payload. */\nexport function toTagPayload(tag: TagInput): EventPayload {\n  return {\n    id: tag.id,\n    name: tag.name,\n    slug: tag.slug,\n    description: tag.description ?? null,\n    createdAt: serializeDate(tag.createdAt),\n    updatedAt: serializeDate(tag.updatedAt),\n  };\n}\n\nexport interface MediaInput {\n  id: string;\n  name: string;\n  url?: string | null;\n  alt?: string | null;\n  type: string;\n  size: number;\n  mimeType?: string | null;\n  width?: number | null;\n  height?: number | null;\n  duration?: number | null;\n  blurHash?: string | null;\n  createdAt?: Dateish;\n  updatedAt?: Dateish;\n}\n\n/** Converts a media record into Marble's public media webhook payload. */\nexport function toMediaPayload(media: MediaInput): EventPayload {\n  return {\n    id: media.id,\n    name: media.name,\n    url: media.url ?? null,\n    alt: media.alt ?? null,\n    type: media.type,\n    size: media.size,\n    mimeType: media.mimeType ?? null,\n    width: media.width ?? null,\n    height: media.height ?? null,\n    duration: media.duration ?? null,\n    blurHash: media.blurHash ?? null,\n    createdAt: serializeDate(media.createdAt),\n    updatedAt: serializeDate(media.updatedAt),\n  };\n}\n\nexport interface PostInput {\n  id: string;\n  title: string;\n  slug: string;\n  description?: string | null;\n  coverImage?: string | null;\n  status?: string | null;\n  featured?: boolean | null;\n  categoryId?: string | null;\n  primaryAuthorId?: string | null;\n  publishedAt?: Dateish;\n  createdAt?: Dateish;\n  updatedAt?: Dateish;\n}\n\n/** Converts a post record into Marble's public post webhook payload. */\nexport function toPostPayload(post: PostInput): EventPayload {\n  return {\n    id: post.id,\n    title: post.title,\n    slug: post.slug,\n    description: post.description ?? null,\n    coverImage: post.coverImage ?? null,\n    status: post.status ?? null,\n    featured: post.featured ?? null,\n    categoryId: post.categoryId ?? null,\n    primaryAuthorId: post.primaryAuthorId ?? null,\n    publishedAt: serializeDate(post.publishedAt),\n    createdAt: serializeDate(post.createdAt),\n    updatedAt: serializeDate(post.updatedAt),\n  };\n}\n\n/** Attaches changed field names to an update event payload. */\nexport function withChanges(\n  payload: EventPayload,\n  changes: string[]\n): EventPayload {\n  return {\n    ...payload,\n    changes,\n  };\n}\n"
  },
  {
    "path": "packages/events/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/base.json\",\n  \"compilerOptions\": {\n    \"declaration\": false,\n    \"declarationMap\": false\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/parser/.gitignore",
    "content": "node_modules\n# Keep environment variables out of version control\n.env\n\nscripts\n\n*.md"
  },
  {
    "path": "packages/parser/package.json",
    "content": "{\n  \"name\": \"@marble/parser\",\n  \"version\": \"0.0.0\",\n  \"exports\": {\n    \"./tiptap\": \"./src/tiptap.ts\"\n  },\n  \"scripts\": {\n    \"test\": \"vitest run\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"typescript\": \"^5.9.3\",\n    \"vitest\": \"^3.2.4\"\n  },\n  \"dependencies\": {\n    \"@tiptap/core\": \"^3.17.1\",\n    \"marked\": \"^16.4.0\",\n    \"prosemirror-model\": \"^1.25.3\"\n  }\n}\n"
  },
  {
    "path": "packages/parser/src/tiptap.ts",
    "content": "import type { JSONContent } from \"@tiptap/core\";\nimport { marked, type Token, type Tokens } from \"marked\";\n\nexport class MarkdownToTiptapParser {\n  private tokens: Token[] = [];\n\n  constructor() {\n    marked.setOptions({ gfm: true, breaks: true });\n  }\n\n  parse(markdown: string): JSONContent {\n    this.tokens = marked.lexer(markdown);\n    return { type: \"doc\", content: this.parseTokens(this.tokens) };\n  }\n\n  private parseTokens(tokens: Token[]): JSONContent[] {\n    const content: JSONContent[] = [];\n    for (const token of tokens) {\n      const node = this.parseToken(token);\n      if (node) {\n        if (Array.isArray(node)) {\n          content.push(...node);\n        } else {\n          content.push(node);\n        }\n      }\n    }\n    return content;\n  }\n\n  private parseToken(token: Token): JSONContent | JSONContent[] | null {\n    switch (token.type) {\n      case \"heading\":\n        return MarkdownToTiptapParser.parseHeading(token as Tokens.Heading);\n      case \"paragraph\":\n        return MarkdownToTiptapParser.parseParagraph(token as Tokens.Paragraph);\n      case \"blockquote\":\n        return MarkdownToTiptapParser.parseBlockquote(\n          token as Tokens.Blockquote\n        );\n      case \"list\":\n        return MarkdownToTiptapParser.parseList(token as Tokens.List);\n      case \"code\":\n        return MarkdownToTiptapParser.parseCodeBlock(token as Tokens.Code);\n      case \"hr\":\n        return { type: \"horizontalRule\" };\n      case \"table\":\n        return MarkdownToTiptapParser.parseTable(token as Tokens.Table);\n      case \"html\":\n        return MarkdownToTiptapParser.parseHTML(token as Tokens.HTML);\n      case \"space\":\n        return null;\n      default:\n        return null;\n    }\n  }\n\n  static parseHeading(token: Tokens.Heading): JSONContent {\n    return {\n      type: \"heading\",\n      attrs: { level: token.depth },\n      content: MarkdownToTiptapParser.parseInlineTokens(token.tokens || []),\n    };\n  }\n\n  static parseParagraph(token: Tokens.Paragraph): JSONContent {\n    return {\n      type: \"paragraph\",\n      content: MarkdownToTiptapParser.parseInlineTokens(token.tokens || []),\n    };\n  }\n\n  static parseBlockquote(token: Tokens.Blockquote): JSONContent {\n    const parser = new MarkdownToTiptapParser();\n    return {\n      type: \"blockquote\",\n      content: parser.parseTokens(token.tokens || []),\n    };\n  }\n\n  static parseList(token: Tokens.List): JSONContent {\n    const isTaskList = token.items.some((item) => item.task);\n\n    const type = isTaskList\n      ? \"taskList\"\n      : token.ordered\n        ? \"orderedList\"\n        : \"bulletList\";\n    const items = token.items.map((item) =>\n      isTaskList\n        ? MarkdownToTiptapParser.parseTaskListItem(item)\n        : MarkdownToTiptapParser.parseListItem(item)\n    );\n\n    const result: JSONContent = {\n      type,\n      content: items,\n    };\n\n    if (\n      !isTaskList &&\n      token.ordered &&\n      typeof token.start === \"number\" &&\n      token.start !== 1\n    ) {\n      result.attrs = { start: token.start };\n    }\n\n    return result;\n  }\n\n  static parseTaskListItem(item: Tokens.ListItem): JSONContent {\n    const base = MarkdownToTiptapParser.parseListItem(item);\n\n    return {\n      type: \"taskItem\",\n      attrs: { checked: !!item.checked },\n      content: base.content,\n    };\n  }\n\n  static parseListItem(item: Tokens.ListItem): JSONContent {\n    const parser = new MarkdownToTiptapParser();\n    let content = parser.parseTokens(item.tokens || []);\n\n    // In tight lists, marked doesn't wrap content in paragraphs\n    // If the content is empty but we have text, or if content exists without paragraph wrapping\n    // Check if we need to wrap in a paragraph\n    if (\n      content.length > 0 &&\n      content.every(\n        (node) =>\n          node.type !== \"paragraph\" &&\n          node.type !== \"codeBlock\" &&\n          node.type !== \"blockquote\"\n      )\n    ) {\n      // Content exists but isn't block-level, wrap it in a paragraph\n      content = [{ type: \"paragraph\", content }];\n    } else if (content.length === 0 && item.text) {\n      // Fallback: parse the text as markdown if tokens are empty\n      const textTokens = marked.lexer(item.text);\n      content = parser.parseTokens(textTokens);\n      if (\n        content.length === 0 ||\n        content.every((node) => node.type !== \"paragraph\")\n      ) {\n        // Still no paragraph, create one from the raw text\n        content = [\n          { type: \"paragraph\", content: [{ type: \"text\", text: item.text }] },\n        ];\n      }\n    }\n\n    return {\n      type: \"listItem\",\n      content,\n    };\n  }\n\n  static parseCodeBlock(token: Tokens.Code): JSONContent {\n    return {\n      type: \"codeBlock\",\n      attrs: { language: token.lang || null },\n      content: [{ type: \"text\", text: token.text }],\n    };\n  }\n\n  static parseTable(token: Tokens.Table): JSONContent {\n    const rows: JSONContent[] = [];\n    const alignments = token.align || [];\n\n    const headerRow: JSONContent = {\n      type: \"tableRow\",\n      content: token.header.map((cell: Tokens.TableCell, index: number) => ({\n        type: \"tableHeader\",\n        attrs: {\n          style: null,\n          colspan: 1,\n          rowspan: 1,\n          colwidth: null,\n        },\n        content: [\n          {\n            type: \"paragraph\",\n            attrs: { textAlign: alignments[index] || null },\n            content: MarkdownToTiptapParser.parseInlineTokens(\n              cell.tokens || []\n            ),\n          },\n        ],\n      })),\n    };\n    rows.push(headerRow);\n\n    for (const row of token.rows) {\n      rows.push({\n        type: \"tableRow\",\n        content: row.map((cell: Tokens.TableCell, index: number) => ({\n          type: \"tableCell\",\n          attrs: {\n            style: null,\n            colspan: 1,\n            rowspan: 1,\n            colwidth: null,\n          },\n          content: [\n            {\n              type: \"paragraph\",\n              attrs: { textAlign: alignments[index] || null },\n              content: MarkdownToTiptapParser.parseInlineTokens(\n                cell.tokens || []\n              ),\n            },\n          ],\n        })),\n      });\n    }\n\n    return { type: \"table\", content: rows };\n  }\n\n  static parseHTML(token: Tokens.HTML): JSONContent | null {\n    const text = token.text;\n    const imgMatch = text.match(\n      /<img[^>]+src=\"([^\"]+)\"[^>]*alt=\"([^\"]*)\"[^>]*>/i\n    );\n    if (imgMatch) {\n      return {\n        type: \"image\",\n        attrs: { src: imgMatch[1], alt: imgMatch[2] },\n      };\n    }\n    return { type: \"paragraph\", content: [{ type: \"text\", text }] };\n  }\n\n  static parseInlineTokens(tokens: Token[]): JSONContent[] {\n    const content: JSONContent[] = [];\n    for (const token of tokens) {\n      const nodes = MarkdownToTiptapParser.parseInlineToken(token);\n      if (nodes) {\n        if (Array.isArray(nodes)) {\n          content.push(...nodes);\n        } else {\n          content.push(nodes);\n        }\n      }\n    }\n    return content;\n  }\n\n  static parseInlineToken(token: Token): JSONContent | JSONContent[] | null {\n    switch (token.type) {\n      case \"text\":\n        return { type: \"text\", text: (token as Tokens.Text).text };\n      case \"strong\":\n        return MarkdownToTiptapParser.parseStrong(token as Tokens.Strong);\n      case \"em\":\n        return MarkdownToTiptapParser.parseEm(token as Tokens.Em);\n      case \"codespan\":\n        return MarkdownToTiptapParser.parseCodespan(token as Tokens.Codespan);\n      case \"del\":\n        return MarkdownToTiptapParser.parseDel(token as Tokens.Del);\n      case \"link\":\n        return MarkdownToTiptapParser.parseLink(token as Tokens.Link);\n      case \"image\":\n        return MarkdownToTiptapParser.parseImage(token as Tokens.Image);\n      case \"br\":\n        return { type: \"hardBreak\" };\n      default:\n        return null;\n    }\n  }\n\n  static parseStrong(token: Tokens.Strong): JSONContent[] {\n    const content = MarkdownToTiptapParser.parseInlineTokens(\n      token.tokens || []\n    );\n    return content.map((node) => ({\n      ...node,\n      marks: [...(node.marks || []), { type: \"bold\" }],\n    }));\n  }\n\n  static parseEm(token: Tokens.Em): JSONContent[] {\n    const content = MarkdownToTiptapParser.parseInlineTokens(\n      token.tokens || []\n    );\n    return content.map((node) => ({\n      ...node,\n      marks: [...(node.marks || []), { type: \"italic\" }],\n    }));\n  }\n\n  static parseCodespan(token: Tokens.Codespan): JSONContent {\n    return {\n      type: \"text\",\n      text: token.text,\n      marks: [{ type: \"code\" }],\n    };\n  }\n\n  static parseDel(token: Tokens.Del): JSONContent[] {\n    const content = MarkdownToTiptapParser.parseInlineTokens(\n      token.tokens || []\n    );\n    return content.map((node) => ({\n      ...node,\n      marks: [...(node.marks || []), { type: \"strike\" }],\n    }));\n  }\n\n  static parseLink(token: Tokens.Link): JSONContent[] {\n    const content = MarkdownToTiptapParser.parseInlineTokens(\n      token.tokens || []\n    );\n    return content.map((node) => ({\n      ...node,\n      marks: [\n        ...(node.marks || []),\n        { type: \"link\", attrs: { href: token.href, title: token.title } },\n      ],\n    }));\n  }\n\n  static parseImage(token: Tokens.Image): JSONContent {\n    return {\n      type: \"image\",\n      attrs: { src: token.href, alt: token.text, title: token.title },\n    };\n  }\n}\n\nexport function markdownToTiptap(markdown: string): JSONContent {\n  const parser = new MarkdownToTiptapParser();\n  return parser.parse(markdown);\n}\n\nexport async function markdownToHtml(markdown: string): Promise<string> {\n  marked.setOptions({ gfm: true, breaks: true });\n  return await marked(markdown);\n}\n"
  },
  {
    "path": "packages/parser/tests/tiptap-parser.test.ts",
    "content": "import type { Tokens } from \"marked\";\nimport { describe, expect, it } from \"vitest\";\nimport { MarkdownToTiptapParser, markdownToTiptap } from \"../src/tiptap\";\n\ndescribe(\"MarkdownToTiptapParser static inline parsers\", () => {\n  it(\"parseStrong adds bold mark to inner content\", () => {\n    const token: Tokens.Strong = {\n      type: \"strong\",\n      raw: \"**bold**\",\n      text: \"bold\",\n      tokens: [{ type: \"text\", raw: \"bold\", text: \"bold\" }],\n    };\n\n    const result = MarkdownToTiptapParser.parseStrong(token);\n    expect(result).toEqual([\n      { type: \"text\", text: \"bold\", marks: [{ type: \"bold\" }] },\n    ]);\n  });\n\n  it(\"parseEm adds italic mark to inner content\", () => {\n    const token: Tokens.Em = {\n      type: \"em\",\n      raw: \"*italic*\",\n      text: \"italic\",\n      tokens: [{ type: \"text\", raw: \"italic\", text: \"italic\" }],\n    };\n\n    const result = MarkdownToTiptapParser.parseEm(token);\n    expect(result).toEqual([\n      { type: \"text\", text: \"italic\", marks: [{ type: \"italic\" }] },\n    ]);\n  });\n\n  it(\"parseDel adds strike mark to inner content\", () => {\n    const token: Tokens.Del = {\n      type: \"del\",\n      raw: \"~~strike~~\",\n      text: \"strike\",\n      tokens: [{ type: \"text\", raw: \"strike\", text: \"strike\" }],\n    };\n\n    const result = MarkdownToTiptapParser.parseDel(token);\n    expect(result).toEqual([\n      { type: \"text\", text: \"strike\", marks: [{ type: \"strike\" }] },\n    ]);\n  });\n\n  it(\"parseCodespan adds code mark to text\", () => {\n    const token: Tokens.Codespan = {\n      type: \"codespan\",\n      raw: \"`code`\",\n      text: \"code\",\n    };\n\n    const result = MarkdownToTiptapParser.parseCodespan(token);\n    expect(result).toEqual({\n      type: \"text\",\n      text: \"code\",\n      marks: [{ type: \"code\" }],\n    });\n  });\n\n  it(\"parseLink adds link mark with href and title to inner content\", () => {\n    const token: Tokens.Link = {\n      type: \"link\",\n      raw: \"[click](https://example.com)\",\n      href: \"https://example.com\",\n      title: null,\n      text: \"click\",\n      tokens: [{ type: \"text\", raw: \"click\", text: \"click\" }],\n    };\n\n    const result = MarkdownToTiptapParser.parseLink(token);\n    expect(result).toEqual([\n      {\n        type: \"text\",\n        text: \"click\",\n        marks: [\n          { type: \"link\", attrs: { href: \"https://example.com\", title: null } },\n        ],\n      },\n    ]);\n  });\n\n  it(\"parseImage returns image node with attrs\", () => {\n    const token: Tokens.Image = {\n      type: \"image\",\n      raw: \"![alt](https://img)\",\n      href: \"https://img\",\n      title: \"title\",\n      text: \"alt\",\n      tokens: [{ type: \"text\", raw: \"alt\", text: \"alt\" }],\n    };\n\n    const result = MarkdownToTiptapParser.parseImage(token);\n    expect(result).toEqual({\n      type: \"image\",\n      attrs: { src: \"https://img\", alt: \"alt\", title: \"title\" },\n    });\n  });\n});\n\ndescribe(\"MarkdownToTiptapParser static block-level parsers\", () => {\n  it(\"parseHeading returns heading node with level and content\", () => {\n    const token: Tokens.Heading = {\n      type: \"heading\",\n      raw: \"## Hello\",\n      depth: 2,\n      text: \"Hello\",\n      tokens: [{ type: \"text\", raw: \"Hello\", text: \"Hello\" }],\n    };\n\n    const result = MarkdownToTiptapParser.parseHeading(token);\n    expect(result).toEqual({\n      type: \"heading\",\n      attrs: { level: 2 },\n      content: [{ type: \"text\", text: \"Hello\" }],\n    });\n  });\n\n  it(\"parseParagraph returns paragraph node with inline content\", () => {\n    const token: Tokens.Paragraph = {\n      type: \"paragraph\",\n      raw: \"Hello world\",\n      text: \"Hello world\",\n      tokens: [\n        { type: \"text\", raw: \"Hello\", text: \"Hello\" },\n        { type: \"text\", raw: \" world\", text: \" world\" },\n      ],\n    } as unknown as Tokens.Paragraph;\n\n    const result = MarkdownToTiptapParser.parseParagraph(token);\n    expect(result).toEqual({\n      type: \"paragraph\",\n      content: [\n        { type: \"text\", text: \"Hello\" },\n        { type: \"text\", text: \" world\" },\n      ],\n    });\n  });\n\n  it(\"parseBlockquote returns blockquote wrapping parsed tokens\", () => {\n    const token: Tokens.Blockquote = {\n      type: \"blockquote\",\n      raw: \"> quote\",\n      text: \"quote\",\n      tokens: [\n        {\n          type: \"paragraph\",\n          raw: \"quote\",\n          text: \"quote\",\n          tokens: [{ type: \"text\", raw: \"quote\", text: \"quote\" }],\n        } as unknown as Tokens.Paragraph,\n      ],\n    };\n\n    const result = MarkdownToTiptapParser.parseBlockquote(token);\n    expect(result).toEqual({\n      type: \"blockquote\",\n      content: [\n        { type: \"paragraph\", content: [{ type: \"text\", text: \"quote\" }] },\n      ],\n    });\n  });\n\n  it(\"parseList returns bulletList with listItem content for unordered lists\", () => {\n    const token: Tokens.List = {\n      type: \"list\",\n      raw: \"- a\\n- b\",\n      ordered: false,\n      start: \"\",\n      loose: false,\n      items: [\n        {\n          type: \"list_item\",\n          raw: \"- a\",\n          task: false,\n          checked: undefined,\n          loose: false,\n          text: \"a\",\n          tokens: [\n            {\n              type: \"paragraph\",\n              raw: \"a\",\n              text: \"a\",\n              tokens: [{ type: \"text\", raw: \"a\", text: \"a\" }],\n            } as unknown as Tokens.Paragraph,\n          ],\n        },\n        {\n          type: \"list_item\",\n          raw: \"- b\",\n          task: false,\n          checked: undefined,\n          loose: false,\n          text: \"b\",\n          tokens: [\n            {\n              type: \"paragraph\",\n              raw: \"b\",\n              text: \"b\",\n              tokens: [{ type: \"text\", raw: \"b\", text: \"b\" }],\n            } as unknown as Tokens.Paragraph,\n          ],\n        },\n      ],\n    };\n\n    const result = MarkdownToTiptapParser.parseList(token);\n    expect(result).toEqual({\n      type: \"bulletList\",\n      content: [\n        {\n          type: \"listItem\",\n          content: [\n            { type: \"paragraph\", content: [{ type: \"text\", text: \"a\" }] },\n          ],\n        },\n        {\n          type: \"listItem\",\n          content: [\n            { type: \"paragraph\", content: [{ type: \"text\", text: \"b\" }] },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parseList returns orderedList and preserves start when not 1\", () => {\n    const token: Tokens.List = {\n      type: \"list\",\n      raw: \"2. a\\n3. b\",\n      ordered: true,\n      start: 2,\n      loose: false,\n      items: [\n        {\n          type: \"list_item\",\n          raw: \"2. a\",\n          task: false,\n          checked: undefined,\n          loose: false,\n          text: \"a\",\n          tokens: [\n            {\n              type: \"paragraph\",\n              raw: \"a\",\n              text: \"a\",\n              tokens: [{ type: \"text\", raw: \"a\", text: \"a\" }],\n            } as unknown as Tokens.Paragraph,\n          ],\n        },\n      ],\n    };\n\n    const result = MarkdownToTiptapParser.parseList(token);\n    expect(result).toEqual({\n      type: \"orderedList\",\n      attrs: { start: 2 },\n      content: [\n        {\n          type: \"listItem\",\n          content: [\n            { type: \"paragraph\", content: [{ type: \"text\", text: \"a\" }] },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parseList returns orderedList without attrs when starting at 1\", () => {\n    const token: Tokens.List = {\n      type: \"list\",\n      raw: \"1. first\\n2. second\",\n      ordered: true,\n      start: 1,\n      loose: false,\n      items: [\n        {\n          type: \"list_item\",\n          raw: \"1. first\",\n          task: false,\n          checked: undefined,\n          loose: false,\n          text: \"first\",\n          tokens: [\n            {\n              type: \"paragraph\",\n              raw: \"first\",\n              text: \"first\",\n              tokens: [{ type: \"text\", raw: \"first\", text: \"first\" }],\n            } as unknown as Tokens.Paragraph,\n          ],\n        },\n        {\n          type: \"list_item\",\n          raw: \"2. second\",\n          task: false,\n          checked: undefined,\n          loose: false,\n          text: \"second\",\n          tokens: [\n            {\n              type: \"paragraph\",\n              raw: \"second\",\n              text: \"second\",\n              tokens: [{ type: \"text\", raw: \"second\", text: \"second\" }],\n            } as unknown as Tokens.Paragraph,\n          ],\n        },\n      ],\n    };\n\n    const result = MarkdownToTiptapParser.parseList(token);\n    expect(result).toEqual({\n      type: \"orderedList\",\n      content: [\n        {\n          type: \"listItem\",\n          content: [\n            { type: \"paragraph\", content: [{ type: \"text\", text: \"first\" }] },\n          ],\n        },\n        {\n          type: \"listItem\",\n          content: [\n            { type: \"paragraph\", content: [{ type: \"text\", text: \"second\" }] },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parseListItem returns listItem for non-task items\", () => {\n    const item: Tokens.ListItem = {\n      type: \"list_item\",\n      raw: \"- normal\",\n      task: false,\n      checked: undefined,\n      loose: false,\n      text: \"normal\",\n      tokens: [\n        {\n          type: \"paragraph\",\n          raw: \"normal\",\n          text: \"normal\",\n          tokens: [{ type: \"text\", raw: \"normal\", text: \"normal\" }],\n        } as unknown as Tokens.Paragraph,\n      ],\n    };\n\n    const result = MarkdownToTiptapParser.parseListItem(item);\n    expect(result).toEqual({\n      type: \"listItem\",\n      content: [\n        { type: \"paragraph\", content: [{ type: \"text\", text: \"normal\" }] },\n      ],\n    });\n  });\n\n  it(\"parseCodeBlock returns codeBlock with language and text content\", () => {\n    const token: Tokens.Code = {\n      type: \"code\",\n      raw: \"```js\\nconsole.log('x')\\n```\",\n      lang: \"js\",\n      text: \"console.log('x')\",\n    };\n\n    const result = MarkdownToTiptapParser.parseCodeBlock(token);\n    expect(result).toEqual({\n      type: \"codeBlock\",\n      attrs: { language: \"js\" },\n      content: [{ type: \"text\", text: \"console.log('x')\" }],\n    });\n  });\n\n  it(\"parseTable returns table with header and rows with proper attrs\", () => {\n    const token: Tokens.Table = {\n      type: \"table\",\n      raw: \"| H1 | H2 |\\n| --- | --- |\\n| A | B |\",\n      align: [],\n      header: [\n        {\n          type: \"tablecell\",\n          raw: \"H1\",\n          text: \"H1\",\n          tokens: [{ type: \"text\", raw: \"H1\", text: \"H1\" }],\n        } as unknown as Tokens.TableCell,\n        {\n          type: \"tablecell\",\n          raw: \"H2\",\n          text: \"H2\",\n          tokens: [{ type: \"text\", raw: \"H2\", text: \"H2\" }],\n        } as unknown as Tokens.TableCell,\n      ],\n      rows: [\n        [\n          {\n            type: \"tablecell\",\n            raw: \"A\",\n            text: \"A\",\n            tokens: [{ type: \"text\", raw: \"A\", text: \"A\" }],\n          } as unknown as Tokens.TableCell,\n          {\n            type: \"tablecell\",\n            raw: \"B\",\n            text: \"B\",\n            tokens: [{ type: \"text\", raw: \"B\", text: \"B\" }],\n          } as unknown as Tokens.TableCell,\n        ],\n      ],\n    };\n\n    const result = MarkdownToTiptapParser.parseTable(token);\n    expect(result).toEqual({\n      type: \"table\",\n      content: [\n        {\n          type: \"tableRow\",\n          content: [\n            {\n              type: \"tableHeader\",\n              attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },\n              content: [\n                {\n                  type: \"paragraph\",\n                  attrs: { textAlign: null },\n                  content: [{ type: \"text\", text: \"H1\" }],\n                },\n              ],\n            },\n            {\n              type: \"tableHeader\",\n              attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },\n              content: [\n                {\n                  type: \"paragraph\",\n                  attrs: { textAlign: null },\n                  content: [{ type: \"text\", text: \"H2\" }],\n                },\n              ],\n            },\n          ],\n        },\n        {\n          type: \"tableRow\",\n          content: [\n            {\n              type: \"tableCell\",\n              attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },\n              content: [\n                {\n                  type: \"paragraph\",\n                  attrs: { textAlign: null },\n                  content: [{ type: \"text\", text: \"A\" }],\n                },\n              ],\n            },\n            {\n              type: \"tableCell\",\n              attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },\n              content: [\n                {\n                  type: \"paragraph\",\n                  attrs: { textAlign: null },\n                  content: [{ type: \"text\", text: \"B\" }],\n                },\n              ],\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parseHTML returns image node for <img> tag with src and alt\", () => {\n    const token: Tokens.HTML = {\n      type: \"html\",\n      raw: '<img src=\"https://img\" alt=\"Alt\"/>',\n      pre: false,\n      text: '<img src=\"https://img\" alt=\"Alt\"/>',\n      block: false,\n    };\n\n    const result = MarkdownToTiptapParser.parseHTML(token);\n    expect(result).toEqual({\n      type: \"image\",\n      attrs: { src: \"https://img\", alt: \"Alt\" },\n    });\n  });\n\n  it(\"parseHTML falls back to paragraph with raw HTML text when not img\", () => {\n    const token: Tokens.HTML = {\n      type: \"html\",\n      raw: \"<div>content</div>\",\n      pre: false,\n      text: \"<div>content</div>\",\n      block: false,\n    };\n\n    const result = MarkdownToTiptapParser.parseHTML(token);\n    expect(result).toEqual({\n      type: \"paragraph\",\n      content: [{ type: \"text\", text: \"<div>content</div>\" }],\n    });\n  });\n});\n\ndescribe(\"MarkdownToTiptapParser inline helpers\", () => {\n  it(\"parseInlineToken maps text to text node\", () => {\n    const token: Tokens.Text = { type: \"text\", raw: \"a\", text: \"a\" };\n    const result = MarkdownToTiptapParser.parseInlineToken(token);\n    expect(result).toEqual({ type: \"text\", text: \"a\" });\n  });\n\n  it(\"parseInlineToken maps br to hardBreak\", () => {\n    const token: Tokens.Br = { type: \"br\", raw: \"  \\n\" } as Tokens.Br;\n    const result = MarkdownToTiptapParser.parseInlineToken(token);\n    expect(result).toEqual({ type: \"hardBreak\" });\n  });\n\n  it(\"parseInlineToken returns null for unhandled token types\", () => {\n    const token = { type: \"unknown\", raw: \"?\" } as unknown as Tokens.Generic;\n    const result = MarkdownToTiptapParser.parseInlineToken(token as never);\n    expect(result).toBeNull();\n  });\n\n  it(\"parseInlineTokens flattens arrays from strong/em/del and preserves order\", () => {\n    const tokens: Tokens.Generic[] = [\n      { type: \"text\", raw: \"a\", text: \"a\" } as Tokens.Text,\n      {\n        type: \"strong\",\n        raw: \"**b**\",\n        text: \"b\",\n        tokens: [{ type: \"text\", raw: \"b\", text: \"b\" }],\n      } as unknown as Tokens.Strong,\n      { type: \"br\", raw: \"  \\n\" } as unknown as Tokens.Br,\n    ];\n\n    const result = MarkdownToTiptapParser.parseInlineTokens(\n      tokens as unknown as Tokens.Generic[]\n    );\n    expect(result).toEqual([\n      { type: \"text\", text: \"a\" },\n      { type: \"text\", text: \"b\", marks: [{ type: \"bold\" }] },\n      { type: \"hardBreak\" },\n    ]);\n  });\n});\n\ndescribe(\"MarkdownToTiptapParser integration tests\", () => {\n  it(\"parses bullet list correctly from markdown\", () => {\n    const markdown = \"- First item\\n- Second item\\n- Third item\";\n    const result = markdownToTiptap(markdown);\n\n    expect(result).toEqual({\n      type: \"doc\",\n      content: [\n        {\n          type: \"bulletList\",\n          content: [\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"First item\" }],\n                },\n              ],\n            },\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Second item\" }],\n                },\n              ],\n            },\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Third item\" }],\n                },\n              ],\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parses ordered list starting at 1 correctly from markdown\", () => {\n    const markdown = \"1. First item\\n2. Second item\\n3. Third item\";\n    const result = markdownToTiptap(markdown);\n\n    expect(result).toEqual({\n      type: \"doc\",\n      content: [\n        {\n          type: \"orderedList\",\n          content: [\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"First item\" }],\n                },\n              ],\n            },\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Second item\" }],\n                },\n              ],\n            },\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Third item\" }],\n                },\n              ],\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parses ordered list starting at custom number correctly from markdown\", () => {\n    const markdown = \"5. Fifth item\\n6. Sixth item\\n7. Seventh item\";\n    const result = markdownToTiptap(markdown);\n\n    expect(result).toEqual({\n      type: \"doc\",\n      content: [\n        {\n          type: \"orderedList\",\n          attrs: { start: 5 },\n          content: [\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Fifth item\" }],\n                },\n              ],\n            },\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Sixth item\" }],\n                },\n              ],\n            },\n            {\n              type: \"listItem\",\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Seventh item\" }],\n                },\n              ],\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parses mixed content with lists correctly from markdown\", () => {\n    const markdown =\n      \"# Title\\n\\nSome text.\\n\\n- Bullet 1\\n- Bullet 2\\n\\n1. Ordered 1\\n2. Ordered 2\";\n    const result = markdownToTiptap(markdown);\n\n    expect(result.type).toEqual(\"doc\");\n    expect(result.content).toHaveLength(4);\n    expect(result.content?.[0]?.type).toEqual(\"heading\");\n    expect(result.content?.[1]?.type).toEqual(\"paragraph\");\n    expect(result.content?.[2]?.type).toEqual(\"bulletList\");\n    expect(result.content?.[3]?.type).toEqual(\"orderedList\");\n  });\n\n  it(\"parses task list with checked and unchecked items\", () => {\n    const markdown = \"- [x] Done\\n- [ ] Not done\";\n    const result = markdownToTiptap(markdown);\n    expect(result).toEqual({\n      type: \"doc\",\n      content: [\n        {\n          type: \"taskList\",\n          content: [\n            {\n              type: \"taskItem\",\n              attrs: { checked: true },\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Done\" }],\n                },\n              ],\n            },\n            {\n              type: \"taskItem\",\n              attrs: { checked: false },\n              content: [\n                {\n                  type: \"paragraph\",\n                  content: [{ type: \"text\", text: \"Not done\" }],\n                },\n              ],\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parses table with headers and cells correctly from markdown\", () => {\n    const markdown =\n      \"| Title 1 | Title 2 |\\n| --- | --- |\\n| Field 1 | Field 2 |\\n| Field 3 | Field 4 |\";\n    const result = markdownToTiptap(markdown);\n    expect(result).toEqual({\n      type: \"doc\",\n      content: [\n        {\n          type: \"table\",\n          content: [\n            {\n              type: \"tableRow\",\n              content: [\n                {\n                  type: \"tableHeader\",\n                  attrs: {\n                    style: null,\n                    colspan: 1,\n                    rowspan: 1,\n                    colwidth: null,\n                  },\n                  content: [\n                    {\n                      type: \"paragraph\",\n                      attrs: { textAlign: null },\n                      content: [{ type: \"text\", text: \"Title 1\" }],\n                    },\n                  ],\n                },\n                {\n                  type: \"tableHeader\",\n                  attrs: {\n                    style: null,\n                    colspan: 1,\n                    rowspan: 1,\n                    colwidth: null,\n                  },\n                  content: [\n                    {\n                      type: \"paragraph\",\n                      attrs: { textAlign: null },\n                      content: [{ type: \"text\", text: \"Title 2\" }],\n                    },\n                  ],\n                },\n              ],\n            },\n            {\n              type: \"tableRow\",\n              content: [\n                {\n                  type: \"tableCell\",\n                  attrs: {\n                    style: null,\n                    colspan: 1,\n                    rowspan: 1,\n                    colwidth: null,\n                  },\n                  content: [\n                    {\n                      type: \"paragraph\",\n                      attrs: { textAlign: null },\n                      content: [{ type: \"text\", text: \"Field 1\" }],\n                    },\n                  ],\n                },\n                {\n                  type: \"tableCell\",\n                  attrs: {\n                    style: null,\n                    colspan: 1,\n                    rowspan: 1,\n                    colwidth: null,\n                  },\n                  content: [\n                    {\n                      type: \"paragraph\",\n                      attrs: { textAlign: null },\n                      content: [{ type: \"text\", text: \"Field 2\" }],\n                    },\n                  ],\n                },\n              ],\n            },\n            {\n              type: \"tableRow\",\n              content: [\n                {\n                  type: \"tableCell\",\n                  attrs: {\n                    style: null,\n                    colspan: 1,\n                    rowspan: 1,\n                    colwidth: null,\n                  },\n                  content: [\n                    {\n                      type: \"paragraph\",\n                      attrs: { textAlign: null },\n                      content: [{ type: \"text\", text: \"Field 3\" }],\n                    },\n                  ],\n                },\n                {\n                  type: \"tableCell\",\n                  attrs: {\n                    style: null,\n                    colspan: 1,\n                    rowspan: 1,\n                    colwidth: null,\n                  },\n                  content: [\n                    {\n                      type: \"paragraph\",\n                      attrs: { textAlign: null },\n                      content: [{ type: \"text\", text: \"Field 4\" }],\n                    },\n                  ],\n                },\n              ],\n            },\n          ],\n        },\n      ],\n    });\n  });\n\n  it(\"parses table with mixed alignment (left, center, right)\", () => {\n    const markdown = `| Syntax | Description | Test Text |\n| :--- | :----: | ---: |\n| Header | Title | Here's this |\n| Paragraph | Text | And more |`;\n\n    const result = markdownToTiptap(markdown);\n\n    expect(result.type).toBe(\"doc\");\n    expect(result.content).toHaveLength(1);\n\n    const table = result.content?.[0];\n    expect(table?.type).toBe(\"table\");\n    expect(table?.content).toHaveLength(3);\n\n    const headerRow = table?.content?.[0];\n    expect(headerRow?.content?.[0]?.content?.[0]?.attrs?.textAlign).toBe(\n      \"left\"\n    );\n    expect(headerRow?.content?.[1]?.content?.[0]?.attrs?.textAlign).toBe(\n      \"center\"\n    );\n    expect(headerRow?.content?.[2]?.content?.[0]?.attrs?.textAlign).toBe(\n      \"right\"\n    );\n\n    const dataRow1 = table?.content?.[1];\n    expect(dataRow1?.content?.[0]?.content?.[0]?.attrs?.textAlign).toBe(\"left\");\n    expect(dataRow1?.content?.[0]?.content?.[0]?.content?.[0]?.text).toBe(\n      \"Header\"\n    );\n    expect(dataRow1?.content?.[1]?.content?.[0]?.attrs?.textAlign).toBe(\n      \"center\"\n    );\n    expect(dataRow1?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe(\n      \"Title\"\n    );\n    expect(dataRow1?.content?.[2]?.content?.[0]?.attrs?.textAlign).toBe(\n      \"right\"\n    );\n    expect(dataRow1?.content?.[2]?.content?.[0]?.content?.[0]?.text).toBe(\n      \"Here's this\"\n    );\n\n    const dataRow2 = table?.content?.[2];\n    expect(dataRow2?.content?.[0]?.content?.[0]?.content?.[0]?.text).toBe(\n      \"Paragraph\"\n    );\n    expect(dataRow2?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe(\n      \"Text\"\n    );\n    expect(dataRow2?.content?.[2]?.content?.[0]?.content?.[0]?.text).toBe(\n      \"And more\"\n    );\n  });\n});\n"
  },
  {
    "path": "packages/parser/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/base.json\",\n  \"compilerOptions\": {\n    \"baseUrl\": \".\"\n  },\n  \"include\": [\"**/*.ts\", \"**/*.tsx\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/tsconfig/base.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"esModuleInterop\": true,\n    \"incremental\": false,\n    \"isolatedModules\": true,\n    \"lib\": [\"es2022\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"NodeNext\",\n    \"moduleDetection\": \"force\",\n    \"moduleResolution\": \"NodeNext\",\n    \"noUncheckedIndexedAccess\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"target\": \"ES2022\"\n  }\n}\n"
  },
  {
    "path": "packages/tsconfig/nextjs.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"forceConsistentCasingInFileNames\": true,\n    \"plugins\": [{ \"name\": \"next\" }],\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Bundler\",\n    \"allowJs\": true,\n    \"jsx\": \"preserve\",\n    \"noEmit\": true\n  }\n}\n"
  },
  {
    "path": "packages/tsconfig/package.json",
    "content": "{\n  \"name\": \"@marble/tsconfig\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"license\": \"MIT\",\n  \"publishConfig\": {\n    \"access\": \"public\"\n  }\n}\n"
  },
  {
    "path": "packages/tsconfig/react-library.json",
    "content": "{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"extends\": \"./base.json\",\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/README.md",
    "content": "# UI\n\nThis package contains the UI components for the application.\n"
  },
  {
    "path": "packages/ui/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/styles/globals.css\",\n    \"baseColor\": \"zinc\",\n    \"cssVariables\": true\n  },\n  \"iconLibrary\": \"phosphor\",\n  \"aliases\": {\n    \"components\": \"@marble/ui/components\",\n    \"utils\": \"@marble/ui/lib/utils\",\n    \"ui\": \"@marble/ui/components\",\n    \"lib\": \"@marble/ui/lib\",\n    \"hooks\": \"@marble/ui/hooks\"\n  },\n  \"registries\": {\n    \"@kibo-ui\": \"https://www.kibo-ui.com/r/{name}.json\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/package.json",
    "content": "{\n  \"name\": \"@marble/ui\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"exports\": {\n    \"./globals.css\": \"./src/styles/globals.css\",\n    \"./postcss.config\": \"./postcss.config.mjs\",\n    \"./tailwind.config\": \"./tailwind.config.ts\",\n    \"./lib/*\": \"./src/lib/*.ts\",\n    \"./components/*\": \"./src/components/*.tsx\",\n    \"./components/kibo-ui/*\": \"./src/components/kibo-ui/*/index.tsx\",\n    \"./hooks/*\": \"./src/hooks/*.ts\"\n  },\n  \"scripts\": {\n    \"lint\": \"biome check .\",\n    \"format\": \"biome --write .\"\n  },\n  \"dependencies\": {\n    \"@base-ui/react\": \"^1.0.0\",\n    \"@heroicons/react\": \"^2.2.0\",\n    \"@hugeicons/core-free-icons\": \"^3.1.1\",\n    \"@hugeicons/react\": \"^1.1.4\",\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"@tanstack/react-table\": \"^8.20.5\",\n    \"@toolwind/corner-shape\": \"0.0.8-3\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"1.0.0\",\n    \"date-fns\": \"^4.1.0\",\n    \"input-otp\": \"^1.4.2\",\n    \"next-themes\": \"^0.4.4\",\n    \"react-day-picker\": \"9.9.0\",\n    \"react-image-crop\": \"^11.0.10\",\n    \"react-medium-image-zoom\": \"^5.3.0\",\n    \"recharts\": \"2.15.4\",\n    \"sonner\": \"^1.7.1\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"vaul\": \"^1.1.2\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"@tailwindcss/postcss\": \"^4.2.1\",\n    \"@types/node\": \"^22.9.0\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@types/react-image-crop\": \"^9.0.2\",\n    \"postcss\": \"^8.4.24\",\n    \"react\": \"^19.2.4\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/ui/postcss.config.mjs",
    "content": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    \"@tailwindcss/postcss\": {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "packages/ui/src/components/alert-dialog.tsx",
    "content": "\"use client\";\n\nimport { AlertDialog as AlertDialogPrimitive } from \"@base-ui/react/alert-dialog\";\nimport { Cancel01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type * as React from \"react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />;\n}\n\nfunction AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {\n  return (\n    <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />\n  );\n}\n\nfunction AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {\n  return (\n    <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />\n  );\n}\n\nfunction AlertDialogOverlay({\n  className,\n  onClick,\n  ...props\n}: AlertDialogPrimitive.Backdrop.Props) {\n  return (\n    <AlertDialogPrimitive.Backdrop\n      className={cn(\n        \"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/10 backdrop-blur-xs duration-100 data-closed:animate-out data-open:animate-in\",\n        className\n      )}\n      data-slot=\"alert-dialog-overlay\"\n      onClick={(e) => {\n        // Stop propagation to prevent clicks on the overlay from bubbling\n        // through to parent elements when rendered in a portal (this is specifically for the post card links)\n        e.stopPropagation();\n        onClick?.(e);\n      }}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogContent({\n  className,\n  size = \"default\",\n  variant = \"default\",\n  ...props\n}: AlertDialogPrimitive.Popup.Props & {\n  size?: \"default\" | \"sm\";\n  variant?: \"default\" | \"card\";\n}) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Popup\n        className={cn(\n          \"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 duration-200 data-closed:animate-out data-open:animate-in sm:max-w-lg\",\n          variant === \"default\" && \"bg-background border gap-6 rounded-xl p-6 shadow-lg\",\n          variant === \"card\" && \"gap-0 rounded-[1.5rem] bg-surface p-1 sm:p-1.5 shadow-2xl\",\n          className\n        )}\n        data-size={size}\n        data-slot=\"alert-dialog-content\"\n        {...props}\n      />\n    </AlertDialogPortal>\n  );\n}\n\nfunction AlertDialogHeader({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      data-slot=\"alert-dialog-header\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogBody({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"bg-background flex flex-col gap-4 rounded-[calc(1.5rem-4px)] p-4 shadow-xs sm:rounded-[calc(1.5rem-6px)]\", className)}\n      data-slot=\"alert-dialog-body\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogFooter({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      data-slot=\"alert-dialog-footer\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogMedia({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8\",\n        className\n      )}\n      data-slot=\"alert-dialog-media\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      className={cn(\"text-lg font-semibold\", className)}\n      data-slot=\"alert-dialog-title\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      data-slot=\"alert-dialog-description\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogAction({\n  className,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  return (\n    <Button\n      className={cn(className)}\n      data-slot=\"alert-dialog-action\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogCancel({\n  className,\n  variant = \"outline\",\n  size = \"default\",\n  ...props\n}: AlertDialogPrimitive.Close.Props &\n  Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n  return (\n    <AlertDialogPrimitive.Close\n      className={cn(buttonVariants({ variant, size }), className)}\n      data-slot=\"alert-dialog-cancel\"\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogX({\n  className,\n  icon,\n  ...props\n}: AlertDialogPrimitive.Close.Props & {\n  icon?: React.ReactNode;\n}) {\n  return (\n    <AlertDialogPrimitive.Close\n      className={cn(\n        \"rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"alert-dialog-close\"\n      {...props}\n    >\n      {icon ?? <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />}\n      <span className=\"sr-only\">Close</span>\n    </AlertDialogPrimitive.Close>\n  );\n}\n\nexport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogBody,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogMedia,\n  AlertDialogOverlay,\n  AlertDialogPortal,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n  AlertDialogX,\n};\n"
  },
  {
    "path": "packages/ui/src/components/avatar.tsx",
    "content": "\"use client\";\n\nimport { Avatar as AvatarPrimitive } from \"@base-ui/react/avatar\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Avatar({\n  className,\n  size = \"default\",\n  ...props\n}: AvatarPrimitive.Root.Props & {\n  size?: \"default\" | \"sm\" | \"lg\";\n}) {\n  return (\n    <AvatarPrimitive.Root\n      className={cn(\n        \"group/avatar relative flex size-8 shrink-0 select-none overflow-hidden rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6\",\n        className\n      )}\n      data-size={size}\n      data-slot=\"avatar\"\n      {...props}\n    />\n  );\n}\n\nfunction AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {\n  return (\n    <AvatarPrimitive.Image\n      className={cn(\n        \"aspect-square size-full rounded-full object-cover\",\n        className\n      )}\n      data-slot=\"avatar-image\"\n      {...props}\n    />\n  );\n}\n\nfunction AvatarFallback({\n  className,\n  ...props\n}: AvatarPrimitive.Fallback.Props) {\n  return (\n    <AvatarPrimitive.Fallback\n      className={cn(\n        \"flex size-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm group-data-[size=sm]/avatar:text-xs\",\n        className\n      )}\n      data-slot=\"avatar-fallback\"\n      {...props}\n    />\n  );\n}\n\nfunction AvatarBadge({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"absolute right-0 bottom-0 z-10 inline-flex select-none items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background\",\n        \"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden\",\n        \"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2\",\n        \"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2\",\n        className\n      )}\n      data-slot=\"avatar-badge\"\n      {...props}\n    />\n  );\n}\n\nfunction AvatarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background\",\n        className\n      )}\n      data-slot=\"avatar-group\"\n      {...props}\n    />\n  );\n}\n\nfunction AvatarGroupCount({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-sm ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3\",\n        className\n      )}\n      data-slot=\"avatar-group-count\"\n      {...props}\n    />\n  );\n}\n\nexport {\n  Avatar,\n  AvatarImage,\n  AvatarFallback,\n  AvatarGroup,\n  AvatarGroupCount,\n  AvatarBadge,\n};\n"
  },
  {
    "path": "packages/ui/src/components/badge.tsx",
    "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nconst badgeVariants = cva(\n  \"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-4xl border border-transparent px-2 py-0.5 font-medium text-xs transition-all transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground [a]:hover:bg-primary/80\",\n        secondary:\n          \"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80\",\n        destructive:\n          \"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20\",\n        outline:\n          \"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground\",\n        ghost:\n          \"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n        positive:\n          \"border-0 bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400 [a]:hover:bg-emerald-500/20 dark:[a]:hover:bg-emerald-500/25\",\n        negative:\n          \"border-0 bg-red-500/10 text-red-700 dark:bg-red-500/15 dark:text-red-400 [a]:hover:bg-red-500/20 dark:[a]:hover:bg-red-500/25\",\n        pending:\n          \"border-0 bg-amber-500/10 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400 [a]:hover:bg-amber-500/20 dark:[a]:hover:bg-amber-500/25\",\n        info: \"border-0 bg-blue-500/20 text-blue-800 dark:bg-blue-500/25 dark:text-blue-300 [a]:hover:bg-blue-500/30 dark:[a]:hover:bg-blue-500/35\",\n        neutral:\n          \"border-0 bg-gray-500/10 text-gray-700 dark:bg-gray-500/15 dark:text-gray-400 [a]:hover:bg-gray-500/20 dark:[a]:hover:bg-gray-500/25\",\n        paid: \"border-0 bg-blue-500/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-400 [a]:hover:bg-blue-500/20 dark:[a]:hover:bg-blue-500/25\",\n        free: \"border-0 bg-gray-500/10 text-gray-600 dark:bg-gray-500/15 dark:text-gray-400 [a]:hover:bg-gray-500/20 dark:[a]:hover:bg-gray-500/25\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n);\n\nfunction Badge({\n  className,\n  variant = \"default\",\n  render,\n  ...props\n}: useRender.ComponentProps<\"span\"> & VariantProps<typeof badgeVariants>) {\n  return useRender({\n    defaultTagName: \"span\",\n    props: mergeProps<\"span\">(\n      {\n        className: cn(badgeVariants({ className, variant })),\n      },\n      props\n    ),\n    render,\n    state: {\n      slot: \"badge\",\n      variant,\n    },\n  });\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "packages/ui/src/components/breadcrumb.tsx",
    "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport {\n  ArrowRight01Icon,\n  MoreHorizontalCircle01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Breadcrumb({ className, ...props }: React.ComponentProps<\"nav\">) {\n  return (\n    <nav\n      aria-label=\"breadcrumb\"\n      className={cn(className)}\n      data-slot=\"breadcrumb\"\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbList({ className, ...props }: React.ComponentProps<\"ol\">) {\n  return (\n    <ol\n      className={cn(\n        \"flex flex-wrap items-center gap-1.5 break-words text-muted-foreground text-sm sm:gap-2.5\",\n        className\n      )}\n      data-slot=\"breadcrumb-list\"\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      className={cn(\"inline-flex items-center gap-1.5\", className)}\n      data-slot=\"breadcrumb-item\"\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbLink({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"a\">) {\n  return useRender({\n    defaultTagName: \"a\",\n    props: mergeProps<\"a\">(\n      {\n        className: cn(\"transition-colors hover:text-foreground\", className),\n      },\n      props\n    ),\n    render,\n    state: {\n      slot: \"breadcrumb-link\",\n    },\n  });\n}\n\nfunction BreadcrumbPage({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      aria-current=\"page\"\n      aria-disabled=\"true\"\n      className={cn(\"font-normal text-foreground\", className)}\n      data-slot=\"breadcrumb-page\"\n      role=\"link\"\n      {...props}\n    />\n  );\n}\n\nfunction BreadcrumbSeparator({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      aria-hidden=\"true\"\n      className={cn(\"[&>svg]:size-3.5\", className)}\n      data-slot=\"breadcrumb-separator\"\n      role=\"presentation\"\n      {...props}\n    >\n      {children ?? <HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />}\n    </li>\n  );\n}\n\nfunction BreadcrumbEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"flex size-5 items-center justify-center [&>svg]:size-4\",\n        className\n      )}\n      data-slot=\"breadcrumb-ellipsis\"\n      role=\"presentation\"\n      {...props}\n    >\n      <HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />\n      <span className=\"sr-only\">More</span>\n    </span>\n  );\n}\n\nexport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n  BreadcrumbEllipsis,\n};\n"
  },
  {
    "path": "packages/ui/src/components/button.tsx",
    "content": "\"use client\";\n\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\ntype ButtonProps = ButtonPrimitive.Props &\n  VariantProps<typeof buttonVariants> & {\n    ref?: React.Ref<HTMLButtonElement>;\n  };\n\nconst buttonVariants = cva(\n  \"group/button inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-xl corner-squircle supports-[corner-shape:squircle]:rounded-lg font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-97 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer disabled:cursor-not-allowed\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/35\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        xs: \"h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3\",\n        sm: \"h-8 gap-1.5 rounded-lg px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-lg px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n        \"icon-xs\": \"size-6 rounded-[min(var(--radius-md),8px)] [&_svg:not([class*='size-'])]:size-3\",\n        \"icon-sm\": \"size-8 rounded-[min(var(--radius-md),10px)]\",\n        \"icon-lg\": \"size-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n);\n\nfunction Button({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  ...props\n}: ButtonProps) {\n  return (\n    <ButtonPrimitive\n      className={cn(buttonVariants({ variant, size, className }))}\n      data-slot=\"button\"\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants, type ButtonProps };\n"
  },
  {
    "path": "packages/ui/src/components/calendar.tsx",
    "content": "\"use client\";\n\nimport {\n  ArrowDown01Icon,\n  ArrowLeft01Icon,\n  ArrowRight01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport * as React from \"react\";\nimport {\n  type DayButton,\n  DayPicker,\n  getDefaultClassNames,\n} from \"react-day-picker\";\n\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  captionLayout = \"label\",\n  buttonVariant = \"ghost\",\n  formatters,\n  components,\n  ...props\n}: React.ComponentProps<typeof DayPicker> & {\n  buttonVariant?: React.ComponentProps<typeof Button>[\"variant\"];\n}) {\n  const defaultClassNames = getDefaultClassNames();\n\n  return (\n    <DayPicker\n      captionLayout={captionLayout}\n      className={cn(\n        \"group/calendar bg-background p-3 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent\",\n        String.raw`rtl:**:[.rdp-button\\_next>svg]:rotate-180`,\n        String.raw`rtl:**:[.rdp-button\\_previous>svg]:rotate-180`,\n        className\n      )}\n      classNames={{\n        root: cn(\"w-fit\", defaultClassNames.root),\n        months: cn(\n          \"relative flex flex-col gap-4 md:flex-row\",\n          defaultClassNames.months\n        ),\n        month: cn(\"flex w-full flex-col gap-4\", defaultClassNames.month),\n        nav: cn(\n          \"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1\",\n          defaultClassNames.nav\n        ),\n        button_previous: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"size-(--cell-size) select-none p-0 aria-disabled:opacity-50\",\n          defaultClassNames.button_previous\n        ),\n        button_next: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"size-(--cell-size) select-none p-0 aria-disabled:opacity-50\",\n          defaultClassNames.button_next\n        ),\n        month_caption: cn(\n          \"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)\",\n          defaultClassNames.month_caption\n        ),\n        dropdowns: cn(\n          \"flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm\",\n          defaultClassNames.dropdowns\n        ),\n        dropdown_root: cn(\n          \"cn-calendar-dropdown-root relative rounded-(--cell-radius)\",\n          defaultClassNames.dropdown_root\n        ),\n        dropdown: cn(\n          \"absolute inset-0 bg-popover opacity-0\",\n          defaultClassNames.dropdown\n        ),\n        caption_label: cn(\n          \"select-none font-medium\",\n          captionLayout === \"label\"\n            ? \"text-sm\"\n            : \"cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground\",\n          defaultClassNames.caption_label\n        ),\n        table: \"w-full border-collapse\",\n        weekdays: cn(\"flex\", defaultClassNames.weekdays),\n        weekday: cn(\n          \"flex-1 select-none rounded-(--cell-radius) font-normal text-[0.8rem] text-muted-foreground\",\n          defaultClassNames.weekday\n        ),\n        week: cn(\"mt-2 flex w-full\", defaultClassNames.week),\n        week_number_header: cn(\n          \"w-(--cell-size) select-none\",\n          defaultClassNames.week_number_header\n        ),\n        week_number: cn(\n          \"select-none text-[0.8rem] text-muted-foreground\",\n          defaultClassNames.week_number\n        ),\n        day: cn(\n          \"group/day relative aspect-square h-full w-full select-none rounded-(--cell-radius) p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)\",\n          props.showWeekNumber\n            ? \"[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)\"\n            : \"[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)\",\n          defaultClassNames.day\n        ),\n        range_start: cn(\n          \"relative isolate -z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted\",\n          defaultClassNames.range_start\n        ),\n        range_middle: cn(\"rounded-none\", defaultClassNames.range_middle),\n        range_end: cn(\n          \"relative isolate -z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted\",\n          defaultClassNames.range_end\n        ),\n        today: cn(\n          \"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none\",\n          defaultClassNames.today\n        ),\n        outside: cn(\n          \"text-muted-foreground aria-selected:text-muted-foreground\",\n          defaultClassNames.outside\n        ),\n        disabled: cn(\n          \"text-muted-foreground opacity-50\",\n          defaultClassNames.disabled\n        ),\n        hidden: cn(\"invisible\", defaultClassNames.hidden),\n        ...classNames,\n      }}\n      components={{\n        Root: ({ className, rootRef, ...props }) => {\n          return (\n            <div\n              className={cn(className)}\n              data-slot=\"calendar\"\n              ref={rootRef}\n              {...props}\n            />\n          );\n        },\n        Chevron: ({ className, orientation, ...props }) => {\n          if (orientation === \"left\") {\n            return (\n              <HugeiconsIcon\n                className={cn(\"size-4\", className)}\n                icon={ArrowLeft01Icon}\n                strokeWidth={2}\n                {...props}\n              />\n            );\n          }\n\n          if (orientation === \"right\") {\n            return (\n              <HugeiconsIcon\n                className={cn(\"size-4\", className)}\n                icon={ArrowRight01Icon}\n                strokeWidth={2}\n                {...props}\n              />\n            );\n          }\n\n          return (\n            <HugeiconsIcon\n              className={cn(\"size-4\", className)}\n              icon={ArrowDown01Icon}\n              strokeWidth={2}\n              {...props}\n            />\n          );\n        },\n        DayButton: CalendarDayButton,\n        WeekNumber: ({ children, ...props }) => {\n          return (\n            <td {...props}>\n              <div className=\"flex size-(--cell-size) items-center justify-center text-center\">\n                {children}\n              </div>\n            </td>\n          );\n        },\n        ...components,\n      }}\n      formatters={{\n        formatMonthDropdown: (date) =>\n          date.toLocaleString(\"default\", { month: \"short\" }),\n        ...formatters,\n      }}\n      showOutsideDays={showOutsideDays}\n      {...props}\n    />\n  );\n}\n\nfunction CalendarDayButton({\n  className,\n  day,\n  modifiers,\n  ...props\n}: React.ComponentProps<typeof DayButton>) {\n  const defaultClassNames = getDefaultClassNames();\n\n  const ref = React.useRef<HTMLButtonElement>(null);\n  React.useEffect(() => {\n    if (modifiers.focused) ref.current?.focus();\n  }, [modifiers.focused]);\n\n  return (\n    <Button\n      className={cn(\n        \"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 font-normal leading-none data-[range-end=true]:rounded-(--cell-radius) data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-end=true]:bg-primary data-[range-middle=true]:bg-muted data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70\",\n        defaultClassNames.day,\n        className\n      )}\n      data-day={day.date.toLocaleDateString()}\n      data-range-end={modifiers.range_end}\n      data-range-middle={modifiers.range_middle}\n      data-range-start={modifiers.range_start}\n      data-selected-single={\n        modifiers.selected &&\n        !modifiers.range_start &&\n        !modifiers.range_end &&\n        !modifiers.range_middle\n      }\n      ref={ref}\n      size=\"icon\"\n      variant=\"ghost\"\n      {...props}\n    />\n  );\n}\n\nexport { Calendar, CalendarDayButton };\n"
  },
  {
    "path": "packages/ui/src/components/card.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn(\"flex items-center px-6 pt-6\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n}\n"
  },
  {
    "path": "packages/ui/src/components/chart.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as RechartsPrimitive from \"recharts\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\n// Format: { THEME_NAME: CSS_SELECTOR }\nconst THEMES = { light: \"\", dark: \".dark\" } as const\n\nexport type ChartConfig = {\n  [k in string]: {\n    label?: React.ReactNode\n    icon?: React.ComponentType\n  } & (\n    | { color?: string; theme?: never }\n    | { color?: never; theme: Record<keyof typeof THEMES, string> }\n  )\n}\n\ntype ChartContextProps = {\n  config: ChartConfig\n}\n\nconst ChartContext = React.createContext<ChartContextProps | null>(null)\n\nfunction useChart() {\n  const context = React.useContext(ChartContext)\n\n  if (!context) {\n    throw new Error(\"useChart must be used within a <ChartContainer />\")\n  }\n\n  return context\n}\n\nfunction ChartContainer({\n  id,\n  className,\n  children,\n  config,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  config: ChartConfig\n  children: React.ComponentProps<\n    typeof RechartsPrimitive.ResponsiveContainer\n  >[\"children\"]\n}) {\n  const uniqueId = React.useId()\n  const chartId = `chart-${id || uniqueId.replace(/:/g, \"\")}`\n\n  return (\n    <ChartContext.Provider value={{ config }}>\n      <div\n        data-slot=\"chart\"\n        data-chart={chartId}\n        className={cn(\n          \"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden\",\n          className\n        )}\n        {...props}\n      >\n        <ChartStyle id={chartId} config={config} />\n        <RechartsPrimitive.ResponsiveContainer>\n          {children}\n        </RechartsPrimitive.ResponsiveContainer>\n      </div>\n    </ChartContext.Provider>\n  )\n}\n\nconst ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {\n  const colorConfig = Object.entries(config).filter(\n    ([, config]) => config.theme || config.color\n  )\n\n  if (!colorConfig.length) {\n    return null\n  }\n\n  return (\n    <style\n      dangerouslySetInnerHTML={{\n        __html: Object.entries(THEMES)\n          .map(\n            ([theme, prefix]) => `\n${prefix} [data-chart=${id}] {\n${colorConfig\n  .map(([key, itemConfig]) => {\n    const color =\n      itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||\n      itemConfig.color\n    return color ? `  --color-${key}: ${color};` : null\n  })\n  .join(\"\\n\")}\n}\n`\n          )\n          .join(\"\\n\"),\n      }}\n    />\n  )\n}\n\nconst ChartTooltip = RechartsPrimitive.Tooltip\n\nfunction ChartTooltipContent({\n  active,\n  payload,\n  className,\n  indicator = \"dot\",\n  hideLabel = false,\n  hideIndicator = false,\n  label,\n  labelFormatter,\n  labelClassName,\n  formatter,\n  color,\n  nameKey,\n  labelKey,\n}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &\n  React.ComponentProps<\"div\"> & {\n    hideLabel?: boolean\n    hideIndicator?: boolean\n    indicator?: \"line\" | \"dot\" | \"dashed\"\n    nameKey?: string\n    labelKey?: string\n  }) {\n  const { config } = useChart()\n\n  const tooltipLabel = React.useMemo(() => {\n    if (hideLabel || !payload?.length) {\n      return null\n    }\n\n    const [item] = payload\n    const key = `${labelKey || item?.dataKey || item?.name || \"value\"}`\n    const itemConfig = getPayloadConfigFromPayload(config, item, key)\n    const value =\n      !labelKey && typeof label === \"string\"\n        ? config[label as keyof typeof config]?.label || label\n        : itemConfig?.label\n\n    if (labelFormatter) {\n      return (\n        <div className={cn(\"font-medium\", labelClassName)}>\n          {labelFormatter(value, payload)}\n        </div>\n      )\n    }\n\n    if (!value) {\n      return null\n    }\n\n    return <div className={cn(\"font-medium\", labelClassName)}>{value}</div>\n  }, [\n    label,\n    labelFormatter,\n    payload,\n    hideLabel,\n    labelClassName,\n    config,\n    labelKey,\n  ])\n\n  if (!active || !payload?.length) {\n    return null\n  }\n\n  const nestLabel = payload.length === 1 && indicator !== \"dot\"\n\n  return (\n    <div\n      className={cn(\n        \"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl\",\n        className\n      )}\n    >\n      {!nestLabel ? tooltipLabel : null}\n      <div className=\"grid gap-1.5\">\n        {payload.map((item, index) => {\n          const key = `${nameKey || item.name || item.dataKey || \"value\"}`\n          const itemConfig = getPayloadConfigFromPayload(config, item, key)\n          const indicatorColor = color || item.payload.fill || item.color\n\n          return (\n            <div\n              key={item.dataKey}\n              className={cn(\n                \"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5\",\n                indicator === \"dot\" && \"items-center\"\n              )}\n            >\n              {formatter && item?.value !== undefined && item.name ? (\n                formatter(item.value, item.name, item, index, item.payload)\n              ) : (\n                <>\n                  {itemConfig?.icon ? (\n                    <itemConfig.icon />\n                  ) : (\n                    !hideIndicator && (\n                      <div\n                        className={cn(\n                          \"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)\",\n                          {\n                            \"h-2.5 w-2.5\": indicator === \"dot\",\n                            \"w-1\": indicator === \"line\",\n                            \"w-0 border-[1.5px] border-dashed bg-transparent\":\n                              indicator === \"dashed\",\n                            \"my-0.5\": nestLabel && indicator === \"dashed\",\n                          }\n                        )}\n                        style={\n                          {\n                            \"--color-bg\": indicatorColor,\n                            \"--color-border\": indicatorColor,\n                          } as React.CSSProperties\n                        }\n                      />\n                    )\n                  )}\n                  <div\n                    className={cn(\n                      \"flex flex-1 justify-between leading-none\",\n                      nestLabel ? \"items-end\" : \"items-center\"\n                    )}\n                  >\n                    <div className=\"grid gap-1.5\">\n                      {nestLabel ? tooltipLabel : null}\n                      <span className=\"text-muted-foreground\">\n                        {itemConfig?.label || item.name}\n                      </span>\n                    </div>\n                    {item.value && (\n                      <span className=\"text-foreground font-mono font-medium tabular-nums\">\n                        {item.value.toLocaleString()}\n                      </span>\n                    )}\n                  </div>\n                </>\n              )}\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\nconst ChartLegend = RechartsPrimitive.Legend\n\nfunction ChartLegendContent({\n  className,\n  hideIcon = false,\n  payload,\n  verticalAlign = \"bottom\",\n  nameKey,\n}: React.ComponentProps<\"div\"> &\n  Pick<RechartsPrimitive.LegendProps, \"payload\" | \"verticalAlign\"> & {\n    hideIcon?: boolean\n    nameKey?: string\n  }) {\n  const { config } = useChart()\n\n  if (!payload?.length) {\n    return null\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex items-center justify-center gap-4\",\n        verticalAlign === \"top\" ? \"pb-3\" : \"pt-3\",\n        className\n      )}\n    >\n      {payload.map((item) => {\n        const key = `${nameKey || item.dataKey || \"value\"}`\n        const itemConfig = getPayloadConfigFromPayload(config, item, key)\n\n        return (\n          <div\n            key={item.value}\n            className={cn(\n              \"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3\"\n            )}\n          >\n            {itemConfig?.icon && !hideIcon ? (\n              <itemConfig.icon />\n            ) : (\n              <div\n                className=\"h-2 w-2 shrink-0 rounded-[2px]\"\n                style={{\n                  backgroundColor: item.color,\n                }}\n              />\n            )}\n            {itemConfig?.label}\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n\n// Helper to extract item config from a payload.\nfunction getPayloadConfigFromPayload(\n  config: ChartConfig,\n  payload: unknown,\n  key: string\n) {\n  if (typeof payload !== \"object\" || payload === null) {\n    return undefined\n  }\n\n  const payloadPayload =\n    \"payload\" in payload &&\n    typeof payload.payload === \"object\" &&\n    payload.payload !== null\n      ? payload.payload\n      : undefined\n\n  let configLabelKey: string = key\n\n  if (\n    key in payload &&\n    typeof payload[key as keyof typeof payload] === \"string\"\n  ) {\n    configLabelKey = payload[key as keyof typeof payload] as string\n  } else if (\n    payloadPayload &&\n    key in payloadPayload &&\n    typeof payloadPayload[key as keyof typeof payloadPayload] === \"string\"\n  ) {\n    configLabelKey = payloadPayload[\n      key as keyof typeof payloadPayload\n    ] as string\n  }\n\n  return configLabelKey in config\n    ? config[configLabelKey]\n    : config[key as keyof typeof config]\n}\n\nexport {\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n  ChartLegend,\n  ChartLegendContent,\n  ChartStyle,\n}\n"
  },
  {
    "path": "packages/ui/src/components/checkbox.tsx",
    "content": "\"use client\";\n\nimport { Checkbox as CheckboxPrimitive } from \"@base-ui/react/checkbox\";\nimport { Tick02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {\n  return (\n    <CheckboxPrimitive.Root\n      className={cn(\n        \"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs outline-none transition-shadow after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 group-has-disabled/field:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:bg-input/30 dark:data-checked:bg-primary dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n        className\n      )}\n      data-slot=\"checkbox\"\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        className=\"grid place-content-center text-current transition-none [&>svg]:size-3.5\"\n        data-slot=\"checkbox-indicator\"\n      >\n        <HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  );\n}\n\nexport { Checkbox };\n"
  },
  {
    "path": "packages/ui/src/components/collapsible.tsx",
    "content": "\"use client\";\n\nimport { Collapsible as CollapsiblePrimitive } from \"@base-ui/react/collapsible\";\n\nfunction Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />;\n}\n\nfunction CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {\n  return (\n    <CollapsiblePrimitive.Trigger data-slot=\"collapsible-trigger\" {...props} />\n  );\n}\n\nfunction CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {\n  return (\n    <CollapsiblePrimitive.Panel data-slot=\"collapsible-content\" {...props} />\n  );\n}\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n"
  },
  {
    "path": "packages/ui/src/components/command.tsx",
    "content": "\"use client\";\n\nimport { SearchIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@marble/ui/components/dialog\";\n\nfunction Command({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        \"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandDialog({\n  title = \"Command Palette\",\n  description = \"Search for a command to run...\",\n  children,\n  className,\n  showCloseButton = false,\n  ...props\n}: Omit<React.ComponentProps<typeof Dialog>, \"children\"> & {\n  title?: string;\n  description?: string;\n  className?: string;\n  showCloseButton?: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent\n        className={cn(\"overflow-hidden p-0\", className)}\n        showCloseButton={showCloseButton}\n      >\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction CommandInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div\n      data-slot=\"command-input-wrapper\"\n      className=\"flex h-9 items-center gap-2 border-b px-3\"\n    >\n      <HugeiconsIcon icon={SearchIcon} className=\"size-4 shrink-0 opacity-50\" strokeWidth={2} />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          \"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50\",\n          className\n        )}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction CommandList({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.List>) {\n  return (\n    <CommandPrimitive.List\n      data-slot=\"command-list\"\n      className={cn(\n        \"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandEmpty({\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return (\n    <CommandPrimitive.Empty\n      data-slot=\"command-empty\"\n      className=\"py-6 text-center text-sm\"\n      {...props}\n    />\n  );\n}\n\nfunction CommandGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        \"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn(\"bg-border -mx-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CommandItem({\n  className,\n  ...props\n}: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n};\n"
  },
  {
    "path": "packages/ui/src/components/dialog.tsx",
    "content": "\"use client\";\n\nimport { Dialog as DialogPrimitive } from \"@base-ui/react/dialog\";\nimport { Cancel01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { Button, buttonVariants } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport type * as React from \"react\";\n\nfunction Dialog({ ...props }: DialogPrimitive.Root.Props) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />;\n}\n\nfunction DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />;\n}\n\nfunction DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />;\n}\n\nfunction DialogClose({\n  className,\n  variant = \"outline\",\n  size = \"default\",\n  ...props\n}: DialogPrimitive.Close.Props &\n  Pick<React.ComponentProps<typeof Button>, \"variant\" | \"size\">) {\n  return (\n    <DialogPrimitive.Close\n      className={cn(buttonVariants({ variant, size }), className)}\n      data-slot=\"dialog-close\"\n      {...props}\n    />\n  );\n}\n\n\nfunction DialogOverlay({\n  className,\n  ...props\n}: DialogPrimitive.Backdrop.Props) {\n  return (\n    <DialogPrimitive.Backdrop\n      className={cn(\n        \"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/10 backdrop-blur-xs duration-100 data-closed:animate-out data-open:animate-in\",\n        className\n      )}\n      data-slot=\"dialog-overlay\"\n      {...props}\n    />\n  );\n}\n\ninterface DialogContentProps extends DialogPrimitive.Popup.Props {\n  showCloseButton?: boolean;\n  variant?: \"default\" | \"card\";\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = false,\n  variant = \"default\",\n  ...props\n}: DialogContentProps) {\n  return (\n    <DialogPortal>\n      <DialogOverlay />\n      <DialogPrimitive.Popup\n        className={cn(\n          \"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 duration-200 data-closed:animate-out data-open:animate-in sm:max-w-lg\",\n          variant === \"default\" && \"bg-background border gap-6 rounded-xl p-6 shadow-lg\",\n          variant === \"card\" && \"gap-0 rounded-[1.5rem] bg-surface p-1 sm:p-1.5 shadow-2xl\",\n          className\n        )}\n        data-slot=\"dialog-content\"\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot=\"dialog-close\"\n            render={\n              <Button\n                className=\"absolute top-4 right-4\"\n                size=\"icon-sm\"\n                variant=\"ghost\"\n              />\n            }\n          >\n            <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Popup>\n    </DialogPortal>\n  );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      data-slot=\"dialog-header\"\n      {...props}\n    />\n  );\n}\n\nfunction DialogBody({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"bg-background flex flex-col gap-4 rounded-[calc(1.5rem-4px)] p-4 shadow-xs sm:rounded-[calc(1.5rem-6px)]\", className)}\n      data-slot=\"dialog-body\"\n      {...props}\n    />\n  );\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\",\n        className\n      )}\n      data-slot=\"dialog-footer\"\n      {...props}\n    />\n  );\n}\n\nfunction DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {\n  return (\n    <DialogPrimitive.Title\n      className={cn(\"font-medium text-lg leading-none\", className)}\n      data-slot=\"dialog-title\"\n      {...props}\n    />\n  );\n}\n\nfunction DialogDescription({\n  className,\n  ...props\n}: DialogPrimitive.Description.Props) {\n  return (\n    <DialogPrimitive.Description\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      data-slot=\"dialog-description\"\n      {...props}\n    />\n  );\n}\n\nfunction DialogX({\n  className,\n  icon,\n  ...props\n}: DialogPrimitive.Close.Props & {\n  icon?: React.ReactNode;\n}) {\n  return (\n    <DialogPrimitive.Close\n      className={cn(\n        \"rounded-xs cursor-pointer opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"dialog-close\"\n      {...props}\n    >\n      {icon ?? <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />}\n      <span className=\"sr-only\">Close</span>\n    </DialogPrimitive.Close>\n  );\n}\n\nexport {\n  Dialog,\n  DialogBody,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n  DialogX\n};\n\n"
  },
  {
    "path": "packages/ui/src/components/drawer.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Drawer as DrawerPrimitive } from \"vaul\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction Drawer({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Root>) {\n  return <DrawerPrimitive.Root data-slot=\"drawer\" {...props} />\n}\n\nfunction DrawerTrigger({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {\n  return <DrawerPrimitive.Trigger data-slot=\"drawer-trigger\" {...props} />\n}\n\nfunction DrawerPortal({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {\n  return <DrawerPrimitive.Portal data-slot=\"drawer-portal\" {...props} />\n}\n\nfunction DrawerClose({\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Close>) {\n  return <DrawerPrimitive.Close data-slot=\"drawer-close\" {...props} />\n}\n\nfunction DrawerOverlay({\n  className,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {\n  return (\n    <DrawerPrimitive.Overlay\n      data-slot=\"drawer-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerContent({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Content>) {\n  return (\n    <DrawerPortal data-slot=\"drawer-portal\">\n      <DrawerOverlay />\n      <DrawerPrimitive.Content\n        data-slot=\"drawer-content\"\n        className={cn(\n          \"group/drawer-content bg-background fixed z-50 flex h-auto flex-col\",\n          \"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b\",\n          \"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t\",\n          \"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm\",\n          \"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm\",\n          className\n        )}\n        {...props}\n      >\n        <div className=\"bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block\" />\n        {children}\n      </DrawerPrimitive.Content>\n    </DrawerPortal>\n  )\n}\n\nfunction DrawerHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-header\"\n      className={cn(\n        \"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"drawer-footer\"\n      className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerTitle({\n  className,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Title>) {\n  return (\n    <DrawerPrimitive.Title\n      data-slot=\"drawer-title\"\n      className={cn(\"text-foreground font-semibold\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DrawerDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Description>) {\n  return (\n    <DrawerPrimitive.Description\n      data-slot=\"drawer-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Drawer,\n  DrawerPortal,\n  DrawerOverlay,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerContent,\n  DrawerHeader,\n  DrawerFooter,\n  DrawerTitle,\n  DrawerDescription,\n}\n"
  },
  {
    "path": "packages/ui/src/components/dropdown-menu.tsx",
    "content": "\"use client\";\n\nimport { Menu as MenuPrimitive } from \"@base-ui/react/menu\";\nimport { ArrowRight01Icon, Tick02Icon, CircleIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type * as React from \"react\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {\n  return <MenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />;\n}\n\nfunction DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {\n  return <MenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />;\n}\n\nfunction DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {\n  return <MenuPrimitive.Trigger data-slot=\"dropdown-menu-trigger\" {...props} />;\n}\n\nfunction createDropdownMenuHandle() {\n  return MenuPrimitive.createHandle();\n}\n\nfunction DropdownMenuContent({\n  align = \"start\",\n  alignOffset = 0,\n  side = \"bottom\",\n  sideOffset = 4,\n  className,\n  ...props\n}: MenuPrimitive.Popup.Props &\n  Pick<\n    MenuPrimitive.Positioner.Props,\n    \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n  >) {\n  return (\n    <MenuPrimitive.Portal>\n      <MenuPrimitive.Positioner\n        align={align}\n        alignOffset={alignOffset}\n        className=\"isolate z-50 outline-none\"\n        side={side}\n        sideOffset={sideOffset}\n      >\n        <MenuPrimitive.Popup\n          className={cn(\n            \"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none duration-100 data-closed:animate-out data-open:animate-in data-closed:overflow-hidden\",\n            className\n          )}\n          data-slot=\"dropdown-menu-content\"\n          {...props}\n        />\n      </MenuPrimitive.Positioner>\n    </MenuPrimitive.Portal>\n  );\n}\n\nfunction DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {\n  return <MenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />;\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: MenuPrimitive.GroupLabel.Props & {\n  inset?: boolean;\n}) {\n  return (\n    <MenuPrimitive.GroupLabel\n      className={cn(\n        \"px-2 py-1.5 font-medium text-sm data-[inset]:pl-8\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-label\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: MenuPrimitive.Item.Props & {\n  inset?: boolean;\n  variant?: \"default\" | \"destructive\";\n}) {\n  return (\n    <MenuPrimitive.Item\n      className={cn(\n        \"group/dropdown-menu-item relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-item\"\n      data-variant={variant}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {\n  return <MenuPrimitive.SubmenuRoot data-slot=\"dropdown-menu-sub\" {...props} />;\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: MenuPrimitive.SubmenuTrigger.Props & {\n  inset?: boolean;\n}) {\n  return (\n    <MenuPrimitive.SubmenuTrigger\n      className={cn(\n        \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-[inset]:pl-8 data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-inset={inset}\n      data-slot=\"dropdown-menu-sub-trigger\"\n      {...props}\n    >\n      {children}\n      <HugeiconsIcon\n        className=\"ml-auto size-4\"\n        icon={ArrowRight01Icon}\n        strokeWidth={2}\n      />\n    </MenuPrimitive.SubmenuTrigger>\n  );\n}\n\nfunction DropdownMenuSubContent({\n  align = \"start\",\n  alignOffset = -3,\n  side = \"right\",\n  sideOffset = 0,\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuContent>) {\n  return (\n    <DropdownMenuContent\n      align={align}\n      alignOffset={alignOffset}\n      className={cn(\n        \"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-[8rem] rounded-lg border bg-popover p-1 text-popover-foreground shadow-md duration-100 data-closed:animate-out data-open:animate-in\",\n        className\n      )}\n      data-slot=\"dropdown-menu-sub-content\"\n      side={side}\n      sideOffset={sideOffset}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: MenuPrimitive.CheckboxItem.Props) {\n  return (\n    <MenuPrimitive.CheckboxItem\n      checked={checked}\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"dropdown-menu-checkbox-item\"\n      {...props}\n    >\n      <span\n        className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\"\n        data-slot=\"dropdown-menu-checkbox-item-indicator\"\n      >\n        <MenuPrimitive.CheckboxItemIndicator>\n          <HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className=\"size-4\" />\n        </MenuPrimitive.CheckboxItemIndicator>\n      </span>\n      {children}\n    </MenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {\n  return (\n    <MenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: MenuPrimitive.RadioItem.Props) {\n  return (\n    <MenuPrimitive.RadioItem\n      className={cn(\n        \"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-slot=\"dropdown-menu-radio-item\"\n      {...props}\n    >\n      <span\n        className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\"\n        data-slot=\"dropdown-menu-radio-item-indicator\"\n      >\n        <MenuPrimitive.RadioItemIndicator>\n          <HugeiconsIcon icon={CircleIcon} strokeWidth={2} className=\"size-2 fill-current\" />\n        </MenuPrimitive.RadioItemIndicator>\n      </span>\n      {children}\n    </MenuPrimitive.RadioItem>\n  );\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: MenuPrimitive.Separator.Props) {\n  return (\n    <MenuPrimitive.Separator\n      className={cn(\"-mx-1 my-1 h-px bg-border\", className)}\n      data-slot=\"dropdown-menu-separator\"\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-muted-foreground text-xs tracking-widest group-focus/dropdown-menu-item:text-accent-foreground\",\n        className\n      )}\n      data-slot=\"dropdown-menu-shortcut\"\n      {...props}\n    />\n  );\n}\n\nexport {\n  createDropdownMenuHandle,\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n};\n"
  },
  {
    "path": "packages/ui/src/components/input-otp.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { OTPInput, OTPInputContext } from \"input-otp\"\nimport { HugeiconsIcon } from \"@hugeicons/react\"\nimport { MinusSignIcon } from \"@hugeicons/core-free-icons\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction InputOTP({\n  className,\n  containerClassName,\n  ...props\n}: React.ComponentProps<typeof OTPInput> & {\n  containerClassName?: string\n}) {\n  return (\n    <OTPInput\n      data-slot=\"input-otp\"\n      containerClassName={cn(\n        \"flex items-center gap-2 has-disabled:opacity-50\",\n        containerClassName\n      )}\n      className={cn(\"disabled:cursor-not-allowed\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction InputOTPGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"input-otp-group\"\n      className={cn(\"flex items-center\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction InputOTPSlot({\n  index,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  index: number\n}) {\n  const inputOTPContext = React.useContext(OTPInputContext)\n  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}\n\n  return (\n    <div\n      data-slot=\"input-otp-slot\"\n      data-active={isActive}\n      className={cn(\n        \"relative flex h-9 w-9 items-center justify-center border border-input rounded-md text-sm shadow-xs transition-all outline-none\",\n        \"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:ring-[3px] data-[active=true]:z-10\",\n        \"aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive\",\n        \"data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40\",\n        \"dark:bg-input/30\",\n        className\n      )}\n      {...props}\n    >\n      {char}\n      {hasFakeCaret && (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div className=\"animate-caret-blink bg-foreground h-4 w-px duration-1000\" />\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction InputOTPSeparator({ ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"input-otp-separator\" role=\"separator\" {...props}>\n      <HugeiconsIcon icon={MinusSignIcon} strokeWidth={2} />\n    </div>\n  )\n}\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }"
  },
  {
    "path": "packages/ui/src/components/input.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-lg border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Input }\n"
  },
  {
    "path": "packages/ui/src/components/kibo-ui/contribution-graph/index.tsx",
    "content": "\"use client\";\n\nimport type { Day as WeekDay } from \"date-fns\";\nimport {\n  differenceInCalendarDays,\n  eachDayOfInterval,\n  formatISO,\n  getDay,\n  getMonth,\n  getYear,\n  nextDay,\n  parseISO,\n  subWeeks,\n} from \"date-fns\";\nimport {\n  type CSSProperties,\n  createContext,\n  Fragment,\n  type HTMLAttributes,\n  type ReactNode,\n  useContext,\n  useMemo,\n} from \"react\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nexport type Activity = {\n  date: string;\n  count: number;\n  level: number;\n};\n\ntype Week = Array<Activity | undefined>;\n\nexport type Labels = {\n  months?: string[];\n  weekdays?: string[];\n  totalCount?: string;\n  legend?: {\n    less?: string;\n    more?: string;\n  };\n};\n\ntype MonthLabel = {\n  weekIndex: number;\n  label: string;\n};\n\nconst DEFAULT_MONTH_LABELS = [\n  \"Jan\",\n  \"Feb\",\n  \"Mar\",\n  \"Apr\",\n  \"May\",\n  \"Jun\",\n  \"Jul\",\n  \"Aug\",\n  \"Sep\",\n  \"Oct\",\n  \"Nov\",\n  \"Dec\",\n];\n\nconst DEFAULT_LABELS: Labels = {\n  months: DEFAULT_MONTH_LABELS,\n  weekdays: [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"],\n  totalCount: \"{{count}} activities in {{year}}\",\n  legend: {\n    less: \"Less\",\n    more: \"More\",\n  },\n};\n\ntype ContributionGraphContextType = {\n  data: Activity[];\n  weeks: Week[];\n  blockMargin: number;\n  blockRadius: number;\n  blockSize: number;\n  fontSize: number;\n  labels: Labels;\n  labelHeight: number;\n  maxLevel: number;\n  totalCount: number;\n  weekStart: WeekDay;\n  year: number;\n  width: number;\n  height: number;\n};\n\nconst ContributionGraphContext =\n  createContext<ContributionGraphContextType | null>(null);\n\nconst useContributionGraph = () => {\n  const context = useContext(ContributionGraphContext);\n\n  if (!context) {\n    throw new Error(\n      \"ContributionGraph components must be used within a ContributionGraph\"\n    );\n  }\n\n  return context;\n};\n\nconst fillHoles = (activities: Activity[]): Activity[] => {\n  if (activities.length === 0) {\n    return [];\n  }\n\n  // Sort activities by date to ensure correct date range\n  const sortedActivities = [...activities].sort((a, b) =>\n    a.date.localeCompare(b.date)\n  );\n\n  const calendar = new Map<string, Activity>(\n    activities.map((a) => [a.date, a])\n  );\n\n  const firstActivity = sortedActivities[0] as Activity;\n  const lastActivity = sortedActivities.at(-1);\n\n  if (!lastActivity) {\n    return [];\n  }\n\n  return eachDayOfInterval({\n    start: parseISO(firstActivity.date),\n    end: parseISO(lastActivity.date),\n  }).map((day) => {\n    const date = formatISO(day, { representation: \"date\" });\n\n    if (calendar.has(date)) {\n      return calendar.get(date) as Activity;\n    }\n\n    return {\n      date,\n      count: 0,\n      level: 0,\n    };\n  });\n};\n\nconst groupByWeeks = (\n  activities: Activity[],\n  weekStart: WeekDay = 0\n): Week[] => {\n  if (activities.length === 0) {\n    return [];\n  }\n\n  const normalizedActivities = fillHoles(activities);\n  const firstActivity = normalizedActivities[0] as Activity;\n  const firstDate = parseISO(firstActivity.date);\n  const firstCalendarDate =\n    getDay(firstDate) === weekStart\n      ? firstDate\n      : subWeeks(nextDay(firstDate, weekStart), 1);\n\n  const paddedActivities = [\n    ...(new Array(differenceInCalendarDays(firstDate, firstCalendarDate)).fill(\n      undefined\n    ) as Activity[]),\n    ...normalizedActivities,\n  ];\n\n  const numberOfWeeks = Math.ceil(paddedActivities.length / 7);\n\n  return new Array(numberOfWeeks)\n    .fill(undefined)\n    .map((_, weekIndex) =>\n      paddedActivities.slice(weekIndex * 7, weekIndex * 7 + 7)\n    );\n};\n\nconst getMonthLabels = (\n  weeks: Week[],\n  monthNames: string[] = DEFAULT_MONTH_LABELS\n): MonthLabel[] => {\n  return weeks\n    .reduce<MonthLabel[]>((labels, week, weekIndex) => {\n      const firstActivity = week.find((activity) => activity !== undefined);\n\n      if (!firstActivity) {\n        throw new Error(\n          `Unexpected error: Week ${weekIndex + 1} is empty: [${week}].`\n        );\n      }\n\n      const month = monthNames[getMonth(parseISO(firstActivity.date))];\n\n      if (!month) {\n        const monthName = new Date(firstActivity.date).toLocaleString(\"en-US\", {\n          month: \"short\",\n        });\n        throw new Error(\n          `Unexpected error: undefined month label for ${monthName}.`\n        );\n      }\n\n      const prevLabel = labels.at(-1);\n\n      if (weekIndex === 0 || !prevLabel || prevLabel.label !== month) {\n        return labels.concat({ weekIndex, label: month });\n      }\n\n      return labels;\n    }, [])\n    .filter(({ weekIndex }, index, labels) => {\n      const minWeeks = 3;\n\n      if (index === 0) {\n        return labels[1] && labels[1].weekIndex - weekIndex >= minWeeks;\n      }\n\n      if (index === labels.length - 1) {\n        return weeks.slice(weekIndex).length >= minWeeks;\n      }\n\n      return true;\n    });\n};\n\nexport type ContributionGraphProps = HTMLAttributes<HTMLDivElement> & {\n  data: Activity[];\n  blockMargin?: number;\n  blockRadius?: number;\n  blockSize?: number;\n  fontSize?: number;\n  labels?: Labels;\n  maxLevel?: number;\n  style?: CSSProperties;\n  totalCount?: number;\n  weekStart?: WeekDay;\n  children: ReactNode;\n  className?: string;\n};\n\nexport const ContributionGraph = ({\n  data,\n  blockMargin = 4,\n  blockRadius = 2,\n  blockSize = 12,\n  fontSize = 14,\n  labels: labelsProp = undefined,\n  maxLevel: maxLevelProp = 4,\n  style = {},\n  totalCount: totalCountProp = undefined,\n  weekStart = 0,\n  className,\n  ...props\n}: ContributionGraphProps) => {\n  const maxLevel = Math.max(1, maxLevelProp);\n  const weeks = useMemo(() => groupByWeeks(data, weekStart), [data, weekStart]);\n  const LABEL_MARGIN = 8;\n\n  const labels = { ...DEFAULT_LABELS, ...labelsProp };\n  const labelHeight = fontSize + LABEL_MARGIN;\n\n  const year =\n    data && data.length > 0 && data[0] && data[0].date\n      ? getYear(parseISO(data[0].date))\n      : new Date().getFullYear();\n\n  const totalCount =\n    typeof totalCountProp === \"number\"\n      ? totalCountProp\n      : data.reduce((sum, activity) => sum + activity.count, 0);\n\n  const width = weeks.length * (blockSize + blockMargin) - blockMargin;\n  const height = labelHeight + (blockSize + blockMargin) * 7 - blockMargin;\n\n  if (data.length === 0) {\n    return null;\n  }\n\n  return (\n    <ContributionGraphContext.Provider\n      value={{\n        data,\n        weeks,\n        blockMargin,\n        blockRadius,\n        blockSize,\n        fontSize,\n        labels,\n        labelHeight,\n        maxLevel,\n        totalCount,\n        weekStart,\n        year,\n        width,\n        height,\n      }}\n    >\n      <div\n        className={cn(\"flex w-max max-w-full flex-col gap-2\", className)}\n        style={{ fontSize, ...style }}\n        {...props}\n      />\n    </ContributionGraphContext.Provider>\n  );\n};\n\nexport type ContributionGraphBlockProps = HTMLAttributes<SVGRectElement> & {\n  activity: Activity;\n  dayIndex: number;\n  weekIndex: number;\n};\n\nexport const ContributionGraphBlock = ({\n  activity,\n  dayIndex,\n  weekIndex,\n  className,\n  ...props\n}: ContributionGraphBlockProps) => {\n  const { blockSize, blockMargin, blockRadius, labelHeight, maxLevel } =\n    useContributionGraph();\n\n  if (activity.level < 0 || activity.level > maxLevel) {\n    throw new RangeError(\n      `Provided activity level ${activity.level} for ${activity.date} is out of range. It must be between 0 and ${maxLevel}.`\n    );\n  }\n\n  return (\n    <rect\n      className={cn(\n        'data-[level=\"0\"]:fill-muted',\n        'data-[level=\"1\"]:fill-muted-foreground/20',\n        'data-[level=\"2\"]:fill-muted-foreground/40',\n        'data-[level=\"3\"]:fill-muted-foreground/60',\n        'data-[level=\"4\"]:fill-muted-foreground/80',\n        className\n      )}\n      data-count={activity.count}\n      data-date={activity.date}\n      data-level={activity.level}\n      height={blockSize}\n      rx={blockRadius}\n      ry={blockRadius}\n      width={blockSize}\n      x={(blockSize + blockMargin) * weekIndex}\n      y={labelHeight + (blockSize + blockMargin) * dayIndex}\n      {...props}\n    />\n  );\n};\n\nexport type ContributionGraphCalendarProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  hideMonthLabels?: boolean;\n  className?: string;\n  children: (props: {\n    activity: Activity;\n    dayIndex: number;\n    weekIndex: number;\n  }) => ReactNode;\n};\n\nexport const ContributionGraphCalendar = ({\n  hideMonthLabels = false,\n  className,\n  children,\n  ...props\n}: ContributionGraphCalendarProps) => {\n  const { weeks, width, height, blockSize, blockMargin, labels } =\n    useContributionGraph();\n\n  const monthLabels = useMemo(\n    () => getMonthLabels(weeks, labels.months),\n    [weeks, labels.months]\n  );\n\n  return (\n    <div\n      className={cn(\"max-w-full overflow-x-auto overflow-y-hidden\", className)}\n      {...props}\n    >\n      <svg\n        className=\"block overflow-visible\"\n        height={height}\n        viewBox={`0 0 ${width} ${height}`}\n        width={width}\n      >\n        <title>Contribution Graph</title>\n        {!hideMonthLabels && (\n          <g className=\"fill-current\">\n            {monthLabels.map(({ label, weekIndex }) => (\n              <text\n                dominantBaseline=\"hanging\"\n                key={weekIndex}\n                x={(blockSize + blockMargin) * weekIndex}\n              >\n                {label}\n              </text>\n            ))}\n          </g>\n        )}\n        {weeks.map((week, weekIndex) =>\n          week.map((activity, dayIndex) => {\n            if (!activity) {\n              return null;\n            }\n\n            return (\n              <Fragment key={`${weekIndex}-${dayIndex}`}>\n                {children({ activity, dayIndex, weekIndex })}\n              </Fragment>\n            );\n          })\n        )}\n      </svg>\n    </div>\n  );\n};\n\nexport type ContributionGraphFooterProps = HTMLAttributes<HTMLDivElement>;\n\nexport const ContributionGraphFooter = ({\n  className,\n  ...props\n}: ContributionGraphFooterProps) => (\n  <div\n    className={cn(\n      \"flex flex-wrap gap-1 whitespace-nowrap sm:gap-x-4\",\n      className\n    )}\n    {...props}\n  />\n);\n\nexport type ContributionGraphTotalCountProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  children?: (props: { totalCount: number; year: number }) => ReactNode;\n};\n\nexport const ContributionGraphTotalCount = ({\n  className,\n  children,\n  ...props\n}: ContributionGraphTotalCountProps) => {\n  const { totalCount, year, labels } = useContributionGraph();\n\n  if (children) {\n    return <>{children({ totalCount, year })}</>;\n  }\n\n  return (\n    <div className={cn(\"text-muted-foreground\", className)} {...props}>\n      {labels.totalCount\n        ? labels.totalCount\n            .replace(\"{{count}}\", String(totalCount))\n            .replace(\"{{year}}\", String(year))\n        : `${totalCount} activities in ${year}`}\n    </div>\n  );\n};\n\nexport type ContributionGraphLegendProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  children?: (props: { level: number }) => ReactNode;\n};\n\nexport const ContributionGraphLegend = ({\n  className,\n  children,\n  ...props\n}: ContributionGraphLegendProps) => {\n  const { labels, maxLevel, blockSize, blockRadius } = useContributionGraph();\n\n  return (\n    <div\n      className={cn(\"ml-auto flex items-center gap-[3px]\", className)}\n      {...props}\n    >\n      <span className=\"mr-1 text-muted-foreground\">\n        {labels.legend?.less || \"Less\"}\n      </span>\n      {new Array(maxLevel + 1).fill(undefined).map((_, level) =>\n        children ? (\n          <Fragment key={level}>{children({ level })}</Fragment>\n        ) : (\n          <svg height={blockSize} key={level} width={blockSize}>\n            <title>{`${level} contributions`}</title>\n            <rect\n              className={cn(\n                \"stroke-[1px] stroke-border\",\n                'data-[level=\"0\"]:fill-muted',\n                'data-[level=\"1\"]:fill-muted-foreground/20',\n                'data-[level=\"2\"]:fill-muted-foreground/40',\n                'data-[level=\"3\"]:fill-muted-foreground/60',\n                'data-[level=\"4\"]:fill-muted-foreground/80'\n              )}\n              data-level={level}\n              height={blockSize}\n              rx={blockRadius}\n              ry={blockRadius}\n              width={blockSize}\n            />\n          </svg>\n        )\n      )}\n      <span className=\"ml-1 text-muted-foreground\">\n        {labels.legend?.more || \"More\"}\n      </span>\n    </div>\n  );\n};\n"
  },
  {
    "path": "packages/ui/src/components/kibo-ui/image-crop/index.tsx",
    "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { CropIcon, RotateLeft01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  type RefObject,\n  type SyntheticEvent,\n  createContext,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport ReactCrop, {\n  type PercentCrop,\n  type PixelCrop,\n  type ReactCropProps,\n  centerCrop,\n  makeAspectCrop,\n} from \"react-image-crop\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nimport \"react-image-crop/dist/ReactCrop.css\";\n\nconst centerAspectCrop = (\n  mediaWidth: number,\n  mediaHeight: number,\n  aspect: number | undefined\n): PercentCrop =>\n  centerCrop(\n    aspect\n      ? makeAspectCrop(\n          {\n            unit: \"%\",\n            width: 90,\n          },\n          aspect,\n          mediaWidth,\n          mediaHeight\n        )\n      : { x: 0, y: 0, width: 90, height: 90, unit: \"%\" },\n    mediaWidth,\n    mediaHeight\n  );\n\nconst getCroppedPngImage = async (\n  imageSrc: HTMLImageElement,\n  scaleFactor: number,\n  pixelCrop: PixelCrop,\n  maxImageSize: number\n): Promise<string> => {\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n\n  if (!ctx) {\n    throw new Error(\"Context is null, this should never happen.\");\n  }\n\n  const scaleX = imageSrc.naturalWidth / imageSrc.width;\n  const scaleY = imageSrc.naturalHeight / imageSrc.height;\n\n  ctx.imageSmoothingEnabled = false;\n  canvas.width = pixelCrop.width;\n  canvas.height = pixelCrop.height;\n\n  ctx.drawImage(\n    imageSrc,\n    pixelCrop.x * scaleX,\n    pixelCrop.y * scaleY,\n    pixelCrop.width * scaleX,\n    pixelCrop.height * scaleY,\n    0,\n    0,\n    canvas.width,\n    canvas.height\n  );\n\n  const croppedImageUrl = canvas.toDataURL(\"image/png\");\n  const response = await fetch(croppedImageUrl);\n  const blob = await response.blob();\n\n  if (blob.size > maxImageSize) {\n    return await getCroppedPngImage(\n      imageSrc,\n      scaleFactor * 0.9,\n      pixelCrop,\n      maxImageSize\n    );\n  }\n\n  return croppedImageUrl;\n};\n\ntype ImageCropContextType = {\n  file: File;\n  maxImageSize: number;\n  imgSrc: string;\n  crop: PercentCrop | undefined;\n  completedCrop: PixelCrop | null;\n  imgRef: RefObject<HTMLImageElement | null>;\n  onCrop?: (croppedImage: string) => void;\n  reactCropProps: Omit<ReactCropProps, \"onChange\" | \"onComplete\" | \"children\">;\n  handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void;\n  handleComplete: (\n    pixelCrop: PixelCrop,\n    percentCrop: PercentCrop\n  ) => Promise<void>;\n  onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void;\n  applyCrop: () => Promise<void>;\n  resetCrop: () => void;\n};\n\nconst ImageCropContext = createContext<ImageCropContextType | null>(null);\n\nconst useImageCrop = () => {\n  const context = useContext(ImageCropContext);\n  if (!context) {\n    throw new Error(\"ImageCrop components must be used within ImageCrop\");\n  }\n  return context;\n};\n\nexport type ImageCropProps = {\n  file: File;\n  maxImageSize?: number;\n  onCrop?: (croppedImage: string) => void;\n  children: ReactNode;\n  onChange?: ReactCropProps[\"onChange\"];\n  onComplete?: ReactCropProps[\"onComplete\"];\n} & Omit<ReactCropProps, \"onChange\" | \"onComplete\" | \"children\">;\n\nexport const ImageCrop = ({\n  file,\n  maxImageSize = 1024 * 1024 * 5,\n  onCrop,\n  children,\n  onChange,\n  onComplete,\n  ...reactCropProps\n}: ImageCropProps) => {\n  const imgRef = useRef<HTMLImageElement | null>(null);\n  const [imgSrc, setImgSrc] = useState<string>(\"\");\n  const [crop, setCrop] = useState<PercentCrop>();\n  const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);\n  const [initialCrop, setInitialCrop] = useState<PercentCrop>();\n\n  useEffect(() => {\n    const reader = new FileReader();\n    reader.addEventListener(\"load\", () =>\n      setImgSrc(reader.result?.toString() || \"\")\n    );\n    reader.readAsDataURL(file);\n  }, [file]);\n\n  const onImageLoad = useCallback(\n    (e: SyntheticEvent<HTMLImageElement>) => {\n      const { width, height } = e.currentTarget;\n      const newCrop = centerAspectCrop(width, height, reactCropProps.aspect);\n      setCrop(newCrop);\n      setInitialCrop(newCrop);\n    },\n    [reactCropProps.aspect]\n  );\n\n  const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {\n    setCrop(percentCrop);\n    onChange?.(pixelCrop, percentCrop);\n  };\n\n  // biome-ignore lint/suspicious/useAwait: \"onComplete is async\"\n  const handleComplete = async (\n    pixelCrop: PixelCrop,\n    percentCrop: PercentCrop\n  ) => {\n    setCompletedCrop(pixelCrop);\n    onComplete?.(pixelCrop, percentCrop);\n  };\n\n  const applyCrop = async () => {\n    if (!(imgRef.current && completedCrop)) {\n      return;\n    }\n\n    const croppedImage = await getCroppedPngImage(\n      imgRef.current,\n      1,\n      completedCrop,\n      maxImageSize\n    );\n\n    onCrop?.(croppedImage);\n  };\n\n  const resetCrop = () => {\n    if (initialCrop) {\n      setCrop(initialCrop);\n      setCompletedCrop(null);\n    }\n  };\n\n  const contextValue: ImageCropContextType = {\n    file,\n    maxImageSize,\n    imgSrc,\n    crop,\n    completedCrop,\n    imgRef,\n    onCrop,\n    reactCropProps,\n    handleChange,\n    handleComplete,\n    onImageLoad,\n    applyCrop,\n    resetCrop,\n  };\n\n  return (\n    <ImageCropContext.Provider value={contextValue}>\n      {children}\n    </ImageCropContext.Provider>\n  );\n};\n\nexport type ImageCropContentProps = {\n  style?: CSSProperties;\n  className?: string;\n};\n\nexport const ImageCropContent = ({\n  style,\n  className,\n}: ImageCropContentProps) => {\n  const {\n    imgSrc,\n    crop,\n    handleChange,\n    handleComplete,\n    onImageLoad,\n    imgRef,\n    reactCropProps,\n  } = useImageCrop();\n\n  const shadcnStyle = {\n    \"--rc-border-color\": \"var(--color-border)\",\n    \"--rc-focus-color\": \"var(--color-primary)\",\n  } as CSSProperties;\n\n  return (\n    <ReactCrop\n      className={cn(\"max-h-[277px] max-w-full\", className)}\n      crop={crop}\n      onChange={handleChange}\n      onComplete={handleComplete}\n      style={{ ...shadcnStyle, ...style }}\n      {...reactCropProps}\n    >\n      {imgSrc && (\n        <img\n          alt=\"crop\"\n          className=\"size-full\"\n          onLoad={onImageLoad}\n          ref={imgRef}\n          src={imgSrc}\n        />\n      )}\n    </ReactCrop>\n  );\n};\n\nexport type ImageCropApplyProps = useRender.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n  children?: ReactNode;\n};\n\nexport const ImageCropApply = ({\n  children,\n  render,\n  asChild,\n  ...props\n}: ImageCropApplyProps) => {\n  const { applyCrop } = useImageCrop();\n\n  const handleClick = async () => {\n    await applyCrop();\n  };\n\n  // Convert asChild to render for backward compatibility\n  const renderProp =\n    asChild && isValidElement(children) ? children : render;\n\n  const mergedProps = mergeProps<\"button\">(\n    {\n      onClick: handleClick,\n    },\n    props\n  );\n\n  const rendered = useRender({\n    defaultTagName: \"button\",\n    props: mergedProps,\n    render: renderProp,\n    state: {\n      slot: \"image-crop-apply\",\n    },\n  });\n\n  if (renderProp) {\n    return rendered;\n  }\n\n  return (\n    <Button onClick={handleClick} size=\"icon\" variant=\"ghost\" {...props}>\n      {children ?? <HugeiconsIcon icon={CropIcon} strokeWidth={2} />}\n    </Button>\n  );\n};\n\nexport type ImageCropResetProps = useRender.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n  children?: ReactNode;\n};\n\nexport const ImageCropReset = ({\n  children,\n  render,\n  asChild,\n  ...props\n}: ImageCropResetProps) => {\n  const { resetCrop } = useImageCrop();\n\n  const handleClick = () => {\n    resetCrop();\n  };\n\n  // Convert asChild to render for backward compatibility\n  const renderProp =\n    asChild && isValidElement(children) ? children : render;\n\n  const mergedProps = mergeProps<\"button\">(\n    {\n      onClick: handleClick,\n    },\n    props\n  );\n\n  const rendered = useRender({\n    defaultTagName: \"button\",\n    props: mergedProps,\n    render: renderProp,\n    state: {\n      slot: \"image-crop-reset\",\n    },\n  });\n\n  if (renderProp) {\n    return rendered;\n  }\n\n  return (\n    <Button onClick={handleClick} size=\"icon\" variant=\"ghost\" {...props}>\n      {children ?? <HugeiconsIcon icon={RotateLeft01Icon} strokeWidth={2} />}\n    </Button>\n  );\n};\n\n// Keep the original Cropper component for backward compatibility\nexport type CropperProps = Omit<ReactCropProps, \"onChange\"> & {\n  file: File;\n  maxImageSize?: number;\n  onCrop?: (croppedImage: string) => void;\n  onChange?: ReactCropProps[\"onChange\"];\n};\n\nexport const Cropper = ({\n  onChange,\n  onComplete,\n  onCrop,\n  style,\n  className,\n  file,\n  maxImageSize,\n  ...props\n}: CropperProps) => (\n  <ImageCrop\n    file={file}\n    maxImageSize={maxImageSize}\n    onChange={onChange}\n    onComplete={onComplete}\n    onCrop={onCrop}\n    {...props}\n  >\n    <ImageCropContent className={className} style={style} />\n  </ImageCrop>\n);\n"
  },
  {
    "path": "packages/ui/src/components/label.tsx",
    "content": "\"use client\";\n\nimport type * as React from \"react\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Label({ className, ...props }: React.ComponentProps<\"label\">) {\n  return (\n    <label\n      className={cn(\n        \"flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50\",\n        className\n      )}\n      data-slot=\"label\"\n      {...props}\n    />\n  );\n}\n\nexport { Label };\n"
  },
  {
    "path": "packages/ui/src/components/pagination.tsx",
    "content": "\"use client\";\n\nimport {\n  ArrowLeft01Icon,\n  ArrowRight01Icon,\n  MoreHorizontalCircle01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type * as React from \"react\";\n\nimport { Button } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Pagination({ className, ...props }: React.ComponentProps<\"nav\">) {\n  return (\n    <nav\n      aria-label=\"pagination\"\n      className={cn(\"mx-auto flex w-full justify-center\", className)}\n      data-slot=\"pagination\"\n      {...props}\n    />\n  );\n}\n\nfunction PaginationContent({\n  className,\n  ...props\n}: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      className={cn(\"flex items-center gap-1\", className)}\n      data-slot=\"pagination-content\"\n      {...props}\n    />\n  );\n}\n\nfunction PaginationItem({ ...props }: React.ComponentProps<\"li\">) {\n  return <li data-slot=\"pagination-item\" {...props} />;\n}\n\ntype PaginationLinkProps = {\n  isActive?: boolean;\n} & Pick<React.ComponentProps<typeof Button>, \"size\"> &\n  React.ComponentProps<\"a\">;\n\nfunction PaginationLink({\n  className,\n  isActive,\n  size = \"icon\",\n  ...props\n}: PaginationLinkProps) {\n  return (\n    <Button\n      className={cn(className)}\n      render={\n        <a\n          aria-current={isActive ? \"page\" : undefined}\n          data-active={isActive}\n          data-slot=\"pagination-link\"\n          {...props}\n        />\n      }\n      size={size}\n      variant={isActive ? \"outline\" : \"ghost\"}\n    />\n  );\n}\n\nfunction PaginationPrevious({\n  className,\n  size: _size,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to previous page\"\n      className={cn(\"pl-2!\", className)}\n      size=\"default\"\n      {...props}\n    >\n      <HugeiconsIcon\n        data-icon=\"inline-start\"\n        icon={ArrowLeft01Icon}\n        strokeWidth={2}\n      />\n      <span className=\"hidden sm:block\">Previous</span>\n    </PaginationLink>\n  );\n}\n\nfunction PaginationNext({\n  className,\n  size: _size,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) {\n  return (\n    <PaginationLink\n      aria-label=\"Go to next page\"\n      className={cn(\"pr-2!\", className)}\n      size=\"default\"\n      {...props}\n    >\n      <span className=\"hidden sm:block\">Next</span>\n      <HugeiconsIcon\n        data-icon=\"inline-end\"\n        icon={ArrowRight01Icon}\n        strokeWidth={2}\n      />\n    </PaginationLink>\n  );\n}\n\nfunction PaginationEllipsis({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"flex size-9 items-center justify-center [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      data-slot=\"pagination-ellipsis\"\n      {...props}\n    >\n      <HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />\n      <span className=\"sr-only\">More pages</span>\n    </span>\n  );\n}\n\nexport {\n  Pagination,\n  PaginationContent,\n  PaginationEllipsis,\n  PaginationItem,\n  PaginationLink,\n  PaginationNext,\n  PaginationPrevious,\n};\n"
  },
  {
    "path": "packages/ui/src/components/popover.tsx",
    "content": "\"use client\";\n\nimport { Popover as PopoverPrimitive } from \"@base-ui/react/popover\";\nimport type * as React from \"react\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Popover({ ...props }: PopoverPrimitive.Root.Props) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />;\n}\n\nfunction PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />;\n}\n\n\nfunction PopoverContent({\n  className,\n  align = \"center\",\n  alignOffset = 0,\n  anchor,\n  side = \"bottom\",\n  sideOffset = 4,\n  ...props\n}: PopoverPrimitive.Popup.Props &\n  Pick<\n    PopoverPrimitive.Positioner.Props,\n    \"align\" | \"alignOffset\" | \"anchor\" | \"side\" | \"sideOffset\"\n  >) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Positioner\n        align={align}\n        alignOffset={alignOffset}\n        anchor={anchor}\n        className=\"isolate z-50\"\n        side={side}\n        sideOffset={sideOffset}\n      >\n        <PopoverPrimitive.Popup\n          className={cn(\n            \"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden duration-100 data-closed:animate-out data-open:animate-in\",\n            className\n          )}\n          data-slot=\"popover-content\"\n          {...props}\n        />\n      </PopoverPrimitive.Positioner>\n    </PopoverPrimitive.Portal>\n  );\n}\n\nfunction PopoverHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-1 text-sm\", className)}\n      data-slot=\"popover-header\"\n      {...props}\n    />\n  );\n}\n\nfunction PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {\n  return (\n    <PopoverPrimitive.Title\n      className={cn(\"font-medium\", className)}\n      data-slot=\"popover-title\"\n      {...props}\n    />\n  );\n}\n\nfunction PopoverDescription({\n  className,\n  ...props\n}: PopoverPrimitive.Description.Props) {\n  return (\n    <PopoverPrimitive.Description\n      className={cn(\"text-muted-foreground\", className)}\n      data-slot=\"popover-description\"\n      {...props}\n    />\n  );\n}\n\nexport {\n  Popover,\n  PopoverContent,\n  PopoverDescription,\n  PopoverHeader,\n  PopoverTitle,\n  PopoverTrigger,\n};\n"
  },
  {
    "path": "packages/ui/src/components/progress.tsx",
    "content": "\"use client\";\n\nimport { Progress as ProgressPrimitive } from \"@base-ui/react/progress\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Progress({\n  className,\n  children,\n  value,\n  ...props\n}: ProgressPrimitive.Root.Props) {\n  return (\n    <ProgressPrimitive.Root\n      className={cn(\"flex flex-wrap gap-3\", className)}\n      data-slot=\"progress\"\n      value={value}\n      {...props}\n    >\n      {children}\n      <ProgressTrack>\n        <ProgressIndicator />\n      </ProgressTrack>\n    </ProgressPrimitive.Root>\n  );\n}\n\nfunction ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {\n  return (\n    <ProgressPrimitive.Track\n      className={cn(\n        \"relative flex h-2 w-full items-center overflow-x-hidden rounded-full bg-primary/20\",\n        className\n      )}\n      data-slot=\"progress-track\"\n      {...props}\n    />\n  );\n}\n\nfunction ProgressIndicator({\n  className,\n  ...props\n}: ProgressPrimitive.Indicator.Props) {\n  return (\n    <ProgressPrimitive.Indicator\n      className={cn(\"h-full bg-primary transition-all\", className)}\n      data-slot=\"progress-indicator\"\n      {...props}\n    />\n  );\n}\n\nfunction ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {\n  return (\n    <ProgressPrimitive.Label\n      className={cn(\"font-medium text-sm\", className)}\n      data-slot=\"progress-label\"\n      {...props}\n    />\n  );\n}\n\nfunction ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {\n  return (\n    <ProgressPrimitive.Value\n      className={cn(\n        \"ml-auto text-muted-foreground text-sm tabular-nums\",\n        className\n      )}\n      data-slot=\"progress-value\"\n      {...props}\n    />\n  );\n}\n\nexport {\n  Progress,\n  ProgressTrack,\n  ProgressIndicator,\n  ProgressLabel,\n  ProgressValue,\n};\n"
  },
  {
    "path": "packages/ui/src/components/radio-group.tsx",
    "content": "\"use client\";\n\nimport { Radio as RadioPrimitive } from \"@base-ui/react/radio\";\nimport { RadioGroup as RadioGroupPrimitive } from \"@base-ui/react/radio-group\";\nimport { CircleIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {\n  return (\n    <RadioGroupPrimitive\n      className={cn(\"grid gap-3\", className)}\n      data-slot=\"radio-group\"\n      {...props}\n    />\n  );\n}\n\nfunction RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {\n  return (\n    <RadioPrimitive.Root\n      className={cn(\n        \"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n        className\n      )}\n      data-slot=\"radio-group-item\"\n      {...props}\n    >\n      <RadioPrimitive.Indicator\n        className=\"flex size-4 items-center justify-center text-primary group-aria-invalid/radio-group-item:text-destructive\"\n        data-slot=\"radio-group-indicator\"\n      >\n        <HugeiconsIcon\n          className=\"absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-current\"\n          icon={CircleIcon}\n          strokeWidth={2}\n        />\n      </RadioPrimitive.Indicator>\n    </RadioPrimitive.Root>\n  );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"
  },
  {
    "path": "packages/ui/src/components/scroll-area.tsx",
    "content": "\"use client\";\n\nimport { ScrollArea as ScrollAreaPrimitive } from \"@base-ui/react/scroll-area\";\nimport * as React from \"react\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction ScrollArea({\n  className,\n  children,\n  ...props\n}: ScrollAreaPrimitive.Root.Props) {\n  return (\n    <ScrollAreaPrimitive.Root\n      className={cn(\"relative\", className)}\n      data-slot=\"scroll-area\"\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        className=\"size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n        data-slot=\"scroll-area-viewport\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      <ScrollBar />\n      <ScrollAreaPrimitive.Corner />\n    </ScrollAreaPrimitive.Root>\n  );\n}\n\nfunction ScrollBar({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: ScrollAreaPrimitive.Scrollbar.Props) {\n  return (\n    <ScrollAreaPrimitive.Scrollbar\n      className={cn(\n        \"flex touch-none select-none p-px transition-colors data-horizontal:h-2.5 data-vertical:h-full data-vertical:w-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:border-l data-vertical:border-l-transparent\",\n        className\n      )}\n      data-orientation={orientation}\n      data-slot=\"scroll-area-scrollbar\"\n      orientation={orientation}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Thumb\n        className=\"relative flex-1 rounded-full bg-border\"\n        data-slot=\"scroll-area-thumb\"\n      />\n    </ScrollAreaPrimitive.Scrollbar>\n  );\n}\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "packages/ui/src/components/select.tsx",
    "content": "\"use client\";\n\nimport { Select as SelectPrimitive } from \"@base-ui/react/select\";\nimport {\n  ArrowDown01Icon,\n  ArrowUp01Icon,\n  Tick02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type * as React from \"react\";\nimport { cn } from \"@marble/ui/lib/utils\";\nimport { CaretUpDownIcon } from \"@phosphor-icons/react\";\n\nconst Select = SelectPrimitive.Root;\n\nfunction SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {\n  return (\n    <SelectPrimitive.Group\n      className={cn(\"scroll-my-1 p-1\", className)}\n      data-slot=\"select-group\"\n      {...props}\n    />\n  );\n}\n\nfunction SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {\n  return (\n    <SelectPrimitive.Value\n      className={cn(\"flex flex-1 text-left\", className)}\n      data-slot=\"select-value\"\n      {...props}\n    />\n  );\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: SelectPrimitive.Trigger.Props & {\n  size?: \"sm\" | \"default\";\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      className={cn(\n        \"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[size=default]:h-9 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className\n      )}\n      data-size={size}\n      data-slot=\"select-trigger\"\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon\n        render={\n          <CaretUpDownIcon className=\"pointer-events-none size-4 text-muted-foreground\" />\n        }\n      />\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  side = \"bottom\",\n  sideOffset = 4,\n  align = \"start\",\n  alignOffset = 0,\n  alignItemWithTrigger = false,\n  ...props\n}: SelectPrimitive.Popup.Props &\n  Pick<\n    SelectPrimitive.Positioner.Props,\n    \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\" | \"alignItemWithTrigger\"\n  >) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Positioner\n        align={align}\n        alignItemWithTrigger={alignItemWithTrigger}\n        alignOffset={alignOffset}\n        className=\"isolate z-50\"\n        side={side}\n        sideOffset={sideOffset}\n      >\n        <SelectPrimitive.Popup\n          className={cn(\n            \"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative isolate z-50 max-h-(--available-height) min-w-[8rem] origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md duration-100 data-closed:animate-out data-open:animate-in\",\n            className\n          )}\n          data-slot=\"select-content\"\n          style={{ width: \"var(--anchor-width)\" }}\n          {...props}\n        >\n          <SelectScrollUpButton />\n          <SelectPrimitive.List className=\"p-1\">{children}</SelectPrimitive.List>\n        <SelectScrollDownButton />\n        </SelectPrimitive.Popup>\n      </SelectPrimitive.Positioner>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: SelectPrimitive.GroupLabel.Props) {\n  return (\n    <SelectPrimitive.GroupLabel\n      className={cn(\"px-2 py-1.5 text-muted-foreground text-xs\", className)}\n      data-slot=\"select-label\"\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: SelectPrimitive.Item.Props) {\n  return (\n    <SelectPrimitive.Item\n      className={cn(\n        \"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className\n      )}\n      data-slot=\"select-item\"\n      {...props}\n    >\n      <SelectPrimitive.ItemText className=\"flex flex-1 shrink-0 gap-2 whitespace-nowrap\">\n        {children}\n      </SelectPrimitive.ItemText>\n      <SelectPrimitive.ItemIndicator\n        render={\n          <span className=\"pointer-events-none absolute right-2 flex size-4 items-center justify-center\" />\n        }\n      >\n        <HugeiconsIcon\n          className=\"pointer-events-none size-4\"\n          icon={Tick02Icon}\n          strokeWidth={2}\n        />\n        </SelectPrimitive.ItemIndicator>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: SelectPrimitive.Separator.Props) {\n  return (\n    <SelectPrimitive.Separator\n      className={cn(\"pointer-events-none -mx-1 my-1 h-px bg-border\", className)}\n      data-slot=\"select-separator\"\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {\n  return (\n    <SelectPrimitive.ScrollUpArrow\n      className={cn(\n        \"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      data-slot=\"select-scroll-up-button\"\n      {...props}\n    >\n      <HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} className=\"size-4\" />\n    </SelectPrimitive.ScrollUpArrow>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {\n  return (\n    <SelectPrimitive.ScrollDownArrow\n      className={cn(\n        \"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      data-slot=\"select-scroll-down-button\"\n      {...props}\n    >\n      <HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} className=\"size-4\" />\n    </SelectPrimitive.ScrollDownArrow>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "packages/ui/src/components/separator.tsx",
    "content": "\"use client\";\n\nimport { Separator as SeparatorPrimitive } from \"@base-ui/react/separator\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  ...props\n}: SeparatorPrimitive.Props) {\n  return (\n    <SeparatorPrimitive\n      className={cn(\n        \"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch\",\n        className\n      )}\n      data-slot=\"separator\"\n      orientation={orientation}\n      {...props}\n    />\n  );\n}\n\nexport { Separator };\n"
  },
  {
    "path": "packages/ui/src/components/sheet.tsx",
    "content": "\"use client\";\n\nimport { Dialog as SheetPrimitive } from \"@base-ui/react/dialog\";\nimport { Cancel01Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport type * as React from \"react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Sheet({ ...props }: SheetPrimitive.Root.Props) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />;\n}\n\nfunction SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />;\n}\n\nfunction SheetClose({ ...props }: SheetPrimitive.Close.Props) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />;\n}\n\nfunction SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />;\n}\n\nfunction SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {\n  return (\n    <SheetPrimitive.Backdrop\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        \"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = \"right\",\n  showCloseButton = true,\n  ...props\n}: SheetPrimitive.Popup.Props & {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  showCloseButton?: boolean;\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Popup\n        data-slot=\"sheet-content\"\n        data-side={side}\n        className={cn(\n          \"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <SheetPrimitive.Close\n            data-slot=\"sheet-close\"\n            render={\n              <Button variant=\"ghost\" className=\"absolute top-4 right-4\" size=\"icon-sm\">\n                <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />\n                <span className=\"sr-only\">Close</span>\n              </Button>\n            }\n          />\n        )}\n      </SheetPrimitive.Popup>\n    </SheetPortal>\n  );\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-header\"\n      className={cn(\"gap-1.5 p-4 flex flex-col\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sheet-footer\"\n      className={cn(\"gap-2 p-4 mt-auto flex flex-col\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {\n  return (\n    <SheetPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn(\"text-foreground font-medium\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetDescription({\n  className,\n  ...props\n}: SheetPrimitive.Description.Props) {\n  return (\n    <SheetPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetX({\n  className,\n  icon,\n  ...props\n}: SheetPrimitive.Close.Props & {\n  icon?: React.ReactNode;\n}) {\n  return (\n    <SheetPrimitive.Close\n      className={cn(\n        \"rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-open:bg-secondary\",\n        className\n      )}\n      data-slot=\"sheet-close\"\n      {...props}\n    >\n      {icon ?? <HugeiconsIcon icon={Cancel01Icon} className=\"size-4\" strokeWidth={2} />}\n      <span className=\"sr-only\">Close</span>\n    </SheetPrimitive.Close>\n  );\n}\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n  SheetX,\n};\n"
  },
  {
    "path": "packages/ui/src/components/sidebar.tsx",
    "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { SidebarLeftIcon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\nimport { Button } from \"@marble/ui/components/button\";\nimport { Input } from \"@marble/ui/components/input\";\nimport { Separator } from \"@marble/ui/components/separator\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@marble/ui/components/sheet\";\nimport { Skeleton } from \"@marble/ui/components/skeleton\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@marble/ui/components/tooltip\";\nimport { useIsMobile } from \"@marble/ui/hooks/use-mobile\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nconst SIDEBAR_COOKIE_NAME = \"sidebar_state\";\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH = \"16rem\";\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\";\nconst SIDEBAR_WIDTH_ICON = \"3rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"b\";\n\nfunction getSidebarKeyboardShortcutLabel() {\n  const isMac =\n    typeof navigator !== \"undefined\" &&\n    navigator.platform.toUpperCase().indexOf(\"MAC\") >= 0;\n\n  return isMac ? \"⌘⇧B\" : \"Ctrl+Shift+B\";\n}\n\ntype SidebarContextProps = {\n  state: \"expanded\" | \"collapsed\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  toggleSidebar: () => void;\n};\n\nconst SidebarContext = React.createContext<SidebarContextProps | null>(null);\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext);\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\");\n  }\n\n  return context;\n}\n\nfunction SidebarProvider({\n  defaultOpen = true,\n  open: openProp,\n  onOpenChange: setOpenProp,\n  className,\n  style,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}) {\n  const isMobile = useIsMobile();\n  const [openMobile, setOpenMobile] = React.useState(false);\n  // This is the internal state of the sidebar.\n  // We use openProp and setOpenProp for control from outside the component.\n  const [_open, _setOpen] = React.useState(defaultOpen);\n  const open = openProp ?? _open;\n  const setOpen = React.useCallback(\n    (value: boolean | ((value: boolean) => boolean)) => {\n      const openState = typeof value === \"function\" ? value(open) : value;\n      if (setOpenProp) {\n        setOpenProp(openState);\n      } else {\n        _setOpen(openState);\n      }\n\n      // This sets the cookie to keep the sidebar state.\n      // biome-ignore lint/suspicious/noDocumentCookie: <>\n      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n    },\n    [setOpenProp, open]\n  );\n\n  // Helper to toggle the sidebar.\n  const toggleSidebar = React.useCallback(() => {\n    return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);\n  }, [isMobile, setOpen, setOpenMobile]);\n\n  // Adds a keyboard shortcut to toggle the sidebar.\n  React.useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (\n        event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT &&\n        (event.metaKey || event.ctrlKey) &&\n        event.shiftKey\n      ) {\n        event.preventDefault();\n        toggleSidebar();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [toggleSidebar]);\n\n  // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n  // This makes it easier to style the sidebar with Tailwind classes.\n  const state = open ? \"expanded\" : \"collapsed\";\n\n  const contextValue = React.useMemo<SidebarContextProps>(\n    () => ({\n      state,\n      open,\n      setOpen,\n      isMobile,\n      openMobile,\n      setOpenMobile,\n      toggleSidebar,\n    }),\n    [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]\n  );\n\n  return (\n    <SidebarContext.Provider value={contextValue}>\n      <TooltipProvider delay={0}>\n        <div\n          className={cn(\n            \"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar\",\n            className\n          )}\n          data-slot=\"sidebar-wrapper\"\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH,\n              \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n              ...style,\n            } as React.CSSProperties\n          }\n          {...props}\n        >\n          {children}\n        </div>\n      </TooltipProvider>\n    </SidebarContext.Provider>\n  );\n}\n\nfunction Sidebar({\n  side = \"left\",\n  variant = \"sidebar\",\n  collapsible = \"offcanvas\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  side?: \"left\" | \"right\";\n  variant?: \"sidebar\" | \"floating\" | \"inset\";\n  collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n}) {\n  const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n  if (collapsible === \"none\") {\n    return (\n      <div\n        className={cn(\n          \"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground\",\n          className\n        )}\n        data-slot=\"sidebar\"\n        {...props}\n      >\n        {children}\n      </div>\n    );\n  }\n\n  if (isMobile) {\n    return (\n      <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}>\n        <SheetContent\n          className=\"w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n          data-mobile=\"true\"\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar\"\n          side={side}\n          style={\n            {\n              \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n            } as React.CSSProperties\n          }\n        >\n          <SheetHeader className=\"sr-only\">\n            <SheetTitle>Sidebar</SheetTitle>\n            <SheetDescription>Displays the mobile sidebar.</SheetDescription>\n          </SheetHeader>\n          <div className=\"flex h-full w-full flex-col\">{children}</div>\n        </SheetContent>\n      </Sheet>\n    );\n  }\n\n  return (\n    <div\n      className=\"group peer hidden text-sidebar-foreground md:block\"\n      data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n      data-side={side}\n      data-slot=\"sidebar\"\n      data-state={state}\n      data-variant={variant}\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        className={cn(\n          \"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear\",\n          \"group-data-[collapsible=offcanvas]:w-0\",\n          \"group-data-[side=right]:rotate-180\",\n          variant === \"floating\" || variant === \"inset\"\n            ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon)\"\n        )}\n        data-slot=\"sidebar-gap\"\n      />\n      <div\n        className={cn(\n          \"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex\",\n          side === \"left\"\n            ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n            : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n          // Adjust the padding for floating and inset variants.\n          variant === \"floating\" || variant === \"inset\"\n            ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]\"\n            : \"group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n          className\n        )}\n        data-slot=\"sidebar-container\"\n        {...props}\n      >\n        <div\n          className=\"flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm\"\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar-inner\"\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction SidebarTrigger({\n  className,\n  onClick,\n  children,\n  ...props\n}: React.ComponentProps<typeof Button>) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <Button\n      className={cn(\"size-7\", className)}\n      data-sidebar=\"trigger\"\n      data-slot=\"sidebar-trigger\"\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      size=\"icon\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <HugeiconsIcon icon={SidebarLeftIcon} />}\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  );\n}\n\nfunction SidebarRail({ className, ...props }: React.ComponentProps<\"button\">) {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <button\n      aria-label=\"Toggle Sidebar\"\n      className={cn(\n        \"-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex\",\n        \"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className\n      )}\n      data-sidebar=\"rail\"\n      data-slot=\"sidebar-rail\"\n      onClick={toggleSidebar}\n      tabIndex={-1}\n      title=\"Toggle Sidebar\"\n      type=\"button\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n  return (\n    <main\n      className={cn(\n        \"relative flex w-full flex-1 flex-col bg-background\",\n        \"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm\",\n        className\n      )}\n      data-slot=\"sidebar-inset\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInput({\n  className,\n  ...props\n}: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      className={cn(\"h-8 w-full bg-background shadow-none\", className)}\n      data-sidebar=\"input\"\n      data-slot=\"sidebar-input\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      data-sidebar=\"header\"\n      data-slot=\"sidebar-header\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      data-sidebar=\"footer\"\n      data-slot=\"sidebar-footer\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      className={cn(\"mx-2 w-auto bg-sidebar-border\", className)}\n      data-sidebar=\"separator\"\n      data-slot=\"sidebar-separator\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className\n      )}\n      data-sidebar=\"content\"\n      data-slot=\"sidebar-content\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      data-sidebar=\"group\"\n      data-slot=\"sidebar-group\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupLabel({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"div\"> & React.ComponentProps<\"div\">) {\n  return useRender({\n    defaultTagName: \"div\",\n    props: mergeProps<\"div\">(\n      {\n        className: cn(\n          \"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n          \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n          className\n        ),\n      },\n      props\n    ),\n    render,\n    state: {\n      slot: \"sidebar-group-label\",\n      sidebar: \"group-label\",\n    },\n  });\n}\n\nfunction SidebarGroupAction({\n  className,\n  render,\n  ...props\n}: useRender.ComponentProps<\"button\"> & React.ComponentProps<\"button\">) {\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(\n      {\n        className: cn(\n          \"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n          // Increases the hit area of the button on mobile.\n          \"after:-inset-2 after:absolute md:after:hidden\",\n          \"group-data-[collapsible=icon]:hidden\",\n          className\n        ),\n      },\n      props\n    ),\n    render,\n    state: {\n      slot: \"sidebar-group-action\",\n      sidebar: \"group-action\",\n    },\n  });\n}\n\nfunction SidebarGroupContent({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\"w-full text-sm\", className)}\n      data-sidebar=\"group-content\"\n      data-slot=\"sidebar-group-content\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n      data-sidebar=\"menu\"\n      data-slot=\"sidebar-menu\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      className={cn(\"group/menu-item relative\", className)}\n      data-sidebar=\"menu-item\"\n      data-slot=\"sidebar-menu-item\"\n      {...props}\n    />\n  );\n}\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n);\n\nfunction SidebarMenuButton({\n  render,\n  asChild,\n  children,\n  isActive = false,\n  variant = \"default\",\n  size = \"default\",\n  tooltip,\n  className,\n  ...props\n}: useRender.ComponentProps<\"button\"> &\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean;\n    isActive?: boolean;\n    tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n  } & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const { isMobile, state } = useSidebar();\n\n  // Convert asChild to render for backward compatibility\n  const renderProp =\n    asChild && React.isValidElement(children) ? children : render;\n\n  const comp = useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(\n      {\n        className: cn(sidebarMenuButtonVariants({ variant, size }), className),\n        children: asChild ? undefined : children,\n      },\n      props\n    ),\n    render: renderProp,\n    state: {\n      slot: \"sidebar-menu-button\",\n      sidebar: \"menu-button\",\n      size,\n      active: isActive,\n    },\n  });\n\n  if (!tooltip) {\n    return comp;\n  }\n\n  let tooltipProps = tooltip;\n  if (typeof tooltipProps === \"string\") {\n    tooltipProps = {\n      children: tooltipProps,\n    };\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger render={comp} />\n      <TooltipContent\n        align=\"center\"\n        hidden={state !== \"collapsed\" || isMobile}\n        side=\"right\"\n        {...tooltipProps}\n      />\n    </Tooltip>\n  );\n}\n\nfunction SidebarMenuAction({\n  className,\n  render,\n  showOnHover = false,\n  ...props\n}: useRender.ComponentProps<\"button\"> &\n  React.ComponentProps<\"button\"> & {\n    showOnHover?: boolean;\n  }) {\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(\n      {\n        className: cn(\n          \"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0\",\n          // Increases the hit area of the button on mobile.\n          \"after:-inset-2 after:absolute md:after:hidden\",\n          \"peer-data-[size=sm]/menu-button:top-1\",\n          \"peer-data-[size=default]/menu-button:top-1.5\",\n          \"peer-data-[size=lg]/menu-button:top-2.5\",\n          \"group-data-[collapsible=icon]:hidden\",\n          showOnHover &&\n            \"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-open:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0\",\n          className\n        ),\n      },\n      props\n    ),\n    render,\n    state: {\n      slot: \"sidebar-menu-action\",\n      sidebar: \"menu-action\",\n    },\n  });\n}\n\nfunction SidebarMenuBadge({\n  className,\n  ...props\n}: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums\",\n        \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      data-sidebar=\"menu-badge\"\n      data-slot=\"sidebar-menu-badge\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showIcon?: boolean;\n}) {\n  // Random width between 50 to 90%.\n  const [width] = React.useState(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`;\n  });\n\n  return (\n    <div\n      className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n      data-sidebar=\"menu-skeleton\"\n      data-slot=\"sidebar-menu-skeleton\"\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 max-w-(--skeleton-width) flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      className={cn(\n        \"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className\n      )}\n      data-sidebar=\"menu-sub\"\n      data-slot=\"sidebar-menu-sub\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubItem({\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      className={cn(\"group/menu-sub-item relative\", className)}\n      data-sidebar=\"menu-sub-item\"\n      data-slot=\"sidebar-menu-sub-item\"\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubButton({\n  render,\n  asChild,\n  children,\n  size = \"md\",\n  isActive = false,\n  className,\n  ...props\n}: useRender.ComponentProps<\"a\"> &\n  React.ComponentProps<\"a\"> & {\n    asChild?: boolean;\n    size?: \"sm\" | \"md\";\n    isActive?: boolean;\n  }) {\n  // Convert asChild to render for backward compatibility\n  const renderProp =\n    asChild && React.isValidElement(children) ? children : render;\n\n  return useRender({\n    defaultTagName: \"a\",\n    props: mergeProps<\"a\">(\n      {\n        className: cn(\n          \"-translate-x-px flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground\",\n          \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n          size === \"sm\" && \"text-xs\",\n          size === \"md\" && \"text-sm\",\n          \"group-data-[collapsible=icon]:hidden\",\n          className\n        ),\n        children: asChild ? undefined : children,\n      },\n      props\n    ),\n    render: renderProp,\n    state: {\n      slot: \"sidebar-menu-sub-button\",\n      sidebar: \"menu-sub-button\",\n      size,\n      active: isActive,\n    },\n  });\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  getSidebarKeyboardShortcutLabel,\n  useSidebar,\n};\n"
  },
  {
    "path": "packages/ui/src/components/skeleton.tsx",
    "content": "import { cn } from \"@marble/ui/lib/utils\"\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"skeleton\"\n      className={cn(\"bg-accent animate-pulse rounded-md\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Skeleton }\n"
  },
  {
    "path": "packages/ui/src/components/sonner.tsx",
    "content": "\"use client\"\n\nimport { useTheme } from \"next-themes\"\nimport { Toaster as Sonner, ToasterProps, toast } from \"sonner\"\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme()\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  )\n}\n\nexport { Toaster, toast }\n"
  },
  {
    "path": "packages/ui/src/components/switch.tsx",
    "content": "\"use client\";\n\nimport { Switch as SwitchPrimitive } from \"@base-ui/react/switch\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Switch({\n  className,\n  size = \"default\",\n  ...props\n}: SwitchPrimitive.Root.Props & {\n  size?: \"sm\" | \"default\";\n}) {\n  return (\n    <SwitchPrimitive.Root\n      className={cn(\n        \"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none transition-all after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[size=default]:h-[1.15rem] data-[size=sm]:h-[14px] data-[size=default]:w-8 data-[size=sm]:w-[24px] data-disabled:cursor-not-allowed data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-50 dark:data-unchecked:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40\",\n        className\n      )}\n      data-size={size}\n      data-slot=\"switch\"\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        className=\"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground\"\n        data-slot=\"switch-thumb\"\n      />\n    </SwitchPrimitive.Root>\n  );\n}\n\nexport { Switch };\n"
  },
  {
    "path": "packages/ui/src/components/table.tsx",
    "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction Table({ className, ...props }: React.ComponentProps<\"table\">) {\n  return (\n    <div\n      data-slot=\"table-container\"\n      className=\"relative w-full overflow-x-auto\"\n    >\n      <table\n        data-slot=\"table\"\n        className={cn(\"w-full caption-bottom text-sm\", className)}\n        {...props}\n      />\n    </div>\n  )\n}\n\nfunction TableHeader({ className, ...props }: React.ComponentProps<\"thead\">) {\n  return (\n    <thead\n      data-slot=\"table-header\"\n      className={cn(\"[&_tr]:border-b\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableBody({ className, ...props }: React.ComponentProps<\"tbody\">) {\n  return (\n    <tbody\n      data-slot=\"table-body\"\n      className={cn(\"[&_tr:last-child]:border-0\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction TableFooter({ className, ...props }: React.ComponentProps<\"tfoot\">) {\n  return (\n    <tfoot\n      data-slot=\"table-footer\"\n      className={cn(\n        \"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableRow({ className, ...props }: React.ComponentProps<\"tr\">) {\n  return (\n    <tr\n      data-slot=\"table-row\"\n      className={cn(\n        \"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableHead({ className, ...props }: React.ComponentProps<\"th\">) {\n  return (\n    <th\n      data-slot=\"table-head\"\n      className={cn(\n        \"text-foreground h-10 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCell({ className, ...props }: React.ComponentProps<\"td\">) {\n  return (\n    <td\n      data-slot=\"table-cell\"\n      className={cn(\n        \"p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TableCaption({\n  className,\n  ...props\n}: React.ComponentProps<\"caption\">) {\n  return (\n    <caption\n      data-slot=\"table-caption\"\n      className={cn(\"text-muted-foreground mt-4 text-sm\", className)}\n      {...props}\n    />\n  )\n}\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n}"
  },
  {
    "path": "packages/ui/src/components/tabs.tsx",
    "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nfunction Tabs({\n  className,\n  orientation = \"horizontal\",\n  ...props\n}: TabsPrimitive.Root.Props) {\n  return (\n    <TabsPrimitive.Root\n      className={cn(\n        \"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col\",\n        className\n      )}\n      data-orientation={orientation}\n      data-slot=\"tabs\"\n      {...props}\n    />\n  );\n}\n\nconst tabsListVariants = cva(\n  \"group/tabs-list relative inline-flex w-full items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-muted h-9\",\n        line: \"gap-1 bg-transparent h-10 rounded-none p-0 border-b border-border\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  }\n);\n\nfunction TabsList({\n  className,\n  variant = \"default\",\n  children,\n  ...props\n}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {\n  return (\n    <TabsPrimitive.List\n      className={cn(tabsListVariants({ variant }), className)}\n      data-slot=\"tabs-list\"\n      data-variant={variant}\n      {...props}\n    >\n      {children}\n      {variant === \"line\" && (\n        <TabsPrimitive.Indicator\n          className=\"absolute bottom-0 h-0.5 bg-primary transition-all duration-200 ease-out\"\n          style={{\n            left: \"var(--active-tab-left)\",\n            width: \"var(--active-tab-width)\",\n          }}\n        />\n      )}\n    </TabsPrimitive.List>\n  );\n}\n\nfunction TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {\n  return (\n    <TabsPrimitive.Tab\n      className={cn(\n        // Base styles\n        \"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-all\",\n        // Text colors\n        \"text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground\",\n        // Focus styles (consistent ring-based approach)\n        \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring\",\n        // Disabled\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        // SVG\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        // Default variant active state\n        \"group-data-[variant=default]/tabs-list:data-active:bg-background group-data-[variant=default]/tabs-list:data-active:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm\",\n        // Line variant styling\n        \"group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:border-none group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:px-4 group-data-[variant=line]/tabs-list:py-2 group-data-[variant=line]/tabs-list:shadow-none\",\n        \"group-data-[variant=line]/tabs-list:data-active:text-foreground group-data-[variant=line]/tabs-list:data-active:bg-transparent\",\n        // Vertical orientation\n        \"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start\",\n        className\n      )}\n      data-slot=\"tabs-trigger\"\n      {...props}\n    />\n  );\n}\n\nfunction TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {\n  return (\n    <TabsPrimitive.Panel\n      className={cn(\"flex-1 text-sm outline-none\", className)}\n      data-slot=\"tabs-content\"\n      {...props}\n    />\n  );\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };\n"
  },
  {
    "path": "packages/ui/src/components/textarea.tsx",
    "content": "import * as React from \"react\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-lg border bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport { Textarea }\n"
  },
  {
    "path": "packages/ui/src/components/toggle-group.tsx",
    "content": "\"use client\";\n\nimport { Toggle as TogglePrimitive } from \"@base-ui/react/toggle\";\nimport { ToggleGroup as ToggleGroupPrimitive } from \"@base-ui/react/toggle-group\";\nimport type { VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\nimport { toggleVariants } from \"@marble/ui/components/toggle\";\nimport { cn } from \"@marble/ui/lib/utils\";\n\nconst ToggleGroupContext = React.createContext<\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number;\n    orientation?: \"horizontal\" | \"vertical\";\n  }\n>({\n  size: \"default\",\n  variant: \"default\",\n  spacing: 0,\n  orientation: \"horizontal\",\n});\n\nfunction ToggleGroup({\n  className,\n  variant,\n  size,\n  spacing = 0,\n  orientation = \"horizontal\",\n  children,\n  ...props\n}: ToggleGroupPrimitive.Props &\n  VariantProps<typeof toggleVariants> & {\n    spacing?: number;\n    orientation?: \"horizontal\" | \"vertical\";\n  }) {\n  return (\n    <ToggleGroupPrimitive\n      className={cn(\n        \"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=0]:data-[variant=outline]:shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch\",\n        className\n      )}\n      data-orientation={orientation}\n      data-size={size}\n      data-slot=\"toggle-group\"\n      data-spacing={spacing}\n      data-variant={variant}\n      style={{ \"--gap\": spacing } as React.CSSProperties}\n      {...props}\n    >\n      <ToggleGroupContext.Provider\n        value={{ variant, size, spacing, orientation }}\n      >\n        {children}\n      </ToggleGroupContext.Provider>\n    </ToggleGroupPrimitive>\n  );\n}\n\nfunction ToggleGroupItem({\n  className,\n  children,\n  variant = \"default\",\n  size = \"default\",\n  ...props\n}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {\n  const context = React.useContext(ToggleGroupContext);\n\n  return (\n    <TogglePrimitive\n      className={cn(\n        \"shrink-0 focus:z-10 focus-visible:z-10 data-[state=on]:bg-muted group-data-[spacing=0]/toggle-group:rounded-none group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:shadow-none group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md\",\n        toggleVariants({\n          variant: context.variant ?? variant,\n          size: context.size ?? size,\n        }),\n        className\n      )}\n      data-size={context.size ?? size}\n      data-slot=\"toggle-group-item\"\n      data-spacing={context.spacing}\n      data-variant={context.variant ?? variant}\n      {...props}\n    >\n      {children}\n    </TogglePrimitive>\n  );\n}\n\nexport { ToggleGroup, ToggleGroupItem };\n\n"
  },
  {
    "path": "packages/ui/src/components/toggle.tsx",
    "content": "\"use client\";\n\nimport { Toggle as TogglePrimitive } from \"@base-ui/react/toggle\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@marble/ui/lib/utils\";\n\nconst toggleVariants = cva(\n  \"group/toggle inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:bg-muted aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline: \"border border-input bg-transparent shadow-xs hover:bg-muted\",\n      },\n      size: {\n        default: \"h-9 min-w-9 px-2\",\n        sm: \"h-8 min-w-8 px-1.5\",\n        lg: \"h-10 min-w-10 px-2.5\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n);\n\nfunction Toggle({\n  className,\n  variant = \"default\",\n  size = \"default\",\n  ...props\n}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {\n  return (\n    <TogglePrimitive\n      className={cn(toggleVariants({ variant, size, className }))}\n      data-slot=\"toggle\"\n      {...props}\n    />\n  );\n}\n\nexport { Toggle, toggleVariants };\n\n"
  },
  {
    "path": "packages/ui/src/components/tooltip.tsx",
    "content": "\"use client\"\n\nimport { Tooltip as TooltipPrimitive } from \"@base-ui/react/tooltip\"\n\nimport { cn } from \"@marble/ui/lib/utils\"\n\nfunction TooltipProvider({\n  delay = 0,\n  ...props\n}: TooltipPrimitive.Provider.Props) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delay={delay}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({ ...props }: TooltipPrimitive.Root.Props) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  )\n}\n\nfunction TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  side = \"top\",\n  sideOffset = 8,\n  align = \"center\",\n  alignOffset = 0,\n  children,\n  ...props\n}: TooltipPrimitive.Popup.Props &\n  Pick<\n    TooltipPrimitive.Positioner.Props,\n    \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\"\n  >) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Positioner\n        align={align}\n        alignOffset={alignOffset}\n        side={side}\n        sideOffset={sideOffset}\n        className=\"isolate z-50\"\n      >\n        <TooltipPrimitive.Popup\n          data-slot=\"tooltip-content\"\n          className={cn(\n            \"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)\",\n            className\n          )}\n          {...props}\n        >\n          {children}\n          <TooltipPrimitive.Arrow className=\"size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5\" />\n        </TooltipPrimitive.Popup>\n      </TooltipPrimitive.Positioner>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "packages/ui/src/hooks/use-mobile.ts",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "packages/ui/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
  },
  {
    "path": "packages/ui/src/styles/globals.css",
    "content": "@import 'tailwindcss';\n@import 'tw-animate-css';\n\n/* Custom variant for dark mode */\n@custom-variant dark (&:is(.dark *));\n\n/* Plugins */\n@plugin \"@toolwind/corner-shape\";\n\n:root {\n  --background: hsl(0 0% 100%);\n  --foreground: hsl(0 0% 9%);\n  --card: hsl(0 0% 100%);\n  --card-foreground: hsl(0 0% 9%);\n  --popover: hsl(0 0% 100%);\n  --popover-foreground: hsl(0 0% 9%);\n  --primary: hsl(244 100% 65%);\n  --primary-foreground: hsl(0 0% 98%);\n  --secondary: hsl(0 0% 96.1%);\n  --secondary-foreground: hsl(0 0% 9%);\n  --muted: hsl(0 0% 96.1%);\n  --muted-foreground: hsl(0 0% 45.1%);\n  --accent: hsl(0 0% 96.1%);\n  --accent-foreground: hsl(0 0% 9%);\n  --destructive: hsl(0 84.2% 60.2%);\n  --destructive-foreground: hsl(0 0% 98%);\n  --border: hsl(0 0% 89.8%);\n  --input: hsl(0 0% 89.8%);\n  --ring: hsl(244, 93%, 83%);\n\n  /* chart */\n  --chart-1: hsl(12 76% 61%);\n  --chart-2: hsl(173 58% 39%);\n  --chart-3: hsl(197 37% 24%);\n  --chart-4: hsl(43 74% 66%);\n  --chart-5: hsl(27 87% 67%);\n\n  /* radius */\n  --radius: 0.4rem;\n\n  /* sidebar */\n  --sidebar-background: hsl(0 0% 98%);\n  --sidebar-foreground: hsl(240 5.3% 26.1%);\n  --sidebar-primary: hsl(240 5.9% 10%);\n  --sidebar-primary-foreground: hsl(0 0% 98%);\n  --sidebar-accent: hsl(240 4.8% 95.9%);\n  --sidebar-accent-foreground: hsl(240 5.9% 10%);\n  --sidebar-border: hsl(220 13% 91%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n\n  /* scrollbar */\n  --scrollbar-thumb: hsl(0 0% 37%);\n  --scrollbar-thumb-hover: hsl(0 0% 40%);\n  --sidebar: hsl(0 0% 98%);\n}\n.dark {\n  --background: hsl(150 4% 9%);\n  --foreground: hsl(0 0% 98%);\n  --card: hsl(0 0% 9%);\n  --card-foreground: hsl(0 0% 98%);\n  --popover: hsl(0 0% 9%);\n  --popover-foreground: hsl(0 0% 98%);\n  --primary: hsl(244 98% 69%);\n  --primary-foreground: hsl(0 0% 98%);\n  --secondary: hsl(0 0% 14.9%);\n  --secondary-foreground: hsl(0 0% 98%);\n  --muted: hsl(0 0% 14.9%);\n  --muted-foreground: hsl(0 0% 63.9%);\n  --accent: hsl(0 0% 14.9%);\n  --accent-foreground: hsl(0 0% 98%);\n  --destructive: hsl(0 84% 56%);\n  --destructive-foreground: hsl(0 0% 98%);\n  --border: hsl(0 0% 14.9%);\n  --input: hsl(0 0% 14.9%);\n  --ring: hsl(244 100% 82%);\n\n  /* chart */\n  --chart-1: hsl(220 70% 50%);\n  --chart-2: hsl(160 60% 45%);\n  --chart-3: hsl(30 80% 55%);\n  --chart-4: hsl(280 65% 60%);\n  --chart-5: hsl(340 75% 55%);\n\n  /* sidebar */\n  --sidebar-background: hsl(240 5.9% 10%);\n  --sidebar-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-primary: hsl(224.3 76.3% 48%);\n  --sidebar-primary-foreground: hsl(0 0% 100%);\n  --sidebar-accent: hsl(240 3.7% 15.9%);\n  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);\n  --sidebar-border: hsl(240 3.7% 15.9%);\n  --sidebar-ring: hsl(217.2 91.2% 59.8%);\n\n  /* scrollbar */\n  --scrollbar-thumb: hsl(0, 0%, 60%);\n  --scrollbar-thumb-hover: hsl(0, 0%, 53%);\n  --sidebar: hsl(240 5.9% 10%);\n}\n\n@theme inline {\n  /* Typography */\n  --font-sans:\n    var(--font-sans), ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',\n    'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n  --font-mono:\n    var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,\n    'Liberation Mono', 'Courier New', monospace;\n\n  /* Border radius */\n  --radius-lg: var(--radius);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-sm: calc(var(--radius) - 4px);\n\n  /* Palette mapped to root design tokens */\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n\n  --color-destructive: var(--destructive);\n  --color-destructive-foreground: var(--destructive-foreground);\n\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n\n  /* Chart colors */\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n\n  /* Sidebar colors */\n  --color-sidebar: var(--sidebar-background);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n/*\n  The default border color has changed to `currentcolor` in Tailwind CSS v4,\n  so we've added these compatibility styles to make sure everything still\n  looks the same as it did with Tailwind CSS v3.\n\n  If we ever want to remove these styles, we need to add an explicit border\n  color utility to any element that depends on these defaults.\n*/\n@layer base {\n  *,\n  ::after,\n  ::before,\n  ::backdrop,\n  ::file-selector-button {\n    border-color: var(--color-gray-200, currentcolor);\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n@layer utilities {\n  ::-webkit-scrollbar {\n    width: 0.4rem;\n  }\n  ::-webkit-scrollbar-thumb {\n    background-color: var(--scrollbar-thumb);\n  }\n  ::-webkit-scrollbar-thumb:hover {\n    background-color: var(--scrollbar-thumb-hover);\n  }\n  ::-webkit-scrollbar-track {\n    background: --alpha(var(--background) / 80%);\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n"
  },
  {
    "path": "packages/ui/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/react-library.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@marble/ui/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\".\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "packages/utils/package.json",
    "content": "{\n  \"name\": \"@marble/utils\",\n  \"version\": \"0.0.0\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"dependencies\": {\n    \"nanoid\": \"^5.0.9\",\n    \"shiki\": \"^3.12.2\"\n  },\n  \"devDependencies\": {\n    \"@marble/tsconfig\": \"workspace:*\",\n    \"@types/node\": \"^22.9.0\",\n    \"typescript\": \"^5.9.3\"\n  }\n}\n"
  },
  {
    "path": "packages/utils/src/constants/api-key.ts",
    "content": "export const API_KEY_PREFIXES = {\n  public: \"mpk\",\n  private: \"msk\",\n} as const;\n"
  },
  {
    "path": "packages/utils/src/constants/plans.ts",
    "content": "export type PlanType = \"pro\" | \"hobby\";\n\nexport interface PlanLimits {\n  maxMembers: number;\n  maxMediaStorage: number;\n  maxApiRequests: number;\n  maxWebhookEvents: number;\n  features: {\n    inviteMembers: boolean;\n    advancedReadability: boolean;\n    keywordOptimization: boolean;\n    unlimitedPosts: boolean;\n  };\n}\n\nexport const PLAN_LIMITS: Record<PlanType, PlanLimits> = {\n  hobby: {\n    maxMembers: 1,\n    maxMediaStorage: 1024,\n    maxApiRequests: 10_000,\n    maxWebhookEvents: 100,\n    features: {\n      inviteMembers: true,\n      advancedReadability: false,\n      keywordOptimization: false,\n      unlimitedPosts: true,\n    },\n  },\n  pro: {\n    maxMembers: 5,\n    maxMediaStorage: 10_240,\n    maxApiRequests: 50_000,\n    maxWebhookEvents: 1000,\n    features: {\n      inviteMembers: true,\n      advancedReadability: true,\n      keywordOptimization: false,\n      unlimitedPosts: true,\n    },\n  },\n};\n\n/**\n * Returns true when a subscription should grant its paid plan limits.\n */\nexport function isSubscriptionActive(\n  subscription?: {\n    status?: string;\n    cancelAtPeriodEnd?: boolean;\n    currentPeriodEnd?: string | Date | null;\n  } | null\n): boolean {\n  if (!subscription) {\n    return false;\n  }\n\n  if (subscription.status === \"active\" || subscription.status === \"trialing\") {\n    return true;\n  }\n\n  if (\n    subscription.status === \"canceled\" &&\n    subscription.cancelAtPeriodEnd &&\n    subscription.currentPeriodEnd\n  ) {\n    const periodEnd =\n      subscription.currentPeriodEnd instanceof Date\n        ? subscription.currentPeriodEnd\n        : new Date(subscription.currentPeriodEnd);\n    return periodEnd > new Date();\n  }\n\n  return false;\n}\n\n/**\n * Resolves the active billing plan for a workspace subscription.\n */\nexport function getWorkspacePlan(\n  subscription?: {\n    plan?: string;\n    status?: string;\n    cancelAtPeriodEnd?: boolean;\n    currentPeriodEnd?: string | Date | null;\n  } | null\n): PlanType {\n  if (!subscription?.plan || !isSubscriptionActive(subscription)) {\n    return \"hobby\";\n  }\n\n  return subscription.plan.toLowerCase() === \"pro\" ? \"pro\" : \"hobby\";\n}\n\n/**\n * Checks whether a plan includes a named feature.\n */\nexport function canPerformAction(\n  plan: PlanType,\n  action: keyof PlanLimits[\"features\"]\n): boolean {\n  return PLAN_LIMITS[plan].features[action];\n}\n\n/**\n * Checks whether another member can be invited under the plan's member limit.\n */\nexport function canInviteMoreMembers(\n  plan: PlanType,\n  currentMemberCount: number\n): boolean {\n  const limits = PLAN_LIMITS[plan];\n  return (\n    currentMemberCount < limits.maxMembers && limits.features.inviteMembers\n  );\n}\n\n/**\n * Returns the number of remaining member seats for a plan.\n */\nexport function getRemainingMemberSlots(\n  plan: PlanType,\n  currentMemberCount: number\n): number {\n  return Math.max(0, PLAN_LIMITS[plan].maxMembers - currentMemberCount);\n}\n\n/**\n * Returns all configured limits for a plan.\n */\nexport function getPlanLimits(plan: PlanType): PlanLimits {\n  return PLAN_LIMITS[plan];\n}\n\n/**\n * Compares workspace usage values against the limits for a plan.\n */\nexport function isOverLimit(\n  plan: PlanType,\n  usage: {\n    members?: number;\n    mediaStorage?: number;\n    apiRequests?: number;\n    webhookEvents?: number;\n  }\n): {\n  isOver: boolean;\n  violations: string[];\n} {\n  const limits = PLAN_LIMITS[plan];\n  const violations: string[] = [];\n\n  if (usage.members && usage.members > limits.maxMembers) {\n    violations.push(\n      `Member count (${usage.members}) exceeds limit (${limits.maxMembers})`\n    );\n  }\n\n  if (usage.mediaStorage && usage.mediaStorage > limits.maxMediaStorage) {\n    violations.push(\n      `Media storage (${usage.mediaStorage}MB) exceeds limit (${limits.maxMediaStorage}MB)`\n    );\n  }\n\n  if (\n    usage.apiRequests &&\n    limits.maxApiRequests > 0 &&\n    usage.apiRequests > limits.maxApiRequests\n  ) {\n    violations.push(\n      `API requests (${usage.apiRequests}) exceed limit (${limits.maxApiRequests})`\n    );\n  }\n\n  if (usage.webhookEvents && usage.webhookEvents > limits.maxWebhookEvents) {\n    violations.push(\n      `Webhook events (${usage.webhookEvents}) exceed limit (${limits.maxWebhookEvents})`\n    );\n  }\n\n  return {\n    isOver: violations.length > 0,\n    violations,\n  };\n}\n"
  },
  {
    "path": "packages/utils/src/constants/pricing.ts",
    "content": "export interface PricingPlan {\n  id: string;\n  title: string;\n  description: string;\n  price: {\n    monthly: string;\n    yearly: string;\n  };\n  trial?: string;\n  features: string[];\n  button: {\n    href: string;\n    label: string;\n  };\n}\n\nexport const PRICING_PLANS: PricingPlan[] = [\n  {\n    id: \"hobby\",\n    title: \"Hobby\",\n    description: \"For solo creators\",\n    price: {\n      monthly: \"$0\",\n      yearly: \"$0\",\n    },\n    features: [\n      \"Unlimited posts\",\n      \"1 Author\",\n      \"1GB media storage\",\n      \"AI Readability insights\",\n      \"10k API requests per month\",\n      \"100 webhook events per month\",\n      \"1 team member\",\n    ],\n    button: {\n      href: \"https://app.marblecms.com\",\n      label: \"Start for free\",\n    },\n  },\n  {\n    id: \"pro\",\n    title: \"Pro\",\n    description: \"For growing teams\",\n    price: {\n      monthly: \"$20\",\n      yearly: \"$200\",\n    },\n    trial: \"3 day free trial\",\n    features: [\n      \"Unlimited posts\",\n      \"Unlimited authors\",\n      \"10GB media storage\",\n      \"AI Readability insights\",\n      \"50k API requests per month\",\n      \"1k webhook events per month\",\n      \"5 team members\",\n      \"Share post drafts\",\n    ],\n    button: {\n      href: \"https://app.marblecms.com\",\n      label: \"Get Started\",\n    },\n  },\n];\n"
  },
  {
    "path": "packages/utils/src/constants/site.ts",
    "content": "export const YOUTUBE_VIDEO_ID = \"vAPVCCayBIA\";\n\nexport const YOUTUBE_EMBED_URL = `https://www.youtube.com/embed/${YOUTUBE_VIDEO_ID}`;\n"
  },
  {
    "path": "packages/utils/src/functions/api-key.ts",
    "content": "import { createHash, timingSafeEqual } from \"node:crypto\";\nimport { customAlphabet } from \"nanoid\";\nimport { API_KEY_PREFIXES } from \"../constants/api-key\";\nimport type { ApiKeyType } from \"../types/api-key\";\n\n/**\n * Hash an API key using SHA-256\n * @param key - The plaintext API key to hash\n * @returns The SHA-256 hash of the key as a hex string (64 characters)\n */\nexport function hashApiKey(key: string): string {\n  return createHash(\"sha256\").update(key).digest(\"hex\");\n}\n\n/**\n * Verify a plaintext API key against its hash using constant-time comparison\n * to prevent timing attacks\n * @param plainKey - The plaintext API key to verify\n * @param hashedKey - The hashed API key to compare against\n * @returns True if the plaintext key matches the hash\n */\nexport function verifyApiKey(plainKey: string, hashedKey: string): boolean {\n  const candidateHash = hashApiKey(plainKey);\n\n  // Convert hashes to buffers for constant-time comparison\n  // SHA-256 produces hex output, so use 'hex' encoding\n  const expectedBuffer = Buffer.from(hashedKey, \"hex\");\n  const candidateBuffer = Buffer.from(candidateHash, \"hex\");\n\n  // Check lengths first - if different, keys don't match\n  if (expectedBuffer.length !== candidateBuffer.length) {\n    return false;\n  }\n\n  // Use constant-time comparison to prevent timing attacks\n  return timingSafeEqual(expectedBuffer, candidateBuffer);\n}\n\n/**\n * Generates an API key with prefix, hash, and preview\n * @param type - The type of API key (public or private)\n * @returns Object containing the full plaintext key, its hash, prefix, and preview\n */\nexport function generateApiKey(type: ApiKeyType) {\n  const prefix =\n    type === \"public\" ? API_KEY_PREFIXES.public : API_KEY_PREFIXES.private;\n  const nanoid = customAlphabet(\n    \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n    24\n  );\n  const randomSuffix = nanoid();\n  const fullKey = `${prefix}_${randomSuffix}`;\n  const hash = hashApiKey(fullKey);\n  const preview = `${prefix}...${randomSuffix.slice(-4)}`;\n\n  return {\n    key: fullKey,\n    hash,\n    prefix,\n    preview,\n  };\n}\n"
  },
  {
    "path": "packages/utils/src/functions/highlight.ts",
    "content": "import { createHighlighter } from \"shiki\";\n\n// Create highlighter instance\n// Should be a singleton\n// https://shiki.style/guide/install#highlighter-usage\nlet highlighter: Awaited<ReturnType<typeof createHighlighter>> | null = null;\n\nasync function getHighlighter() {\n  if (!highlighter) {\n    highlighter = await createHighlighter({\n      themes: [\"github-dark\", \"github-light\"],\n      langs: [\n        \"javascript\",\n        \"typescript\",\n        \"json\",\n        \"html\",\n        \"css\",\n        \"bash\",\n        \"shell\",\n        \"jsx\",\n        \"tsx\",\n        \"markdown\",\n        \"yaml\",\n        \"xml\",\n        \"python\",\n        \"java\",\n        \"c\",\n        \"cpp\",\n        \"csharp\",\n        \"php\",\n        \"ruby\",\n        \"go\",\n        \"rust\",\n        \"swift\",\n        \"kotlin\",\n        \"scala\",\n        \"sql\",\n        \"dockerfile\",\n        \"diff\",\n        \"plaintext\",\n      ],\n    });\n  }\n  return highlighter;\n}\n\n/**\n * Transform content from Marble to add syntax highlighting to code blocks\n */\nexport async function highlightContent(\n  htmlContent: string,\n  theme: \"light\" | \"dark\" = \"dark\"\n): Promise<string> {\n  const highlighter = await getHighlighter();\n\n  // Marble returns the language as a class attribute on the <code> tag\n  // i.e <pre><code class=\"language-jsx\">...</code></pre>\n  // so we use a regex to find and pick the language from the classname\n  const codeBlockRegex =\n    /<pre[^>]*>\\s*<code(?:\\s+[^>]*?class=\"[^\"]*?language-([^\"\\s]+)[^\"]*?\")?[^>]*>([\\s\\S]*?)<\\/code>\\s*<\\/pre>/g;\n\n  return htmlContent.replace(codeBlockRegex, (match, language, code) => {\n    try {\n      // Decode HTML entities in the code\n      const decodedCode = code\n        .replace(/&lt;/g, \"<\")\n        .replace(/&gt;/g, \">\")\n        .replace(/&amp;/g, \"&\")\n        .replace(/&quot;/g, '\"')\n        .replace(/&#39;/g, \"'\");\n\n      // Use detected language or default to text if none specified\n      const lang = language || \"text\";\n\n      // Check if the language is supported\n      const supportedLanguages = highlighter.getLoadedLanguages();\n      const finalLang = supportedLanguages.includes(lang) ? lang : \"text\";\n\n      const highlighted = highlighter.codeToHtml(decodedCode, {\n        lang: finalLang,\n        theme: theme === \"dark\" ? \"github-dark\" : \"github-light\",\n      });\n\n      return highlighted;\n    } catch (error) {\n      console.warn(\"Failed to highlight code block:\", error);\n      // We return the original content if highlighting fails\n      return match;\n    }\n  });\n}\n"
  },
  {
    "path": "packages/utils/src/functions/webhooks.ts",
    "content": "const BLOCKED_HOSTNAMES = new Set([\"localhost\"]);\n\nfunction parseIPv4(hostname: string) {\n  const parts = hostname.split(\".\");\n\n  if (parts.length !== 4) {\n    return null;\n  }\n\n  const bytes = parts.map((part) => {\n    if (!/^\\d+$/.test(part)) {\n      return null;\n    }\n\n    const value = Number(part);\n    return value >= 0 && value <= 255 ? value : null;\n  });\n\n  if (bytes.some((byte) => byte === null)) {\n    return null;\n  }\n\n  return bytes as [number, number, number, number];\n}\n\nfunction isPrivateIPv4(hostname: string) {\n  const bytes = parseIPv4(hostname);\n\n  if (!bytes) {\n    return false;\n  }\n\n  const [first, second] = bytes;\n\n  return (\n    first === 0 ||\n    first === 10 ||\n    first === 127 ||\n    (first === 100 && second >= 64 && second <= 127) ||\n    (first === 169 && second === 254) ||\n    (first === 172 && second >= 16 && second <= 31) ||\n    (first === 192 && second === 0) ||\n    (first === 192 && second === 168) ||\n    (first === 198 && (second === 18 || second === 19)) ||\n    first >= 224\n  );\n}\n\nfunction normalizeIPv6(hostname: string) {\n  return hostname.toLowerCase().replace(/^\\[|\\]$/g, \"\");\n}\n\nfunction isPrivateIPv6(hostname: string) {\n  const value = normalizeIPv6(hostname);\n\n  return (\n    value === \"::\" ||\n    value === \"::1\" ||\n    value.startsWith(\"fc\") ||\n    value.startsWith(\"fd\") ||\n    value.startsWith(\"fe8\") ||\n    value.startsWith(\"fe9\") ||\n    value.startsWith(\"fea\") ||\n    value.startsWith(\"feb\") ||\n    value.startsWith(\"ff\") ||\n    value.startsWith(\"::ffff:\")\n  );\n}\n\nfunction isBlockedHostname(hostname: string) {\n  const value = hostname.toLowerCase();\n\n  return (\n    BLOCKED_HOSTNAMES.has(value) ||\n    value.endsWith(\".localhost\") ||\n    value.endsWith(\".local\") ||\n    value.endsWith(\".internal\")\n  );\n}\n\nexport function isSafeWebhookUrl(rawUrl: string) {\n  try {\n    const url = new URL(rawUrl);\n    const hostname = url.hostname.toLowerCase();\n\n    if (url.protocol !== \"https:\") {\n      return false;\n    }\n\n    if (isBlockedHostname(hostname)) {\n      return false;\n    }\n\n    if (isPrivateIPv4(hostname) || isPrivateIPv6(hostname)) {\n      return false;\n    }\n\n    return true;\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "packages/utils/src/index.ts",
    "content": "/** biome-ignore-all lint/performance/noBarrelFile: <> */\nexport * from \"./constants/api-key\";\nexport * from \"./constants/plans\";\nexport * from \"./constants/pricing\";\nexport * from \"./constants/site\";\nexport * from \"./functions/api-key\";\nexport * from \"./functions/highlight\";\nexport * from \"./functions/webhooks\";\nexport * from \"./types/api-key\";\n"
  },
  {
    "path": "packages/utils/src/types/api-key.ts",
    "content": "import type { API_KEY_PREFIXES } from \"../constants/api-key\";\n\nexport type ApiKeyPrefix =\n  (typeof API_KEY_PREFIXES)[keyof typeof API_KEY_PREFIXES];\n\nexport type ApiKeyType = \"public\" | \"private\";\n"
  },
  {
    "path": "packages/utils/tsconfig.json",
    "content": "{\n  \"extends\": \"@marble/tsconfig/base.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - \"apps/*\"\n  - \"packages/*\"\n"
  },
  {
    "path": "skills-lock.json",
    "content": "{\n  \"version\": 1,\n  \"skills\": {\n    \"cloudflare\": {\n      \"source\": \"cloudflare/skills\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"02ef72b4906d295d03da15d52357babdef06a762f0374b85c89c73c9b22a7bad\"\n    },\n    \"email-best-practices\": {\n      \"source\": \"resend/email-best-practices\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"7e6c8d495e66e88a20dc45d17d24b445d6c5c9072855c78d8800fff994bc813f\"\n    },\n    \"react-email\": {\n      \"source\": \"resend/react-email\",\n      \"sourceType\": \"github\",\n      \"skillPath\": \"skills/react-email/SKILL.md\",\n      \"computedHash\": \"f086aedad8c9d9e00fa133f7c3ce33762ba60413c664fb3faf4f3a69a83bd1b1\"\n    },\n    \"tiptap\": {\n      \"source\": \"ueberdosis/tiptap\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"dc5e9b14821039aaf1b02e37893db35541fe6c360d5536b4375646dd42c46115\"\n    },\n    \"vercel-react-best-practices\": {\n      \"source\": \"vercel-labs/agent-skills\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"c858d2d9023e73edb4e5504868cb0e36313e58fd56af12ef888b43c5634a3c17\"\n    },\n    \"web-design-guidelines\": {\n      \"source\": \"vercel-labs/agent-skills\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"a6a44d5498f7e8f68289902f3dedfc6f38ae0cee1e96527c80724cf27f727c2a\"\n    },\n    \"workers-best-practices\": {\n      \"source\": \"cloudflare/skills\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"420e1ad3eaeb8ed2016e6b34fff3757de6aa39899ec2bd6e9784a29c38e70f9a\"\n    },\n    \"wrangler\": {\n      \"source\": \"cloudflare/skills\",\n      \"sourceType\": \"github\",\n      \"computedHash\": \"cd0ac8f97a2f54d19166acfd380fca8236db5e9f37d3b2066f713967e2673d36\"\n    }\n  }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"strictNullChecks\": true\n  }\n}\n"
  },
  {
    "path": "turbo.json",
    "content": "{\n  \"$schema\": \"https://turbo.build/schema.json\",\n  \"ui\": \"tui\",\n  \"tasks\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\", \"^db:generate\"],\n      \"inputs\": [\"$TURBO_DEFAULT$\", \".env*\"],\n      \"outputs\": [\".next/**\", \"!.next/cache/**\", \"dist/**\", \".vercel/**\"],\n      \"env\": [\n        \"NODE_ENV\",\n        \"DATABASE_URL\",\n        \"BETTER_AUTH_URL\",\n        \"BETTER_AUTH_SECRET\",\n        \"GOOGLE_CLIENT_ID\",\n        \"GOOGLE_CLIENT_SECRET\",\n        \"GITHUB_ID\",\n        \"GITHUB_SECRET\",\n        \"RESEND_API_KEY\",\n        \"CLOUDFLARE_ACCESS_KEY_ID\",\n        \"CLOUDFLARE_SECRET_ACCESS_KEY\",\n        \"CLOUDFLARE_BUCKET_NAME\",\n        \"CLOUDFLARE_TOKEN\",\n        \"CLOUDFLARE_S3_ENDPOINT\",\n        \"CLOUDFLARE_PUBLIC_URL\",\n        \"POLAR_ACCESS_TOKEN\",\n        \"POLAR_WEBHOOK_SECRET\",\n        \"POLAR_HOBBY_PRODUCT_ID\",\n        \"POLAR_PRO_PRODUCT_ID\",\n        \"POLAR_PRO_YEARLY_PRODUCT_ID\",\n        \"POLAR_SUCCESS_URL\",\n        \"MARBLE_API_KEY\",\n        \"MARBLE_API_URL\",\n        \"SYSTEM_SECRET\",\n        \"REDIS_URL\",\n        \"REDIS_TOKEN\",\n        \"AI_GATEWAY_API_KEY\",\n        \"DATABUDDY_CLIENT_ID\"\n      ]\n    },\n    \"lint\": {\n      \"dependsOn\": [\"transit\"]\n    },\n    \"dev\": {\n      \"dependsOn\": [\"^db:generate\"],\n      \"cache\": false,\n      \"persistent\": true\n    },\n    \"//#format-lint:fix\": {\n      \"cache\": false\n    },\n    \"test\": {\n      \"dependsOn\": [\"transit\"]\n    },\n    \"transit\": {\n      \"dependsOn\": [\"^transit\"]\n    },\n    \"db:generate\": {\n      \"cache\": false\n    }\n  }\n}\n"
  }
]