Terms of service
Last updated: {formattedDate}
================================================
FILE: apps/web/src/styles/globals.css
================================================
@import "tailwindcss";
/* Plugins (and related config) */
@plugin "@tailwindcss/typography";
@config "../../tailwind.config.ts";
:root {
--background: hsl(60 11% 98%);
--foreground: hsl(240 15% 14%);
--primary: hsl(240 10% 14%);
--primary-foreground: hsl(0 0% 98%);
--muted: hsl(0, 0%, 92%);
--muted-foreground: hsl(0, 0%, 25%);
--accent: hsl(244 100% 65%);
--accent-foreground: hsl(0 0% 9%);
--border: hsl(0 0% 86%);
/* radius */
--radius: 0.7rem;
}
@theme inline {
--font-sans:
var(--font-geist), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-serif:
var(--font-literata), ui-serif, Georgia, Cambria, "Times New Roman", Times,
serif;
/* border radius */
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
/* Palette mapped to root design tokens */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility prose {
& li p {
margin: 0;
}
}
@layer base {
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
html {
color: var(--foreground);
background: var(--background);
font-family: var(--font-geist), system-ui, sans-serif;
}
* {
@apply border-border selection:bg-border;
}
body {
@apply bg-background text-foreground antialiased;
}
.faq-answer a {
@apply text-foreground hover:text-primary underline font-medium;
}
}
================================================
FILE: apps/web/tailwind.config.ts
================================================
import type { Config } from "tailwindcss";
import defaultTheme from "tailwindcss/defaultTheme";
export default {
theme: {
extend: {
fontFamily: {
sans: ["var(--font-geist)", ...defaultTheme.fontFamily.sans],
serif: ["var(--font-literata)", ...defaultTheme.fontFamily.serif],
},
typography: () => ({
marble: {
css: {
"--tw-prose-bold": "var(--foreground)",
"--tw-prose-counters": "var(--foreground)",
"--tw-prose-bullets": "var(--muted-foreground)",
"--tw-prose-quotes": "var(--foreground)",
"--tw-prose-quote-borders": "var(--border)",
"--tw-prose-captions": "var(--muted-foreground)",
"--tw-prose-code": "var(--foreground)",
"--tw-prose-code-bg": "var(--muted)",
"--tw-prose-pre-code": "var(--color-zinc-100)",
"--tw-prose-pre-bg": "var(--color-zinc-800)",
"--tw-prose-th-borders": "var(--border)",
"--tw-prose-td-borders": "var(--border)",
"code:not(pre code)": {
color: "var(--tw-prose-code)",
backgroundColor: "var(--tw-prose-code-bg)",
borderRadius: "0.375rem",
paddingInline: "0.275rem",
fontSize: "0.875rem",
fontWeight: "600",
display: "inline-block",
},
},
},
DEFAULT: {
css: {
a: {
"&:hover": {
color: "var(--accent)",
},
},
},
},
}),
},
},
} satisfies Config;
================================================
FILE: apps/web/tsconfig.json
================================================
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
================================================
FILE: biome.jsonc
================================================
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": [
"ultracite/biome/core",
"ultracite/biome/react",
"ultracite/biome/next",
"ultracite/biome/astro"
],
"html": {
"experimentalFullSupportEnabled": true
},
"files": {
"includes": [
"!packages/ui/src",
"packages/editor/src",
"!apps/cms/src/hooks/use-mobile.tsx"
]
},
"linter": {
"rules": {
"suspicious": {
/* Needs more work to fix */
"noConsole": "off",
/* Needs more work to fix */
"useAwait": "off",
/* Allowed for Tailwind */
"noUnknownAtRules": "off"
},
"style": {
/* Needs more work to fix */
"noMagicNumbers": "off",
/* Needs more work to fix */
"noNestedTernary": "off",
/* Doesn't work with Astro */
"useFilenamingConvention": "off"
},
"complexity": {
/* Has false positives */
"useSimplifiedLogicExpression": "off",
/* Needs more work to fix */
"noExcessiveCognitiveComplexity": "off"
},
"nursery": {
/* Needs more work to fix */
"noShadow": "off",
/* Has false positives */
"noUnnecessaryConditions": "off"
},
"performance": {
/* Needs more work to fix */
"useTopLevelRegex": "off",
"noNamespaceImport": "warn"
},
"correctness": {
/* Doesn't work with Astro */
"noUnusedImports": "off",
/* Needs more work to fix */
"noUnusedVariables": "off"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"overrides": [
{
"includes": ["apps/mcp/**/*.tsx"],
"linter": {
"rules": {
"style": {
"noHeadElement": "off"
},
"performance": {
"noImgElement": "off"
}
}
}
}
]
}
================================================
FILE: commitlint.config.ts
================================================
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"build",
"chore",
"ci",
"docs",
"feat",
"fix",
"perf",
"refactor",
"revert",
"style",
"test",
],
],
},
};
================================================
FILE: docker-compose.yml
================================================
services:
db:
image: postgres:15
environment:
POSTGRES_USER: usemarble
POSTGRES_PASSWORD: justusemarble
POSTGRES_DB: marble
TZ: UTC
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
redis:
image: redis
restart: always
ports:
- "6379:6379"
serverless-redis-http:
ports:
- "8079:80"
image: hiett/serverless-redis-http:latest
restart: always
environment:
SRH_MODE: env
SRH_TOKEN: justusemarble
SRH_CONNECTION_STRING: "redis://redis:6379" # Using `redis` hostname since they're in the same Docker network.
volumes:
pgdata:
================================================
FILE: package.json
================================================
{
"name": "marblecms",
"private": true,
"scripts": {
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:clean": "docker compose down -v",
"docker:logs": "docker compose logs -f",
"docker:ps": "docker compose ps",
"docker:restart": "docker compose restart",
"db:migrate": "pnpm --filter @marble/db db:migrate",
"db:deploy": "pnpm --filter @marble/db db:deploy",
"db:generate": "pnpm --filter @marble/db db:generate",
"db:studio": "pnpm --filter @marble/db db:studio",
"db:push": "pnpm --filter @marble/db db:push",
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "pnpx ultracite@latest check",
"format": "pnpx ultracite@latest fix",
"web:dev": "turbo run dev --filter=web",
"cms:dev": "turbo run dev --filter=cms",
"api:dev": "turbo run dev --filter=api",
"docs:dev": "turbo run dev --filter=docs",
"mcp:dev": "turbo run dev --filter=mcp",
"test": "turbo run test",
"prepare": "husky"
},
"devDependencies": {
"@biomejs/biome": "2.3.2",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@marble/tsconfig": "workspace:*",
"husky": "^9.1.7",
"turbo": "^2.7.2",
"typescript": "^5.9.3",
"ultracite": "7.0.3"
},
"engines": {
"node": ">=22.12.0"
},
"pnpm": {
"overrides": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
}
},
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
}
================================================
FILE: packages/db/.gitignore
================================================
node_modules
# Keep environment variables out of version control
.env
scripts
*.md
================================================
FILE: packages/db/package.json
================================================
{
"name": "@marble/db",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts",
"./browser": "./src/browser.ts",
"./workers": "./src/workers.ts",
"./hyperdrive": "./src/hyperdrive.ts"
},
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push --skip-generate",
"db:studio": "prisma studio",
"postinstall": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy"
},
"dependencies": {
"@neondatabase/serverless": "^1.0.1",
"@prisma/adapter-neon": "^7.0.0",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"pg": "^8.18.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@marble/tsconfig": "workspace:*",
"@types/node": "^22.9.0",
"@types/ws": "^8.5.13",
"bufferutil": "^4.0.9",
"dotenv": "^16.4.7",
"prisma": "^7.0.0",
"typescript": "^5.9.3"
}
}
================================================
FILE: packages/db/prisma/migrations/0_init/migration.sql
================================================
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "public"."PostStatus" AS ENUM ('published', 'draft');
-- CreateEnum
CREATE TYPE "public"."PlanType" AS ENUM ('team', 'pro');
-- CreateEnum
CREATE TYPE "public"."SubscriptionStatus" AS ENUM ('active', 'cancelled', 'expired', 'trialing', 'past_due');
-- CreateEnum
CREATE 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');
-- CreateEnum
CREATE TYPE "public"."PayloadFormat" AS ENUM ('json', 'discord');
-- CreateEnum
CREATE TYPE "public"."MediaType" AS ENUM ('image', 'video', 'audio', 'document');
-- CreateTable
CREATE TABLE "public"."subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"plan" "public"."PlanType" NOT NULL,
"status" "public"."SubscriptionStatus" NOT NULL DEFAULT 'active',
"updatedAt" TIMESTAMP(3) NOT NULL,
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
"canceledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"currentPeriodEnd" TIMESTAMP(3) NOT NULL,
"currentPeriodStart" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endsAt" TIMESTAMP(3),
"polarId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
CONSTRAINT "subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."workspace" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"logo" TEXT,
"metadata" TEXT,
"description" TEXT,
"subdomain" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"timezone" TEXT NOT NULL DEFAULT 'Europe/London',
CONSTRAINT "workspace_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."post" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"coverImage" TEXT,
"contentJson" JSONB NOT NULL,
"description" TEXT NOT NULL,
"views" INTEGER NOT NULL DEFAULT 0,
"workspaceId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
"status" "public"."PostStatus" NOT NULL DEFAULT 'draft',
"featured" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"attribution" JSONB,
"primaryAuthorId" TEXT NOT NULL,
CONSTRAINT "post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"workspaceId" TEXT NOT NULL,
CONSTRAINT "tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."media" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"workspaceId" TEXT NOT NULL,
"type" "public"."MediaType" NOT NULL DEFAULT 'image',
CONSTRAINT "media_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."category" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"slug" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"workspaceId" TEXT NOT NULL,
CONSTRAINT "category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."webhook" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"workspaceId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"events" "public"."WebhookEvent"[],
"format" "public"."PayloadFormat" NOT NULL DEFAULT 'json',
CONSTRAINT "webhook_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
"activeOrganizationId" TEXT,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3),
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."member" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "member_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."invitation" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT,
"status" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"inviterId" TEXT NOT NULL,
CONSTRAINT "invitation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."_PostToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PostToTag_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "public"."_PostToUser" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PostToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "subscription_polarId_key" ON "public"."subscription"("polarId");
-- CreateIndex
CREATE UNIQUE INDEX "subscription_workspaceId_key" ON "public"."subscription"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "workspace_slug_key" ON "public"."workspace"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "workspace_subdomain_key" ON "public"."workspace"("subdomain");
-- CreateIndex
CREATE UNIQUE INDEX "post_workspaceId_slug_key" ON "public"."post"("workspaceId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "tag_workspaceId_slug_key" ON "public"."tag"("workspaceId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "category_workspaceId_slug_key" ON "public"."category"("workspaceId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "public"."user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "public"."session"("token");
-- CreateIndex
CREATE INDEX "_PostToTag_B_index" ON "public"."_PostToTag"("B");
-- CreateIndex
CREATE INDEX "_PostToUser_B_index" ON "public"."_PostToUser"("B");
-- AddForeignKey
ALTER TABLE "public"."subscription" ADD CONSTRAINT "subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."subscription" ADD CONSTRAINT "subscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."post" ADD CONSTRAINT "post_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "public"."category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."post" ADD CONSTRAINT "post_primaryAuthorId_fkey" FOREIGN KEY ("primaryAuthorId") REFERENCES "public"."user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."post" ADD CONSTRAINT "post_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."tag" ADD CONSTRAINT "tag_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."media" ADD CONSTRAINT "media_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."category" ADD CONSTRAINT "category_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."webhook" ADD CONSTRAINT "webhook_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_PostToTag" ADD CONSTRAINT "_PostToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_PostToTag" ADD CONSTRAINT "_PostToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_PostToUser" ADD CONSTRAINT "_PostToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_PostToUser" ADD CONSTRAINT "_PostToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250831193214_add_author_table/migration.sql
================================================
-- AlterTable
ALTER TABLE "public"."post" ADD COLUMN "newPrimaryAuthorId" TEXT;
-- CreateTable
CREATE TABLE "public"."author" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"bio" TEXT,
"image" TEXT,
"role" TEXT,
"slug" TEXT NOT NULL,
"socials" JSONB,
"workspaceId" TEXT NOT NULL,
"userId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "author_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."_PostToAuthor" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PostToAuthor_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "author_workspaceId_userId_key" ON "public"."author"("workspaceId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "author_workspaceId_slug_key" ON "public"."author"("workspaceId", "slug");
-- CreateIndex
CREATE INDEX "_PostToAuthor_B_index" ON "public"."_PostToAuthor"("B");
-- AddForeignKey
ALTER TABLE "public"."post" ADD CONSTRAINT "post_newPrimaryAuthorId_fkey" FOREIGN KEY ("newPrimaryAuthorId") REFERENCES "public"."author"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."author" ADD CONSTRAINT "author_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."author" ADD CONSTRAINT "author_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_PostToAuthor" ADD CONSTRAINT "_PostToAuthor_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."author"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."_PostToAuthor" ADD CONSTRAINT "_PostToAuthor_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250907120320_make_new_primary_author_required/migration.sql
================================================
/*
Warnings:
- Made the column `newPrimaryAuthorId` on table `post` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "public"."post" DROP CONSTRAINT "post_newPrimaryAuthorId_fkey";
-- AlterTable
ALTER TABLE "public"."post" ALTER COLUMN "newPrimaryAuthorId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "public"."post" ADD CONSTRAINT "post_newPrimaryAuthorId_fkey" FOREIGN KEY ("newPrimaryAuthorId") REFERENCES "public"."author"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250907125704_drop_legacy_user_author_fields/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `primaryAuthorId` on the `post` table. All the data in the column will be lost.
- You are about to drop the `_PostToUser` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "public"."_PostToUser" DROP CONSTRAINT "_PostToUser_A_fkey";
-- DropForeignKey
ALTER TABLE "public"."_PostToUser" DROP CONSTRAINT "_PostToUser_B_fkey";
-- DropForeignKey
ALTER TABLE "public"."post" DROP CONSTRAINT "post_primaryAuthorId_fkey";
-- AlterTable
ALTER TABLE "public"."post" DROP COLUMN "primaryAuthorId";
-- DropTable
DROP TABLE "public"."_PostToUser";
================================================
FILE: packages/db/prisma/migrations/20250907194746_rename_author_fields_to_final_names/migration.sql
================================================
/*
Rename author fields to final names:
- Rename newPrimaryAuthorId to primaryAuthorId
- Update foreign key constraint names
*/
-- Drop the old foreign key constraint
ALTER TABLE "public"."post" DROP CONSTRAINT "post_newPrimaryAuthorId_fkey";
-- Rename the column
ALTER TABLE "public"."post" RENAME COLUMN "newPrimaryAuthorId" TO "primaryAuthorId";
-- Add the new foreign key constraint with the renamed column
ALTER TABLE "public"."post" ADD CONSTRAINT "post_primaryAuthorId_fkey" FOREIGN KEY ("primaryAuthorId") REFERENCES "public"."author"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250908090455_make_primary_author_optional/migration.sql
================================================
-- DropForeignKey
ALTER TABLE "public"."post" DROP CONSTRAINT "post_primaryAuthorId_fkey";
-- AlterTable
ALTER TABLE "public"."post" ALTER COLUMN "primaryAuthorId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "public"."post" ADD CONSTRAINT "post_primaryAuthorId_fkey" FOREIGN KEY ("primaryAuthorId") REFERENCES "public"."author"("id") ON DELETE SET NULL ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250909162749_make_published_at_optional/migration.sql
================================================
-- AlterTable
ALTER TABLE "public"."post" ALTER COLUMN "publishedAt" DROP NOT NULL,
ALTER COLUMN "publishedAt" DROP DEFAULT;
================================================
FILE: packages/db/prisma/migrations/20250909171017_make_published_at_required/migration.sql
================================================
/*
Warnings:
- Made the column `publishedAt` on table `post` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "public"."post" ALTER COLUMN "publishedAt" SET NOT NULL;
================================================
FILE: packages/db/prisma/migrations/20250911083948_add_slack_payload_format/migration.sql
================================================
-- AlterEnum
ALTER TYPE "public"."PayloadFormat" ADD VALUE 'slack';
================================================
FILE: packages/db/prisma/migrations/20250915114755_add_database_indices/migration.sql
================================================
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "public"."account"("userId");
-- CreateIndex
CREATE INDEX "account_providerId_accountId_idx" ON "public"."account"("providerId", "accountId");
-- CreateIndex
CREATE INDEX "author_workspaceId_isActive_idx" ON "public"."author"("workspaceId", "isActive");
-- CreateIndex
CREATE INDEX "author_userId_idx" ON "public"."author"("userId");
-- CreateIndex
CREATE INDEX "category_workspaceId_idx" ON "public"."category"("workspaceId");
-- CreateIndex
CREATE INDEX "invitation_organizationId_idx" ON "public"."invitation"("organizationId");
-- CreateIndex
CREATE INDEX "invitation_email_idx" ON "public"."invitation"("email");
-- CreateIndex
CREATE INDEX "invitation_inviterId_idx" ON "public"."invitation"("inviterId");
-- CreateIndex
CREATE INDEX "media_workspaceId_createdAt_idx" ON "public"."media"("workspaceId", "createdAt");
-- CreateIndex
CREATE INDEX "media_workspaceId_type_idx" ON "public"."media"("workspaceId", "type");
-- CreateIndex
CREATE INDEX "member_userId_idx" ON "public"."member"("userId");
-- CreateIndex
CREATE INDEX "member_organizationId_idx" ON "public"."member"("organizationId");
-- CreateIndex
CREATE INDEX "member_organizationId_userId_idx" ON "public"."member"("organizationId", "userId");
-- CreateIndex
CREATE INDEX "post_workspaceId_status_idx" ON "public"."post"("workspaceId", "status");
-- CreateIndex
CREATE INDEX "post_workspaceId_createdAt_idx" ON "public"."post"("workspaceId", "createdAt");
-- CreateIndex
CREATE INDEX "post_workspaceId_status_publishedAt_idx" ON "public"."post"("workspaceId", "status", "publishedAt");
-- CreateIndex
CREATE INDEX "post_categoryId_idx" ON "public"."post"("categoryId");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "public"."session"("userId");
-- CreateIndex
CREATE INDEX "session_activeOrganizationId_idx" ON "public"."session"("activeOrganizationId");
-- CreateIndex
CREATE INDEX "subscription_userId_idx" ON "public"."subscription"("userId");
-- CreateIndex
CREATE INDEX "subscription_status_idx" ON "public"."subscription"("status");
-- CreateIndex
CREATE INDEX "tag_workspaceId_idx" ON "public"."tag"("workspaceId");
-- CreateIndex
CREATE INDEX "webhook_workspaceId_idx" ON "public"."webhook"("workspaceId");
-- CreateIndex
CREATE INDEX "webhook_workspaceId_enabled_idx" ON "public"."webhook"("workspaceId", "enabled");
================================================
FILE: packages/db/prisma/migrations/20250919210238_add_share_link_table/migration.sql
================================================
-- CreateTable
CREATE TABLE "public"."ShareLink" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"password" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ShareLink_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ShareLink_token_key" ON "public"."ShareLink"("token");
-- CreateIndex
CREATE INDEX "ShareLink_postId_idx" ON "public"."ShareLink"("postId");
-- CreateIndex
CREATE INDEX "ShareLink_workspaceId_idx" ON "public"."ShareLink"("workspaceId");
-- CreateIndex
CREATE INDEX "ShareLink_expiresAt_idx" ON "public"."ShareLink"("expiresAt");
-- CreateIndex
CREATE INDEX "ShareLink_isActive_idx" ON "public"."ShareLink"("isActive");
-- AddForeignKey
ALTER TABLE "public"."ShareLink" ADD CONSTRAINT "ShareLink_postId_fkey" FOREIGN KEY ("postId") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ShareLink" ADD CONSTRAINT "ShareLink_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250923212858_add_ai_editor_preferences/migration.sql
================================================
-- CreateTable
CREATE TABLE "public"."editor_preferences" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
CONSTRAINT "editor_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."ai" (
"id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"editorPreferencesId" TEXT NOT NULL,
CONSTRAINT "ai_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "editor_preferences_workspaceId_key" ON "public"."editor_preferences"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "ai_editorPreferencesId_key" ON "public"."ai"("editorPreferencesId");
-- AddForeignKey
ALTER TABLE "public"."editor_preferences" ADD CONSTRAINT "editor_preferences_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."ai" ADD CONSTRAINT "ai_editorPreferencesId_fkey" FOREIGN KEY ("editorPreferencesId") REFERENCES "public"."editor_preferences"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20250924180405_add_missing_better_auth_indices/migration.sql
================================================
-- CreateIndex
CREATE INDEX "session_token_idx" ON "public"."session"("token");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "public"."verification"("identifier");
================================================
FILE: packages/db/prisma/migrations/20250927161627_add_author_social_links/migration.sql
================================================
/*
Warnings:
- You are about to drop the column `socials` on the `author` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "public"."author" DROP COLUMN "socials";
-- CreateTable
CREATE TABLE "public"."author_social" (
"id" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"url" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "author_social_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "author_social_authorId_idx" ON "public"."author_social"("authorId");
-- AddForeignKey
ALTER TABLE "public"."author_social" ADD CONSTRAINT "author_social_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."author"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20251114225009_add_usage_event_table/migration.sql
================================================
-- CreateEnum
CREATE TYPE "UsageEventType" AS ENUM ('api_request', 'media_upload', 'webhook_delivery');
-- CreateTable
CREATE TABLE "usage_event" (
"id" TEXT NOT NULL,
"type" "UsageEventType" NOT NULL,
"workspaceId" TEXT NOT NULL,
"endpoint" TEXT,
"size" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "usage_event_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "usage_event_workspaceId_type_createdAt_idx" ON "usage_event"("workspaceId", "type", "createdAt");
-- CreateIndex
CREATE INDEX "usage_event_workspaceId_createdAt_idx" ON "usage_event"("workspaceId", "createdAt");
-- AddForeignKey
ALTER TABLE "usage_event" ADD CONSTRAINT "usage_event_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20251116173412_new_media_enum_and_alt_text_column/migration.sql
================================================
-- AlterEnum
ALTER TYPE "WebhookEvent" ADD VALUE 'media_updated';
-- AlterTable
ALTER TABLE "media" ADD COLUMN "alt" TEXT;
================================================
FILE: packages/db/prisma/migrations/20251201001521_add_api_keys/migration.sql
================================================
-- CreateEnum
CREATE TYPE "public"."ApiKeyType" AS ENUM ('public', 'private');
-- CreateEnum
CREATE 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');
-- CreateTable
CREATE TABLE "public"."api_key" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"userId" TEXT,
"name" TEXT NOT NULL,
"prefix" TEXT,
"key" TEXT NOT NULL,
"preview" TEXT NOT NULL,
"type" "public"."ApiKeyType" NOT NULL DEFAULT 'public',
"scopes" "public"."ApiScope"[] DEFAULT ARRAY[]::"public"."ApiScope"[],
"requestCount" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"rateLimitTimeWindow" INTEGER,
"rateLimitMax" INTEGER,
"lastRequest" TIMESTAMP(3),
"lastUsed" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "api_key_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "api_key_key_key" ON "public"."api_key"("key");
-- CreateIndex
CREATE INDEX "api_key_workspaceId_idx" ON "public"."api_key"("workspaceId");
-- CreateIndex
CREATE INDEX "api_key_workspaceId_createdAt_idx" ON "public"."api_key"("workspaceId", "createdAt");
-- CreateIndex
CREATE INDEX "api_key_workspaceId_enabled_idx" ON "public"."api_key"("workspaceId", "enabled");
-- CreateIndex
CREATE INDEX "api_key_workspaceId_type_idx" ON "public"."api_key"("workspaceId", "type");
-- CreateIndex
CREATE INDEX "api_key_key_idx" ON "public"."api_key"("key");
-- AddForeignKey
ALTER TABLE "public"."api_key" ADD CONSTRAINT "api_key_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."api_key" ADD CONSTRAINT "api_key_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20251210213108_subscription_history/migration.sql
================================================
/*
Warnings:
- The values [team] on the enum `PlanType` will be removed. If these variants are still used in the database, this will fail.
- The values [cancelled] on the enum `SubscriptionStatus` will be removed. If these variants are still used in the database, this will fail.
*/
-- CreateEnum
CREATE TYPE "public"."SubscriptionRecurringInterval" AS ENUM ('day', 'week', 'month', 'year');
-- AlterEnum
BEGIN;
CREATE TYPE "public"."PlanType_new" AS ENUM ('hobby', 'pro');
ALTER TABLE "public"."subscription" ALTER COLUMN "plan" TYPE "public"."PlanType_new" USING ("plan"::text::"public"."PlanType_new");
ALTER TYPE "public"."PlanType" RENAME TO "PlanType_old";
ALTER TYPE "public"."PlanType_new" RENAME TO "PlanType";
DROP TYPE "public"."PlanType_old";
COMMIT;
-- AlterEnum
BEGIN;
CREATE TYPE "public"."SubscriptionStatus_new" AS ENUM ('active', 'expired', 'trialing', 'past_due', 'incomplete', 'incomplete_expired', 'unpaid', 'canceled');
ALTER TABLE "public"."subscription" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "public"."subscription" ALTER COLUMN "status" TYPE "public"."SubscriptionStatus_new" USING ("status"::text::"public"."SubscriptionStatus_new");
ALTER TYPE "public"."SubscriptionStatus" RENAME TO "SubscriptionStatus_old";
ALTER TYPE "public"."SubscriptionStatus_new" RENAME TO "SubscriptionStatus";
DROP TYPE "public"."SubscriptionStatus_old";
COMMIT;
-- DropIndex
DROP INDEX "public"."subscription_workspaceId_key";
-- AlterTable
ALTER TABLE "public"."invitation" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "public"."subscription" ADD COLUMN "amount" INTEGER NOT NULL DEFAULT 20,
ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'USD',
ADD COLUMN "discountId" TEXT,
ADD COLUMN "productId" TEXT,
ADD COLUMN "recurringInterval" "public"."SubscriptionRecurringInterval" NOT NULL DEFAULT 'month',
ADD COLUMN "startedAt" TIMESTAMP(3),
ALTER COLUMN "status" DROP DEFAULT,
ALTER COLUMN "cancelAtPeriodEnd" DROP DEFAULT;
-- CreateIndex
CREATE INDEX "subscription_workspaceId_status_idx" ON "public"."subscription"("workspaceId", "status");
================================================
FILE: packages/db/prisma/migrations/20260331143009_add_fields/migration.sql
================================================
/*
Warnings:
- A unique constraint covering the columns `[id,workspaceId]` on the table `post` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateEnum
CREATE TYPE "public"."FieldType" AS ENUM ('text', 'number', 'boolean', 'date', 'richtext', 'select', 'multiselect');
-- CreateTable
CREATE TABLE "public"."field" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" "public"."FieldType" NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "field_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."field_option" (
"id" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"value" TEXT NOT NULL,
"label" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "field_option_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."field_value" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "field_value_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "field_workspaceId_idx" ON "public"."field"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "field_workspaceId_key_key" ON "public"."field"("workspaceId", "key");
-- CreateIndex
CREATE UNIQUE INDEX "field_id_workspaceId_key" ON "public"."field"("id", "workspaceId");
-- CreateIndex
CREATE INDEX "field_option_fieldId_idx" ON "public"."field_option"("fieldId");
-- CreateIndex
CREATE INDEX "field_option_workspaceId_idx" ON "public"."field_option"("workspaceId");
-- CreateIndex
CREATE INDEX "field_option_fieldId_position_idx" ON "public"."field_option"("fieldId", "position");
-- CreateIndex
CREATE UNIQUE INDEX "field_option_fieldId_value_key" ON "public"."field_option"("fieldId", "value");
-- CreateIndex
CREATE UNIQUE INDEX "field_option_id_workspaceId_key" ON "public"."field_option"("id", "workspaceId");
-- CreateIndex
CREATE INDEX "field_value_postId_idx" ON "public"."field_value"("postId");
-- CreateIndex
CREATE INDEX "field_value_fieldId_idx" ON "public"."field_value"("fieldId");
-- CreateIndex
CREATE INDEX "field_value_workspaceId_idx" ON "public"."field_value"("workspaceId");
-- CreateIndex
CREATE UNIQUE INDEX "field_value_postId_fieldId_key" ON "public"."field_value"("postId", "fieldId");
-- CreateIndex
CREATE UNIQUE INDEX "post_id_workspaceId_key" ON "public"."post"("id", "workspaceId");
-- AddForeignKey
ALTER TABLE "public"."field" ADD CONSTRAINT "field_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER 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;
-- AddForeignKey
ALTER TABLE "public"."field_option" ADD CONSTRAINT "field_option_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER 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;
-- AddForeignKey
ALTER 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;
-- AddForeignKey
ALTER TABLE "public"."field_value" ADD CONSTRAINT "field_value_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20260505135201_add_media_metadata/migration.sql
================================================
-- AlterTable
ALTER TABLE "media" ADD COLUMN "blurHash" TEXT,
ADD COLUMN "duration" INTEGER,
ADD COLUMN "height" INTEGER,
ADD COLUMN "mimeType" TEXT,
ADD COLUMN "width" INTEGER;
================================================
FILE: packages/db/prisma/migrations/20260508223056_add_notification_preferences/migration.sql
================================================
/*
Warnings:
- You are about to drop the `ai` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `editor_preferences` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ai" DROP CONSTRAINT "ai_editorPreferencesId_fkey";
-- DropForeignKey
ALTER TABLE "editor_preferences" DROP CONSTRAINT "editor_preferences_workspaceId_fkey";
-- DropTable
DROP TABLE "ai";
-- DropTable
DROP TABLE "editor_preferences";
-- CreateTable
CREATE TABLE "user_notification_preferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"marketing" BOOLEAN NOT NULL DEFAULT false,
"product" BOOLEAN NOT NULL DEFAULT true,
"marketingConsentedAt" TIMESTAMP(3),
"marketingConsentSource" TEXT,
"marketingUnsubscribedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_notification_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_notification_preferences" (
"id" TEXT NOT NULL,
"memberId" TEXT NOT NULL,
"usageAlerts" BOOLEAN NOT NULL DEFAULT true,
"subscriptions" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "workspace_notification_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_notification_preferences_userId_key" ON "user_notification_preferences"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "workspace_notification_preferences_memberId_key" ON "workspace_notification_preferences"("memberId");
-- AddForeignKey
ALTER TABLE "user_notification_preferences" ADD CONSTRAINT "user_notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workspace_notification_preferences" ADD CONSTRAINT "workspace_notification_preferences_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "member"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: packages/db/prisma/migrations/20260511_rename_webhook_to_webhook_endpoint/migration.sql
================================================
-- Rename the enum type (no data change, all existing values are preserved)
ALTER TYPE "WebhookEvent" RENAME TO "WorkspaceEventType";
-- Add the new post_created value to the enum
ALTER TYPE "WorkspaceEventType" ADD VALUE IF NOT EXISTS 'post_created';
-- Rename the table
ALTER TABLE "webhook" RENAME TO "webhook_endpoint";
-- Rename the endpoint column to url
ALTER TABLE "webhook_endpoint" RENAME COLUMN "endpoint" TO "url";
================================================
FILE: packages/db/prisma/migrations/20260513192507_add_workspace_events/migration.sql
================================================
-- CreateEnum
CREATE TYPE "WorkspaceEventSource" AS ENUM ('dashboard', 'api', 'mcp', 'workflow', 'system');
-- CreateEnum
CREATE TYPE "WorkspaceEventActorType" AS ENUM ('user', 'api_key', 'mcp', 'system');
-- CreateEnum
CREATE TYPE "WorkspaceEventResourceType" AS ENUM ('post', 'category', 'tag', 'media', 'author', 'workspace');
-- CreateEnum
CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('pending', 'sending', 'success', 'retrying', 'failed');
-- CreateEnum
CREATE TYPE "UsageAlertKind" AS ENUM ('warning', 'critical', 'exhausted');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "WorkspaceEventType" ADD VALUE 'post_unpublished';
ALTER TYPE "WorkspaceEventType" ADD VALUE 'author_created';
ALTER TYPE "WorkspaceEventType" ADD VALUE 'author_updated';
ALTER TYPE "WorkspaceEventType" ADD VALUE 'author_deleted';
-- AlterTable
ALTER TABLE "webhook_endpoint" RENAME CONSTRAINT "webhook_pkey" TO "webhook_endpoint_pkey";
-- CreateTable
CREATE TABLE "usage_alert" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"type" "UsageEventType" NOT NULL,
"kind" "UsageAlertKind" NOT NULL,
"periodStart" TIMESTAMP(3) NOT NULL,
"periodEnd" TIMESTAMP(3) NOT NULL,
"emailSentTo" TEXT NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "usage_alert_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "workspace_event" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"type" "WorkspaceEventType" NOT NULL,
"source" "WorkspaceEventSource" NOT NULL DEFAULT 'dashboard',
"resourceType" "WorkspaceEventResourceType",
"resourceId" TEXT,
"actorType" "WorkspaceEventActorType",
"actorId" TEXT,
"payload" JSONB NOT NULL DEFAULT '{}',
"processedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workspace_event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "webhook_delivery" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"webhookEndpointId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'pending',
"isTest" BOOLEAN NOT NULL DEFAULT false,
"attemptCount" INTEGER NOT NULL DEFAULT 0,
"maxAttempts" INTEGER NOT NULL DEFAULT 3,
"nextRetryAt" TIMESTAMP(3),
"lastAttemptAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"failedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "webhook_delivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "webhook_delivery_attempt" (
"id" TEXT NOT NULL,
"deliveryId" TEXT NOT NULL,
"attemptNumber" INTEGER NOT NULL,
"success" BOOLEAN NOT NULL DEFAULT false,
"statusCode" INTEGER,
"responseBody" TEXT,
"errorMessage" TEXT,
"durationMs" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "webhook_delivery_attempt_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "usage_alert_workspaceId_type_periodStart_periodEnd_idx" ON "usage_alert"("workspaceId", "type", "periodStart", "periodEnd");
-- CreateIndex
CREATE UNIQUE INDEX "usage_alert_workspaceId_type_kind_periodStart_periodEnd_key" ON "usage_alert"("workspaceId", "type", "kind", "periodStart", "periodEnd");
-- CreateIndex
CREATE INDEX "workspace_event_workspaceId_createdAt_idx" ON "workspace_event"("workspaceId", "createdAt");
-- CreateIndex
CREATE INDEX "workspace_event_workspaceId_type_idx" ON "workspace_event"("workspaceId", "type");
-- CreateIndex
CREATE INDEX "workspace_event_workspaceId_resourceType_resourceId_idx" ON "workspace_event"("workspaceId", "resourceType", "resourceId");
-- CreateIndex
CREATE INDEX "workspace_event_workspaceId_processedAt_idx" ON "workspace_event"("workspaceId", "processedAt");
-- CreateIndex
CREATE INDEX "webhook_delivery_eventId_idx" ON "webhook_delivery"("eventId");
-- CreateIndex
CREATE INDEX "webhook_delivery_workspaceId_status_idx" ON "webhook_delivery"("workspaceId", "status");
-- CreateIndex
CREATE INDEX "webhook_delivery_workspaceId_createdAt_idx" ON "webhook_delivery"("workspaceId", "createdAt");
-- CreateIndex
CREATE INDEX "webhook_delivery_webhookEndpointId_idx" ON "webhook_delivery"("webhookEndpointId");
-- CreateIndex
CREATE UNIQUE INDEX "webhook_delivery_eventId_webhookEndpointId_key" ON "webhook_delivery"("eventId", "webhookEndpointId");
-- CreateIndex
CREATE INDEX "webhook_delivery_attempt_deliveryId_idx" ON "webhook_delivery_attempt"("deliveryId");
-- CreateIndex
CREATE UNIQUE INDEX "webhook_delivery_attempt_deliveryId_attemptNumber_key" ON "webhook_delivery_attempt"("deliveryId", "attemptNumber");
-- RenameForeignKey
ALTER TABLE "webhook_endpoint" RENAME CONSTRAINT "webhook_workspaceId_fkey" TO "webhook_endpoint_workspaceId_fkey";
-- AddForeignKey
ALTER TABLE "usage_alert" ADD CONSTRAINT "usage_alert_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "workspace_event" ADD CONSTRAINT "workspace_event_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "workspace_event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_webhookEndpointId_fkey" FOREIGN KEY ("webhookEndpointId") REFERENCES "webhook_endpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "webhook_delivery_attempt" ADD CONSTRAINT "webhook_delivery_attempt_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "webhook_delivery"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "webhook_workspaceId_enabled_idx" RENAME TO "webhook_endpoint_workspaceId_enabled_idx";
-- RenameIndex
ALTER INDEX "webhook_workspaceId_idx" RENAME TO "webhook_endpoint_workspaceId_idx";
================================================
FILE: packages/db/prisma/migrations/20260515000100_add_subscription_polar_event_ordering/migration.sql
================================================
-- Store the latest Polar webhook timestamp applied to a subscription so stale
-- webhook deliveries cannot overwrite newer subscription state.
ALTER TABLE "subscription" ADD COLUMN "lastPolarEventAt" TIMESTAMP(3);
================================================
FILE: packages/db/prisma/migrations/migration_lock.toml
================================================
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
================================================
FILE: packages/db/prisma/schema.prisma
================================================
generator client {
provider = "prisma-client"
output = "../src/generated/node"
moduleFormat = "esm"
generatedFileExtension = "ts"
importFileExtension = "ts"
}
generator client_workers {
provider = "prisma-client"
runtime = "workerd"
output = "../src/generated/workerd"
moduleFormat = "esm"
generatedFileExtension = "ts"
importFileExtension = "ts"
}
datasource db {
provider = "postgresql"
}
model Subscription {
id String @id @default(cuid())
userId String
plan PlanType
status SubscriptionStatus
updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean
canceledAt DateTime?
createdAt DateTime @default(now())
currentPeriodEnd DateTime
currentPeriodStart DateTime
endedAt DateTime?
endsAt DateTime?
polarId String @unique
workspaceId String
startedAt DateTime?
productId String?
amount Int @default(20)
currency String @default("USD")
discountId String?
lastPolarEventAt DateTime?
recurringInterval SubscriptionRecurringInterval @default(month)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@index([workspaceId, status])
@@map("subscription")
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
logo String?
metadata String?
description String?
subdomain String? @unique
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
timezone String @default("Europe/London")
authors Author[]
categories Category[]
fields Field[]
invitations Invitation[]
media Media[]
members Member[]
posts Post[]
subscriptions Subscription[]
tags Tag[]
fieldOptions FieldOption[]
webhookEndpoints WebhookEndpoint[]
ShareLink ShareLink[]
usageEvents UsageEvent[]
ApiToken ApiKey[]
fieldValues FieldValue[]
workspaceEvents WorkspaceEvent[]
webhookDeliveries WebhookDelivery[]
usageAlerts UsageAlert[]
@@map("workspace")
}
model Post {
id String @id @default(cuid())
title String
content String
coverImage String?
contentJson Json
description String
views Int @default(0)
workspaceId String
slug String
categoryId String
status PostStatus @default(draft)
featured Boolean @default(false)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
publishedAt DateTime
attribution Json?
primaryAuthorId String?
primaryAuthor Author? @relation("PrimaryAuthor", fields: [primaryAuthorId], references: [id], onDelete: SetNull)
authors Author[] @relation("PostToAuthor")
category Category @relation(fields: [categoryId], references: [id])
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
tags Tag[] @relation("PostToTag")
shareLinks ShareLink[]
fieldValues FieldValue[]
@@unique([workspaceId, slug])
@@unique([id, workspaceId])
@@index([workspaceId, status])
@@index([workspaceId, createdAt])
@@index([workspaceId, status, publishedAt])
@@index([categoryId])
@@map("post")
}
model ShareLink {
id String @id @default(cuid())
token String @unique
postId String
workspaceId String
password String?
expiresAt DateTime
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([postId])
@@index([workspaceId])
@@index([expiresAt])
@@index([isActive])
}
model Tag {
id String @id @default(cuid())
name String
description String?
slug String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspaceId String
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
posts Post[] @relation("PostToTag")
@@unique([workspaceId, slug])
@@index([workspaceId])
@@map("tag")
}
model Media {
id String @id @default(cuid())
name String
url String
size Int
alt String?
mimeType String?
width Int?
height Int?
duration Int? // video length in milliseconds
blurHash String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspaceId String
type MediaType @default(image)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId, createdAt])
@@index([workspaceId, type])
@@map("media")
}
model Category {
id String @id @default(cuid())
name String
description String?
slug String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspaceId String
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
posts Post[]
@@unique([workspaceId, slug])
@@index([workspaceId])
@@map("category")
}
model WebhookEndpoint {
id String @id @default(cuid())
name String
url String
secret String
enabled Boolean @default(true)
workspaceId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events WorkspaceEventType[]
format PayloadFormat @default(json)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
deliveries WebhookDelivery[]
@@index([workspaceId])
@@index([workspaceId, enabled])
@@map("webhook_endpoint")
}
model User {
id String @id @default(cuid())
name String
email String @unique
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
accounts Account[]
authors Author[]
invitations Invitation[]
members Member[]
sessions Session[]
subscriptions Subscription[]
ApiKey ApiKey[]
notificationPreferences UserNotificationPreferences?
@@map("user")
}
model UserNotificationPreferences {
id String @id @default(cuid())
userId String @unique
marketing Boolean @default(false)
product Boolean @default(true)
marketingConsentedAt DateTime?
marketingConsentSource String?
marketingUnsubscribedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_notification_preferences")
}
model Author {
id String @id @default(cuid())
name String
email String?
bio String?
image String?
role String?
slug String
socials AuthorSocial[]
workspaceId String
userId String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
primaryPosts Post[] @relation("PrimaryAuthor")
coAuthoredPosts Post[] @relation("PostToAuthor")
@@unique([workspaceId, userId])
@@unique([workspaceId, slug])
@@index([workspaceId, isActive])
@@index([userId])
@@map("author")
}
model AuthorSocial {
id String @id @default(cuid())
authorId String
platform String
url String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author Author @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([authorId])
@@map("author_social")
}
model Session {
id String @id @default(cuid())
expiresAt DateTime
token String @unique
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
activeOrganizationId String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
@@index([activeOrganizationId])
@@map("session")
}
model Account {
id String @id @default(cuid())
accountId String
providerId String
userId String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([providerId, accountId])
@@map("account")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
@@index([identifier])
@@map("verification")
}
model Member {
id String @id @default(cuid())
organizationId String
userId String
role String?
createdAt DateTime
notificationPreferences WorkspaceNotificationPreferences?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([organizationId])
@@index([organizationId, userId])
@@map("member")
}
model WorkspaceNotificationPreferences {
id String @id @default(cuid())
memberId String @unique
usageAlerts Boolean @default(true)
subscriptions Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
@@map("workspace_notification_preferences")
}
model Invitation {
id String @id @default(cuid())
organizationId String
email String
role String?
status String
expiresAt DateTime
inviterId String
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([organizationId])
@@index([email])
@@index([inviterId])
@@map("invitation")
}
model UsageEvent {
id String @id @default(cuid())
type UsageEventType
workspaceId String
endpoint String?
size Int?
createdAt DateTime @default(now())
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId, type, createdAt])
@@index([workspaceId, createdAt])
@@map("usage_event")
}
model UsageAlert {
id String @id @default(cuid())
workspaceId String
type UsageEventType
kind UsageAlertKind
periodStart DateTime
periodEnd DateTime
emailSentTo String
sentAt DateTime @default(now())
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, type, kind, periodStart, periodEnd])
@@index([workspaceId, type, periodStart, periodEnd])
@@map("usage_alert")
}
enum UsageAlertKind {
warning
critical
exhausted
}
model ApiKey {
id String @id @default(cuid())
workspaceId String
userId String?
name String
prefix String?
key String @unique // SHA-256 hash of the API key
preview String
type ApiKeyType @default(public)
scopes ApiScope[] @default([])
requestCount Int @default(0)
enabled Boolean @default(true)
// Rate limiting fields
rateLimitTimeWindow Int? // milliseconds (e.g., 86400000 for 24 hours)
rateLimitMax Int? // max requests per window
lastRequest DateTime? // for rate limit window tracking
lastUsed DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([workspaceId])
@@index([workspaceId, createdAt])
@@index([workspaceId, enabled])
@@index([workspaceId, type])
@@index([key])
@@map("api_key")
}
enum PostStatus {
published
draft
}
enum PlanType {
hobby
pro
}
enum SubscriptionRecurringInterval {
day
week
month
year
}
enum SubscriptionStatus {
active // Active subscription
expired // End of period
trialing // Still in a trial period
past_due // Payment failed, unpaid, or incomplete
incomplete // Created, but with unpaid invoice
incomplete_expired // Created, but never made a payment and now expired
unpaid // Payment failed and is unpaid
canceled // Canceled (Polar uses American spelling)
}
enum WorkspaceEventType {
post_created
post_published
post_unpublished
post_updated
post_deleted
category_created
category_updated
category_deleted
tag_created
tag_updated
tag_deleted
media_uploaded
media_updated
media_deleted
author_created
author_updated
author_deleted
}
enum WorkspaceEventSource {
dashboard
api
mcp
workflow
system
}
enum WorkspaceEventActorType {
user
api_key
mcp
system
}
enum WorkspaceEventResourceType {
post
category
tag
media
author
workspace
}
enum PayloadFormat {
json
discord
slack
}
enum MediaType {
image
video
audio
document
}
enum UsageEventType {
api_request
media_upload
webhook_delivery
}
enum ApiKeyType {
public
private
}
enum ApiScope {
posts_read
posts_write
authors_read
authors_write
categories_read
categories_write
tags_read
tags_write
media_read
media_write
}
enum FieldType {
text
number
boolean
date
richtext
select
multiselect
}
model Field {
id String @id @default(cuid())
workspaceId String
key String
name String
description String?
type FieldType
required Boolean @default(false)
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
values FieldValue[]
options FieldOption[]
@@unique([workspaceId, key])
@@unique([id, workspaceId])
@@index([workspaceId])
@@map("field")
}
model FieldOption {
id String @id @default(cuid())
fieldId String
workspaceId String
value String
label String
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
field Field @relation(fields: [fieldId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([fieldId, value])
@@unique([id, workspaceId])
@@index([fieldId])
@@index([workspaceId])
@@index([fieldId, position])
@@map("field_option")
}
model FieldValue {
id String @id @default(cuid())
postId String
fieldId String
workspaceId String
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
field Field @relation(fields: [fieldId, workspaceId], references: [id, workspaceId], onDelete: Cascade)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([postId, fieldId])
@@index([postId])
@@index([fieldId])
@@index([workspaceId])
@@map("field_value")
}
model WorkspaceEvent {
id String @id @default(cuid())
workspaceId String
type WorkspaceEventType
source WorkspaceEventSource @default(dashboard)
resourceType WorkspaceEventResourceType?
resourceId String?
actorType WorkspaceEventActorType?
actorId String?
payload Json @default("{}")
processedAt DateTime?
createdAt DateTime @default(now())
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
deliveries WebhookDelivery[]
@@index([workspaceId, createdAt])
@@index([workspaceId, type])
@@index([workspaceId, resourceType, resourceId])
@@index([workspaceId, processedAt])
@@map("workspace_event")
}
model WebhookDelivery {
id String @id @default(cuid())
eventId String
workspaceId String
webhookEndpointId String
url String
status WebhookDeliveryStatus @default(pending)
isTest Boolean @default(false)
attemptCount Int @default(0)
maxAttempts Int @default(3)
nextRetryAt DateTime?
lastAttemptAt DateTime?
deliveredAt DateTime?
failedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event WorkspaceEvent @relation(fields: [eventId], references: [id], onDelete: Cascade)
workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
webhookEndpoint WebhookEndpoint @relation(fields: [webhookEndpointId], references: [id], onDelete: Cascade)
attempts WebhookDeliveryAttempt[]
@@index([eventId])
@@index([workspaceId, status])
@@index([workspaceId, createdAt])
@@index([webhookEndpointId])
@@unique([eventId, webhookEndpointId])
@@map("webhook_delivery")
}
model WebhookDeliveryAttempt {
id String @id @default(cuid())
deliveryId String
attemptNumber Int
success Boolean @default(false)
statusCode Int?
responseBody String?
errorMessage String?
durationMs Int?
createdAt DateTime @default(now())
delivery WebhookDelivery @relation(fields: [deliveryId], references: [id], onDelete: Cascade)
@@unique([deliveryId, attemptNumber])
@@index([deliveryId])
@@map("webhook_delivery_attempt")
}
enum WebhookDeliveryStatus {
pending
sending
success
retrying
failed
}
================================================
FILE: packages/db/prisma.config.ts
================================================
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
// Prefer a direct URL for CLI operations when available, but keep
// DATABASE_URL as a fallback so local generate/build flows stay simple.
url: process.env.DIRECT_URL ?? process.env.DATABASE_URL ?? "",
},
});
================================================
FILE: packages/db/src/browser.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: "required" */
export * from "./generated/node/browser";
================================================
FILE: packages/db/src/client.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: "required" */
export * from "./generated/node/client";
================================================
FILE: packages/db/src/hyperdrive.ts
================================================
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/workerd/client";
/**
* Create a Prisma client for Hyperdrive.
*
* Uses the pg-worker adapter (standard PostgreSQL protocol) instead of the Neon
* serverless driver. Compatible with Cloudflare Hyperdrive, which requires
* direct TCP Postgres connections per CF docs.
*
* Pass env.HYPERDRIVE.connectionString from your Worker. Same Prisma Client
* API for all queries — no schema changes needed.
*/
const createClient = (connectionString: string) => {
const url =
typeof connectionString === "string"
? connectionString.trim()
: String(connectionString || "").trim();
if (!url) {
throw new Error("Connection string is required and must be non-empty");
}
const adapter = new PrismaPg({ connectionString: url });
return new PrismaClient({ adapter });
};
export { createClient };
================================================
FILE: packages/db/src/index.ts
================================================
import { neonConfig } from "@neondatabase/serverless";
import { PrismaNeon } from "@prisma/adapter-neon";
import ws from "ws";
import { PrismaClient } from "./generated/node/client";
neonConfig.webSocketConstructor = ws;
const createClient = () => {
const connectionString = process.env.DATABASE_URL;
if (!connectionString || typeof connectionString !== "string") {
throw new Error("DATABASE_URL is not set");
}
const adapter = new PrismaNeon({ connectionString });
return new PrismaClient({ adapter });
};
declare global {
var prisma: PrismaClient | undefined;
}
let db: PrismaClient;
if (process.env.NODE_ENV === "production") {
db = createClient();
} else {
if (!global.prisma) {
global.prisma = createClient();
}
db = global.prisma;
}
export { db };
================================================
FILE: packages/db/src/workers.ts
================================================
import { PrismaNeon } from "@prisma/adapter-neon";
import { PrismaClient } from "./generated/workerd/client";
const createClient = (url: string) => {
const connectionString =
typeof url === "string" ? url.trim() : String(url || "").trim();
if (!connectionString) {
throw new Error("DATABASE_URL is required and must be a non-empty string");
}
const adapter = new PrismaNeon({ connectionString });
return new PrismaClient({ adapter });
};
export { createClient };
================================================
FILE: packages/db/tsconfig.json
================================================
{
"extends": "@marble/tsconfig/base.json",
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2023"
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
================================================
FILE: packages/demo-markdown.md
================================================
# Markdown Demo File
This is a demo markdown file to test markdown paste functionality in the editor.
## Images with Alt Text
Here's a simple image with alt text:

## Linked Image
Here's an image that's also a link:
[](https://taqib.dev)
## Multiple Images



## Text Formatting
This paragraph has **bold text**, _italic text_, and `inline code`.
## Lists
### Unordered List
- Item one
- Item two
- Item three
### Ordered List
1. First item
2. Second item
3. Third item
## Blockquote
> This is a blockquote with an image below it.
## Code Block
```javascript
function hello() {
console.log("Hello, World!");
}
```
## Links
Here'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:

## Table
| Column 1 | Column 2 | Column 3 |
| -------- | -------- | -------- |
| Data 1 | Data 2 | Data 3 |
| Data 4 | Data 5 | Data 6 |
## Horizontal Rule
---
## Final Image

End of demo file.
================================================
FILE: packages/editor/package.json
================================================
{
"name": "@marble/editor",
"version": "0.1.0",
"private": true,
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx",
"./extensions/*": "./src/extensions/*"
},
"scripts": {
"lint": "biome check .",
"format": "biome --write ."
},
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.6",
"@marble/ui": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"@tiptap/core": "3.22.3",
"@tiptap/extension-code-block-lowlight": "3.22.3",
"@tiptap/extension-document": "3.22.3",
"@tiptap/extension-drag-handle": "3.22.3",
"@tiptap/extension-drag-handle-react": "3.22.3",
"@tiptap/extension-file-handler": "3.22.3",
"@tiptap/extension-highlight": "3.22.3",
"@tiptap/extension-image": "3.22.3",
"@tiptap/extension-list": "3.22.3",
"@tiptap/extension-subscript": "3.22.3",
"@tiptap/extension-superscript": "3.22.3",
"@tiptap/extension-table": "3.22.3",
"@tiptap/extension-text-align": "3.22.3",
"@tiptap/extension-text-style": "3.22.3",
"@tiptap/extension-twitch": "3.22.3",
"@tiptap/extension-typography": "3.22.3",
"@tiptap/extension-youtube": "3.22.3",
"@tiptap/extensions": "3.22.3",
"@tiptap/markdown": "3.22.3",
"@tiptap/pm": "3.22.3",
"@tiptap/react": "3.22.3",
"@tiptap/starter-kit": "3.22.3",
"@tiptap/suggestion": "3.22.3",
"fuse.js": "^7.1.0",
"lowlight": "^3.3.0",
"react-colorful": "^5.6.1",
"react-tweet": "^3.3.0",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@marble/tsconfig": "workspace:*",
"@types/node": "^22.9.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
}
}
================================================
FILE: packages/editor/src/components/color-picker.tsx
================================================
import { Button } from "@marble/ui/components/button";
import { Input } from "@marble/ui/components/input";
import { ArrowUUpLeft } from "@phosphor-icons/react";
import { useCallback, useState } from "react";
import { HexColorPicker } from "react-colorful";
import "../styles/color-picker.css";
const PRESET_COLORS = [
"#fb7185", // Rose
"#fdba74", // Orange
"#d9f99d", // Lime
"#a7f3d0", // Emerald
"#a5f3fc", // Cyan
"#a5b4fc", // Indigo
];
export const ColorPicker = ({
color,
onChange,
onClear,
}: {
color?: string;
onChange: (color: string) => void;
onClear: () => void;
}) => {
const [hexInput, setHexInput] = useState(color || "");
const [prevColor, setPrevColor] = useState(color);
if (color !== prevColor) {
setPrevColor(color);
setHexInput(color || "");
}
const handleHexInputChange = useCallback(
(e: React.ChangeEvent
) => {
const value = e.target.value;
setHexInput(value);
// Validate hex color format
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
onChange(value);
}
},
[onChange]
);
const handleColorChange = useCallback(
(newColor: string) => {
setHexInput(newColor);
onChange(newColor);
},
[onChange]
);
return (
{PRESET_COLORS.map((presetColor) => (
);
};
================================================
FILE: packages/editor/src/components/editor-character-count.tsx
================================================
import { cn } from "@marble/ui/lib/utils";
import { useCurrentEditor } from "@tiptap/react";
import type { ReactNode } from "react";
export interface EditorCharacterCountProps {
children: ReactNode;
className?: string;
}
/**
* Character Count Component
*
* Displays character or word count statistics for the editor content.
* Provides two variants: Characters and Words.
*
* @example
* ```tsx
* Words:
* Characters:
* ```
*/
export const EditorCharacterCount = {
Characters({ children, className }: EditorCharacterCountProps) {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
{children}
{editor.storage.characterCount.characters()}
);
},
Words({ children, className }: EditorCharacterCountProps) {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
{children}
{editor.storage.characterCount.words()}
);
},
};
================================================
FILE: packages/editor/src/components/editor-content.tsx
================================================
import {
EditorContent as TiptapEditorContent,
useCurrentEditor,
} from "@tiptap/react";
/**
* EditorContent Component
*
* Component that renders the actual editor content area.
* This is the EditorContent component from @tiptap/react - the main editable area
* where users type and edit content.
*
*/
export function EditorContent() {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return ;
}
================================================
FILE: packages/editor/src/components/editor-provider.tsx
================================================
/** biome-ignore-all lint/suspicious/noExplicitAny: <> */
import type { AnyExtension } from "@tiptap/core";
import {
EditorProvider as TiptapEditorProvider,
type EditorProviderProps as TiptapEditorProviderProps,
type UseEditorOptions,
useEditor,
} from "@tiptap/react";
import { ExtensionKit } from "../extensions/extension-kit";
import { handleCommandNavigation } from "../extensions/slash-command";
function deduplicateExtensions(
defaults: AnyExtension[],
overrides: AnyExtension[]
): AnyExtension[] {
const overrideNames = new Set(overrides.map((ext) => ext.name));
return [
...defaults.filter((ext) => !overrideNames.has(ext.name)),
...overrides,
];
}
export type EditorProviderProps = Omit<
TiptapEditorProviderProps,
"extensions"
> & {
limit?: number;
placeholder?: string;
extensions?: any[];
};
/**
* Editor Provider Component
*
* The root component that wraps the Tiptap editor with default extensions and configuration.
* Provides the editor context to all child components. Use this as the wrapper for your
* editor content and menus.
*
*
* @example
* ```tsx
*
* ...
*
*
* ```
*/
export const EditorProvider = ({
extensions,
limit,
placeholder,
onUpdate,
...props
}: EditorProviderProps) => {
const defaultExtensions = ExtensionKit({ limit, placeholder });
return (
{
handleCommandNavigation(event);
},
}}
extensions={deduplicateExtensions(defaultExtensions, extensions ?? [])}
immediatelyRender={false}
onUpdate={onUpdate}
{...props}
/>
);
};
// biome-ignore lint/performance/noBarrelFile: Re-exporting TipTap hooks for convenience
export { EditorContext, useCurrentEditor, useEditor } from "@tiptap/react";
/**
* Hook to create a Marble editor instance with default extensions and configuration.
* This is a convenience hook that sets up the editor with ExtensionKit and handleCommandNavigation.
*
* Use this with EditorContext.Provider to avoid layout issues:
*
* @example
* ```tsx
* const editor = useMarbleEditor({
* content: "Hello
",
* placeholder: "Start typing...",
* onUpdate: ({ editor }) => {
* console.log(editor.getHTML());
* },
* });
*
* return (
*
*
*
*
* );
* ```
*/
export function useMarbleEditor(options: UseMarbleEditorOptions) {
const { limit, placeholder, extensions = [], ...restOptions } = options;
const defaultExtensions = ExtensionKit({ limit, placeholder });
return useEditor({
immediatelyRender: false,
editorProps: {
handleKeyDown: (_view, event) => {
handleCommandNavigation(event);
},
...restOptions.editorProps,
},
extensions: deduplicateExtensions(defaultExtensions, extensions),
...restOptions,
});
}
export type UseMarbleEditorOptions = Omit & {
limit?: number;
placeholder?: string;
extensions?: any[];
};
================================================
FILE: packages/editor/src/components/editor-table-menus.tsx
================================================
import { useCurrentEditor } from "@tiptap/react";
import { TableColumnMenu, TableRowMenu } from "../extensions/table";
/**
* EditorTableMenus Component
*
* Component that renders table row and column menus.
* These menus appear when clicking on table grip handles (row grips on the left,
* column grips on the top) and allow users to add/remove rows and columns.
*
* The menus are automatically shown/hidden based on which grip handle is selected.
* This component handles the editor instance check internally.
*
* @example
* ```tsx
*
*
*
*
* ```
*/
export function EditorTableMenus() {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
<>
>
);
}
================================================
FILE: packages/editor/src/components/icons/twitter.tsx
================================================
import type { SVGProps } from "react";
const Twitter = (props: SVGProps) => (
);
export { Twitter };
================================================
FILE: packages/editor/src/components/icons/youtube.tsx
================================================
import type { SVGProps } from "react";
const YouTubeIcon = (props: SVGProps) => (
);
export { YouTubeIcon };
================================================
FILE: packages/editor/src/components/index.ts
================================================
// Components
/** biome-ignore-all lint/performance/noBarrelFile: <> */
// Utility Components
export {
EditorCharacterCount,
type EditorCharacterCountProps,
} from "./editor-character-count";
export { EditorContent } from "./editor-content";
export {
EditorContext,
EditorProvider,
type EditorProviderProps,
type UseMarbleEditorOptions,
useCurrentEditor,
useEditor,
useMarbleEditor,
} from "./editor-provider";
export { EditorTableMenus } from "./editor-table-menus";
// Mark Components
export * from "./marks";
export {
EditorBlockHandleMenu,
type EditorBlockHandleMenuProps,
EditorBubbleMenu,
type EditorBubbleMenuProps,
EditorFloatingMenu,
type EditorFloatingMenuProps,
} from "./menus";
// Node Components
export * from "./nodes";
export {
FieldRichTextEditor,
type FieldRichTextEditorProps,
} from "./rich-text-field";
export * from "./ui";
================================================
FILE: packages/editor/src/components/marks/editor-clear-formatting.tsx
================================================
import { TextTSlashIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui";
/**
* Clear Formatting Button
*
* Button that removes all formatting (marks and node styles) from the selected text.
* Resets the selection to plain text/paragraph format.
*
* @example
* ```tsx
*
*
* ```
*/
export type EditorClearFormattingProps = Pick;
export const EditorClearFormatting = ({
hideName = true,
}: EditorClearFormattingProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().clearNodes().unsetAllMarks().run()}
hideName={hideName}
icon={TextTSlashIcon}
isActive={() => false}
name="Clear Formatting"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-link-selector.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@marble/ui/components/popover";
import { Separator } from "@marble/ui/components/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@marble/ui/components/tooltip";
import { cn } from "@marble/ui/lib/utils";
import {
ArrowSquareOutIcon,
ArrowsInSimpleIcon,
ArrowsOutSimpleIcon,
CheckIcon,
LinkIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { FormEventHandler } from "react";
import { useEffect, useRef, useState } from "react";
export interface EditorLinkSelectorProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* Link Selector Component
*
* A popover component for adding, editing, or removing links from selected text.
* Opens a popover with an input field to enter a URL. If text is already linked,
* shows a delete button to remove the link.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorLinkSelector = ({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: EditorLinkSelectorProps) => {
const { editor } = useCurrentEditor();
const [internalOpen, setInternalOpen] = useState(false);
const [url, setUrl] = useState("");
const [openInNewTab, setOpenInNewTab] = useState(true);
const inputReference = useRef(null);
const isOpen = controlledOpen ?? internalOpen;
const setIsOpen = controlledOnOpenChange ?? setInternalOpen;
const isValidUrl = (text: string): boolean => {
try {
new URL(text);
return true;
} catch {
return false;
}
};
const getUrlFromString = (text: string): string | null => {
if (isValidUrl(text)) {
return text;
}
try {
if (text.includes(".") && !text.includes(" ")) {
return new URL(`https://${text}`).toString();
}
return null;
} catch {
return null;
}
};
useEffect(() => {
if (isOpen) {
const linkAttributes = editor?.getAttributes("link") ?? {};
const href = linkAttributes.href ?? "";
const target = linkAttributes.target ?? "_blank";
setUrl(href);
setOpenInNewTab(target === "_blank");
setTimeout(() => inputReference.current?.focus(), 0);
} else {
setUrl("");
}
}, [isOpen, editor]);
if (!editor) {
return null;
}
const applyLink = () => {
const href = getUrlFromString(url);
if (href) {
editor
.chain()
.focus()
.setLink({
href,
target: openInNewTab ? "_blank" : "_self",
})
.run();
setUrl("");
setIsOpen(false);
}
};
const handleSubmit: FormEventHandler = (event) => {
event.preventDefault();
event.stopPropagation();
applyLink();
};
return (
}
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-bold.tsx
================================================
import { TextBIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkBoldProps = Pick;
/**
* Bold Mark Button
*
* Button to toggle bold formatting on the selected text.
* Active when the selection has bold formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkBold = ({ hideName = false }: EditorMarkBoldProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleBold().run()}
hideName={hideName}
icon={TextBIcon}
isActive={() => editor.isActive("bold") ?? false}
name="Bold"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-code.tsx
================================================
import { CodeIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkCodeProps = Pick;
/**
* Inline Code Mark Button
*
* Button to toggle inline code formatting on the selected text (monospace font).
* Active when the selection has inline code formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkCode = ({ hideName = false }: EditorMarkCodeProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleCode().run()}
hideName={hideName}
icon={CodeIcon}
isActive={() => editor.isActive("code") ?? false}
name="Code"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-highlight.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@marble/ui/components/popover";
import { cn } from "@marble/ui/lib/utils";
import { HighlighterIcon } from "@phosphor-icons/react";
import { useCurrentEditor, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import type { EditorButtonProps } from "../../types";
import { ColorPicker } from "../color-picker";
export type EditorMarkHighlightProps = Pick;
/**
* Highlight Mark Button
*
* Button that opens a color picker to highlight the selected text.
* Uses a Popover to display the ColorPicker component.
* Active when the selection has a highlight color applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkHighlight = ({
hideName = true,
}: EditorMarkHighlightProps) => {
const { editor } = useCurrentEditor();
const currentHighlight = useEditorState({
editor,
selector: (ctx) =>
ctx.editor?.getAttributes("highlight")?.color || undefined,
});
const isActive = Boolean(currentHighlight);
const handleColorChange = useCallback(
(color: string) => {
if (!editor) {
return;
}
editor.chain().focus().setHighlight({ color }).run();
},
[editor]
);
const handleClearHighlight = useCallback(() => {
if (!editor) {
return;
}
editor.chain().focus().unsetHighlight().run();
}, [editor]);
if (!editor) {
return null;
}
// Check if Highlight extension is available
const hasHighlightExtension = editor.can().setHighlight({ color: "#000000" });
if (!hasHighlightExtension) {
return null;
}
return (
{!hideName && Highlight}
}
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-italic.tsx
================================================
import { TextItalicIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkItalicProps = Pick;
/**
* Italic Mark Button
*
* Button to toggle italic formatting on the selected text.
* Active when the selection has italic formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkItalic = ({
hideName = false,
}: EditorMarkItalicProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleItalic().run()}
hideName={hideName}
icon={TextItalicIcon}
isActive={() => editor.isActive("italic") ?? false}
name="Italic"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-strike.tsx
================================================
import { TextStrikethroughIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkStrikeProps = Pick;
/**
* Strikethrough Mark Button
*
* Button to toggle strikethrough formatting on the selected text.
* Active when the selection has strikethrough formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkStrike = ({
hideName = false,
}: EditorMarkStrikeProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleStrike().run()}
hideName={hideName}
icon={TextStrikethroughIcon}
isActive={() => editor.isActive("strike") ?? false}
name="Strikethrough"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-subscript.tsx
================================================
import { TextSubscriptIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkSubscriptProps = Pick;
/**
* Subscript Mark Button
*
* Button to toggle subscript formatting on the selected text.
* Active when the selection has subscript formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkSubscript = ({
hideName = false,
}: EditorMarkSubscriptProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleSubscript().run()}
hideName={hideName}
icon={TextSubscriptIcon}
isActive={() => editor.isActive("subscript") ?? false}
name="Subscript"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-superscript.tsx
================================================
import { TextSuperscriptIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkSuperscriptProps = Pick;
/**
* Superscript Mark Button
*
* Button to toggle superscript formatting on the selected text.
* Active when the selection has superscript formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkSuperscript = ({
hideName = false,
}: EditorMarkSuperscriptProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleSuperscript().run()}
hideName={hideName}
icon={TextSuperscriptIcon}
isActive={() => editor.isActive("superscript") ?? false}
name="Superscript"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-text-color.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@marble/ui/components/popover";
import { cn } from "@marble/ui/lib/utils";
import { PaletteIcon } from "@phosphor-icons/react";
import { useCurrentEditor, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import type { EditorButtonProps } from "../../types";
import { ColorPicker } from "../color-picker";
export type EditorMarkTextColorProps = Pick;
/**
* Text Color Mark Button
*
* Button that opens a color picker to change the text color of the selected text.
* Uses a Popover to display the ColorPicker component.
* Active when the selection has a text color applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkTextColor = ({
hideName = true,
}: EditorMarkTextColorProps) => {
const { editor } = useCurrentEditor();
const currentColor = useEditorState({
editor,
selector: (ctx) =>
ctx.editor?.getAttributes("textStyle")?.color || undefined,
});
const isActive = Boolean(currentColor);
const handleColorChange = useCallback(
(color: string) => {
if (!editor) {
return;
}
editor.chain().focus().setColor(color).run();
},
[editor]
);
const handleClearColor = useCallback(() => {
if (!editor) {
return;
}
editor.chain().focus().unsetColor().run();
}, [editor]);
if (!editor) {
return null;
}
// Check if Color extension is available
const hasColorExtension = editor.can().setColor("#000000");
if (!hasColorExtension) {
return null;
}
return (
{!hideName && Text Color}
}
/>
);
};
================================================
FILE: packages/editor/src/components/marks/editor-mark-underline.tsx
================================================
import { TextUnderlineIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorMarkUnderlineProps = Pick;
/**
* Underline Mark Button
*
* Button to toggle underline formatting on the selected text.
* Active when the selection has underline formatting applied.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorMarkUnderline = ({
hideName = false,
}: EditorMarkUnderlineProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleUnderline().run()}
hideName={hideName}
icon={TextUnderlineIcon}
isActive={() => editor.isActive("underline") ?? false}
name="Underline"
/>
);
};
================================================
FILE: packages/editor/src/components/marks/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export {
EditorClearFormatting,
type EditorClearFormattingProps,
} from "./editor-clear-formatting";
export {
EditorLinkSelector,
type EditorLinkSelectorProps,
} from "./editor-link-selector";
export { EditorMarkBold, type EditorMarkBoldProps } from "./editor-mark-bold";
export { EditorMarkCode, type EditorMarkCodeProps } from "./editor-mark-code";
export {
EditorMarkHighlight,
type EditorMarkHighlightProps,
} from "./editor-mark-highlight";
export {
EditorMarkItalic,
type EditorMarkItalicProps,
} from "./editor-mark-italic";
export {
EditorMarkStrike,
type EditorMarkStrikeProps,
} from "./editor-mark-strike";
export {
EditorMarkSubscript,
type EditorMarkSubscriptProps,
} from "./editor-mark-subscript";
export {
EditorMarkSuperscript,
type EditorMarkSuperscriptProps,
} from "./editor-mark-superscript";
export {
EditorMarkTextColor,
type EditorMarkTextColorProps,
} from "./editor-mark-text-color";
export {
EditorMarkUnderline,
type EditorMarkUnderlineProps,
} from "./editor-mark-underline";
================================================
FILE: packages/editor/src/components/menus/block-handle-menu.tsx
================================================
"use client";
import { offset } from "@floating-ui/dom";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@marble/ui/components/button";
import {
createDropdownMenuHandle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@marble/ui/components/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@marble/ui/components/tooltip";
import { cn } from "@marble/ui/lib/utils";
import {
CheckSquareIcon,
CodeIcon,
CopyIcon,
ListBulletsIcon,
ListNumbersIcon,
QuotesIcon,
TextAlignLeftIcon,
TextHOneIcon,
TextHThreeIcon,
TextHTwoIcon,
TextTSlashIcon,
TrashIcon,
} from "@phosphor-icons/react";
import DragHandle from "@tiptap/extension-drag-handle-react";
import {
DOMSerializer,
Fragment,
type Node as ProseMirrorNode,
} from "@tiptap/pm/model";
import { NodeSelection } from "@tiptap/pm/state";
import { useCurrentEditor } from "@tiptap/react";
import {
type ComponentType,
type SVGProps,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
interface TargetBlock {
node: ProseMirrorNode;
pos: number;
}
interface TransformOption {
icon: ComponentType>;
isActive: (node: ProseMirrorNode) => boolean;
label: string;
run: (focusPos: number) => void;
}
export interface EditorBlockHandleMenuProps {
className?: string;
}
const HANDLE_PLUGIN_KEY = "marble-block-handle";
const SUPPORTED_NODE_TYPES = new Set([
"paragraph",
"heading",
"blockquote",
"codeBlock",
"bulletList",
"orderedList",
"taskList",
"figure",
"image",
"imageUpload",
"video",
"videoUpload",
"twitter",
"twitterUpload",
"youtube",
"youtubeUpload",
"horizontalRule",
]);
const TURN_INTO_SOURCE_TYPES = new Set([
"paragraph",
"heading",
"blockquote",
"codeBlock",
]);
const CLEAR_FORMATTING_TYPES = new Set([
"paragraph",
"heading",
"blockquote",
"codeBlock",
]);
const HANDLE_CONTROL_CLASSNAME =
"flex size-6.5 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground";
function getFocusPos(target: TargetBlock) {
return target.node.isTextblock ? target.pos + 1 : target.pos;
}
function isSupportedNode(
node: ProseMirrorNode | null
): node is ProseMirrorNode {
return node !== null && SUPPORTED_NODE_TYPES.has(node.type.name);
}
function canTurnInto(node: ProseMirrorNode) {
return TURN_INTO_SOURCE_TYPES.has(node.type.name);
}
function canClearFormatting(node: ProseMirrorNode) {
return CLEAR_FORMATTING_TYPES.has(node.type.name);
}
function getScrollParent(node: HTMLElement | null) {
if (!node) {
return null;
}
let current: HTMLElement | null = node.parentElement;
while (current) {
const { overflowY } = window.getComputedStyle(current);
if (overflowY === "auto" || overflowY === "scroll") {
return current;
}
current = current.parentElement;
}
return null;
}
function serializeNodeToClipboardData(
node: ProseMirrorNode,
schema: Parameters[0],
ownerDocument: Document
) {
const serializer = DOMSerializer.fromSchema(schema);
const fragment = serializer.serializeFragment(Fragment.from(node), {
document: ownerDocument,
});
const container = ownerDocument.createElement("div");
container.appendChild(fragment);
return {
html: container.innerHTML,
text: node.textContent || container.textContent || "",
};
}
export function EditorBlockHandleMenu({
className,
}: EditorBlockHandleMenuProps = {}) {
const { editor } = useCurrentEditor();
const [menuOpen, setMenuOpen] = useState(false);
const [target, setTarget] = useState(null);
const menuHandle = useMemo(() => createDropdownMenuHandle(), []);
const menuTriggerRef = useRef(null);
useEffect(() => {
if (!editor) {
return;
}
const transaction = editor.state.tr.setMeta("lockDragHandle", menuOpen);
editor.view.dispatch(transaction);
}, [editor, menuOpen]);
useEffect(() => {
if (!editor) {
return;
}
const hideHandle = () => {
setMenuOpen(false);
setTarget(null);
editor.view.dispatch(editor.state.tr.setMeta("hideDragHandle", true));
};
const scrollParent = getScrollParent(editor.view.dom as HTMLElement);
scrollParent?.addEventListener("scroll", hideHandle, { passive: true });
window.addEventListener("scroll", hideHandle, { passive: true });
return () => {
scrollParent?.removeEventListener("scroll", hideHandle);
window.removeEventListener("scroll", hideHandle);
};
}, [editor]);
const handleNodeChange = useCallback(
({ node, pos }: { node: ProseMirrorNode | null; pos: number }) => {
if (!editor || !editor.isEditable || !isSupportedNode(node)) {
if (!menuOpen) {
setTarget(null);
}
return;
}
setTarget({ node, pos });
},
[editor, menuOpen]
);
const selectTargetNode = useCallback(() => {
if (!editor || !target) {
return null;
}
const nextSelection = NodeSelection.create(editor.state.doc, target.pos);
editor.view.dispatch(editor.state.tr.setSelection(nextSelection));
return editor.state.doc.nodeAt(target.pos);
}, [editor, target]);
const handleAdd = useCallback(() => {
if (!editor || !target) {
return;
}
const currentNode = editor.state.doc.nodeAt(target.pos);
if (!currentNode) {
return;
}
const currentNodeIsEmptyParagraph =
currentNode.type.name === "paragraph" && currentNode.content.size === 0;
const insertPos = target.pos + currentNode.nodeSize;
const focusPos = currentNodeIsEmptyParagraph
? target.pos + 2
: insertPos + 2;
editor
.chain()
.command(({ dispatch, state, tr }) => {
if (!dispatch) {
return true;
}
if (currentNodeIsEmptyParagraph) {
tr.insertText("/", target.pos + 1);
dispatch(tr);
return true;
}
const paragraphNodeType = state.schema.nodes.paragraph;
if (!paragraphNodeType) {
return false;
}
const slashParagraph = paragraphNodeType.create(
null,
state.schema.text("/")
);
tr.insert(insertPos, slashParagraph);
dispatch(tr);
return true;
})
.focus(focusPos)
.run();
}, [editor, target]);
const handleDuplicate = useCallback(() => {
if (!editor || !target) {
return;
}
const currentNode = editor.state.doc.nodeAt(target.pos);
if (!currentNode) {
return;
}
editor
.chain()
.focus()
.insertContentAt(target.pos + currentNode.nodeSize, currentNode.toJSON())
.run();
}, [editor, target]);
const handleDelete = useCallback(() => {
if (!editor || !target) {
return;
}
editor.chain().focus().setNodeSelection(target.pos).deleteSelection().run();
}, [editor, target]);
const handleCopy = useCallback(async () => {
if (!editor || !target) {
return;
}
const currentNode =
editor.state.doc.nodeAt(target.pos) ?? selectTargetNode();
if (!currentNode) {
return;
}
const ownerDocument = editor.view.dom.ownerDocument;
const { html, text } = serializeNodeToClipboardData(
currentNode,
editor.schema,
ownerDocument
);
try {
if (
typeof window !== "undefined" &&
"ClipboardItem" in window &&
html.trim().length > 0
) {
const clipboardItem = new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([text || html], { type: "text/plain" }),
});
await navigator.clipboard.write([clipboardItem]);
return;
}
await navigator.clipboard.writeText(text || html);
} catch (error) {
console.error("Failed to copy block content:", error);
}
}, [editor, selectTargetNode, target]);
const handleClearFormatting = useCallback(() => {
if (!editor || !target || !canClearFormatting(target.node)) {
return;
}
const focusPos = getFocusPos(target);
const chain = editor.chain().focus(focusPos).unsetAllMarks();
if (target.node.type.name !== "paragraph") {
chain.clearNodes();
}
chain.run();
}, [editor, target]);
const transformOptions = useMemo(() => {
if (!editor) {
return [];
}
return [
{
icon: TextAlignLeftIcon,
isActive: (node) => node.type.name === "paragraph",
label: "Text",
run: (focusPos) => {
editor.chain().focus(focusPos).clearNodes().run();
},
},
{
icon: TextHOneIcon,
isActive: (node) =>
node.type.name === "heading" && node.attrs.level === 1,
label: "Heading 1",
run: (focusPos) => {
editor
.chain()
.focus(focusPos)
.clearNodes()
.setNode("heading", { level: 1 })
.run();
},
},
{
icon: TextHTwoIcon,
isActive: (node) =>
node.type.name === "heading" && node.attrs.level === 2,
label: "Heading 2",
run: (focusPos) => {
editor
.chain()
.focus(focusPos)
.clearNodes()
.setNode("heading", { level: 2 })
.run();
},
},
{
icon: TextHThreeIcon,
isActive: (node) =>
node.type.name === "heading" && node.attrs.level === 3,
label: "Heading 3",
run: (focusPos) => {
editor
.chain()
.focus(focusPos)
.clearNodes()
.setNode("heading", { level: 3 })
.run();
},
},
{
icon: ListBulletsIcon,
isActive: (node) => node.type.name === "bulletList",
label: "Bullet List",
run: (focusPos) => {
editor.chain().focus(focusPos).clearNodes().toggleBulletList().run();
},
},
{
icon: ListNumbersIcon,
isActive: (node) => node.type.name === "orderedList",
label: "Numbered List",
run: (focusPos) => {
editor.chain().focus(focusPos).clearNodes().toggleOrderedList().run();
},
},
{
icon: CheckSquareIcon,
isActive: (node) => node.type.name === "taskList",
label: "Task List",
run: (focusPos) => {
editor
.chain()
.focus(focusPos)
.clearNodes()
.toggleList("taskList", "taskItem")
.run();
},
},
{
icon: QuotesIcon,
isActive: (node) => node.type.name === "blockquote",
label: "Quote",
run: (focusPos) => {
editor.chain().focus(focusPos).clearNodes().toggleBlockquote().run();
},
},
{
icon: CodeIcon,
isActive: (node) => node.type.name === "codeBlock",
label: "Code",
run: (focusPos) => {
editor.chain().focus(focusPos).clearNodes().toggleCodeBlock().run();
},
},
];
}, [editor]);
if (!editor) {
return null;
}
const canShowMenu = !!target && editor.isEditable;
const canTransformTarget = !!target && canTurnInto(target.node);
const canClearTarget = !!target && canClearFormatting(target.node);
return (
{
setMenuOpen(false);
}}
onNodeChange={handleNodeChange}
pluginKey={HANDLE_PLUGIN_KEY}
>
Insert block below
}
/>
Click to insert block below
{
if (menuOpen) {
menuHandle.close();
return;
}
menuTriggerRef.current?.click();
}}
type="button"
>
Open block actions
}
/>
Drag to move, click to open menu
}
/>
{canTransformTarget ? (
Turn into
{transformOptions.map((option) => {
const Icon = option.icon;
const isActive = target
? option.isActive(target.node)
: false;
return (
{
if (!target) {
return;
}
option.run(getFocusPos(target));
}}
>
{option.label}
);
})}
) : null}
{canTransformTarget && canClearTarget ? (
) : null}
{canClearTarget ? (
Clear formatting
) : null}
{canTransformTarget || canClearTarget ? (
) : null}
Duplicate
Copy
Delete
);
}
================================================
FILE: packages/editor/src/components/menus/bubble-menu.tsx
================================================
/** biome-ignore-all lint/suspicious/noArrayIndexKey: <> */
import { cn } from "@marble/ui/lib/utils";
import { useCurrentEditor } from "@tiptap/react";
import {
BubbleMenu as TiptapBubbleMenu,
type BubbleMenuProps as TiptapBubbleMenuProps,
} from "@tiptap/react/menus";
import { useCallback } from "react";
import { isCustomNodeSelected, isTextSelected } from "../../lib";
export type EditorBubbleMenuProps = Omit;
/**
* Bubble Menu Component
*
* A floating menu that appears when text is selected in the editor.
* Displays formatting options like text styles, marks, and other tools.
* Automatically positions itself above the selected text using Floating UI.
*
* The menu will not appear when custom nodes (like YouTube embeds, code blocks, etc.) are selected.
*
* @example
* ```tsx
*
*
*
*
*
*
* ```
*/
export const EditorBubbleMenu = ({
className,
children,
shouldShow: customShouldShow,
...props
}: EditorBubbleMenuProps) => {
const { editor } = useCurrentEditor();
const shouldShow = useCallback(
(
props: Parameters>[0]
) => {
if (!editor || !props.view || editor.view.dragging) {
return false;
}
// If a custom shouldShow is provided, check it first
if (customShouldShow) {
const customResult = customShouldShow(props);
if (!customResult) {
return false;
}
}
const fromPos = props.from ?? 0;
const domAtPos = props.view.domAtPos(fromPos).node as HTMLElement | null;
const nodeDOM = props.view.nodeDOM(fromPos) as HTMLElement | null;
const node = nodeDOM ?? domAtPos;
// Don't show bubble menu if a custom node is selected
if (isCustomNodeSelected(editor, node)) {
return false;
}
// Only show if text is actually selected
return isTextSelected({ editor });
},
[editor, customShouldShow]
);
if (!editor) {
return null;
}
return (
*:first-child]:rounded-l-[9px]",
"[&>*:last-child]:rounded-r-[9px]",
className
)}
editor={editor}
shouldShow={shouldShow}
{...props}
>
{children}
);
};
================================================
FILE: packages/editor/src/components/menus/floating-menu.tsx
================================================
import { cn } from "@marble/ui/lib/utils";
import { useCurrentEditor } from "@tiptap/react";
import {
FloatingMenu as TiptapFloatingMenu,
type FloatingMenuProps as TiptapFloatingMenuProps,
} from "@tiptap/react/menus";
export type EditorFloatingMenuProps = Omit;
/**
* Floating Menu Component
* Shows formatting options on empty lines
* Updated for Tiptap v3 with Floating UI
*/
export const EditorFloatingMenu = ({
className,
...props
}: EditorFloatingMenuProps) => {
const { editor } = useCurrentEditor();
return (
);
};
================================================
FILE: packages/editor/src/components/menus/index.ts
================================================
/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */
export {
EditorBlockHandleMenu,
type EditorBlockHandleMenuProps,
} from "./block-handle-menu";
export { EditorBubbleMenu, type EditorBubbleMenuProps } from "./bubble-menu";
export {
EditorFloatingMenu,
type EditorFloatingMenuProps,
} from "./floating-menu";
================================================
FILE: packages/editor/src/components/nodes/editor-align-selector.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@marble/ui/components/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@marble/ui/components/tooltip";
import { cn } from "@marble/ui/lib/utils";
import {
TextAlignCenter,
TextAlignJustify,
TextAlignLeft,
TextAlignRight,
} from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import { useState } from "react";
export interface EditorAlignSelectorProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type Alignment = "left" | "center" | "right" | "justify";
const alignments: {
value: Alignment;
icon: typeof TextAlignLeft;
label: string;
}[] = [
{ value: "left", icon: TextAlignLeft, label: "Align Left" },
{ value: "center", icon: TextAlignCenter, label: "Align Center" },
{ value: "right", icon: TextAlignRight, label: "Align Right" },
{ value: "justify", icon: TextAlignJustify, label: "Justify" },
];
/**
* Align Selector Component
*
* A popover component for setting text alignment.
* Shows alignment options when clicked.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorAlignSelector = ({
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: EditorAlignSelectorProps) => {
const { editor } = useCurrentEditor();
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = controlledOpen ?? internalOpen;
const setIsOpen = controlledOnOpenChange ?? setInternalOpen;
if (!editor) {
return null;
}
const getCurrentAlignment = (): Alignment => {
for (const alignment of alignments) {
if (editor.isActive({ textAlign: alignment.value })) {
return alignment.value;
}
}
return "left";
};
const currentAlignment = getCurrentAlignment();
const CurrentIcon =
alignments.find((a) => a.value === currentAlignment)?.icon ?? TextAlignLeft;
const handleAlignmentChange = (alignment: Alignment) => {
editor.chain().focus().setTextAlign(alignment).run();
setIsOpen(false);
};
return (
}
/>
{alignments.map(({ value, icon: Icon, label }) => (
handleAlignmentChange(value)}
size="icon"
type="button"
variant="ghost"
>
}
/>
{label}
))}
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-align.tsx
================================================
import {
TextAlignCenter,
TextAlignJustify,
TextAlignLeft,
TextAlignRight,
} from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
/**
* Align Left Button
*
* Button that aligns text to the left.
*
* @example
* ```tsx
*
*
* ```
*/
export type EditorAlignProps = Pick;
export const EditorAlignLeft = ({ hideName = true }: EditorAlignProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().setTextAlign("left").run()}
hideName={hideName}
icon={TextAlignLeft}
isActive={() => editor.isActive({ textAlign: "left" }) ?? false}
name="Align Left"
/>
);
};
/**
* Align Center Button
*
* Button that centers text.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorAlignCenter = ({ hideName = true }: EditorAlignProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().setTextAlign("center").run()}
hideName={hideName}
icon={TextAlignCenter}
isActive={() => editor.isActive({ textAlign: "center" }) ?? false}
name="Align Center"
/>
);
};
/**
* Align Right Button
*
* Button that aligns text to the right.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorAlignRight = ({ hideName = true }: EditorAlignProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().setTextAlign("right").run()}
hideName={hideName}
icon={TextAlignRight}
isActive={() => editor.isActive({ textAlign: "right" }) ?? false}
name="Align Right"
/>
);
};
/**
* Justify Button
*
* Button that justifies text.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorAlignJustify = ({ hideName = true }: EditorAlignProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().setTextAlign("justify").run()}
hideName={hideName}
icon={TextAlignJustify}
isActive={() => editor.isActive({ textAlign: "justify" }) ?? false}
name="Justify"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-bullet-list.tsx
================================================
import { ListBulletsIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeBulletListProps = Pick;
/**
* Bullet List Node Button
*
* Button to toggle the current selection to a bullet list (unordered list).
* Active when the selection is within a bullet list.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeBulletList = ({
hideName = false,
}: EditorNodeBulletListProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleBulletList().run()}
hideName={hideName}
icon={ListBulletsIcon}
isActive={() => editor.isActive("bulletList") ?? false}
name="Bullet List"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-code.tsx
================================================
import { CodeIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeCodeProps = Pick;
/**
* Code Block Node Button
*
* Button to toggle the current selection to a code block (syntax-highlighted code).
* Active when the selection is within a code block.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeCode = ({ hideName = false }: EditorNodeCodeProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleCodeBlock().run()}
hideName={hideName}
icon={CodeIcon}
isActive={() => editor.isActive("codeBlock") ?? false}
name="Code"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-heading1.tsx
================================================
import { TextHOneIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeHeading1Props = Pick;
/**
* Heading 1 Node Button
*
* Button to toggle the current selection to Heading 1 (largest heading).
* Active when the selection is a heading with level 1.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeHeading1 = ({
hideName = false,
}: EditorNodeHeading1Props) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleHeading({ level: 1 }).run()}
hideName={hideName}
icon={TextHOneIcon}
isActive={() => editor.isActive("heading", { level: 1 }) ?? false}
name="Heading 1"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-heading2.tsx
================================================
import { TextHTwoIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeHeading2Props = Pick;
/**
* Heading 2 Node Button
*
* Button to toggle the current selection to Heading 2 (medium heading).
* Active when the selection is a heading with level 2.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeHeading2 = ({
hideName = false,
}: EditorNodeHeading2Props) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleHeading({ level: 2 }).run()}
hideName={hideName}
icon={TextHTwoIcon}
isActive={() => editor.isActive("heading", { level: 2 }) ?? false}
name="Heading 2"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-heading3.tsx
================================================
import { TextHThreeIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeHeading3Props = Pick;
/**
* Heading 3 Node Button
*
* Button to toggle the current selection to Heading 3 (small heading).
* Active when the selection is a heading with level 3.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeHeading3 = ({
hideName = false,
}: EditorNodeHeading3Props) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleHeading({ level: 3 }).run()}
hideName={hideName}
icon={TextHThreeIcon}
isActive={() => editor.isActive("heading", { level: 3 }) ?? false}
name="Heading 3"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-ordered-list.tsx
================================================
import { ListNumbersIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeOrderedListProps = Pick;
/**
* Ordered List Node Button
*
* Button to toggle the current selection to an ordered list (numbered list).
* Active when the selection is within an ordered list.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeOrderedList = ({
hideName = false,
}: EditorNodeOrderedListProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleOrderedList().run()}
hideName={hideName}
icon={ListNumbersIcon}
isActive={() => editor.isActive("orderedList") ?? false}
name="Numbered List"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-quote.tsx
================================================
import { QuotesIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeQuoteProps = Pick;
/**
* Quote Node Button
*
* Button to toggle the current selection to a blockquote (quote block).
* Active when the selection is within a blockquote.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeQuote = ({ hideName = false }: EditorNodeQuoteProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run()
}
hideName={hideName}
icon={QuotesIcon}
isActive={() => editor.isActive("blockquote") ?? false}
name="Quote"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-table.tsx
================================================
import { TableIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeTableProps = Pick;
/**
* Table Node Button
*
* Button to insert a new table (3x3 with header row) at the current position.
* Active when the cursor is inside a table.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeTable = ({ hideName = false }: EditorNodeTableProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}
hideName={hideName}
icon={TableIcon}
isActive={() => editor.isActive("table") ?? false}
name="Table"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-task-list.tsx
================================================
import { CheckSquareIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeTaskListProps = Pick;
/**
* Task List Node Button
*
* Button to toggle the current selection to a task list (to-do list with checkboxes).
* Active when the selection is within a task list item.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeTaskList = ({
hideName = false,
}: EditorNodeTaskListProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleList("taskList", "taskItem").run()
}
hideName={hideName}
icon={CheckSquareIcon}
isActive={() => editor.isActive("taskItem") ?? false}
name="To-do List"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/editor-node-text.tsx
================================================
import { TextAlignLeftIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import type { EditorButtonProps } from "../../types";
import { BubbleMenuButton } from "../ui/editor-button";
export type EditorNodeTextProps = Pick;
/**
* Text Node Button
*
* Button to toggle the current selection to plain text (paragraph) format.
* Active when the selection is not a heading, list, or other block node.
*
* @example
* ```tsx
*
*
* ```
*/
export const EditorNodeText = ({ hideName = false }: EditorNodeTextProps) => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleNode("paragraph", "paragraph").run()
}
hideName={hideName}
icon={TextAlignLeftIcon}
isActive={() =>
(editor &&
!editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList")) ??
false
}
name="Text"
/>
);
};
================================================
FILE: packages/editor/src/components/nodes/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export {
EditorAlignCenter,
EditorAlignJustify,
EditorAlignLeft,
type EditorAlignProps,
EditorAlignRight,
} from "./editor-align";
export {
EditorAlignSelector,
type EditorAlignSelectorProps,
} from "./editor-align-selector";
export {
EditorNodeBulletList,
type EditorNodeBulletListProps,
} from "./editor-node-bullet-list";
export { EditorNodeCode, type EditorNodeCodeProps } from "./editor-node-code";
export {
EditorNodeHeading1,
type EditorNodeHeading1Props,
} from "./editor-node-heading1";
export {
EditorNodeHeading2,
type EditorNodeHeading2Props,
} from "./editor-node-heading2";
export {
EditorNodeHeading3,
type EditorNodeHeading3Props,
} from "./editor-node-heading3";
export {
EditorNodeOrderedList,
type EditorNodeOrderedListProps,
} from "./editor-node-ordered-list";
export {
EditorNodeQuote,
type EditorNodeQuoteProps,
} from "./editor-node-quote";
export {
EditorNodeTable,
type EditorNodeTableProps,
} from "./editor-node-table";
export {
EditorNodeTaskList,
type EditorNodeTaskListProps,
} from "./editor-node-task-list";
export { EditorNodeText, type EditorNodeTextProps } from "./editor-node-text";
================================================
FILE: packages/editor/src/components/rich-text-field.tsx
================================================
"use client";
import { Button } from "@marble/ui/components/button";
import { cn } from "@marble/ui/lib/utils";
import {
ListBulletsIcon,
ListNumbersIcon,
TextBIcon,
TextItalicIcon,
TextUnderlineIcon,
} from "@phosphor-icons/react";
import { TextStyleKit } from "@tiptap/extension-text-style";
import { Placeholder } from "@tiptap/extensions";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect } from "react";
export interface FieldRichTextEditorProps {
disabled?: boolean;
id?: string;
labelId?: string;
onBlur?: () => void;
onChange: (value: string) => void;
placeholder?: string;
value: string;
}
function ToolbarButton({
active,
disabled,
icon: Icon,
onClick,
}: {
active?: boolean;
disabled?: boolean;
icon: React.ComponentType<{ className?: string }>;
onClick: () => void;
}) {
return (
);
}
export function FieldRichTextEditor({
disabled,
id,
labelId,
onBlur,
onChange,
placeholder = "Write something...",
value,
}: FieldRichTextEditorProps) {
const editor = useEditor({
immediatelyRender: false,
editable: !disabled,
content: value || "",
extensions: [
StarterKit.configure({
blockquote: false,
codeBlock: false,
dropcursor: false,
gapcursor: false,
heading: false,
horizontalRule: false,
}),
TextStyleKit,
Placeholder.configure({
placeholder: ({ node }) => {
if (
node.type.name === "bulletList" ||
node.type.name === "orderedList" ||
node.type.name === "listItem"
) {
return "";
}
return placeholder;
},
emptyEditorClass:
"field-rich-text-placeholder before:content-[attr(data-placeholder)]",
emptyNodeClass:
"field-rich-text-placeholder before:content-[attr(data-placeholder)]",
}),
],
editorProps: {
attributes: {
class:
"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",
...(id ? { id } : {}),
...(labelId ? { "aria-labelledby": labelId } : {}),
},
},
onBlur: () => {
onBlur?.();
},
onUpdate: ({ editor: nextEditor }) => {
onChange(nextEditor.getHTML());
},
});
useEffect(() => {
if (!editor) {
return;
}
const nextValue = value || "";
if (editor.getHTML() === nextValue) {
return;
}
editor.commands.setContent(nextValue, {
emitUpdate: false,
});
}, [editor, value]);
if (!editor) {
return null;
}
return (
editor.chain().focus().toggleBold().run()}
/>
editor.chain().focus().toggleItalic().run()}
/>
editor.chain().focus().toggleUnderline().run()}
/>
editor.chain().focus().toggleBulletList().run()}
/>
editor.chain().focus().toggleOrderedList().run()}
/>
);
}
================================================
FILE: packages/editor/src/components/ui/editor-button.tsx
================================================
import { Button } from "@marble/ui/components/button";
import { cn } from "@marble/ui/lib/utils";
import { CheckIcon } from "@phosphor-icons/react";
import type { EditorButtonProps } from "../../types";
/**
* Base Button Component for Editor Toolbar
* Used in BubbleMenu and other UI components
*/
export const BubbleMenuButton = ({
name,
isActive,
command,
icon: Icon,
hideName,
}: EditorButtonProps) => (
command()}
size="sm"
variant="ghost"
>
{!hideName && {name}}
{!hideName && isActive() ? (
) : null}
);
================================================
FILE: packages/editor/src/components/ui/editor-selector.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@marble/ui/components/popover";
import { cn } from "@marble/ui/lib/utils";
import { CaretDownIcon } from "@phosphor-icons/react";
import { useCurrentEditor } from "@tiptap/react";
import { type HTMLAttributes, type ReactNode, useState } from "react";
export type EditorSelectorProps = HTMLAttributes & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
title: string;
children?: ReactNode;
};
/**
* Editor Selector Component
*
* A popover-based selector that groups related editor buttons together.
* Displays a button with a title and dropdown arrow that opens a popover
* containing child components (typically editor node or mark buttons).
*
* @example
* ```tsx
*
*
*
*
*
* ```
*/
export const EditorSelector = ({
open,
onOpenChange,
title,
className,
children,
...props
}: EditorSelectorProps) => {
const { editor } = useCurrentEditor();
const [internalOpen, setInternalOpen] = useState(false);
if (!editor) {
return null;
}
const isControlled = open !== undefined;
const currentOpen = isControlled ? open : internalOpen;
const handleOpenChange = (newOpen: boolean) => {
if (!isControlled) {
setInternalOpen(newOpen);
}
onOpenChange?.(newOpen);
};
return (
{title}
}
/>
handleOpenChange(false)}
sideOffset={5}
{...props}
>
{children}
);
};
================================================
FILE: packages/editor/src/components/ui/index.ts
================================================
/* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */
export { BubbleMenuButton } from "./editor-button";
export { EditorSelector, type EditorSelectorProps } from "./editor-selector";
================================================
FILE: packages/editor/src/extensions/code-block/code-block-comp.tsx
================================================
import { Button } from "@marble/ui/components/button";
import { Card, CardContent } from "@marble/ui/components/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@marble/ui/components/command";
import { Popover, PopoverContent } from "@marble/ui/components/popover";
import { cn } from "@marble/ui/lib/utils";
import {
CaretUpDownIcon,
CheckIcon,
CopySimpleIcon,
} from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { useCallback, useRef, useState } from "react";
/**
* Supported languages for the code block language selector.
*/
const LANGUAGES = [
{ value: "text", label: "Text" },
{ value: "javascript", label: "JavaScript" },
{ value: "typescript", label: "TypeScript" },
{ value: "python", label: "Python" },
{ value: "html", label: "HTML" },
{ value: "css", label: "CSS" },
{ value: "json", label: "JSON" },
{ value: "bash", label: "Bash" },
{ value: "sql", label: "SQL" },
{ value: "go", label: "Go" },
{ value: "rust", label: "Rust" },
] as const;
/** Common aliases that map to a supported language value. */
const LANGUAGE_ALIASES: Record = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
sh: "bash",
shell: "bash",
zsh: "bash",
htm: "html",
golang: "go",
rs: "rust",
plaintext: "text",
plain: "text",
txt: "text",
};
const LANGUAGE_VALUES: Set = new Set(LANGUAGES.map((l) => l.value));
/**
* Resolve a raw language string (from markdown fences, pasted content, etc.)
* to a known language value. Unrecognised strings fall back to "text".
*/
export const resolveLanguage = (raw: string): string => {
const lower = raw.toLowerCase().trim();
if (LANGUAGE_VALUES.has(lower)) {
return lower;
}
return LANGUAGE_ALIASES[lower] ?? "text";
};
interface CodeBlockCompProps {
/** The currently selected language */
language: string;
/** Callback when the language changes */
onLanguageChange: (language: string) => void;
/** Callback to copy the code block content */
onCopy: () => void;
/** Whether the content was recently copied */
copied: boolean;
/** The editable code content (NodeViewContent) */
children: ReactNode;
}
/**
* Code Block UI Component
*
* Card-based layout with a searchable language selector and copy button
* in the header, and the editable code content in the body.
*/
export const CodeBlockComp = ({
language,
onLanguageChange,
onCopy,
copied,
children,
}: CodeBlockCompProps) => {
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const selectedLabel =
LANGUAGES.find((lang) => lang.value === language)?.label ?? language;
const handleSelect = useCallback(
(value: string) => {
onLanguageChange(value);
setOpen(false);
},
[onLanguageChange]
);
return (
{/* Header with language selector and copy button */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: ProseMirror event isolation */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: ProseMirror event isolation */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: ProseMirror event isolation */}
e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/*
* we are using a regular Button instead of PopoverTrigger to avoid
* Base UI's internal pointer-event handling conflicting with
* ProseMirror's contentEditable. The popover is fully controlled
* via open/onOpenChange and anchored to this button via ref.
*/}
setOpen(!open)}
ref={triggerRef}
size="sm"
type="button"
variant="ghost"
>
{selectedLabel}
No language found.
{LANGUAGES.map((lang) => (
handleSelect(lang.value)}
value={lang.value}
>
{lang.label}
))}
{copied ? (
) : (
)}
{/* Code content area with syntax highlighting */}
{children}
);
};
================================================
FILE: packages/editor/src/extensions/code-block/code-block-view.tsx
================================================
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
import { useCallback, useState } from "react";
import { CodeBlockComp, resolveLanguage } from "./code-block-comp";
export const CodeBlockView = ({ node, updateAttributes }: NodeViewProps) => {
const rawLanguage = (node.attrs.language as string) || "text";
const language = resolveLanguage(rawLanguage);
const [copied, setCopied] = useState(false);
const onLanguageChange = useCallback(
(lang: string) => {
updateAttributes({ language: lang });
},
[updateAttributes]
);
const onCopy = useCallback(() => {
const text = node.textContent;
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
})
.catch((error: unknown) => {
console.error("Failed to copy code block content:", error);
});
}, [node]);
return (
);
};
================================================
FILE: packages/editor/src/extensions/code-block/code-block.ts
================================================
import { textblockTypeInputRule } from "@tiptap/core";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { lowlight } from "../../lib/lowlight";
import { CodeBlockView } from "./code-block-view";
/**
* Code Block extension with syntax highlighting and custom UI.
*
* Extends CodeBlockLowlight with a React NodeView that renders a card-style
* wrapper with a searchable language selector and copy button.
* Lowlight decorations (syntax highlighting) still apply through ProseMirror.
*
* Input rules are overridden so that triple backticks (or tildes) followed by
* space/enter immediately insert a code block with language "text", without
* allowing a language string after the backticks (Notion-style behaviour).
* Language selection happens exclusively via the dropdown in the UI.
*/
export const CodeBlock = CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
addInputRules() {
return [
textblockTypeInputRule({
find: /^```[\s\n]$/,
type: this.type,
getAttributes: () => ({ language: "text" }),
}),
textblockTypeInputRule({
find: /^~~~[\s\n]$/,
type: this.type,
getAttributes: () => ({ language: "text" }),
}),
];
},
}).configure({
lowlight,
defaultLanguage: "text",
});
================================================
FILE: packages/editor/src/extensions/code-block/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export { CodeBlock } from "./code-block";
================================================
FILE: packages/editor/src/extensions/extension-kit.ts
================================================
import { cn } from "@marble/ui/lib/utils";
import { FileHandler } from "@tiptap/extension-file-handler";
import { Highlight } from "@tiptap/extension-highlight";
import { Image } from "@tiptap/extension-image";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Subscript } from "@tiptap/extension-subscript";
import { Superscript } from "@tiptap/extension-superscript";
import { TextAlign } from "@tiptap/extension-text-align";
import { TextStyleKit } from "@tiptap/extension-text-style";
import Typography from "@tiptap/extension-typography";
import { Youtube } from "@tiptap/extension-youtube";
import { CharacterCount, Placeholder } from "@tiptap/extensions";
import { Markdown } from "@tiptap/markdown";
import StarterKit from "@tiptap/starter-kit";
import { CodeBlock } from "./code-block";
import { Figure } from "./figure";
import { ImageUpload } from "./image-upload";
import { MarkdownInput } from "./markdown-input";
import { configureSlashCommand } from "./slash-command";
import { Table, TableCell, TableHeader, TableRow } from "./table";
import { Twitter } from "./twitter/index";
import { TwitterUpload } from "./twitter/twitter-upload";
import { Video } from "./video";
import { VideoUpload } from "./video-upload";
import { YouTubeUpload } from "./youtube/youtube-upload";
import "../styles/task-list.css";
/**
* Extension kit configuration options
*/
export interface ExtensionKitOptions {
/** Character limit for the editor */
limit?: number;
/** Placeholder text for empty editor */
placeholder?: string;
}
/**
* Extension Kit
* Bundles all editor extensions with default configurations
*/
export const ExtensionKit = ({
limit,
placeholder,
}: ExtensionKitOptions = {}) => [
Markdown,
StarterKit.configure({
codeBlock: false, // Using custom CodeBlock with syntax highlighting
bulletList: {
HTMLAttributes: {
class: cn("list-outside list-disc pl-4"),
},
},
link: {
openOnClick: false,
},
orderedList: {
HTMLAttributes: {
class: cn("list-outside list-decimal pl-4"),
},
},
listItem: {
HTMLAttributes: {
class: cn("leading-normal"),
},
},
blockquote: {
HTMLAttributes: {
class: cn("border-l border-l-2 pl-2"),
},
},
code: {
HTMLAttributes: {
class: cn("rounded-md bg-muted px-1.5 py-1 font-medium font-mono"),
spellcheck: "false",
},
},
horizontalRule: {
HTMLAttributes: {
class: cn("mt-4 mb-6 border-muted-foreground border-t"),
},
},
dropcursor: {
color: "var(--border)",
width: 4,
},
}),
// Typography for smart quotes, dashes, etc.
Typography,
Placeholder.configure({
placeholder: ({ editor }) => {
if (!editor) {
return placeholder ?? "";
}
// Hide placeholder inside tables, blockquotes, code blocks, and lists
if (
editor.isActive("table") ||
editor.isActive("tableCell") ||
editor.isActive("tableHeader") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock") ||
editor.isActive("bulletList") ||
editor.isActive("orderedList") ||
editor.isActive("taskList") ||
editor.isActive("listItem") ||
editor.isActive("taskItem")
) {
return "";
}
return placeholder ?? "";
},
emptyEditorClass:
"before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none",
emptyNodeClass:
"before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none",
}),
// Character count
CharacterCount.configure({
limit,
}),
// Code block with syntax highlighting
CodeBlock,
// Subscript and superscript
Superscript,
Subscript,
// Slash command
configureSlashCommand(),
// Table extensions
Table,
TableRow,
TableCell,
TableHeader,
// YouTube
Youtube.configure({
controls: true,
nocookie: false,
}),
// YouTube Upload (placeholder node for YouTube upload component)
YouTubeUpload,
// Twitter
Twitter.configure({
addPasteHandler: true,
inline: false,
}),
// Twitter Upload (placeholder node for Twitter upload component)
TwitterUpload,
// Image extension for backward compatibility with older posts
Image.configure({
inline: false,
allowBase64: false,
}),
// Figure (image with caption support)
Figure,
// Image Upload (placeholder node for image upload component)
// Note: Will be unconfigured by default, CMS app should pass configured version
ImageUpload,
// Video (self-hosted video with caption support)
Video,
// Video Upload (placeholder node for video upload component)
// Note: Will be unconfigured by default, CMS app should pass configured version
VideoUpload,
// File Handler for drag-and-drop and paste image/video uploads
FileHandler.configure({
allowedMimeTypes: [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
],
onDrop: (currentEditor, files, _pos) => {
for (const file of files) {
if (file.type.startsWith("video/")) {
currentEditor.chain().focus().setVideoUpload({ file }).run();
} else {
currentEditor.chain().focus().setImageUpload({ file }).run();
}
}
},
onPaste: (currentEditor, files) => {
for (const file of files) {
if (file.type.startsWith("video/")) {
currentEditor.chain().focus().setVideoUpload({ file }).run();
} else {
currentEditor.chain().focus().setImageUpload({ file }).run();
}
}
},
}),
// Task list
TaskList.configure({
HTMLAttributes: {
class: "list-none p-0",
},
}),
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex",
},
}),
// Text styling kit (includes Color, BackgroundColor, FontFamily, FontSize, LineHeight, TextStyle)
TextStyleKit,
// Highlight extension for text highlighting
Highlight.configure({ multicolor: true }),
// Text alignment
TextAlign.configure({
types: ["heading", "paragraph"],
alignments: ["left", "center", "right", "justify"],
}),
// Markdown input handling (paste and file drop)
MarkdownInput,
];
export default ExtensionKit;
================================================
FILE: packages/editor/src/extensions/figure/figure-view.tsx
================================================
/** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: <> */
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: <> */
import { Button } from "@marble/ui/components/button";
import { Input } from "@marble/ui/components/input";
import { Label } from "@marble/ui/components/label";
import { cn } from "@marble/ui/lib/utils";
import {
FadersHorizontalIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
XIcon,
} from "@phosphor-icons/react";
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewWrapper } from "@tiptap/react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
export const FigureView = ({
node,
updateAttributes,
selected,
}: NodeViewProps) => {
const { src, alt, caption, width, align } = node.attrs as {
src: string;
alt: string;
caption: string;
width: string;
align: "left" | "center" | "right";
};
const [altValue, setAltValue] = useState(alt || "");
const [captionValue, setCaptionValue] = useState(caption || "");
const [widthValue, setWidthValue] = useState(width || "100");
const [alignValue, setAlignValue] = useState<"left" | "center" | "right">(
align || "center"
);
const [isResizing, setIsResizing] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const figureRef = useRef(null);
const settingsPanelRef = useRef(null);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const resizeSideRef = useRef<"left" | "right">("right");
const altId = useId();
const captionId = useId();
const [prevAttrs, setPrevAttrs] = useState({ alt, caption, width, align });
if (
alt !== prevAttrs.alt ||
caption !== prevAttrs.caption ||
width !== prevAttrs.width ||
align !== prevAttrs.align
) {
setPrevAttrs({ alt, caption, width, align });
setAltValue(alt || "");
setCaptionValue(caption || "");
setWidthValue(width || "100");
setAlignValue(align || "center");
}
// Handle click outside settings panel
useEffect(() => {
if (!showSettings) {
return;
}
const handleClickOutside = (e: MouseEvent) => {
if (
settingsPanelRef.current &&
!settingsPanelRef.current.contains(e.target as Node)
) {
// Check if click was on the settings button
const target = e.target as HTMLElement;
if (!target.closest("[data-settings-trigger]")) {
setShowSettings(false);
}
}
};
// Use timeout to avoid the click that opened the panel from closing it
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showSettings]);
const handleAltChange = useCallback(
(e: React.ChangeEvent) => {
const newAlt = e.target.value;
setAltValue(newAlt);
updateAttributes({ alt: newAlt });
},
[updateAttributes]
);
const handleCaptionChange = useCallback(
(e: React.ChangeEvent) => {
const newCaption = e.target.value;
setCaptionValue(newCaption);
updateAttributes({ caption: newCaption });
},
[updateAttributes]
);
const handleAlignChange = useCallback(
(newAlign: "left" | "center" | "right") => {
setAlignValue(newAlign);
setTimeout(() => {
updateAttributes({ align: newAlign });
}, 0);
},
[updateAttributes]
);
const handleResizeStart = useCallback(
(side: "left" | "right") => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
startXRef.current = e.clientX;
resizeSideRef.current = side;
const currentWidth = Number.parseInt(widthValue, 10) || 100;
startWidthRef.current = currentWidth;
},
[widthValue]
);
useEffect(() => {
if (!isResizing) {
return;
}
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startXRef.current;
const containerWidth =
figureRef.current?.parentElement?.clientWidth || 800;
const effectiveDelta =
resizeSideRef.current === "left" ? -deltaX : deltaX;
const deltaPercent = (effectiveDelta / containerWidth) * 100;
const newWidth = Math.max(
10,
Math.min(100, startWidthRef.current + deltaPercent)
);
const roundedWidth = Math.round(newWidth);
setWidthValue(String(roundedWidth));
updateAttributes({ width: String(roundedWidth) });
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, updateAttributes]);
const alignmentStyles: React.CSSProperties = {
width: `${widthValue}%`,
marginLeft: alignValue === "left" ? 0 : "auto",
marginRight: alignValue === "right" ? 0 : "auto",
};
const showToolbar = selected || isHovered || showSettings;
const handleSettingsClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowSettings(!showSettings);
},
[showSettings]
);
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={figureRef}
style={alignmentStyles}
>
{/* biome-ignore lint: Tiptap NodeView requires standard img element */}
{showToolbar && (
handleAlignChange("left")}
size="icon"
title="Align left"
type="button"
variant="ghost"
>
handleAlignChange("center")}
size="icon"
title="Align center"
type="button"
variant="ghost"
>
handleAlignChange("right")}
size="icon"
title="Align right"
type="button"
variant="ghost"
>
{/* Divider */}
{
e.preventDefault();
e.stopPropagation();
setShowSettings((prev) => !prev);
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
title="Image settings"
type="button"
>
)}
{showSettings && (
)}
{showToolbar && (
<>
>
)}
{captionValue && (
{captionValue}
)}
);
};
================================================
FILE: packages/editor/src/extensions/figure/index.ts
================================================
import type { CommandProps } from "@tiptap/core";
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { FigureView } from "./figure-view";
declare module "@tiptap/core" {
interface Commands {
figure: {
setFigure: (options: {
src: string;
alt?: string;
caption?: string;
href?: string;
width?: string;
align?: "left" | "center" | "right";
}) => ReturnType;
updateFigure: (attrs: {
alt?: string;
caption?: string;
href?: string;
width?: string;
align?: "left" | "center" | "right";
}) => ReturnType;
};
}
}
export const Figure = Node.create({
name: "figure",
group: "block",
content: "",
draggable: true,
selectable: true,
isolating: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: (element) =>
element.querySelector("img")?.getAttribute("src") ||
element.querySelector("a img")?.getAttribute("src"),
renderHTML: (attributes) => {
// Return attribute to make it available in HTMLAttributes
// Main renderHTML will apply it to img element
return { src: attributes.src };
},
},
alt: {
default: "",
parseHTML: (element) =>
element.querySelector("img")?.getAttribute("alt") ||
element.querySelector("a img")?.getAttribute("alt") ||
"",
renderHTML: (attributes) => {
// Return attribute to make it available in HTMLAttributes
// Main renderHTML will apply it to img element
return { alt: attributes.alt };
},
},
caption: {
default: "",
parseHTML: (element) =>
element.querySelector("figcaption")?.textContent || "",
renderHTML: (attributes) => {
// Return attribute to make it available in HTMLAttributes
// Main renderHTML will use it for figcaption content
return { caption: attributes.caption };
},
},
href: {
default: null,
parseHTML: (element) =>
element.querySelector("a")?.getAttribute("href") || null,
renderHTML: (attributes) => {
// Return attribute to make it available in HTMLAttributes
// Main renderHTML will apply it to anchor element
return { href: attributes.href };
},
},
width: {
default: "100",
parseHTML: (element) => element.getAttribute("data-width") || "100",
renderHTML: (attributes) => ({
"data-width": attributes.width,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align") || "center",
renderHTML: (attributes) => ({
"data-align": attributes.align,
}),
},
};
},
parseHTML() {
return [
{
tag: "figure",
getAttrs: (element) => {
if (typeof element === "string") {
return false;
}
const img = element.querySelector("img");
return img ? {} : false;
},
},
];
},
renderHTML({ HTMLAttributes }) {
const { src, alt, href, caption, ...figureAttrs } = HTMLAttributes;
// Prepare img attributes
const imgAttrs: Record = {};
if (src) {
imgAttrs.src = src;
}
if (alt) {
imgAttrs.alt = alt;
}
// Prepare figcaption content
const figcaptionContent = caption || "";
// If href exists, wrap img in anchor tag
if (href) {
return [
"figure",
mergeAttributes(figureAttrs),
["a", { href }, ["img", imgAttrs]],
["figcaption", {}, figcaptionContent],
];
}
// Otherwise, render img directly
return [
"figure",
mergeAttributes(figureAttrs),
["img", imgAttrs],
["figcaption", {}, figcaptionContent],
];
},
addCommands() {
return {
setFigure:
(options) =>
({ commands }: CommandProps) =>
commands.insertContent({
type: this.name,
attrs: options,
}),
updateFigure:
(attrs) =>
({ commands, tr, state }: CommandProps) => {
const { selection } = state;
const node = tr.doc.nodeAt(selection.from);
if (node?.type.name === this.name) {
return commands.updateAttributes(this.name, attrs);
}
return false;
},
};
},
addNodeView() {
return ReactNodeViewRenderer(FigureView);
},
});
================================================
FILE: packages/editor/src/extensions/image-upload/hooks.ts
================================================
import { toast } from "@marble/ui/components/sonner";
import type { DragEvent } from "react";
import { useCallback, useRef, useState } from "react";
export const useFileUpload = () => {
const fileInput = useRef(null);
const handleUploadClick = useCallback(() => {
fileInput.current?.click();
}, []);
return { ref: fileInput, handleUploadClick };
};
export const useUploader = ({
onUpload,
upload,
onError,
}: {
onUpload: (url: string) => void;
upload: (file: File) => Promise;
onError?: (error: Error) => void;
}) => {
const [loading, setLoading] = useState(false);
const uploadImage = useCallback(
async (file: File) => {
setLoading(true);
try {
const url = await upload(file);
if (url) {
onUpload(url);
} else {
const error = new Error(
"Upload failed: Invalid response from server."
);
if (onError) {
onError(error);
} else {
toast.error(error.message);
}
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to upload image";
const uploadError = new Error(errorMessage);
if (onError) {
onError(uploadError);
} else {
toast.error(errorMessage);
}
}
setLoading(false);
},
[onUpload, upload, onError]
);
return { loading, uploadImage };
};
export const useDropZone = ({
uploader,
}: {
uploader: (file: File) => void;
}) => {
const [draggedInside, setDraggedInside] = useState(false);
const onDrop = useCallback(
(e: DragEvent) => {
setDraggedInside(false);
e.preventDefault();
e.stopPropagation();
const fileList = e.dataTransfer.files;
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item) {
files.push(item);
}
}
// Validate only image files
if (files.some((file) => !file.type.startsWith("image/"))) {
toast.error("Only image files are allowed");
return;
}
const filteredFiles = files.filter((f) => f.type.startsWith("image/"));
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
uploader(file);
}
},
[uploader]
);
const onDragEnter = useCallback(() => {
setDraggedInside(true);
}, []);
const onDragLeave = useCallback(() => {
setDraggedInside(false);
}, []);
const onDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return { draggedInside, onDragEnter, onDragLeave, onDrop, onDragOver };
};
================================================
FILE: packages/editor/src/extensions/image-upload/image-upload-comp.tsx
================================================
import { Album02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@marble/ui/components/button";
import { Card, CardContent, CardFooter } from "@marble/ui/components/card";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
DialogX,
} from "@marble/ui/components/dialog";
import { Input } from "@marble/ui/components/input";
import { cn } from "@marble/ui/lib/utils";
import {
CheckIcon,
ImagesIcon,
SpinnerIcon,
XIcon,
} from "@phosphor-icons/react";
import type { ChangeEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MediaItem, MediaPage } from "../../types";
import { useDropZone, useFileUpload, useUploader } from "./hooks";
// Simple URL validation
const isValidUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
export interface ImageUploadCompProps {
initialFile?: File;
onUpload: (url: string) => void;
onCancel: () => void;
upload: (file: File) => Promise;
media?: MediaItem[];
fetchMediaPage?: (cursor?: string) => Promise;
onError?: (error: Error) => void;
}
export const ImageUploadComp = ({
initialFile,
onUpload,
onCancel,
upload,
media: providedMedia,
fetchMediaPage,
onError,
}: ImageUploadCompProps) => {
const [showEmbedInput, setShowEmbedInput] = useState(false);
const [embedUrl, setEmbedUrl] = useState("");
const [urlError, setUrlError] = useState(null);
const [isValidatingUrl, setIsValidatingUrl] = useState(false);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [media, setMedia] = useState(providedMedia);
const [isLoadingMedia, setIsLoadingMedia] = useState(false);
const [nextCursor, setNextCursor] = useState(undefined);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { loading, uploadImage } = useUploader({ onUpload, upload, onError });
const { handleUploadClick, ref } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave, onDragOver } =
useDropZone({
uploader: uploadImage,
});
// Fetch initial media page if fetchMediaPage function is provided.
// Uses an `active` flag so stale responses from a previous render are ignored.
useEffect(() => {
if (!fetchMediaPage || providedMedia) {
return;
}
let active = true;
setIsLoadingMedia(true);
fetchMediaPage()
.then((page) => {
if (active) {
setMedia(page.media);
setNextCursor(page.nextCursor);
}
})
.catch(() => {
if (active) {
setMedia([]);
}
})
.finally(() => {
if (active) {
setIsLoadingMedia(false);
}
});
return () => {
active = false;
};
}, [fetchMediaPage, providedMedia]);
// Load more media handler
const handleLoadMore = useCallback(async () => {
if (!fetchMediaPage || !nextCursor || isLoadingMore) {
return;
}
setIsLoadingMore(true);
try {
const page = await fetchMediaPage(nextCursor);
setMedia((prev) => [...(prev || []), ...page.media]);
setNextCursor(page.nextCursor);
} catch {
// Ignore errors on load more
} finally {
setIsLoadingMore(false);
}
}, [fetchMediaPage, nextCursor, isLoadingMore]);
// Update media when providedMedia changes
useEffect(() => {
if (providedMedia) {
setMedia(providedMedia);
}
}, [providedMedia]);
const initialUploadedRef = useRef(false);
useEffect(() => {
if (initialFile && !initialUploadedRef.current) {
initialUploadedRef.current = true;
uploadImage(initialFile);
}
}, [initialFile, uploadImage]);
const onFileChange = useCallback(
(e: ChangeEvent) => {
const file = e.target.files?.[0];
if (file) {
uploadImage(file);
}
},
[uploadImage]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
onDrop(e);
},
[onDrop]
);
const handleEmbedUrl = useCallback(
async (url: string) => {
if (!url) {
return;
}
setIsValidatingUrl(true);
setUrlError(null);
if (!isValidUrl(url)) {
setUrlError("Please enter a valid URL");
setIsValidatingUrl(false);
return;
}
const img = new Image();
img.onload = () => {
onUpload(url);
setEmbedUrl("");
setShowEmbedInput(false);
setIsValidatingUrl(false);
};
img.onerror = () => {
setUrlError("Invalid image URL");
setIsValidatingUrl(false);
};
img.src = url;
},
[onUpload]
);
const handleMediaSelect = useCallback(
(url: string) => {
onUpload(url);
setIsGalleryOpen(false);
},
[onUpload]
);
const handleDropzoneClick = useCallback(() => {
handleUploadClick();
}, [handleUploadClick]);
const handleDropzoneKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleUploadClick();
}
},
[handleUploadClick]
);
// Get dropzone text based on drag state
const getDropzoneText = () => {
if (draggedInside) {
return "Drop image here";
}
return "Drag and drop or click to upload";
};
return (
<>
Upload or embed an image
{/* Dropzone or Uploading state */}
{loading ? (
) : (
// biome-ignore lint/a11y/useSemanticElements: Dropzone requires div for drag-and-drop functionality
)}
{showEmbedInput ? (
{
setEmbedUrl(target.value);
setUrlError(null);
}}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
embedUrl &&
!isValidatingUrl &&
!loading
) {
handleEmbedUrl(embedUrl);
}
}}
placeholder="Paste image URL"
value={embedUrl}
/>
handleEmbedUrl(embedUrl)}
size="icon"
type="button"
variant="outline"
>
{isValidatingUrl ? (
) : (
)}
{
setShowEmbedInput(false);
setEmbedUrl("");
setUrlError(null);
}}
size="icon"
type="button"
variant="outline"
>
{urlError && (
{urlError}
)}
) : (
// Media and Embed URL buttons - shown by default
{(media !== undefined || fetchMediaPage) && (
setIsGalleryOpen(true)}
size="sm"
type="button"
variant="outline"
>
View Gallery
)}
setShowEmbedInput(true)}
size="sm"
type="button"
variant="outline"
>
Embed URL
)}
Cancel
{/* Media Gallery Dialog */}
{(media !== undefined || fetchMediaPage) && (
)}
>
);
};
================================================
FILE: packages/editor/src/extensions/image-upload/image-upload-view.tsx
================================================
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewWrapper } from "@tiptap/react";
import { useCallback, useEffect, useRef } from "react";
import { ImageUploadComp } from "./image-upload-comp";
import type { ImageUploadStorage } from "./index";
export const ImageUploadView = ({
getPos,
editor,
node,
extension,
}: NodeViewProps) => {
const storage = extension.storage as ImageUploadStorage;
const pendingUploads = storage.pendingUploads;
// Get fileId from node attributes
const fileId = node.attrs.fileId as string | null;
const initialFile = fileId ? pendingUploads.get(fileId) : undefined;
// Get extension options from storage
const { options } = storage;
// Track whether the upload was consumed (success or cancel) so the
// unmount cleanup knows whether it still needs to release the entry.
const consumedRef = useRef(false);
// Clean up the pending upload entry when this view unmounts (e.g. the
// node is deleted while an upload is still in progress).
useEffect(() => {
return () => {
if (fileId && !consumedRef.current) {
pendingUploads.delete(fileId);
}
};
}, [fileId, pendingUploads]);
const onUpload = useCallback(
(url: string) => {
if (url && typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
consumedRef.current = true;
if (fileId) {
pendingUploads.delete(fileId);
}
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.setFigure({ src: url, alt: "", caption: "" })
.run();
}
}
},
[getPos, editor, fileId, pendingUploads]
);
const onCancel = useCallback(() => {
if (typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
consumedRef.current = true;
if (fileId) {
pendingUploads.delete(fileId);
}
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run();
}
}
}, [getPos, editor, fileId, pendingUploads]);
// Only render if upload handler is configured
if (!options.upload) {
return (
Image upload is not configured. Please configure the ImageUpload
extension with an upload handler.
);
}
return (
);
};
================================================
FILE: packages/editor/src/extensions/image-upload/index.ts
================================================
/** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */
import type { CommandProps } from "@tiptap/core";
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { ImageUploadOptions } from "../../types";
import { ImageUploadView } from "./image-upload-view";
declare module "@tiptap/core" {
interface Commands {
imageUpload: {
setImageUpload: (options?: { file?: File }) => ReturnType;
};
}
}
export const ImageUpload = Node.create({
name: "imageUpload",
isolating: true,
defining: true,
group: "block",
draggable: true,
selectable: true,
inline: false,
addOptions() {
return {
upload: undefined,
accept: "image/*",
maxSize: undefined,
limit: undefined,
onError: undefined,
media: undefined,
fetchMediaPage: undefined,
};
},
addAttributes() {
return {
fileId: {
default: null,
parseHTML: (element) => element.getAttribute("data-file-id"),
renderHTML: (attributes) => {
if (!attributes.fileId) {
return {};
}
return {
"data-file-id": attributes.fileId,
};
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": this.name, ...HTMLAttributes }];
},
addCommands() {
const extensionStorage = this.storage as ImageUploadStorage;
return {
setImageUpload:
(options) =>
({ commands }: CommandProps) => {
const { file } = options || {};
if (file) {
const fileId = `upload-${Date.now()}-${Math.random()}`;
extensionStorage.pendingUploads.set(fileId, file);
return commands.insertContent({
type: this.name,
attrs: { fileId },
});
}
return commands.insertContent({
type: this.name,
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageUploadView, {
as: "div",
});
},
addStorage() {
return {
pendingUploads: new Map(),
options: this.options,
};
},
onDestroy() {
const storage = this.storage as ImageUploadStorage;
storage.pendingUploads.clear();
},
});
export interface ImageUploadStorage {
pendingUploads: Map;
options: ImageUploadOptions;
}
================================================
FILE: packages/editor/src/extensions/index.ts
================================================
// Extensions
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export type {
ImageUploadOptions,
MediaItem,
VideoUploadOptions,
} from "../types";
export { CodeBlock } from "./code-block";
// Extension Kit
export {
default,
ExtensionKit,
type ExtensionKitOptions,
} from "./extension-kit";
export { Figure } from "./figure";
export { ImageUpload } from "./image-upload";
export { MarkdownInput } from "./markdown-input";
export {
configureSlashCommand,
handleCommandNavigation,
SlashCommand,
} from "./slash-command";
export {
Table,
TableCell,
TableColumnMenu,
TableHeader,
TableRow,
TableRowMenu,
} from "./table";
export { TwitterUpload } from "./twitter/twitter-upload";
export { Video } from "./video";
export { VideoUpload } from "./video-upload";
export { YouTubeUpload } from "./youtube/youtube-upload";
================================================
FILE: packages/editor/src/extensions/markdown-input/index.ts
================================================
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { looksLikeMarkdown, transformContent } from "./utils";
/**
* Unified extension for handling markdown input via paste and file drop
* Handles three scenarios:
* 1. Text paste: Detects and parses markdown text from clipboard
* 2. File drop: Handles dropped markdown files
* 3. File paste: Handles pasted markdown files from clipboard
*/
export const MarkdownInput = Extension.create({
name: "markdownInput",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownInput"),
props: {
handlePaste: (_view: EditorView, event: ClipboardEvent) => {
const { editor } = this;
// First, check for markdown files in clipboard
const files = Array.from(event.clipboardData?.files || []);
const markdownFiles = files.filter(
(file) =>
file.name.endsWith(".md") ||
file.name.endsWith(".markdown") ||
file.type === "text/markdown"
);
if (markdownFiles.length > 0) {
// Handle pasted markdown files
event.preventDefault();
for (const file of markdownFiles) {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (text) {
try {
const json = editor?.markdown?.parse(text);
if (json) {
const transformedContent = transformContent(json);
editor.commands.insertContent(transformedContent);
}
} catch (error) {
console.error("Failed to parse markdown file:", error);
}
}
};
reader.readAsText(file);
}
return true;
}
// If HTML is available, let the normal HTML paste pipeline handle it
const html = event.clipboardData?.getData("text/html");
if (html) {
return false;
}
// If no HTML, check if clipboard text looks like markdown
const text = event.clipboardData?.getData("text/plain");
if (!text) {
return false;
}
if (!looksLikeMarkdown(text)) {
return false;
}
// Prevent default paste behavior
event.preventDefault();
try {
// Parse markdown to JSON using Tiptap's markdown extension
const json = editor?.markdown?.parse(text) ?? {
type: "doc",
content: [],
};
// Transform Image nodes to Figure nodes
const transformedContent = transformContent(json);
// Insert the parsed and transformed content
editor.commands.insertContent(transformedContent);
return true;
} catch (error) {
console.error("Failed to parse markdown:", error);
// Fall back to default paste behavior
return false;
}
},
handleDrop: (_view: EditorView, event: DragEvent, _slice, moved) => {
// Don't handle if this is a move within the editor
if (moved) {
return false;
}
const { editor } = this;
const files = Array.from(event.dataTransfer?.files || []);
// Check if any files are markdown files
const markdownFiles = files.filter(
(file) =>
file.name.endsWith(".md") ||
file.name.endsWith(".markdown") ||
file.type === "text/markdown"
);
if (markdownFiles.length === 0) {
// Let other plugins handle this
return false;
}
// Prevent default browser behavior
event.preventDefault();
// Process all markdown files
for (const file of markdownFiles) {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (text) {
try {
// Parse markdown to JSON
const json = editor?.markdown?.parse(text);
if (json) {
// Transform Image nodes to Figure nodes
const transformedContent = transformContent(json);
// Insert at drop position
editor.commands.insertContent(transformedContent);
}
} catch (error) {
console.error("Failed to parse markdown file:", error);
}
}
};
reader.readAsText(file);
}
// Return true to indicate we handled this event
return true;
},
},
}),
];
},
});
================================================
FILE: packages/editor/src/extensions/markdown-input/utils.ts
================================================
import type { JSONContent } from "@tiptap/core";
/**
* Checks if text looks like markdown by detecting common markdown patterns
*/
export function looksLikeMarkdown(text: string): boolean {
if (!text || text.trim().length === 0) {
return false;
}
const markdownPatterns = [
/^#{1,6}\s+.+/m, // Headings
/\*\*[^*]+\*\*/m, // Bold with **
/__[^_]+__/m, // Bold with __
/\*[^*]+\*/m, // Italic with *
/_[^_]+_/m, // Italic with _
/\[.+\]\(.+\)/m, // Links [text](url)
/^\s*[-*+]\s+/m, // Unordered lists
/^\s*\d+\.\s+/m, // Ordered lists
/```[\s\S]*?```/m, // Code blocks
/`[^`]+`/m, // Inline code
/^\s*>\s+/m, // Blockquotes
/!\[.*\]\(.*\)/m, // Images
/^\s*[-*_]{3,}\s*$/m, // Horizontal rules
/^\|.+\|$/m, // Tables
];
// Check if at least 2 patterns match for better accuracy
const matchCount = markdownPatterns.filter((pattern) =>
pattern.test(text)
).length;
return matchCount >= 2 || /^#{1,6}\s+.+/m.test(text); // Or single heading pattern
}
/**
* Check if a node type is an inline context where block-level figures can't exist
*/
function isInlineContext(nodeType?: string): boolean {
const inlineTypes = ["text", "strong", "em", "code", "strike", "underline"];
return nodeType ? inlineTypes.includes(nodeType) : false;
}
/**
* Recursively transforms Image nodes to Figure nodes in parsed JSON
* Converts markdown image syntax  to Figure nodes with captions
* Handles linked images [](href) by extracting the href
*/
export function transformImageToFigure(
content: JSONContent,
parentType?: string
): JSONContent {
if (!content) {
return content;
}
// Handle link nodes that contain a single image (linked images)
// Transform: link > image -> figure with href
if (content.type === "link") {
const hasOnlyImage =
content.content &&
content.content.length === 1 &&
content.content[0]?.type === "image";
if (hasOnlyImage && content.content) {
const image = content.content[0];
const href = content.attrs?.href;
// Transform to figure with href
return {
type: "figure",
attrs: {
src: image?.attrs?.src || "",
alt: image?.attrs?.alt || "",
caption: image?.attrs?.alt || "",
href: href || null,
},
};
}
}
// Transform the current node if it's an image
if (content.type === "image") {
// Don't transform images in inline contexts (e.g., inside text, strong, etc.)
if (isInlineContext(parentType)) {
return content; // Keep as image
}
// Transform to figure (without href)
return {
type: "figure",
attrs: {
src: content.attrs?.src || "",
alt: content.attrs?.alt || "",
caption: content.attrs?.alt || "", // Use alt text as caption
href: null,
},
};
}
// Recursively transform children, passing current node type as parent
if (content.content && Array.isArray(content.content)) {
return {
...content,
content: content.content.map((child) =>
transformImageToFigure(child, content.type)
),
};
}
return content;
}
/**
* Lifts figures out of paragraphs where they're the only child
* Markdown parsers often wrap standalone images in paragraphs, which becomes
* invalid when the image is transformed to a figure (block-level element)
*/
function liftFiguresFromParagraphs(content: JSONContent): JSONContent {
if (!content) {
return content;
}
// If this is a paragraph with a single figure child, replace the paragraph with the figure
if (
content.type === "paragraph" &&
content.content &&
content.content.length === 1 &&
content.content[0]?.type === "figure"
) {
return content.content[0]; // Replace paragraph with the figure
}
// Recursively process children
if (content.content && Array.isArray(content.content)) {
return {
...content,
content: content.content.map((child) => liftFiguresFromParagraphs(child)),
};
}
return content;
}
/**
* Transforms an array of JSON content, converting images to figures
* and lifting figures out of invalid contexts
*/
export function transformContent(
json: JSONContent | JSONContent[]
): JSONContent | JSONContent[] {
if (Array.isArray(json)) {
// First transform images to figures
const transformed = json.map((item) => transformImageToFigure(item));
// Then lift figures out of paragraphs
return transformed.map((item) => liftFiguresFromParagraphs(item));
}
// First transform images to figures
const transformed = transformImageToFigure(json);
// Then lift figures out of paragraphs
return liftFiguresFromParagraphs(transformed);
}
================================================
FILE: packages/editor/src/extensions/slash-command/groups.ts
================================================
import {
CheckSquareIcon,
CodeIcon,
ImageIcon,
ListBulletsIcon,
ListNumbersIcon,
QuotesIcon,
TableIcon,
TextAlignLeftIcon,
TextHOneIcon,
TextHThreeIcon,
TextHTwoIcon,
VideoCameraIcon,
} from "@phosphor-icons/react";
import type { SuggestionOptions } from "@tiptap/suggestion";
import { Twitter } from "../../components/icons/twitter";
import { YouTubeIcon } from "../../components/icons/youtube";
import type { SuggestionItem } from "../../types";
/**
* Default slash command suggestions
* These are the commands that appear when typing "/" in the editor
*/
export const defaultSlashSuggestions: SuggestionOptions["items"] =
() => [
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: TextAlignLeftIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.run();
},
},
{
title: "Heading 1",
description: "Use for main page title.",
searchTerms: ["title", "big", "large"],
icon: TextHOneIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
},
},
{
title: "Heading 2",
description: "Use for section headings.",
searchTerms: ["subtitle", "medium"],
icon: TextHTwoIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Heading 3",
description: "Use for sub-section headings.",
searchTerms: ["subtitle", "small"],
icon: TextHThreeIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: ListBulletsIcon,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: ListNumbersIcon,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: CheckSquareIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleList("taskList", "taskItem")
.run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: QuotesIcon,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: CodeIcon,
command: ({ editor, range }) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Table",
description: "Add a table view to organize data.",
searchTerms: ["table"],
icon: TableIcon,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run(),
},
{
title: "YouTube",
description: "Embed a YouTube video.",
searchTerms: ["youtube", "video", "embed"],
icon: YouTubeIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "youtubeUpload",
})
.run();
},
},
{
title: "Twitter",
description: "Embed a Tweet.",
searchTerms: ["twitter", "tweet", "x"],
icon: Twitter,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "twitterUpload",
})
.run();
},
},
{
title: "Image",
description: "Upload or embed an image.",
searchTerms: ["image", "picture", "photo", "img"],
icon: ImageIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "imageUpload",
})
.run();
},
},
{
title: "Video",
description: "Upload or embed a video.",
searchTerms: ["video", "mp4", "clip", "media"],
icon: VideoCameraIcon,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "videoUpload",
})
.run();
},
},
];
================================================
FILE: packages/editor/src/extensions/slash-command/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export type { EditorSlashMenuProps, SlashNodeAttrs } from "../../types";
export { defaultSlashSuggestions } from "./groups";
export { EditorSlashMenu, handleCommandNavigation } from "./menu-list";
export type { SlashOptions } from "./slash-command";
export { configureSlashCommand, SlashCommand } from "./slash-command";
================================================
FILE: packages/editor/src/extensions/slash-command/menu-list.tsx
================================================
import {
Command,
CommandEmpty,
CommandItem,
CommandList,
} from "@marble/ui/components/command";
import { useRef } from "react";
import type { EditorSlashMenuProps } from "../../types";
/**
* Menu list component for slash commands
* Displays available commands in a dropdown menu
* Uses cmdk's built-in keyboard navigation (ArrowUp, ArrowDown, Enter)
*/
export const EditorSlashMenu = ({
items,
editor,
range,
}: EditorSlashMenuProps) => {
const commandRef = useRef(null);
const selectItem = (index: number) => {
const item = items.at(index);
if (item) {
item.command({ editor, range });
}
};
return (
No results
{items.map((item, index) => (
selectItem(index)}
value={item.title}
>
{item.title}
{item.description}
))}
);
};
/**
* Handle keyboard navigation for slash command menu
*/
export const handleCommandNavigation = (event: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
// For Enter key, find and trigger the selected item directly
if (event.key === "Enter") {
const selectedItem = slashCommand.querySelector(
'[data-selected="true"], [cmdk-item][aria-selected="true"], [cmdk-item][data-state="selected"]'
);
if (selectedItem) {
event.preventDefault();
event.stopPropagation();
selectedItem.click();
return true;
}
// If no item is selected, select the first item
const firstItem =
slashCommand.querySelector("[cmdk-item]");
if (firstItem) {
event.preventDefault();
event.stopPropagation();
firstItem.click();
return true;
}
}
// For ArrowUp/ArrowDown, dispatch the event to cmdk
const keyboardEvent = new KeyboardEvent("keydown", {
key: event.key,
cancelable: true,
bubbles: true,
});
slashCommand.dispatchEvent(keyboardEvent);
event.preventDefault();
event.stopPropagation();
return true;
}
}
return false;
};
================================================
FILE: packages/editor/src/extensions/slash-command/slash-command.ts
================================================
/** biome-ignore-all lint/suspicious/noExplicitAny: <> */
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { mergeAttributes, Node } from "@tiptap/core";
import type { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import { ReactRenderer } from "@tiptap/react";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
import Fuse from "fuse.js";
import type { EditorSlashMenuProps, SlashNodeAttrs } from "../../types";
import { defaultSlashSuggestions } from "./groups";
import { EditorSlashMenu, handleCommandNavigation } from "./menu-list";
const SlashPluginKey = new PluginKey("slash");
/**
* Slash command options type
*/
export interface SlashOptions<
SlashOptionSuggestionItem = unknown,
Attrs = SlashNodeAttrs,
> {
HTMLAttributes: Record;
renderText: (props: {
options: SlashOptions;
node: ProseMirrorNode;
}) => string;
renderHTML: (props: {
options: SlashOptions;
node: ProseMirrorNode;
}) => DOMOutputSpec;
deleteTriggerWithBackspace: boolean;
suggestion: Omit<
SuggestionOptions,
"editor"
>;
}
/**
* Slash Command Extension
* Allows users to type "/" to open a command menu with formatting options
*/
export const SlashCommand = Node.create({
name: "slash",
priority: 101,
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
deleteTriggerWithBackspace: false,
renderHTML({ options, node }) {
return [
"span",
mergeAttributes(this.HTMLAttributes, options.HTMLAttributes),
`${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`,
];
},
suggestion: {
char: "/",
pluginKey: SlashPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
const overrideSpace = nodeAfter?.text?.startsWith(" ");
if (overrideSpace) {
range.to += 1;
}
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props,
},
{
type: "text",
text: " ",
},
])
.run();
// get reference to `window` object from editor element, to support cross-frame JS usage
editor.view.dom.ownerDocument.defaultView
?.getSelection()
?.collapseToEnd();
},
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
// Check if we're inside a table by looking at ancestor nodes
let isInTable = false;
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth);
if (
node.type.name === "table" ||
node.type.name === "tableRow" ||
node.type.name === "tableCell" ||
node.type.name === "tableHeader"
) {
isInTable = true;
break;
}
}
// Don't allow slash commands inside tables
if (isInTable) {
return false;
}
const isRootDepth = $from.depth === 1;
const isParagraph = $from.parent.type.name === "paragraph";
const isStartOfNode = $from.parent.textContent?.charAt(0) === "/";
// Check if we're in a column (for column layouts) by checking ancestor nodes
let isInColumn = false;
for (let depth = $from.depth; depth > 0; depth -= 1) {
const node = $from.node(depth);
if (node.type.name === "column") {
isInColumn = true;
break;
}
}
// Check if content after '/' is valid (not ending with double space)
const afterContent = $from.parent.textContent?.substring(
$from.parent.textContent?.indexOf("/") ?? 0
);
const isValidAfterContent = !afterContent?.endsWith(" ");
// Only allow slash commands at root depth or in columns, and only in paragraphs at the start
return (
((isRootDepth && isParagraph && isStartOfNode) ||
(isInColumn && isParagraph && isStartOfNode)) &&
isValidAfterContent
);
},
},
};
},
group: "inline",
inline: true,
selectable: false,
atom: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => element.getAttribute("data-id"),
renderHTML: (attributes) => {
if (!attributes.id) {
return {};
}
return {
"data-id": attributes.id,
};
},
},
label: {
default: null,
parseHTML: (element) => element.getAttribute("data-label"),
renderHTML: (attributes) => {
if (!attributes.label) {
return {};
}
return {
"data-label": attributes.label,
};
},
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
const mergedOptions = { ...this.options };
mergedOptions.HTMLAttributes = mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
);
const html = this.options.renderHTML({
options: mergedOptions,
node,
});
if (typeof html === "string") {
return [
"span",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
html,
];
}
return html;
},
renderText({ node }) {
return this.options.renderText({
options: this.options,
node,
});
},
addKeyboardShortcuts() {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false;
const { selection } = state;
const { empty, anchor } = selection;
if (!empty) {
return false;
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true;
tr.insertText(
this.options.deleteTriggerWithBackspace
? ""
: this.options.suggestion.char || "",
pos,
pos + node.nodeSize
);
return false;
}
});
return isMention;
}),
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
/**
* Configure slash command with default suggestions and Floating UI renderer
*/
export const configureSlashCommand = () =>
SlashCommand.configure({
suggestion: {
items: async ({ editor, query }) => {
if (!defaultSlashSuggestions) {
return [];
}
const items = await defaultSlashSuggestions({ editor, query });
if (!query) {
return items;
}
const slashFuse = new Fuse(items, {
keys: ["title", "description", "searchTerms"],
threshold: 0.2,
minMatchCharLength: 1,
});
const results = slashFuse.search(query);
return results.map((result) => result.item);
},
char: "/",
render: () => {
let component: ReactRenderer;
let cleanup: (() => void) | undefined;
return {
onStart: (onStartProps) => {
// Clean up any existing component first (prevents double rendering in Strict Mode)
if (component) {
if (cleanup) {
cleanup();
}
if (component.element.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component.destroy();
}
component = new ReactRenderer(EditorSlashMenu, {
props: onStartProps,
editor: onStartProps.editor,
});
const referenceElement = {
getBoundingClientRect: () =>
onStartProps.clientRect?.() || new DOMRect(),
};
// Use Floating UI for positioning (Tiptap v3)
cleanup = autoUpdate(
referenceElement as any,
component.element,
() => {
computePosition(referenceElement as any, component.element, {
placement: "bottom-start",
middleware: [offset(6), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
Object.assign(component.element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
});
});
}
);
// Only append if not already in DOM (prevents duplicates)
if (!component.element.parentNode) {
document.body.appendChild(component.element);
}
},
onUpdate(onUpdateProps) {
component.updateProps(onUpdateProps);
},
onKeyDown(onKeyDownProps) {
if (onKeyDownProps.event.key === "Escape") {
if (cleanup) {
cleanup();
}
if (component.element.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component.destroy();
return true;
}
return handleCommandNavigation(onKeyDownProps.event) ?? false;
},
onExit() {
if (cleanup) {
cleanup();
}
if (component.element.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component.destroy();
},
};
},
},
});
================================================
FILE: packages/editor/src/extensions/table/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export { TableColumnMenu } from "./menus/table-column";
export { TableRowMenu } from "./menus/table-row";
export { Table } from "./table";
export { TableCell } from "./table-cell";
export { TableHeader } from "./table-header";
export { TableRow } from "./table-row";
================================================
FILE: packages/editor/src/extensions/table/menus/table-column/index.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
ArrowLeftIcon,
ArrowRightIcon,
TrashIcon,
} from "@phosphor-icons/react";
import type { Editor } from "@tiptap/react";
import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus";
import { type JSX, memo, useCallback } from "react";
import { isColumnGripSelected } from "./utils";
interface MenuProps {
editor: Editor;
appendTo?: React.RefObject;
}
interface ShouldShowProps {
view: unknown;
state: unknown;
from: number;
}
function TableColumnMenuComponent({
editor,
appendTo,
}: MenuProps): JSX.Element {
const shouldShow = useCallback(
({ view, state, from }: ShouldShowProps) => {
if (!state) {
return false;
}
return isColumnGripSelected({
editor,
view,
state,
from: from || 0,
} as Parameters[0]);
},
[editor]
);
const onAddColumnBefore = useCallback(() => {
editor.chain().focus().addColumnBefore().run();
}, [editor]);
const onAddColumnAfter = useCallback(() => {
editor.chain().focus().addColumnAfter().run();
}, [editor]);
const onDeleteColumn = useCallback(() => {
editor.chain().focus().deleteColumn().run();
}, [editor]);
return (
appendTo?.current ?? document.body}
className="flex flex-col items-center gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm"
editor={editor}
options={{
placement: "top",
offset: { mainAxis: 24, crossAxis: 0 },
}}
pluginKey="tableColumnMenu"
shouldShow={shouldShow}
updateDelay={0}
>
Add column before
Add column after
Delete column
);
}
export const TableColumnMenu = memo(TableColumnMenuComponent);
TableColumnMenu.displayName = "TableColumnMenu";
export default TableColumnMenu;
================================================
FILE: packages/editor/src/extensions/table/menus/table-column/utils.ts
================================================
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { Editor } from "@tiptap/react";
import { Table } from "../..";
import { isTableSelected } from "../../utils";
export const isColumnGripSelected = ({
editor,
view,
state,
from,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (
!editor.isActive(Table.name) ||
!node ||
isTableSelected(state.selection)
) {
return false;
}
// Find the owning table cell (TD/TH)
const element: Element | null =
node.nodeType === Node.ELEMENT_NODE
? (node as Element)
: node.parentElement;
const cell = element?.closest?.("td, th") ?? null;
const gripColumn = cell?.querySelector?.("a.grip-column.selected");
return !!gripColumn;
};
export default isColumnGripSelected;
================================================
FILE: packages/editor/src/extensions/table/menus/table-row/index.tsx
================================================
import { Button } from "@marble/ui/components/button";
import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "@phosphor-icons/react";
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { Editor } from "@tiptap/react";
import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus";
import { type JSX, memo, useCallback } from "react";
import { isRowGripSelected } from "./utils";
interface MenuProps {
editor: Editor;
appendTo?: React.RefObject;
}
interface ShouldShowProps {
view: EditorView;
state: EditorState;
from: number;
}
function TableRowMenuComponent({ editor, appendTo }: MenuProps): JSX.Element {
const shouldShow = useCallback(
({ view, state, from }: ShouldShowProps) => {
if (!state || !from) {
return false;
}
return isRowGripSelected({ editor, view, state, from } as Parameters<
typeof isRowGripSelected
>[0]);
},
[editor]
);
const onAddRowBefore = useCallback(() => {
editor.chain().focus().addRowBefore().run();
}, [editor]);
const onAddRowAfter = useCallback(() => {
editor.chain().focus().addRowAfter().run();
}, [editor]);
const onDeleteRow = useCallback(() => {
editor.chain().focus().deleteRow().run();
}, [editor]);
return (
appendTo?.current ?? document.body}
className="flex flex-col gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm"
editor={editor}
options={{
placement: "left",
offset: { mainAxis: 24, crossAxis: 0 },
}}
pluginKey="tableRowMenu"
shouldShow={shouldShow}
updateDelay={0}
>
Add row before
Add row after
Delete row
);
}
export const TableRowMenu = memo(TableRowMenuComponent);
TableRowMenu.displayName = "TableRowMenu";
export default TableRowMenu;
================================================
FILE: packages/editor/src/extensions/table/menus/table-row/utils.ts
================================================
import type { EditorState } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { Editor } from "@tiptap/react";
import { Table } from "../..";
import { isTableSelected } from "../../utils";
export const isRowGripSelected = ({
editor,
view,
state,
from,
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (
!editor.isActive(Table.name) ||
!node ||
isTableSelected(state.selection)
) {
return false;
}
const element: Element | null =
node.nodeType === Node.ELEMENT_NODE
? (node as Element)
: node.parentElement;
const cell = element?.closest?.("td, th") ?? null;
const gripRow = cell?.querySelector?.("a.grip-row.selected");
return !!gripRow;
};
export default isRowGripSelected;
================================================
FILE: packages/editor/src/extensions/table/table-cell.ts
================================================
import { mergeAttributes, Node } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { getCellsInColumn, isRowSelected, selectRow } from "./utils";
export interface TableCellOptions {
HTMLAttributes: Record;
}
export const TableCell = Node.create({
name: "tableCell",
content: "block+",
tableRole: "cell",
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [{ tag: "td" }];
},
renderHTML({ HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
addAttributes() {
return {
colspan: {
default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute("colspan");
const value = colspan ? Number.parseInt(colspan, 10) : 1;
return value;
},
},
rowspan: {
default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute("rowspan");
const value = rowspan ? Number.parseInt(rowspan, 10) : 1;
return value;
},
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [Number.parseInt(colwidth, 10)] : null;
return value;
},
},
style: {
default: null,
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { isEditable } = this.editor;
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
if (cells) {
let index = 0;
for (const { pos } of cells) {
const currentIndex = index;
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(currentIndex)(selection);
let className = "grip-row";
if (rowSelected) {
className += " selected";
}
if (currentIndex === 0) {
className += " first";
}
if (currentIndex === cells.length - 1) {
className += " last";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(
selectRow(currentIndex)(this.editor.state.tr)
);
});
return grip;
})
);
index += 1;
}
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
================================================
FILE: packages/editor/src/extensions/table/table-header.ts
================================================
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
import { Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { getCellsInRow, isColumnSelected, selectColumn } from "./utils";
export const TableHeader = TiptapTableHeader.extend({
addAttributes() {
return {
colspan: {
default: 1,
},
rowspan: {
default: 1,
},
colwidth: {
default: null,
parseHTML: (element: HTMLElement) => {
const colwidth = element.getAttribute("colwidth");
const value = colwidth
? colwidth
.split(",")
.map((item: string) => Number.parseInt(item, 10))
: null;
return value;
},
},
style: {
default: null,
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { isEditable } = this.editor;
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
if (cells) {
let index = 0;
for (const { pos } of cells) {
const currentIndex = index;
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected =
isColumnSelected(currentIndex)(selection);
let className = "grip-column";
if (colSelected) {
className += " selected";
}
if (currentIndex === 0) {
className += " first";
}
if (currentIndex === cells.length - 1) {
className += " last";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(
selectColumn(currentIndex)(this.editor.state.tr)
);
});
return grip;
})
);
index += 1;
}
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
export default TableHeader;
================================================
FILE: packages/editor/src/extensions/table/table-row.ts
================================================
import { TableRow as TiptapTableRow } from "@tiptap/extension-table";
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: "(tableCell | tableHeader)*",
});
export default TableRow;
================================================
FILE: packages/editor/src/extensions/table/table.ts
================================================
import { Table as TiptapTable } from "@tiptap/extension-table";
import "../../styles/table.css";
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: false,
});
export default Table;
================================================
FILE: packages/editor/src/extensions/table/utils.ts
================================================
import { findParentNode } from "@tiptap/core";
import type { Node, ResolvedPos } from "@tiptap/pm/model";
import type { Selection, Transaction } from "@tiptap/pm/state";
import { CellSelection, type Rect, TableMap } from "@tiptap/pm/tables";
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(
selection.$anchorCell.pos - start,
selection.$headCell.pos - start
)
);
for (let i = 0, count = cells.length; i < count; i += 1) {
const cell = cells[i];
if (cell !== undefined && selectedCells.indexOf(cell) === -1) {
return false;
}
}
return true;
};
export const findTable = (selection: Selection) =>
findParentNode(
(node) => node.type.spec.tableRole && node.type.spec.tableRole === "table"
)(selection);
export const isCellSelection = (
selection: Selection
): selection is CellSelection => selection instanceof CellSelection;
export const isColumnSelected =
(columnIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1,
})(selection);
}
return false;
};
export const isTableSelected = (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
})(selection);
}
return false;
};
export const getCellsInColumn =
(columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex)
? columnIndex
: Array.from([columnIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInRow =
(rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex)
? rowIndex
: Array.from([rowIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1,
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height,
});
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
});
}
return null;
};
export const findParentNodeClosestToPos = (
$pos: ResolvedPos,
predicate: (node: Node) => boolean
) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
};
}
}
return null;
};
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) =>
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};
const select =
(type: "row" | "column") => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === "row";
if (table) {
const map = TableMap.get(table.node);
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom,
});
const cellsInLastRow =
bottom - top === 1
? cellsInFirstRow
: map.cellsInRect({
left: isRowSelection ? left : right - 1,
top: isRowSelection ? bottom - 1 : top,
right,
bottom,
});
const head = table.start + (cellsInFirstRow[0] ?? 0);
const anchor = table.start + (cellsInLastRow.at(-1) ?? 0);
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select("column");
export const selectRow = select("row");
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map?.length) {
const head = table.start + (map[0] ?? 0);
const anchor = table.start + (map.at(-1) ?? 0);
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
================================================
FILE: packages/editor/src/extensions/twitter/index.tsx
================================================
/** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */
import { mergeAttributes, Node, nodePasteRule } from "@tiptap/core";
import {
NodeViewWrapper,
ReactNodeViewRenderer,
type ReactNodeViewRendererOptions,
} from "@tiptap/react";
import { Tweet } from "react-tweet";
export const TWITTER_REGEX_GLOBAL =
/(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?/g;
export const TWITTER_REGEX =
/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/;
export const isValidTwitterUrl = (url: string) => url.match(TWITTER_REGEX);
const TweetComponent = ({
node,
}: {
node: Partial;
}) => {
const url = (node?.attrs as Record)?.src;
const tweetId = url?.split("/").pop();
if (!tweetId) {
return null;
}
return (
);
};
export interface TwitterOptions {
/**
* Controls if the paste handler for tweets should be added.
* @default true
* @example false
*/
addPasteHandler: boolean;
// biome-ignore lint/suspicious/noExplicitAny: <>
HTMLAttributes: Record;
/**
* Controls if the twitter node should be inline or not.
* @default false
* @example true
*/
inline: boolean;
/**
* The origin of the tweet.
* @default ''
* @example 'https://tiptap.dev'
*/
origin: string;
}
/**
* The options for setting a tweet.
*/
type SetTweetOptions = { src: string };
declare module "@tiptap/core" {
interface Commands {
twitter: {
/**
* Insert a tweet
* @param options The tweet attributes
* @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' })
*/
setTweet: (options: SetTweetOptions) => ReturnType;
};
}
}
/**
* This extension adds support for tweets.
*/
export const Twitter = Node.create({
name: "twitter",
addOptions() {
return {
addPasteHandler: true,
HTMLAttributes: {},
inline: false,
origin: "",
};
},
addNodeView() {
return ReactNodeViewRenderer(TweetComponent, {
attrs: this.options.HTMLAttributes,
});
},
inline() {
return this.options.inline;
},
group() {
return this.options.inline ? "inline" : "block";
},
draggable: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: (element) => element.getAttribute("data-src"),
renderHTML: (attributes) => {
if (!attributes.src) {
return {};
}
return {
"data-src": attributes.src,
};
},
},
};
},
parseHTML() {
return [
{
tag: "div[data-twitter]",
},
];
},
addCommands() {
return {
setTweet:
(options: SetTweetOptions) =>
({ commands }) => {
if (!isValidTwitterUrl(options.src)) {
return false;
}
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addPasteRules() {
if (!this.options.addPasteHandler) {
return [];
}
return [
nodePasteRule({
find: TWITTER_REGEX_GLOBAL,
type: this.type,
getAttributes: (match) => ({ src: match.input }),
}),
];
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes({ "data-twitter": "" }, HTMLAttributes)];
},
});
================================================
FILE: packages/editor/src/extensions/twitter/twitter-comp.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@marble/ui/components/card";
import { Textarea } from "@marble/ui/components/textarea";
import { cn } from "@marble/ui/lib/utils";
import type { ChangeEvent, KeyboardEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Twitter } from "../../components/icons/twitter";
// Validate Twitter/X.com URL
const TWITTER_REGEX =
/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/;
function isValidTwitterUrl(url: string): boolean {
if (!url) {
return false;
}
return TWITTER_REGEX.test(url);
}
export const TwitterComp = ({
onSubmit,
onCancel,
}: {
onSubmit: (url: string) => void;
onCancel: () => void;
}) => {
const [url, setUrl] = useState("");
const [error, setError] = useState(null);
const inputRef = useRef(null);
useEffect(() => {
// Use requestAnimationFrame to ensure the element is rendered
const frame = requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, []);
const validateAndSubmit = useCallback(() => {
if (!isValidTwitterUrl(url)) {
setError("Invalid Tweet link");
return;
}
onSubmit(url);
}, [url, onSubmit]);
const handleInputChange = useCallback(
(e: ChangeEvent) => {
setUrl(e.target.value);
setError(null);
},
[]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
validateAndSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[validateAndSubmit, onCancel]
);
const isValidUrl = isValidTwitterUrl(url);
return (
Paste a Tweet link
Embed Tweet
Cancel
);
};
================================================
FILE: packages/editor/src/extensions/twitter/twitter-upload.ts
================================================
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { TwitterUploadView } from "./twitter-view";
/**
* Twitter Upload Node Extension
* Creates a placeholder node that renders the Twitter upload component
* When a URL is submitted, it replaces itself with an actual Twitter embed
*/
export const TwitterUpload = Node.create({
name: "twitterUpload",
group: "block",
atom: true,
addNodeView() {
return ReactNodeViewRenderer(TwitterUploadView);
},
parseHTML() {
return [
{
tag: 'div[data-type="twitter-upload"]',
},
];
},
renderHTML() {
return ["div", { "data-type": "twitter-upload" }];
},
});
================================================
FILE: packages/editor/src/extensions/twitter/twitter-view.tsx
================================================
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewWrapper } from "@tiptap/react";
import { useCallback } from "react";
import { TwitterComp } from "./twitter-comp";
export const TwitterUploadView = ({ getPos, editor }: NodeViewProps) => {
const onSubmit = useCallback(
(url: string) => {
if (url && typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
// Replace the twitterUpload node with an actual Twitter embed
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.setTweet({ src: url })
.run();
}
}
},
[getPos, editor]
);
const onCancel = useCallback(() => {
if (typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
// Remove the placeholder node
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run();
}
}
}, [getPos, editor]);
return (
);
};
================================================
FILE: packages/editor/src/extensions/video/index.ts
================================================
import type { CommandProps } from "@tiptap/core";
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { VideoView } from "./video-view";
declare module "@tiptap/core" {
interface Commands {
video: {
setVideo: (options: {
src: string;
caption?: string;
width?: string;
align?: "left" | "center" | "right";
}) => ReturnType;
updateVideo: (attrs: {
caption?: string;
width?: string;
align?: "left" | "center" | "right";
}) => ReturnType;
};
}
}
export const Video = Node.create({
name: "video",
group: "block",
content: "",
draggable: true,
selectable: true,
isolating: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: (element) =>
element.querySelector("video")?.getAttribute("src") ||
element.querySelector("video source")?.getAttribute("src"),
renderHTML: (attributes) => ({
src: attributes.src,
}),
},
caption: {
default: "",
parseHTML: (element) =>
element.querySelector("figcaption")?.textContent || "",
renderHTML: (attributes) => ({
caption: attributes.caption,
}),
},
width: {
default: "100",
parseHTML: (element) => element.getAttribute("data-width") || "100",
renderHTML: (attributes) => ({
"data-width": attributes.width,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align") || "center",
renderHTML: (attributes) => ({
"data-align": attributes.align,
}),
},
};
},
parseHTML() {
return [
{
tag: "figure",
getAttrs: (element) => {
if (typeof element === "string") {
return false;
}
const video = element.querySelector("video");
return video ? {} : false;
},
},
{
tag: "video",
getAttrs: (element) => {
if (typeof element === "string") {
return false;
}
return {
src: element.getAttribute("src"),
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
const { src, caption, ...figureAttrs } = HTMLAttributes;
const videoAttrs: Record = { controls: "true" };
if (src) {
videoAttrs.src = src;
}
const figcaptionContent = caption || "";
return [
"figure",
mergeAttributes({ "data-type": "video" }, figureAttrs),
["video", videoAttrs],
["figcaption", {}, figcaptionContent],
];
},
addCommands() {
return {
setVideo:
(options) =>
({ commands }: CommandProps) =>
commands.insertContent({
type: this.name,
attrs: options,
}),
updateVideo:
(attrs) =>
({ commands, tr, state }: CommandProps) => {
const { selection } = state;
const node = tr.doc.nodeAt(selection.from);
if (node?.type.name === this.name) {
return commands.updateAttributes(this.name, attrs);
}
return false;
},
};
},
addNodeView() {
return ReactNodeViewRenderer(VideoView);
},
});
================================================
FILE: packages/editor/src/extensions/video/video-view.tsx
================================================
/** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: <> */
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: <> */
import { Button } from "@marble/ui/components/button";
import { Input } from "@marble/ui/components/input";
import { Label } from "@marble/ui/components/label";
import { cn } from "@marble/ui/lib/utils";
import {
FadersHorizontalIcon,
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from "@phosphor-icons/react";
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewWrapper } from "@tiptap/react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
export const VideoView = ({
node,
updateAttributes,
selected,
}: NodeViewProps) => {
const { src, caption, width, align } = node.attrs as {
src: string;
caption: string;
width: string;
align: "left" | "center" | "right";
};
const [captionValue, setCaptionValue] = useState(caption || "");
const [widthValue, setWidthValue] = useState(width || "100");
const [alignValue, setAlignValue] = useState<"left" | "center" | "right">(
align || "center"
);
const [isResizing, setIsResizing] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const figureRef = useRef(null);
const settingsPanelRef = useRef(null);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const resizeSideRef = useRef<"left" | "right">("right");
const captionId = useId();
const [prevAttrs, setPrevAttrs] = useState({ caption, width, align });
if (
caption !== prevAttrs.caption ||
width !== prevAttrs.width ||
align !== prevAttrs.align
) {
setPrevAttrs({ caption, width, align });
setCaptionValue(caption || "");
setWidthValue(width || "100");
setAlignValue(align || "center");
}
// Handle click outside settings panel
useEffect(() => {
if (!showSettings) {
return;
}
const handleClickOutside = (e: MouseEvent) => {
if (
settingsPanelRef.current &&
!settingsPanelRef.current.contains(e.target as Node)
) {
const target = e.target as HTMLElement;
if (!target.closest("[data-settings-trigger]")) {
setShowSettings(false);
}
}
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showSettings]);
const handleCaptionChange = useCallback(
(e: React.ChangeEvent) => {
const newCaption = e.target.value;
setCaptionValue(newCaption);
updateAttributes({ caption: newCaption });
},
[updateAttributes]
);
const handleAlignChange = useCallback(
(newAlign: "left" | "center" | "right") => {
setAlignValue(newAlign);
setTimeout(() => {
updateAttributes({ align: newAlign });
}, 0);
},
[updateAttributes]
);
const handleResizeStart = useCallback(
(side: "left" | "right") => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
startXRef.current = e.clientX;
resizeSideRef.current = side;
const currentWidth = Number.parseInt(widthValue, 10) || 100;
startWidthRef.current = currentWidth;
},
[widthValue]
);
useEffect(() => {
if (!isResizing) {
return;
}
const handleMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - startXRef.current;
const containerWidth =
figureRef.current?.parentElement?.clientWidth || 800;
const effectiveDelta =
resizeSideRef.current === "left" ? -deltaX : deltaX;
const deltaPercent = (effectiveDelta / containerWidth) * 100;
const newWidth = Math.max(
10,
Math.min(100, startWidthRef.current + deltaPercent)
);
const roundedWidth = Math.round(newWidth);
setWidthValue(String(roundedWidth));
updateAttributes({ width: String(roundedWidth) });
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, updateAttributes]);
const alignmentStyles: React.CSSProperties = {
width: `${widthValue}%`,
marginLeft: alignValue === "left" ? 0 : "auto",
marginRight: alignValue === "right" ? 0 : "auto",
};
const showToolbar = selected || isHovered || showSettings;
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={figureRef}
style={alignmentStyles}
>
{showToolbar && (
handleAlignChange("left")}
size="icon"
title="Align left"
type="button"
variant="ghost"
>
handleAlignChange("center")}
size="icon"
title="Align center"
type="button"
variant="ghost"
>
handleAlignChange("right")}
size="icon"
title="Align right"
type="button"
variant="ghost"
>
{/* Divider */}
{
e.preventDefault();
e.stopPropagation();
setShowSettings((prev) => !prev);
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
title="Video settings"
type="button"
>
)}
{showSettings && (
)}
{showToolbar && (
<>
>
)}
{captionValue && (
{captionValue}
)}
);
};
================================================
FILE: packages/editor/src/extensions/video-upload/hooks.ts
================================================
import { toast } from "@marble/ui/components/sonner";
import type { DragEvent } from "react";
import { useCallback, useRef, useState } from "react";
export const useFileUpload = () => {
const fileInput = useRef(null);
const handleUploadClick = useCallback(() => {
fileInput.current?.click();
}, []);
return { ref: fileInput, handleUploadClick };
};
export const useUploader = ({
onUpload,
upload,
onError,
}: {
onUpload: (url: string) => void;
upload: (file: File) => Promise;
onError?: (error: Error) => void;
}) => {
const [loading, setLoading] = useState(false);
const uploadVideo = useCallback(
async (file: File) => {
setLoading(true);
try {
const url = await upload(file);
if (url) {
onUpload(url);
} else {
const error = new Error(
"Upload failed: Invalid response from server."
);
if (onError) {
onError(error);
} else {
toast.error(error.message);
}
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to upload video";
const uploadError = new Error(errorMessage);
if (onError) {
onError(uploadError);
} else {
toast.error(errorMessage);
}
}
setLoading(false);
},
[onUpload, upload, onError]
);
return { loading, uploadVideo };
};
export const useDropZone = ({
uploader,
}: {
uploader: (file: File) => void;
}) => {
const [draggedInside, setDraggedInside] = useState(false);
const onDrop = useCallback(
(e: DragEvent) => {
setDraggedInside(false);
e.preventDefault();
e.stopPropagation();
const fileList = e.dataTransfer.files;
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item) {
files.push(item);
}
}
// Validate only video files
if (files.some((file) => !file.type.startsWith("video/"))) {
toast.error("Only video files are allowed");
return;
}
const filteredFiles = files.filter((f) => f.type.startsWith("video/"));
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
uploader(file);
}
},
[uploader]
);
const onDragEnter = useCallback(() => {
setDraggedInside(true);
}, []);
const onDragLeave = useCallback(() => {
setDraggedInside(false);
}, []);
const onDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return { draggedInside, onDragEnter, onDragLeave, onDrop, onDragOver };
};
================================================
FILE: packages/editor/src/extensions/video-upload/index.ts
================================================
/** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */
import type { CommandProps } from "@tiptap/core";
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { VideoUploadOptions } from "../../types";
import { VideoUploadView } from "./video-upload-view";
declare module "@tiptap/core" {
interface Commands {
videoUpload: {
setVideoUpload: (options?: { file?: File }) => ReturnType;
};
}
}
export const VideoUpload = Node.create({
name: "videoUpload",
isolating: true,
defining: true,
group: "block",
draggable: true,
selectable: true,
inline: false,
addOptions() {
return {
upload: undefined,
accept: "video/*",
maxSize: undefined,
limit: undefined,
onError: undefined,
media: undefined,
fetchMediaPage: undefined,
};
},
addAttributes() {
return {
fileId: {
default: null,
parseHTML: (element) => element.getAttribute("data-file-id"),
renderHTML: (attributes) => {
if (!attributes.fileId) {
return {};
}
return {
"data-file-id": attributes.fileId,
};
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return ["div", { "data-type": this.name, ...HTMLAttributes }];
},
addCommands() {
const extensionStorage = this.storage as VideoUploadStorage;
return {
setVideoUpload:
(options) =>
({ commands }: CommandProps) => {
const { file } = options || {};
if (file) {
const fileId = `upload-${Date.now()}-${Math.random()}`;
extensionStorage.pendingUploads.set(fileId, file);
return commands.insertContent({
type: this.name,
attrs: { fileId },
});
}
return commands.insertContent({
type: this.name,
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(VideoUploadView, {
as: "div",
});
},
addStorage() {
return {
pendingUploads: new Map(),
options: this.options,
};
},
onDestroy() {
const storage = this.storage as VideoUploadStorage;
storage.pendingUploads.clear();
},
});
export interface VideoUploadStorage {
pendingUploads: Map;
options: VideoUploadOptions;
}
================================================
FILE: packages/editor/src/extensions/video-upload/video-upload-comp.tsx
================================================
import { Video02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Button } from "@marble/ui/components/button";
import { Card, CardContent, CardFooter } from "@marble/ui/components/card";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
DialogX,
} from "@marble/ui/components/dialog";
import { Input } from "@marble/ui/components/input";
import { cn } from "@marble/ui/lib/utils";
import {
CheckIcon,
SpinnerIcon,
VideoCameraIcon,
XIcon,
} from "@phosphor-icons/react";
import type { ChangeEvent } from "react";
import { useCallback, useEffect, useState } from "react";
import type { MediaItem, MediaPage } from "../../types";
import { useDropZone, useFileUpload, useUploader } from "./hooks";
// Simple URL validation
const isValidUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
export interface VideoUploadCompProps {
initialFile?: File;
onUpload: (url: string) => void;
onCancel: () => void;
upload: (file: File) => Promise;
media?: MediaItem[];
fetchMediaPage?: (cursor?: string) => Promise;
onError?: (error: Error) => void;
}
export const VideoUploadComp = ({
initialFile,
onUpload,
onCancel,
upload,
media: providedMedia,
fetchMediaPage,
onError,
}: VideoUploadCompProps) => {
const [showEmbedInput, setShowEmbedInput] = useState(false);
const [embedUrl, setEmbedUrl] = useState("");
const [urlError, setUrlError] = useState(null);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [media, setMedia] = useState(providedMedia);
const [isLoadingMedia, setIsLoadingMedia] = useState(false);
const [nextCursor, setNextCursor] = useState(undefined);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { loading, uploadVideo } = useUploader({ onUpload, upload, onError });
const { handleUploadClick, ref } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave, onDragOver } =
useDropZone({
uploader: uploadVideo,
});
useEffect(() => {
if (!fetchMediaPage || providedMedia) {
return;
}
let active = true;
setIsLoadingMedia(true);
fetchMediaPage()
.then((page) => {
if (active) {
setMedia(page.media);
setNextCursor(page.nextCursor);
}
})
.catch(() => {
if (active) {
setMedia([]);
}
})
.finally(() => {
if (active) {
setIsLoadingMedia(false);
}
});
return () => {
active = false;
};
}, [fetchMediaPage, providedMedia]);
// Load more media handler
const handleLoadMore = useCallback(async () => {
if (!fetchMediaPage || !nextCursor || isLoadingMore) {
return;
}
setIsLoadingMore(true);
try {
const page = await fetchMediaPage(nextCursor);
setMedia((prev) => [...(prev || []), ...page.media]);
setNextCursor(page.nextCursor);
} catch {
// Ignore errors on load more
} finally {
setIsLoadingMore(false);
}
}, [fetchMediaPage, nextCursor, isLoadingMore]);
// Update media when providedMedia changes
useEffect(() => {
if (providedMedia) {
setMedia(providedMedia);
}
}, [providedMedia]);
// Auto-upload if initialFile is provided
useEffect(() => {
if (initialFile) {
uploadVideo(initialFile);
}
}, [initialFile, uploadVideo]);
const onFileChange = useCallback(
(e: ChangeEvent) => {
const file = e.target.files?.[0];
if (file) {
uploadVideo(file);
}
},
[uploadVideo]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
onDrop(e);
},
[onDrop]
);
const handleEmbedUrl = useCallback(
(url: string) => {
if (!url) {
return;
}
setUrlError(null);
if (!isValidUrl(url)) {
setUrlError("Please enter a valid URL");
return;
}
onUpload(url);
setEmbedUrl("");
setShowEmbedInput(false);
},
[onUpload]
);
const handleMediaSelect = useCallback(
(url: string) => {
onUpload(url);
setIsGalleryOpen(false);
},
[onUpload]
);
const handleDropzoneClick = useCallback(() => {
handleUploadClick();
}, [handleUploadClick]);
const handleDropzoneKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleUploadClick();
}
},
[handleUploadClick]
);
// Get dropzone text based on drag state
const getDropzoneText = () => {
if (draggedInside) {
return "Drop video here";
}
return "Drag and drop or click to upload";
};
return (
<>
Upload or embed a video
{/* Dropzone or Uploading state */}
{loading ? (
) : (
// biome-ignore lint/a11y/useSemanticElements: Dropzone requires div for drag-and-drop functionality
)}
{showEmbedInput ? (
{
setEmbedUrl(target.value);
setUrlError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && embedUrl && !loading) {
handleEmbedUrl(embedUrl);
}
}}
placeholder="Paste video URL"
value={embedUrl}
/>
handleEmbedUrl(embedUrl)}
size="icon"
type="button"
variant="outline"
>
{
setShowEmbedInput(false);
setEmbedUrl("");
setUrlError(null);
}}
size="icon"
type="button"
variant="outline"
>
{urlError && (
{urlError}
)}
) : (
// Media and Embed URL buttons - shown by default
{(media !== undefined || fetchMediaPage) && (
setIsGalleryOpen(true)}
size="sm"
type="button"
variant="outline"
>
View Gallery
)}
setShowEmbedInput(true)}
size="sm"
type="button"
variant="outline"
>
Embed URL
)}
Cancel
{/* Media Gallery Dialog */}
{(media !== undefined || fetchMediaPage) && (
)}
>
);
};
================================================
FILE: packages/editor/src/extensions/video-upload/video-upload-view.tsx
================================================
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewWrapper } from "@tiptap/react";
import { useCallback, useEffect, useRef } from "react";
import type { VideoUploadStorage } from "./index";
import { VideoUploadComp } from "./video-upload-comp";
export const VideoUploadView = ({
getPos,
editor,
node,
extension,
}: NodeViewProps) => {
const storage = extension.storage as VideoUploadStorage;
const pendingUploads = storage.pendingUploads;
// Get fileId from node attributes
const fileId = node.attrs.fileId as string | null;
const initialFile = fileId ? pendingUploads.get(fileId) : undefined;
// Get extension options from storage
const { options } = storage;
// Track whether the upload was consumed (success or cancel) so the
// unmount cleanup knows whether it still needs to release the entry.
const consumedRef = useRef(false);
// Clean up the pending upload entry when this view unmounts (e.g. the
// node is deleted while an upload is still in progress).
useEffect(() => {
return () => {
if (fileId && !consumedRef.current) {
pendingUploads.delete(fileId);
}
};
}, [fileId, pendingUploads]);
const onUpload = useCallback(
(url: string) => {
if (url && typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
consumedRef.current = true;
if (fileId) {
pendingUploads.delete(fileId);
}
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.setVideo({ src: url, caption: "" })
.run();
}
}
},
[getPos, editor, fileId, pendingUploads]
);
const onCancel = useCallback(() => {
if (typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
consumedRef.current = true;
if (fileId) {
pendingUploads.delete(fileId);
}
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run();
}
}
}, [getPos, editor, fileId, pendingUploads]);
// Only render if upload handler is configured
if (!options.upload) {
return (
Video upload is not configured. Please configure the VideoUpload
extension with an upload handler.
);
}
return (
);
};
================================================
FILE: packages/editor/src/extensions/youtube/youtube-comp.tsx
================================================
import { Button } from "@marble/ui/components/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@marble/ui/components/card";
import { Textarea } from "@marble/ui/components/textarea";
import { cn } from "@marble/ui/lib/utils";
import type { ChangeEvent, KeyboardEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { YouTubeIcon } from "../../components/icons/youtube";
// Extract YouTube video ID from various URL formats
function extractYouTubeVideoId(url: string): string | null {
if (!url) {
return null;
}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/^([a-zA-Z0-9_-]{11})$/, // Direct video ID
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
}
export const YouTubeComp = ({
onSubmit,
onCancel,
}: {
onSubmit: (url: string) => void;
onCancel: () => void;
}) => {
const [url, setUrl] = useState("");
const [error, setError] = useState(null);
const inputRef = useRef(null);
useEffect(() => {
// Use requestAnimationFrame to ensure the element is rendered
const frame = requestAnimationFrame(() => {
inputRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, []);
const validateAndSubmit = useCallback(() => {
const videoId = extractYouTubeVideoId(url);
if (!videoId) {
setError("Invalid YouTube URL");
return;
}
// Construct a clean YouTube URL
const cleanUrl = `https://www.youtube.com/watch?v=${videoId}`;
onSubmit(cleanUrl);
}, [url, onSubmit]);
const handleInputChange = useCallback(
(e: ChangeEvent) => {
setUrl(e.target.value);
setError(null);
},
[]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
validateAndSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[validateAndSubmit, onCancel]
);
const isValidUrl = extractYouTubeVideoId(url) !== null;
return (
Paste a YouTube URL
Embed Video
Cancel
);
};
================================================
FILE: packages/editor/src/extensions/youtube/youtube-upload.ts
================================================
import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { YouTubeUploadView } from "./youtube-view";
/**
* YouTube Upload Node Extension
* Creates a placeholder node that renders the YouTube upload component
* When a URL is submitted, it replaces itself with an actual YouTube embed
*/
export const YouTubeUpload = Node.create({
name: "youtubeUpload",
group: "block",
atom: true,
addNodeView() {
return ReactNodeViewRenderer(YouTubeUploadView);
},
parseHTML() {
return [
{
tag: 'div[data-type="youtube-upload"]',
},
];
},
renderHTML() {
return ["div", { "data-type": "youtube-upload" }];
},
});
================================================
FILE: packages/editor/src/extensions/youtube/youtube-view.tsx
================================================
import type { NodeViewProps } from "@tiptap/core";
import { NodeViewWrapper } from "@tiptap/react";
import { useCallback } from "react";
import { YouTubeComp } from "./youtube-comp";
export const YouTubeUploadView = ({ getPos, editor }: NodeViewProps) => {
const onSubmit = useCallback(
(url: string) => {
if (url && typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
// Replace the youtubeUpload node with an actual YouTube embed
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.setYoutubeVideo({ src: url })
.run();
}
}
},
[getPos, editor]
);
const onCancel = useCallback(() => {
if (typeof getPos === "function") {
const pos = getPos();
if (typeof pos === "number") {
// Remove the placeholder node
editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + 1 })
.run();
}
}
}, [getPos, editor]);
return (
);
};
================================================
FILE: packages/editor/src/index.ts
================================================
// Components
/** biome-ignore-all lint/performance/noBarrelFile: <> */
// Types
export type { Editor, JSONContent } from "@tiptap/react";
export type {
EditorBlockHandleMenuProps,
EditorBubbleMenuProps,
EditorCharacterCountProps,
EditorFloatingMenuProps,
EditorLinkSelectorProps,
// Mark Component Types
EditorMarkBoldProps,
EditorMarkCodeProps,
EditorMarkHighlightProps,
EditorMarkItalicProps,
EditorMarkStrikeProps,
EditorMarkSubscriptProps,
EditorMarkSuperscriptProps,
EditorMarkTextColorProps,
EditorMarkUnderlineProps,
EditorNodeBulletListProps,
EditorNodeCodeProps,
EditorNodeHeading1Props,
EditorNodeHeading2Props,
EditorNodeHeading3Props,
EditorNodeOrderedListProps,
EditorNodeQuoteProps,
EditorNodeTableProps,
EditorNodeTaskListProps,
// Node Component Types
EditorNodeTextProps,
EditorProviderProps,
// Utility Component Types
EditorSelectorProps,
FieldRichTextEditorProps,
UseMarbleEditorOptions,
} from "./components";
export {
// Alignment Components
EditorAlignCenter,
EditorAlignJustify,
EditorAlignLeft,
EditorAlignRight,
EditorAlignSelector,
EditorBlockHandleMenu,
EditorBubbleMenu,
EditorCharacterCount,
EditorClearFormatting,
EditorContent,
EditorContext,
EditorFloatingMenu,
EditorLinkSelector,
// Mark Components
EditorMarkBold,
EditorMarkCode,
EditorMarkHighlight,
EditorMarkItalic,
EditorMarkStrike,
EditorMarkSubscript,
EditorMarkSuperscript,
EditorMarkTextColor,
EditorMarkUnderline,
EditorNodeBulletList,
EditorNodeCode,
EditorNodeHeading1,
EditorNodeHeading2,
EditorNodeHeading3,
EditorNodeOrderedList,
EditorNodeQuote,
EditorNodeTable,
EditorNodeTaskList,
// Node Components
EditorNodeText,
EditorProvider,
// Utility Components
EditorSelector,
EditorTableMenus,
FieldRichTextEditor,
useCurrentEditor,
useEditor,
useMarbleEditor,
} from "./components";
export * from "./components/ui";
export {
CodeBlock,
configureSlashCommand,
Figure,
handleCommandNavigation,
ImageUpload,
SlashCommand,
Table,
TableCell,
TableColumnMenu,
TableHeader,
TableRow,
TableRowMenu,
Video,
VideoUpload,
} from "./extensions";
export type { ExtensionKitOptions } from "./extensions/extension-kit";
// Extensions
export { ExtensionKit } from "./extensions/extension-kit";
// Lib
export { lowlight } from "./lib";
export type {
EditorButtonProps,
EditorIcon,
EditorSlashMenuProps,
ImageUploadOptions,
MediaItem,
MediaPage,
SlashNodeAttrs,
SuggestionItem,
VideoUploadOptions,
} from "./types";
================================================
FILE: packages/editor/src/lib/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export { lowlight } from "./lowlight";
export { isCustomNodeSelected, isTextSelected } from "./utils";
================================================
FILE: packages/editor/src/lib/lowlight.ts
================================================
import { all, createLowlight } from "lowlight";
/**
* Create a lowlight instance with all languages loaded
* Used for syntax highlighting in code blocks
*/
export const lowlight = createLowlight(all);
================================================
FILE: packages/editor/src/lib/utils.ts
================================================
import type { Editor } from "@tiptap/core";
import { isTextSelection } from "@tiptap/core";
/**
* Check if a table grip is selected
*/
function isTableGripSelected(node: HTMLElement): boolean {
let container: HTMLElement | null = node;
while (container && !["TD", "TH"].includes(container.tagName)) {
container = container.parentElement;
}
if (!container) {
return false;
}
const gripColumn = container.querySelector?.("a.grip-column.selected");
const gripRow = container.querySelector?.("a.grip-row.selected");
return !!(gripColumn || gripRow);
}
/**
* Check if a custom node is currently selected
* Custom nodes are block-level nodes that shouldn't show the bubble menu
* (e.g., YouTube embeds, code blocks, horizontal rules, etc.)
*/
export function isCustomNodeSelected(
editor: Editor | null,
node: HTMLElement | null
): boolean {
if (!editor || !node) {
return false;
}
const customNodes = [
"youtube",
"youtubeUpload",
"twitter",
"twitterUpload",
"codeBlock",
"horizontalRule",
"imageUpload",
"figure",
"image",
"video",
"videoUpload",
];
const isCustomNodeActive = customNodes.some((type) => editor.isActive(type));
return isCustomNodeActive || isTableGripSelected(node);
}
/**
* Check if text is currently selected in the editor
* Returns false if selection is empty or if the editor is not editable
*/
export function isTextSelected({ editor }: { editor: Editor | null }): boolean {
if (!editor) {
return false;
}
const {
state: {
doc,
selection,
selection: { empty, from, to },
},
} = editor;
// Sometimes check for `empty` is not enough.
// Double-click an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return true;
}
================================================
FILE: packages/editor/src/styles/color-picker.css
================================================
.color-picker .react-colorful {
width: 100%;
height: 200px;
}
.color-picker .react-colorful__saturation {
border-radius: 4px;
margin-bottom: 6px;
}
.color-picker .react-colorful__hue {
border-radius: 4px;
}
.color-picker .react-colorful__pointer {
width: 28px;
height: 28px;
}
================================================
FILE: packages/editor/src/styles/table.css
================================================
/* Light mode styles */
.ProseMirror {
.tableWrapper {
margin-top: 3rem;
margin-bottom: 3rem;
}
table {
border-collapse: collapse;
border-color: rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
box-sizing: border-box;
width: 100%;
}
table td,
table th {
border: 1px solid rgba(0, 0, 0, 0.1);
min-width: 100px;
padding: 0.5rem;
position: relative;
text-align: left;
vertical-align: top;
}
table td:not(:last-child)::after,
table th:not(:last-child)::after {
content: "";
position: absolute;
top: 0;
right: -0.5rem;
bottom: 0;
width: 1rem;
cursor: col-resize;
z-index: 1;
}
table td:first-of-type:not(a),
table th:first-of-type:not(a) {
margin-top: 0;
}
table td p,
table th p {
margin: 0;
}
table td p + p,
table th p + p {
margin-top: 0.75rem;
}
table th {
font-weight: 700;
}
table .column-resize-handle {
bottom: -2px;
display: flex;
pointer-events: none;
position: absolute;
right: -0.25rem;
top: 0;
width: 0.5rem;
}
table .column-resize-handle::before {
background-color: rgba(0, 0, 0, 0.2);
height: 100%;
width: 1px;
margin-left: 0.5rem;
content: "";
}
table .selectedCell {
background-color: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.2);
border-style: double;
}
table .grip-column,
table .grip-row {
align-items: center;
background-color: rgba(0, 0, 0, 0.05);
cursor: pointer;
display: flex;
justify-content: center;
position: absolute;
z-index: 10;
}
table .grip-column {
width: calc(100% + 1px);
border-left: 1px solid rgba(0, 0, 0, 0.2);
height: 0.75rem;
left: 0;
margin-left: -1px;
top: -0.75rem;
}
table .grip-column:hover::before,
table .grip-column.selected::before {
content: "";
width: 0.625rem;
}
table .grip-column:hover {
background-color: rgba(0, 0, 0, 0.1);
}
table .grip-column:hover::before {
border-bottom: 2px dotted rgba(0, 0, 0, 0.6);
}
table .grip-column.first {
border-color: transparent;
border-top-left-radius: 0.125rem;
}
table .grip-column.last {
border-top-right-radius: 0.125rem;
}
table .grip-column.selected {
background-color: rgba(0, 0, 0, 0.3);
border-color: rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
table .grip-column.selected::before {
border-bottom: 2px dotted;
}
table .grip-row {
height: calc(100% + 1px);
border-top: 1px solid rgba(0, 0, 0, 0.2);
left: -0.75rem;
width: 0.75rem;
top: 0;
margin-top: -1px;
}
table .grip-row:hover::before,
table .grip-row.selected::before {
height: 0.625rem;
content: "";
}
table .grip-row:hover {
background-color: rgba(0, 0, 0, 0.1);
}
table .grip-row:hover::before {
border-left: 2px dotted rgba(0, 0, 0, 0.6);
}
table .grip-row.first {
border-color: transparent;
border-top-left-radius: 0.125rem;
}
table .grip-row.last {
border-bottom-left-radius: 0.125rem;
}
table .grip-row.selected {
background-color: rgba(0, 0, 0, 0.3);
border-color: rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
table .grip-row.selected::before {
border-left: 2px dotted;
}
}
/* Dark mode styles - using :is(.dark *) to match when .dark is on any ancestor */
:is(.dark *) .ProseMirror {
table {
border-color: rgba(255, 255, 255, 0.2);
}
table td,
table th {
border-color: rgba(255, 255, 255, 0.2);
}
table .column-resize-handle::before {
background-color: rgba(255, 255, 255, 0.2);
}
table .selectedCell {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
table .grip-column,
table .grip-row {
background-color: rgba(255, 255, 255, 0.1);
}
table .grip-column {
border-color: rgba(255, 255, 255, 0.2);
}
table .grip-column:hover {
background-color: rgba(255, 255, 255, 0.2);
}
table .grip-column:hover::before {
border-color: rgba(255, 255, 255, 0.6);
}
table .grip-column.selected {
background-color: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.3);
}
table .grip-row {
border-color: rgba(255, 255, 255, 0.2);
}
table .grip-row:hover {
background-color: rgba(255, 255, 255, 0.2);
}
table .grip-row:hover::before {
border-color: rgba(255, 255, 255, 0.6);
}
table .grip-row.selected {
background-color: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.3);
}
}
================================================
FILE: packages/editor/src/styles/task-list.css
================================================
.ProseMirror {
ul[data-type="taskList"] {
list-style: none;
padding: 0;
p {
margin: 0;
}
li {
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
&[data-checked="true"] {
text-decoration: line-through;
}
}
}
}
================================================
FILE: packages/editor/src/types/index.ts
================================================
/** biome-ignore-all lint/suspicious/noExplicitAny: */
import type { Editor, Range } from "@tiptap/core";
import type { ComponentType, SVGProps } from "react";
/**
* Icon type that accepts Lucide icons, custom SVG components, or render functions
*/
export type EditorIcon =
| ComponentType>
| ComponentType>
| ((props: Record) => React.ReactNode);
/**
* Suggestion item for slash command menu
*/
export interface SuggestionItem {
title: string;
description: string;
icon: EditorIcon;
searchTerms: string[];
command: (props: { editor: Editor; range: Range }) => void;
}
/**
* Props for editor provider component
*/
export interface EditorProviderProps {
className?: string;
limit?: number;
placeholder?: string;
children?: React.ReactNode;
content?: string;
extensions?: any[];
editorProps?: Record;
onUpdate?: (props: { editor: Editor }) => void;
}
/**
* Props for editor button components
*/
export interface EditorButtonProps {
name: string;
isActive: () => boolean;
command: () => void;
icon: EditorIcon;
hideName?: boolean;
}
/**
* Props for slash command menu component
*/
export interface EditorSlashMenuProps {
items: SuggestionItem[];
command: (item: SuggestionItem) => void;
editor: Editor;
range: Range;
}
/**
* Slash node attributes type
*/
export interface SlashNodeAttrs {
id: string | null;
label?: string | null;
}
/**
* Media item type for image upload extension
*/
export interface MediaItem {
id: string;
url: string;
name: string;
type: "image" | "video" | "file";
}
/**
* Paginated media response
*/
export interface MediaPage {
media: MediaItem[];
nextCursor?: string;
}
/**
* Image upload extension options
*/
export interface ImageUploadOptions {
/** Upload handler function - required for upload functionality */
upload?: (file: File) => Promise;
/** File accept types (default: 'image/*') */
accept?: string;
/** Max file size in bytes */
maxSize?: number;
/** Max number of files */
limit?: number;
/** Error handler */
onError?: (error: Error) => void;
/** Pre-loaded media library items */
media?: MediaItem[];
/** Fetch media with pagination - takes optional cursor, returns page with media and next cursor */
fetchMediaPage?: (cursor?: string) => Promise;
}
/**
* Video upload extension options
*/
export interface VideoUploadOptions {
/** Upload handler function - required for upload functionality */
upload?: (file: File) => Promise;
/** File accept types (default: 'video/*') */
accept?: string;
/** Max file size in bytes */
maxSize?: number;
/** Max number of files */
limit?: number;
/** Error handler */
onError?: (error: Error) => void;
/** Pre-loaded media library items */
media?: MediaItem[];
/** Fetch media with pagination - takes optional cursor, returns page with media and next cursor */
fetchMediaPage?: (cursor?: string) => Promise;
}
================================================
FILE: packages/editor/tsconfig.json
================================================
{
"extends": "@marble/tsconfig/react-library.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@marble/*": ["../*"],
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
================================================
FILE: packages/email/package.json
================================================
{
"name": "@marble/email",
"version": "0.0.0",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"dev": "email dev --dir ./src/emails --port 3001"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-email": "^6.1.3",
"resend": "^6.12.3"
},
"devDependencies": {
"@marble/tsconfig": "workspace:*",
"@react-email/ui": "^6.1.3",
"@types/node": "^22.9.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"typescript": "^5.9.3"
}
}
================================================
FILE: packages/email/src/components/button.tsx
================================================
import type { ComponentProps } from "react";
import { Button as ReactEmailButton } from "react-email";
type EmailButtonProps = ComponentProps;
export const EmailButton = ({
children,
className,
...props
}: EmailButtonProps) => {
const baseClasses =
"rounded-lg bg-[#766df8] px-6 py-3 font-semibold text-sm text-white";
const combinedClassName = className
? `${baseClasses} ${className}`
: baseClasses;
return (
{children}
);
};
================================================
FILE: packages/email/src/components/footer.tsx
================================================
import { Hr, Link, Section, Text } from "react-email";
import { EMAIL_CONFIG } from "../lib/config";
export const EmailFooter = () => {
const currentYear = new Date().getFullYear();
const { physicalAddress } = EMAIL_CONFIG;
return (
{physicalAddress.street && (
{physicalAddress.name}
{physicalAddress.street}
{physicalAddress.city}, {physicalAddress.state} {physicalAddress.zip}
{physicalAddress.country}
)}
© {currentYear} Marble. All rights reserved.
Website
{" · "}
Documentation
{" · "}
Support
);
};
================================================
FILE: packages/email/src/emails/founder.tsx
================================================
import {
Body,
Container,
Head,
Hr,
Html,
Link,
Preview,
Tailwind,
Text,
} from "react-email";
import { EMAIL_CONFIG } from "../lib/config";
interface FounderEmailProps {
userEmail: string;
}
export const FounderEmail = ({ userEmail }: FounderEmailProps) => {
const previewText = "A note from Marble";
const twitterLink = EMAIL_CONFIG.twitterLink;
return (
{previewText}
Hello,
Marble started as a simple idea: to make a CMS feel as simple as
possible.
It’s still early, and you can shape where it goes next.
If anything feels confusing, missing, or just off, feel free to
reply to this email. I read every message.
You can also reach me on{" "}
Twitter
{" "}
if that’s easier.
Thanks,
Taqib
This email was sent to {userEmail} because you signed up for
Marble.
);
};
export default FounderEmail;
================================================
FILE: packages/email/src/emails/invite.tsx
================================================
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "react-email";
import { EmailButton } from "../components/button";
import { EmailFooter } from "../components/footer";
import { EMAIL_CONFIG } from "../lib/config";
interface InviteUserEmailProps {
inviteeEmail: string;
invitedByUsername: string;
invitedByEmail: string;
workspaceName: string;
inviteLink: string;
}
export const InviteUserEmail = ({
inviteeEmail,
invitedByUsername,
workspaceName,
inviteLink,
}: InviteUserEmailProps) => {
const previewText = `Join ${invitedByUsername} on Marble`;
const logoUrl = EMAIL_CONFIG.getLogoUrl();
return (
{previewText}
Join {workspaceName} on Marble
{invitedByUsername} has invited you to join the{" "}
{workspaceName} workspace on Marble.
or copy and paste this URL into your browser:{" "}
{inviteLink}
This invitation was intended for{" "}
{inviteeEmail}. If you weren't
expecting this, you can safely ignore this email. Need help? Reach
us at {EMAIL_CONFIG.replyTo}.
);
};
export default InviteUserEmail;
================================================
FILE: packages/email/src/emails/reset.tsx
================================================
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "react-email";
import { EmailButton } from "../components/button";
import { EmailFooter } from "../components/footer";
import { EMAIL_CONFIG } from "../lib/config";
interface ResetPasswordProps {
userEmail: string;
resetLink: string;
baseUrl?: string;
}
export const ResetPasswordEmail = ({
userEmail,
resetLink,
}: ResetPasswordProps) => {
const logoUrl = EMAIL_CONFIG.getLogoUrl();
return (
Reset your password
Reset your password
We received a request to reset the password for your account. To
proceed, click on the button below
This email was intended for{" "}
{userEmail}. If you didn't
request this, you can safely ignore this email. Need help? Reach
us at {EMAIL_CONFIG.replyTo}.
);
};
export default ResetPasswordEmail;
================================================
FILE: packages/email/src/emails/usage-limit.tsx
================================================
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "react-email";
import { EmailButton } from "../components/button";
import { EmailFooter } from "../components/footer";
import { EMAIL_CONFIG } from "../lib/config";
interface UsageLimitEmailProps {
userName?: string;
featureName?: string;
usageAmount?: number;
limitAmount?: number;
workspaceId?: string;
}
function formatNumber(num: number): string {
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
export const UsageLimitEmail = ({
userName,
featureName = "Webhooks",
usageAmount = 75,
limitAmount = 100,
}: UsageLimitEmailProps) => {
const previewText = `You're approaching your ${featureName} limit`;
const logoUrl = EMAIL_CONFIG.getLogoUrl();
const siteurl = EMAIL_CONFIG.getSiteUrl();
const billingUrl = `${siteurl}/pricing`;
const greeting = userName ? `Hi ${userName},` : "Hi there,";
const limitValid = Number.isFinite(limitAmount) && limitAmount > 0;
const usageFormatted = formatNumber(usageAmount);
const limitFormatted = limitValid ? formatNumber(limitAmount) : "N/A";
const percentage = limitValid
? Math.round((usageAmount / limitAmount) * 100)
: 0;
const remaining = limitValid ? Math.max(0, limitAmount - usageAmount) : 0;
return (
{previewText}
{featureName} Usage Alert
{greeting}
You've used {percentage}% of your {featureName.toLowerCase()}{" "}
limit for this billing period. You currently have{" "}
{remaining.toLocaleString()} remaining out of{" "}
{limitFormatted} total.
Current Usage
{usageFormatted}{" "}
/ {limitFormatted}
{percentage}% of limit used
{percentage >= 100
? `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.`
: "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."}
Need help? Send us an email at{" "}
{EMAIL_CONFIG.replyTo}
{" "}
or message us on our{" "}
Discord server
.
);
};
export default UsageLimitEmail;
================================================
FILE: packages/email/src/emails/verify.tsx
================================================
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "react-email";
import { EmailFooter } from "../components/footer";
import { EMAIL_CONFIG } from "../lib/config";
interface VerifyUserEmailProps {
userEmail: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password" | "change-email";
}
export const VerifyUserEmail = ({
userEmail,
otp,
type,
}: VerifyUserEmailProps) => {
const logoUrl = EMAIL_CONFIG.getLogoUrl();
const previewText =
type === "sign-in"
? "Your verification code"
: type === "email-verification"
? "Verify your email address"
: type === "change-email"
? "Confirm your new email address"
: "Reset your password";
return (
{previewText}
{previewText}
Use the verification code below to complete your verification
process. This code will expire in 5 minutes.
This email was intended for{" "}
{userEmail}. If you didn't
request this code, you can safely ignore this email. Need help?
Reach us at {EMAIL_CONFIG.replyTo}.
);
};
export default VerifyUserEmail;
================================================
FILE: packages/email/src/emails/welcome.tsx
================================================
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "react-email";
import { EmailButton } from "../components/button";
import { EmailFooter } from "../components/footer";
import { EMAIL_CONFIG } from "../lib/config";
interface WelcomeEmailProps {
userEmail: string;
baseUrl?: string;
}
export const WelcomeEmail = ({
userEmail,
baseUrl = EMAIL_CONFIG.getAppUrl(),
}: WelcomeEmailProps) => {
const previewText = "Welcome to Marble, let's get started!";
const logoUrl = EMAIL_CONFIG.getLogoUrl();
return (
{previewText}
Welcome aboard
Thanks for signing up! Here's how to get the most out of Marble:
Get started
Check out our{" "}
documentation
{" "}
to learn how to set up your workspace, how to use the API, and
learn more about the features.
Join the community
Have questions? Join our{" "}
Discord
{" "}
to chat with other users and get help from the team.
Stay updated
Follow us on{" "}
Twitter
{" "}
for product updates, tips, and announcements.
This email was intended for{" "}
{userEmail}. If you didn't
create an account, you can safely ignore this email. Need help?
Reach us at {EMAIL_CONFIG.replyTo}.
);
};
export default WelcomeEmail;
================================================
FILE: packages/email/src/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: <> */
export * from "./lib/dev";
export * from "./lib/send";
================================================
FILE: packages/email/src/lib/config.ts
================================================
export const EMAIL_CONFIG = {
/**
* Site URL for marketing site (logo, assets, etc.)
* Falls back to production URL if not set
*/
getSiteUrl(): string {
return process.env.NEXT_PUBLIC_SITE_URL || "https://marblecms.com";
},
/**
* App/Dashboard URL for the CMS application
* Falls back to production URL if not set
*/
getAppUrl(): string {
return process.env.NEXT_PUBLIC_APP_URL || "https://app.marblecms.com";
},
/**
* Get logo URL with fallback (uses site URL)
*/
getLogoUrl(): string {
const siteUrl = this.getSiteUrl();
return `${siteUrl}/logo.svg`;
},
/**
* Reply-to email address
*/
replyTo: "support@marblecms.com",
/**
* From email address
*/
from: "Marble ",
/**
* Founder email configuration
*/
founderFrom: "Taqib ",
founderReplyTo: "taqib@marblecms.com",
calLink: "https://cal.com/taqib",
twitterLink: "https://x.com/retaqib",
/**
* Physical mailing address for CAN-SPAM compliance
*/
physicalAddress: {
name: "Marble",
street: "",
city: "",
state: "",
zip: "",
country: "Federal Republic of Nigeria",
},
} as const;
================================================
FILE: packages/email/src/lib/dev.ts
================================================
import type { CreateEmailOptions } from "resend";
type MockableEmailOptions = CreateEmailOptions & {
_mockContext?: {
type:
| "invite"
| "verification"
| "reset"
| "welcome"
| "usage-limit"
| "founder";
data: Record;
};
};
export async function sendDevEmail(options: MockableEmailOptions) {
console.log("--- MOCK EMAIL SENT (DEVELOPMENT MODE) ---");
console.log("From:", options.from);
console.log("To:", options.to);
console.log("Subject:", options.subject);
if (options._mockContext) {
const { type, data } = options._mockContext;
console.log("Email Type:", type.toUpperCase());
console.log("Email Data:");
for (const [key, value] of Object.entries(data)) {
console.log(` ${key}:`, value);
}
} else {
console.log("React Component: Email component");
}
console.log("----------------------------------------------");
return { data: { id: "mock-email-id" }, error: null };
}
================================================
FILE: packages/email/src/lib/send.ts
================================================
import type { Resend } from "resend";
import { FounderEmail } from "../emails/founder";
import { InviteUserEmail } from "../emails/invite";
import { ResetPasswordEmail } from "../emails/reset";
import { UsageLimitEmail } from "../emails/usage-limit";
import { VerifyUserEmail } from "../emails/verify";
import { WelcomeEmail } from "../emails/welcome";
import { EMAIL_CONFIG } from "./config";
interface SendInviteEmailProps {
inviteeEmail: string;
inviteeUsername?: string;
inviterName: string;
inviterEmail: string;
workspaceName: string;
inviteLink: string;
}
export async function sendInviteEmail(
resend: Resend,
{
inviteeEmail,
inviterName,
inviterEmail,
workspaceName,
inviteLink,
}: SendInviteEmailProps
) {
return await resend.emails.send({
from: EMAIL_CONFIG.from,
replyTo: EMAIL_CONFIG.replyTo,
to: inviteeEmail,
subject: `Join ${workspaceName} on Marble`,
react: InviteUserEmail({
inviteeEmail,
invitedByUsername: inviterName,
invitedByEmail: inviterEmail,
workspaceName,
inviteLink,
}),
});
}
export async function sendVerificationEmail(
resend: Resend,
{
userEmail,
otp,
type,
}: {
userEmail: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password" | "change-email";
}
) {
return await resend.emails.send({
from: EMAIL_CONFIG.from,
replyTo: EMAIL_CONFIG.replyTo,
to: userEmail,
subject: "Verify your email address",
react: VerifyUserEmail({
userEmail,
otp,
type,
}),
});
}
export async function sendResetPassword(
resend: Resend,
{
userEmail,
resetLink,
}: {
userEmail: string;
resetLink: string;
}
) {
return await resend.emails.send({
from: EMAIL_CONFIG.from,
replyTo: EMAIL_CONFIG.replyTo,
to: userEmail,
subject: "Reset Your Password",
react: ResetPasswordEmail({
userEmail,
resetLink,
}),
});
}
export async function sendWelcomeEmail(
resend: Resend,
{
userEmail,
}: {
userEmail: string;
}
) {
return await resend.emails.send({
from: EMAIL_CONFIG.from,
replyTo: EMAIL_CONFIG.replyTo,
to: userEmail,
subject: "Welcome to Marble",
react: WelcomeEmail({
userEmail,
baseUrl: EMAIL_CONFIG.getAppUrl(),
}),
});
}
export async function sendUsageLimitEmail(
resend: Resend,
{
userEmail,
userName,
featureName = "Webhooks",
usageAmount,
limitAmount,
workspaceId,
}: {
userEmail: string;
userName?: string;
featureName?: string;
usageAmount: number;
limitAmount: number;
workspaceId?: string;
}
) {
return await resend.emails.send({
from: EMAIL_CONFIG.from,
replyTo: EMAIL_CONFIG.replyTo,
to: userEmail,
subject: `You're approaching your ${featureName} limit`,
react: UsageLimitEmail({
userName,
featureName,
usageAmount,
limitAmount,
workspaceId,
}),
});
}
export async function sendFounderEmail(
resend: Resend,
{
userEmail,
scheduledAt,
}: {
userEmail: string;
scheduledAt?: Date;
}
) {
return await resend.emails.send({
from: EMAIL_CONFIG.founderFrom,
replyTo: EMAIL_CONFIG.founderReplyTo,
to: userEmail,
subject: "Thanks for trying Marble",
react: FounderEmail({
userEmail,
}),
...(scheduledAt && { scheduledAt: scheduledAt.toISOString() }),
});
}
================================================
FILE: packages/email/tsconfig.json
================================================
{
"extends": "@marble/tsconfig/base.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
================================================
FILE: packages/events/package.json
================================================
{
"name": "@marble/events",
"version": "0.0.0",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@marble/tsconfig": "workspace:*",
"typescript": "^5.9.3"
}
}
================================================
FILE: packages/events/src/index.ts
================================================
/** biome-ignore-all lint/performance/noBarrelFile: package public API */
export * from "./types";
export * from "./utils/demo";
export * from "./utils/envelope";
export * from "./utils/events";
export * from "./utils/resources";
================================================
FILE: packages/events/src/types.ts
================================================
/** Workspace event names persisted internally and exposed as dotted webhook types. */
export const WORKSPACE_EVENT_TYPES = [
"post_created",
"post_published",
"post_unpublished",
"post_updated",
"post_deleted",
"category_created",
"category_updated",
"category_deleted",
"tag_created",
"tag_updated",
"tag_deleted",
"media_uploaded",
"media_updated",
"media_deleted",
"author_created",
"author_updated",
"author_deleted",
] as const;
/** Origin systems that can create workspace events. */
export const WORKSPACE_EVENT_SOURCES = [
"dashboard",
"api",
"mcp",
"workflow",
"system",
] as const;
/** Actor classes that can be attached to a workspace event. */
export const WORKSPACE_EVENT_ACTOR_TYPES = [
"user",
"api_key",
"mcp",
"system",
] as const;
/** Resource classes supported by workspace events and webhook envelopes. */
export const WORKSPACE_EVENT_RESOURCE_TYPES = [
"post",
"category",
"tag",
"media",
"author",
"workspace",
] as const;
export type WorkspaceEventType = (typeof WORKSPACE_EVENT_TYPES)[number];
export type WorkspaceEventSource = (typeof WORKSPACE_EVENT_SOURCES)[number];
export type WorkspaceEventActorType =
(typeof WORKSPACE_EVENT_ACTOR_TYPES)[number];
export type WorkspaceEventResourceType =
(typeof WORKSPACE_EVENT_RESOURCE_TYPES)[number];
export type EventPayloadValue =
| string
| number
| boolean
| null
| EventPayload
| EventPayloadArray;
export interface EventPayloadArray extends Array {}
/** JSON-compatible object used as the event-specific webhook data payload. */
export interface EventPayload {
[key: string]: EventPayloadValue;
}
/** Minimal event shape required to build the public webhook envelope. */
export interface WorkspaceEventLike {
id: string;
type: WorkspaceEventType | string;
createdAt: Date | string;
workspaceId: string;
resourceType?: WorkspaceEventResourceType | string | null;
resourceId?: string | null;
actorType?: WorkspaceEventActorType | string | null;
actorId?: string | null;
payload?: unknown;
}
export type Dateish = Date | string | null | undefined;
================================================
FILE: packages/events/src/utils/demo.ts
================================================
import type { EventPayload } from "../types";
/** Returns the fixed demo payload used by webhook test sends. */
export function getDemoPostPublishedPayload(): EventPayload {
const now = new Date().toISOString();
return {
id: "test_post",
test: true,
title: "Test post",
slug: "test-post",
description: "This is a test webhook event from Marble.",
coverImage: null,
status: "published",
featured: false,
categoryId: "test_category",
primaryAuthorId: "test_author",
publishedAt: now,
createdAt: now,
updatedAt: now,
};
}
================================================
FILE: packages/events/src/utils/envelope.ts
================================================
import type { WorkspaceEventLike } from "../types";
import { serializeEventType } from "./events";
/** Builds the stable JSON body sent to normal webhook endpoints. */
export function buildWebhookPayload(event: WorkspaceEventLike) {
const createdAt =
event.createdAt instanceof Date
? event.createdAt.toISOString()
: event.createdAt;
return {
id: event.id,
type: serializeEventType(event.type),
createdAt,
workspaceId: event.workspaceId,
resource:
event.resourceType && event.resourceId
? {
type: event.resourceType,
id: event.resourceId,
}
: null,
actor: event.actorType
? {
type: event.actorType,
id: event.actorId ?? null,
}
: null,
data:
event.payload &&
typeof event.payload === "object" &&
!Array.isArray(event.payload)
? event.payload
: {},
};
}
================================================
FILE: packages/events/src/utils/events.ts
================================================
import type { WorkspaceEventResourceType, WorkspaceEventType } from "../types";
/** Converts persisted enum names like `post_published` into public webhook names like `post.published`. */
export function serializeEventType(type: string) {
return type.replaceAll("_", ".");
}
/** Derives the affected resource type from a workspace event type. */
export function getResourceTypeForEvent(
type: WorkspaceEventType
): WorkspaceEventResourceType {
const [resourceType] = type.split("_");
if (
resourceType === "post" ||
resourceType === "category" ||
resourceType === "tag" ||
resourceType === "media" ||
resourceType === "author"
) {
return resourceType;
}
return "workspace";
}
================================================
FILE: packages/events/src/utils/resources.ts
================================================
import type { Dateish, EventPayload } from "../types";
/** Serializes optional dates into the string/null shape used in webhook payloads. */
function serializeDate(value: Dateish) {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : value;
}
export interface AuthorInput {
id: string;
name: string;
slug: string;
bio?: string | null;
role?: string | null;
image?: string | null;
email?: string | null;
socials?: Array<{ platform: string; url: string }> | null;
createdAt?: Dateish;
updatedAt?: Dateish;
}
/** Converts an author record into Marble's public author webhook payload. */
export function toAuthorPayload(author: AuthorInput): EventPayload {
return {
id: author.id,
name: author.name,
slug: author.slug,
bio: author.bio ?? null,
role: author.role ?? null,
image: author.image ?? null,
email: author.email ?? null,
socials:
author.socials?.map((social) => ({
platform: social.platform,
url: social.url,
})) ?? [],
createdAt: serializeDate(author.createdAt),
updatedAt: serializeDate(author.updatedAt),
};
}
export interface CategoryInput {
id: string;
name: string;
slug: string;
description?: string | null;
createdAt?: Dateish;
updatedAt?: Dateish;
}
/** Converts a category record into Marble's public category webhook payload. */
export function toCategoryPayload(category: CategoryInput): EventPayload {
return {
id: category.id,
name: category.name,
slug: category.slug,
description: category.description ?? null,
createdAt: serializeDate(category.createdAt),
updatedAt: serializeDate(category.updatedAt),
};
}
export interface TagInput {
id: string;
name: string;
slug: string;
description?: string | null;
createdAt?: Dateish;
updatedAt?: Dateish;
}
/** Converts a tag record into Marble's public tag webhook payload. */
export function toTagPayload(tag: TagInput): EventPayload {
return {
id: tag.id,
name: tag.name,
slug: tag.slug,
description: tag.description ?? null,
createdAt: serializeDate(tag.createdAt),
updatedAt: serializeDate(tag.updatedAt),
};
}
export interface MediaInput {
id: string;
name: string;
url?: string | null;
alt?: string | null;
type: string;
size: number;
mimeType?: string | null;
width?: number | null;
height?: number | null;
duration?: number | null;
blurHash?: string | null;
createdAt?: Dateish;
updatedAt?: Dateish;
}
/** Converts a media record into Marble's public media webhook payload. */
export function toMediaPayload(media: MediaInput): EventPayload {
return {
id: media.id,
name: media.name,
url: media.url ?? null,
alt: media.alt ?? null,
type: media.type,
size: media.size,
mimeType: media.mimeType ?? null,
width: media.width ?? null,
height: media.height ?? null,
duration: media.duration ?? null,
blurHash: media.blurHash ?? null,
createdAt: serializeDate(media.createdAt),
updatedAt: serializeDate(media.updatedAt),
};
}
export interface PostInput {
id: string;
title: string;
slug: string;
description?: string | null;
coverImage?: string | null;
status?: string | null;
featured?: boolean | null;
categoryId?: string | null;
primaryAuthorId?: string | null;
publishedAt?: Dateish;
createdAt?: Dateish;
updatedAt?: Dateish;
}
/** Converts a post record into Marble's public post webhook payload. */
export function toPostPayload(post: PostInput): EventPayload {
return {
id: post.id,
title: post.title,
slug: post.slug,
description: post.description ?? null,
coverImage: post.coverImage ?? null,
status: post.status ?? null,
featured: post.featured ?? null,
categoryId: post.categoryId ?? null,
primaryAuthorId: post.primaryAuthorId ?? null,
publishedAt: serializeDate(post.publishedAt),
createdAt: serializeDate(post.createdAt),
updatedAt: serializeDate(post.updatedAt),
};
}
/** Attaches changed field names to an update event payload. */
export function withChanges(
payload: EventPayload,
changes: string[]
): EventPayload {
return {
...payload,
changes,
};
}
================================================
FILE: packages/events/tsconfig.json
================================================
{
"extends": "@marble/tsconfig/base.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false
},
"include": ["src"]
}
================================================
FILE: packages/parser/.gitignore
================================================
node_modules
# Keep environment variables out of version control
.env
scripts
*.md
================================================
FILE: packages/parser/package.json
================================================
{
"name": "@marble/parser",
"version": "0.0.0",
"exports": {
"./tiptap": "./src/tiptap.ts"
},
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"@marble/tsconfig": "workspace:*",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"dependencies": {
"@tiptap/core": "^3.17.1",
"marked": "^16.4.0",
"prosemirror-model": "^1.25.3"
}
}
================================================
FILE: packages/parser/src/tiptap.ts
================================================
import type { JSONContent } from "@tiptap/core";
import { marked, type Token, type Tokens } from "marked";
export class MarkdownToTiptapParser {
private tokens: Token[] = [];
constructor() {
marked.setOptions({ gfm: true, breaks: true });
}
parse(markdown: string): JSONContent {
this.tokens = marked.lexer(markdown);
return { type: "doc", content: this.parseTokens(this.tokens) };
}
private parseTokens(tokens: Token[]): JSONContent[] {
const content: JSONContent[] = [];
for (const token of tokens) {
const node = this.parseToken(token);
if (node) {
if (Array.isArray(node)) {
content.push(...node);
} else {
content.push(node);
}
}
}
return content;
}
private parseToken(token: Token): JSONContent | JSONContent[] | null {
switch (token.type) {
case "heading":
return MarkdownToTiptapParser.parseHeading(token as Tokens.Heading);
case "paragraph":
return MarkdownToTiptapParser.parseParagraph(token as Tokens.Paragraph);
case "blockquote":
return MarkdownToTiptapParser.parseBlockquote(
token as Tokens.Blockquote
);
case "list":
return MarkdownToTiptapParser.parseList(token as Tokens.List);
case "code":
return MarkdownToTiptapParser.parseCodeBlock(token as Tokens.Code);
case "hr":
return { type: "horizontalRule" };
case "table":
return MarkdownToTiptapParser.parseTable(token as Tokens.Table);
case "html":
return MarkdownToTiptapParser.parseHTML(token as Tokens.HTML);
case "space":
return null;
default:
return null;
}
}
static parseHeading(token: Tokens.Heading): JSONContent {
return {
type: "heading",
attrs: { level: token.depth },
content: MarkdownToTiptapParser.parseInlineTokens(token.tokens || []),
};
}
static parseParagraph(token: Tokens.Paragraph): JSONContent {
return {
type: "paragraph",
content: MarkdownToTiptapParser.parseInlineTokens(token.tokens || []),
};
}
static parseBlockquote(token: Tokens.Blockquote): JSONContent {
const parser = new MarkdownToTiptapParser();
return {
type: "blockquote",
content: parser.parseTokens(token.tokens || []),
};
}
static parseList(token: Tokens.List): JSONContent {
const isTaskList = token.items.some((item) => item.task);
const type = isTaskList
? "taskList"
: token.ordered
? "orderedList"
: "bulletList";
const items = token.items.map((item) =>
isTaskList
? MarkdownToTiptapParser.parseTaskListItem(item)
: MarkdownToTiptapParser.parseListItem(item)
);
const result: JSONContent = {
type,
content: items,
};
if (
!isTaskList &&
token.ordered &&
typeof token.start === "number" &&
token.start !== 1
) {
result.attrs = { start: token.start };
}
return result;
}
static parseTaskListItem(item: Tokens.ListItem): JSONContent {
const base = MarkdownToTiptapParser.parseListItem(item);
return {
type: "taskItem",
attrs: { checked: !!item.checked },
content: base.content,
};
}
static parseListItem(item: Tokens.ListItem): JSONContent {
const parser = new MarkdownToTiptapParser();
let content = parser.parseTokens(item.tokens || []);
// In tight lists, marked doesn't wrap content in paragraphs
// If the content is empty but we have text, or if content exists without paragraph wrapping
// Check if we need to wrap in a paragraph
if (
content.length > 0 &&
content.every(
(node) =>
node.type !== "paragraph" &&
node.type !== "codeBlock" &&
node.type !== "blockquote"
)
) {
// Content exists but isn't block-level, wrap it in a paragraph
content = [{ type: "paragraph", content }];
} else if (content.length === 0 && item.text) {
// Fallback: parse the text as markdown if tokens are empty
const textTokens = marked.lexer(item.text);
content = parser.parseTokens(textTokens);
if (
content.length === 0 ||
content.every((node) => node.type !== "paragraph")
) {
// Still no paragraph, create one from the raw text
content = [
{ type: "paragraph", content: [{ type: "text", text: item.text }] },
];
}
}
return {
type: "listItem",
content,
};
}
static parseCodeBlock(token: Tokens.Code): JSONContent {
return {
type: "codeBlock",
attrs: { language: token.lang || null },
content: [{ type: "text", text: token.text }],
};
}
static parseTable(token: Tokens.Table): JSONContent {
const rows: JSONContent[] = [];
const alignments = token.align || [];
const headerRow: JSONContent = {
type: "tableRow",
content: token.header.map((cell: Tokens.TableCell, index: number) => ({
type: "tableHeader",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: alignments[index] || null },
content: MarkdownToTiptapParser.parseInlineTokens(
cell.tokens || []
),
},
],
})),
};
rows.push(headerRow);
for (const row of token.rows) {
rows.push({
type: "tableRow",
content: row.map((cell: Tokens.TableCell, index: number) => ({
type: "tableCell",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: alignments[index] || null },
content: MarkdownToTiptapParser.parseInlineTokens(
cell.tokens || []
),
},
],
})),
});
}
return { type: "table", content: rows };
}
static parseHTML(token: Tokens.HTML): JSONContent | null {
const text = token.text;
const imgMatch = text.match(
/
]+src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>/i
);
if (imgMatch) {
return {
type: "image",
attrs: { src: imgMatch[1], alt: imgMatch[2] },
};
}
return { type: "paragraph", content: [{ type: "text", text }] };
}
static parseInlineTokens(tokens: Token[]): JSONContent[] {
const content: JSONContent[] = [];
for (const token of tokens) {
const nodes = MarkdownToTiptapParser.parseInlineToken(token);
if (nodes) {
if (Array.isArray(nodes)) {
content.push(...nodes);
} else {
content.push(nodes);
}
}
}
return content;
}
static parseInlineToken(token: Token): JSONContent | JSONContent[] | null {
switch (token.type) {
case "text":
return { type: "text", text: (token as Tokens.Text).text };
case "strong":
return MarkdownToTiptapParser.parseStrong(token as Tokens.Strong);
case "em":
return MarkdownToTiptapParser.parseEm(token as Tokens.Em);
case "codespan":
return MarkdownToTiptapParser.parseCodespan(token as Tokens.Codespan);
case "del":
return MarkdownToTiptapParser.parseDel(token as Tokens.Del);
case "link":
return MarkdownToTiptapParser.parseLink(token as Tokens.Link);
case "image":
return MarkdownToTiptapParser.parseImage(token as Tokens.Image);
case "br":
return { type: "hardBreak" };
default:
return null;
}
}
static parseStrong(token: Tokens.Strong): JSONContent[] {
const content = MarkdownToTiptapParser.parseInlineTokens(
token.tokens || []
);
return content.map((node) => ({
...node,
marks: [...(node.marks || []), { type: "bold" }],
}));
}
static parseEm(token: Tokens.Em): JSONContent[] {
const content = MarkdownToTiptapParser.parseInlineTokens(
token.tokens || []
);
return content.map((node) => ({
...node,
marks: [...(node.marks || []), { type: "italic" }],
}));
}
static parseCodespan(token: Tokens.Codespan): JSONContent {
return {
type: "text",
text: token.text,
marks: [{ type: "code" }],
};
}
static parseDel(token: Tokens.Del): JSONContent[] {
const content = MarkdownToTiptapParser.parseInlineTokens(
token.tokens || []
);
return content.map((node) => ({
...node,
marks: [...(node.marks || []), { type: "strike" }],
}));
}
static parseLink(token: Tokens.Link): JSONContent[] {
const content = MarkdownToTiptapParser.parseInlineTokens(
token.tokens || []
);
return content.map((node) => ({
...node,
marks: [
...(node.marks || []),
{ type: "link", attrs: { href: token.href, title: token.title } },
],
}));
}
static parseImage(token: Tokens.Image): JSONContent {
return {
type: "image",
attrs: { src: token.href, alt: token.text, title: token.title },
};
}
}
export function markdownToTiptap(markdown: string): JSONContent {
const parser = new MarkdownToTiptapParser();
return parser.parse(markdown);
}
export async function markdownToHtml(markdown: string): Promise {
marked.setOptions({ gfm: true, breaks: true });
return await marked(markdown);
}
================================================
FILE: packages/parser/tests/tiptap-parser.test.ts
================================================
import type { Tokens } from "marked";
import { describe, expect, it } from "vitest";
import { MarkdownToTiptapParser, markdownToTiptap } from "../src/tiptap";
describe("MarkdownToTiptapParser static inline parsers", () => {
it("parseStrong adds bold mark to inner content", () => {
const token: Tokens.Strong = {
type: "strong",
raw: "**bold**",
text: "bold",
tokens: [{ type: "text", raw: "bold", text: "bold" }],
};
const result = MarkdownToTiptapParser.parseStrong(token);
expect(result).toEqual([
{ type: "text", text: "bold", marks: [{ type: "bold" }] },
]);
});
it("parseEm adds italic mark to inner content", () => {
const token: Tokens.Em = {
type: "em",
raw: "*italic*",
text: "italic",
tokens: [{ type: "text", raw: "italic", text: "italic" }],
};
const result = MarkdownToTiptapParser.parseEm(token);
expect(result).toEqual([
{ type: "text", text: "italic", marks: [{ type: "italic" }] },
]);
});
it("parseDel adds strike mark to inner content", () => {
const token: Tokens.Del = {
type: "del",
raw: "~~strike~~",
text: "strike",
tokens: [{ type: "text", raw: "strike", text: "strike" }],
};
const result = MarkdownToTiptapParser.parseDel(token);
expect(result).toEqual([
{ type: "text", text: "strike", marks: [{ type: "strike" }] },
]);
});
it("parseCodespan adds code mark to text", () => {
const token: Tokens.Codespan = {
type: "codespan",
raw: "`code`",
text: "code",
};
const result = MarkdownToTiptapParser.parseCodespan(token);
expect(result).toEqual({
type: "text",
text: "code",
marks: [{ type: "code" }],
});
});
it("parseLink adds link mark with href and title to inner content", () => {
const token: Tokens.Link = {
type: "link",
raw: "[click](https://example.com)",
href: "https://example.com",
title: null,
text: "click",
tokens: [{ type: "text", raw: "click", text: "click" }],
};
const result = MarkdownToTiptapParser.parseLink(token);
expect(result).toEqual([
{
type: "text",
text: "click",
marks: [
{ type: "link", attrs: { href: "https://example.com", title: null } },
],
},
]);
});
it("parseImage returns image node with attrs", () => {
const token: Tokens.Image = {
type: "image",
raw: "",
href: "https://img",
title: "title",
text: "alt",
tokens: [{ type: "text", raw: "alt", text: "alt" }],
};
const result = MarkdownToTiptapParser.parseImage(token);
expect(result).toEqual({
type: "image",
attrs: { src: "https://img", alt: "alt", title: "title" },
});
});
});
describe("MarkdownToTiptapParser static block-level parsers", () => {
it("parseHeading returns heading node with level and content", () => {
const token: Tokens.Heading = {
type: "heading",
raw: "## Hello",
depth: 2,
text: "Hello",
tokens: [{ type: "text", raw: "Hello", text: "Hello" }],
};
const result = MarkdownToTiptapParser.parseHeading(token);
expect(result).toEqual({
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "Hello" }],
});
});
it("parseParagraph returns paragraph node with inline content", () => {
const token: Tokens.Paragraph = {
type: "paragraph",
raw: "Hello world",
text: "Hello world",
tokens: [
{ type: "text", raw: "Hello", text: "Hello" },
{ type: "text", raw: " world", text: " world" },
],
} as unknown as Tokens.Paragraph;
const result = MarkdownToTiptapParser.parseParagraph(token);
expect(result).toEqual({
type: "paragraph",
content: [
{ type: "text", text: "Hello" },
{ type: "text", text: " world" },
],
});
});
it("parseBlockquote returns blockquote wrapping parsed tokens", () => {
const token: Tokens.Blockquote = {
type: "blockquote",
raw: "> quote",
text: "quote",
tokens: [
{
type: "paragraph",
raw: "quote",
text: "quote",
tokens: [{ type: "text", raw: "quote", text: "quote" }],
} as unknown as Tokens.Paragraph,
],
};
const result = MarkdownToTiptapParser.parseBlockquote(token);
expect(result).toEqual({
type: "blockquote",
content: [
{ type: "paragraph", content: [{ type: "text", text: "quote" }] },
],
});
});
it("parseList returns bulletList with listItem content for unordered lists", () => {
const token: Tokens.List = {
type: "list",
raw: "- a\n- b",
ordered: false,
start: "",
loose: false,
items: [
{
type: "list_item",
raw: "- a",
task: false,
checked: undefined,
loose: false,
text: "a",
tokens: [
{
type: "paragraph",
raw: "a",
text: "a",
tokens: [{ type: "text", raw: "a", text: "a" }],
} as unknown as Tokens.Paragraph,
],
},
{
type: "list_item",
raw: "- b",
task: false,
checked: undefined,
loose: false,
text: "b",
tokens: [
{
type: "paragraph",
raw: "b",
text: "b",
tokens: [{ type: "text", raw: "b", text: "b" }],
} as unknown as Tokens.Paragraph,
],
},
],
};
const result = MarkdownToTiptapParser.parseList(token);
expect(result).toEqual({
type: "bulletList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "a" }] },
],
},
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "b" }] },
],
},
],
});
});
it("parseList returns orderedList and preserves start when not 1", () => {
const token: Tokens.List = {
type: "list",
raw: "2. a\n3. b",
ordered: true,
start: 2,
loose: false,
items: [
{
type: "list_item",
raw: "2. a",
task: false,
checked: undefined,
loose: false,
text: "a",
tokens: [
{
type: "paragraph",
raw: "a",
text: "a",
tokens: [{ type: "text", raw: "a", text: "a" }],
} as unknown as Tokens.Paragraph,
],
},
],
};
const result = MarkdownToTiptapParser.parseList(token);
expect(result).toEqual({
type: "orderedList",
attrs: { start: 2 },
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "a" }] },
],
},
],
});
});
it("parseList returns orderedList without attrs when starting at 1", () => {
const token: Tokens.List = {
type: "list",
raw: "1. first\n2. second",
ordered: true,
start: 1,
loose: false,
items: [
{
type: "list_item",
raw: "1. first",
task: false,
checked: undefined,
loose: false,
text: "first",
tokens: [
{
type: "paragraph",
raw: "first",
text: "first",
tokens: [{ type: "text", raw: "first", text: "first" }],
} as unknown as Tokens.Paragraph,
],
},
{
type: "list_item",
raw: "2. second",
task: false,
checked: undefined,
loose: false,
text: "second",
tokens: [
{
type: "paragraph",
raw: "second",
text: "second",
tokens: [{ type: "text", raw: "second", text: "second" }],
} as unknown as Tokens.Paragraph,
],
},
],
};
const result = MarkdownToTiptapParser.parseList(token);
expect(result).toEqual({
type: "orderedList",
content: [
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
],
},
{
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "second" }] },
],
},
],
});
});
it("parseListItem returns listItem for non-task items", () => {
const item: Tokens.ListItem = {
type: "list_item",
raw: "- normal",
task: false,
checked: undefined,
loose: false,
text: "normal",
tokens: [
{
type: "paragraph",
raw: "normal",
text: "normal",
tokens: [{ type: "text", raw: "normal", text: "normal" }],
} as unknown as Tokens.Paragraph,
],
};
const result = MarkdownToTiptapParser.parseListItem(item);
expect(result).toEqual({
type: "listItem",
content: [
{ type: "paragraph", content: [{ type: "text", text: "normal" }] },
],
});
});
it("parseCodeBlock returns codeBlock with language and text content", () => {
const token: Tokens.Code = {
type: "code",
raw: "```js\nconsole.log('x')\n```",
lang: "js",
text: "console.log('x')",
};
const result = MarkdownToTiptapParser.parseCodeBlock(token);
expect(result).toEqual({
type: "codeBlock",
attrs: { language: "js" },
content: [{ type: "text", text: "console.log('x')" }],
});
});
it("parseTable returns table with header and rows with proper attrs", () => {
const token: Tokens.Table = {
type: "table",
raw: "| H1 | H2 |\n| --- | --- |\n| A | B |",
align: [],
header: [
{
type: "tablecell",
raw: "H1",
text: "H1",
tokens: [{ type: "text", raw: "H1", text: "H1" }],
} as unknown as Tokens.TableCell,
{
type: "tablecell",
raw: "H2",
text: "H2",
tokens: [{ type: "text", raw: "H2", text: "H2" }],
} as unknown as Tokens.TableCell,
],
rows: [
[
{
type: "tablecell",
raw: "A",
text: "A",
tokens: [{ type: "text", raw: "A", text: "A" }],
} as unknown as Tokens.TableCell,
{
type: "tablecell",
raw: "B",
text: "B",
tokens: [{ type: "text", raw: "B", text: "B" }],
} as unknown as Tokens.TableCell,
],
],
};
const result = MarkdownToTiptapParser.parseTable(token);
expect(result).toEqual({
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableHeader",
attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "H1" }],
},
],
},
{
type: "tableHeader",
attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "H2" }],
},
],
},
],
},
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "A" }],
},
],
},
{
type: "tableCell",
attrs: { style: null, colspan: 1, rowspan: 1, colwidth: null },
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "B" }],
},
],
},
],
},
],
});
});
it("parseHTML returns image node for
tag with src and alt", () => {
const token: Tokens.HTML = {
type: "html",
raw: '
',
pre: false,
text: '
',
block: false,
};
const result = MarkdownToTiptapParser.parseHTML(token);
expect(result).toEqual({
type: "image",
attrs: { src: "https://img", alt: "Alt" },
});
});
it("parseHTML falls back to paragraph with raw HTML text when not img", () => {
const token: Tokens.HTML = {
type: "html",
raw: "content
",
pre: false,
text: "content
",
block: false,
};
const result = MarkdownToTiptapParser.parseHTML(token);
expect(result).toEqual({
type: "paragraph",
content: [{ type: "text", text: "content
" }],
});
});
});
describe("MarkdownToTiptapParser inline helpers", () => {
it("parseInlineToken maps text to text node", () => {
const token: Tokens.Text = { type: "text", raw: "a", text: "a" };
const result = MarkdownToTiptapParser.parseInlineToken(token);
expect(result).toEqual({ type: "text", text: "a" });
});
it("parseInlineToken maps br to hardBreak", () => {
const token: Tokens.Br = { type: "br", raw: " \n" } as Tokens.Br;
const result = MarkdownToTiptapParser.parseInlineToken(token);
expect(result).toEqual({ type: "hardBreak" });
});
it("parseInlineToken returns null for unhandled token types", () => {
const token = { type: "unknown", raw: "?" } as unknown as Tokens.Generic;
const result = MarkdownToTiptapParser.parseInlineToken(token as never);
expect(result).toBeNull();
});
it("parseInlineTokens flattens arrays from strong/em/del and preserves order", () => {
const tokens: Tokens.Generic[] = [
{ type: "text", raw: "a", text: "a" } as Tokens.Text,
{
type: "strong",
raw: "**b**",
text: "b",
tokens: [{ type: "text", raw: "b", text: "b" }],
} as unknown as Tokens.Strong,
{ type: "br", raw: " \n" } as unknown as Tokens.Br,
];
const result = MarkdownToTiptapParser.parseInlineTokens(
tokens as unknown as Tokens.Generic[]
);
expect(result).toEqual([
{ type: "text", text: "a" },
{ type: "text", text: "b", marks: [{ type: "bold" }] },
{ type: "hardBreak" },
]);
});
});
describe("MarkdownToTiptapParser integration tests", () => {
it("parses bullet list correctly from markdown", () => {
const markdown = "- First item\n- Second item\n- Third item";
const result = markdownToTiptap(markdown);
expect(result).toEqual({
type: "doc",
content: [
{
type: "bulletList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "First item" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Second item" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Third item" }],
},
],
},
],
},
],
});
});
it("parses ordered list starting at 1 correctly from markdown", () => {
const markdown = "1. First item\n2. Second item\n3. Third item";
const result = markdownToTiptap(markdown);
expect(result).toEqual({
type: "doc",
content: [
{
type: "orderedList",
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "First item" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Second item" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Third item" }],
},
],
},
],
},
],
});
});
it("parses ordered list starting at custom number correctly from markdown", () => {
const markdown = "5. Fifth item\n6. Sixth item\n7. Seventh item";
const result = markdownToTiptap(markdown);
expect(result).toEqual({
type: "doc",
content: [
{
type: "orderedList",
attrs: { start: 5 },
content: [
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Fifth item" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Sixth item" }],
},
],
},
{
type: "listItem",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Seventh item" }],
},
],
},
],
},
],
});
});
it("parses mixed content with lists correctly from markdown", () => {
const markdown =
"# Title\n\nSome text.\n\n- Bullet 1\n- Bullet 2\n\n1. Ordered 1\n2. Ordered 2";
const result = markdownToTiptap(markdown);
expect(result.type).toEqual("doc");
expect(result.content).toHaveLength(4);
expect(result.content?.[0]?.type).toEqual("heading");
expect(result.content?.[1]?.type).toEqual("paragraph");
expect(result.content?.[2]?.type).toEqual("bulletList");
expect(result.content?.[3]?.type).toEqual("orderedList");
});
it("parses task list with checked and unchecked items", () => {
const markdown = "- [x] Done\n- [ ] Not done";
const result = markdownToTiptap(markdown);
expect(result).toEqual({
type: "doc",
content: [
{
type: "taskList",
content: [
{
type: "taskItem",
attrs: { checked: true },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Done" }],
},
],
},
{
type: "taskItem",
attrs: { checked: false },
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Not done" }],
},
],
},
],
},
],
});
});
it("parses table with headers and cells correctly from markdown", () => {
const markdown =
"| Title 1 | Title 2 |\n| --- | --- |\n| Field 1 | Field 2 |\n| Field 3 | Field 4 |";
const result = markdownToTiptap(markdown);
expect(result).toEqual({
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableHeader",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "Title 1" }],
},
],
},
{
type: "tableHeader",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "Title 2" }],
},
],
},
],
},
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "Field 1" }],
},
],
},
{
type: "tableCell",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "Field 2" }],
},
],
},
],
},
{
type: "tableRow",
content: [
{
type: "tableCell",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "Field 3" }],
},
],
},
{
type: "tableCell",
attrs: {
style: null,
colspan: 1,
rowspan: 1,
colwidth: null,
},
content: [
{
type: "paragraph",
attrs: { textAlign: null },
content: [{ type: "text", text: "Field 4" }],
},
],
},
],
},
],
},
],
});
});
it("parses table with mixed alignment (left, center, right)", () => {
const markdown = `| Syntax | Description | Test Text |
| :--- | :----: | ---: |
| Header | Title | Here's this |
| Paragraph | Text | And more |`;
const result = markdownToTiptap(markdown);
expect(result.type).toBe("doc");
expect(result.content).toHaveLength(1);
const table = result.content?.[0];
expect(table?.type).toBe("table");
expect(table?.content).toHaveLength(3);
const headerRow = table?.content?.[0];
expect(headerRow?.content?.[0]?.content?.[0]?.attrs?.textAlign).toBe(
"left"
);
expect(headerRow?.content?.[1]?.content?.[0]?.attrs?.textAlign).toBe(
"center"
);
expect(headerRow?.content?.[2]?.content?.[0]?.attrs?.textAlign).toBe(
"right"
);
const dataRow1 = table?.content?.[1];
expect(dataRow1?.content?.[0]?.content?.[0]?.attrs?.textAlign).toBe("left");
expect(dataRow1?.content?.[0]?.content?.[0]?.content?.[0]?.text).toBe(
"Header"
);
expect(dataRow1?.content?.[1]?.content?.[0]?.attrs?.textAlign).toBe(
"center"
);
expect(dataRow1?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe(
"Title"
);
expect(dataRow1?.content?.[2]?.content?.[0]?.attrs?.textAlign).toBe(
"right"
);
expect(dataRow1?.content?.[2]?.content?.[0]?.content?.[0]?.text).toBe(
"Here's this"
);
const dataRow2 = table?.content?.[2];
expect(dataRow2?.content?.[0]?.content?.[0]?.content?.[0]?.text).toBe(
"Paragraph"
);
expect(dataRow2?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe(
"Text"
);
expect(dataRow2?.content?.[2]?.content?.[0]?.content?.[0]?.text).toBe(
"And more"
);
});
});
================================================
FILE: packages/parser/tsconfig.json
================================================
{
"extends": "@marble/tsconfig/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
================================================
FILE: packages/tsconfig/base.json
================================================
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleDetection": "force",
"moduleResolution": "NodeNext",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}
================================================
FILE: packages/tsconfig/nextjs.json
================================================
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"plugins": [{ "name": "next" }],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true
}
}
================================================
FILE: packages/tsconfig/package.json
================================================
{
"name": "@marble/tsconfig",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}
================================================
FILE: packages/tsconfig/react-library.json
================================================
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}
================================================
FILE: packages/ui/README.md
================================================
# UI
This package contains the UI components for the application.
================================================
FILE: packages/ui/components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"iconLibrary": "phosphor",
"aliases": {
"components": "@marble/ui/components",
"utils": "@marble/ui/lib/utils",
"ui": "@marble/ui/components",
"lib": "@marble/ui/lib",
"hooks": "@marble/ui/hooks"
},
"registries": {
"@kibo-ui": "https://www.kibo-ui.com/r/{name}.json"
}
}
================================================
FILE: packages/ui/package.json
================================================
{
"name": "@marble/ui",
"version": "0.1.0",
"private": true,
"exports": {
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./tailwind.config": "./tailwind.config.ts",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./components/kibo-ui/*": "./src/components/kibo-ui/*/index.tsx",
"./hooks/*": "./src/hooks/*.ts"
},
"scripts": {
"lint": "biome check .",
"format": "biome --write ."
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@heroicons/react": "^2.2.0",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-table": "^8.20.5",
"@toolwind/corner-shape": "0.0.8-3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"input-otp": "^1.4.2",
"next-themes": "^0.4.4",
"react-day-picker": "9.9.0",
"react-image-crop": "^11.0.10",
"react-medium-image-zoom": "^5.3.0",
"recharts": "2.15.4",
"sonner": "^1.7.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@marble/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^22.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-image-crop": "^9.0.2",
"postcss": "^8.4.24",
"react": "^19.2.4",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3"
}
}
================================================
FILE: packages/ui/postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
================================================
FILE: packages/ui/src/components/alert-dialog.tsx
================================================
"use client";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type * as React from "react";
import { Button, buttonVariants } from "@marble/ui/components/button";
import { cn } from "@marble/ui/lib/utils";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return ;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
);
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
);
}
function AlertDialogOverlay({
className,
onClick,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
{
// Stop propagation to prevent clicks on the overlay from bubbling
// through to parent elements when rendered in a portal (this is specifically for the post card links)
e.stopPropagation();
onClick?.(e);
}}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
variant = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm";
variant?: "default" | "card";
}) {
return (
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
);
}
function AlertDialogBody({
className,
...props
}: React.ComponentProps<"div">) {
return (
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps) {
return (
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps) {
return (
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps) {
return (
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick, "variant" | "size">) {
return (
);
}
function AlertDialogX({
className,
icon,
...props
}: AlertDialogPrimitive.Close.Props & {
icon?: React.ReactNode;
}) {
return (
{icon ?? }
Close
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogBody,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogX,
};
================================================
FILE: packages/ui/src/components/avatar.tsx
================================================
"use client";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import type * as React from "react";
import { cn } from "@marble/ui/lib/utils";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
data-slot="avatar-badge"
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
data-slot="avatar-group-count"
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};
================================================
FILE: packages/ui/src/components/badge.tsx
================================================
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@marble/ui/lib/utils";
const badgeVariants = cva(
"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!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"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",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
positive:
"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",
negative:
"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",
pending:
"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",
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",
neutral:
"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",
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",
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",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps
) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ className, variant })),
},
props
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
================================================
FILE: packages/ui/src/components/breadcrumb.tsx
================================================
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import {
ArrowRight01Icon,
MoreHorizontalCircle01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type * as React from "react";
import { cn } from "@marble/ui/lib/utils";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
});
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
svg]:size-3.5", className)}
data-slot="breadcrumb-separator"
role="presentation"
{...props}
>
{children ?? }
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
svg]:size-4",
className
)}
data-slot="breadcrumb-ellipsis"
role="presentation"
{...props}
>
More
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
================================================
FILE: packages/ui/src/components/button.tsx
================================================
"use client";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@marble/ui/lib/utils";
type ButtonProps = ButtonPrimitive.Props &
VariantProps & {
ref?: React.Ref;
};
const buttonVariants = cva(
"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",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/35",
outline:
"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",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
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",
sm: "h-8 gap-1.5 rounded-lg px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)]",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonProps) {
return (
);
}
export { Button, buttonVariants, type ButtonProps };
================================================
FILE: packages/ui/src/components/calendar.tsx
================================================
"use client";
import {
ArrowDown01Icon,
ArrowLeft01Icon,
ArrowRight01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import * as React from "react";
import {
type DayButton,
DayPicker,
getDefaultClassNames,
} from "react-day-picker";
import { Button, buttonVariants } from "@marble/ui/components/button";
import { cn } from "@marble/ui/lib/utils";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps & {
buttonVariant?: React.ComponentProps["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 font-medium text-sm",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"cn-calendar-dropdown-root relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 select-none rounded-(--cell-radius) font-normal text-[0.8rem] text-muted-foreground",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"select-none text-[0.8rem] text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"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)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"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",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"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",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
);
}
if (orientation === "right") {
return (
);
}
return (
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
{children}
|
);
},
...components,
}}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
showOutsideDays={showOutsideDays}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
data-day={day.date.toLocaleDateString()}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
data-range-start={modifiers.range_start}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
ref={ref}
size="icon"
variant="ghost"
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
================================================
FILE: packages/ui/src/components/card.tsx
================================================
import * as React from "react"
import { cn } from "@marble/ui/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
================================================
FILE: packages/ui/src/components/chart.tsx
================================================
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@marble/ui/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a ")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
{children}
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (