diff --git a/.github/README.md b/.github/README.md index d74af53b2..ed147b11d 100644 --- a/.github/README.md +++ b/.github/README.md @@ -27,7 +27,7 @@ from `main`. } ``` -2. Ensure `package.json` has a `build` script and (if needed) `wrangler.jsonc` exists. +2. Ensure `package.json` has a `build` script and (if needed) `wrangler.example.jsonc` exists. 3. Push to `main` → production deploy runs when that app (or `packages/`) has changes. Open a PR → preview deploy runs unless skipped. @@ -50,10 +50,10 @@ Already configured; push to `main` or open PRs as usual. | `workerBuildCommand` | `null` | pnpm script for Worker bundle (e.g. OpenNext); `null` for TanStack | | `outputDirectory` | `"dist"` | Dir to verify after build | | `verifyPaths` | `["dist", "cloudflare-worker.ts"]` | Paths that must exist after build | -| `wranglerConfig` | `"wrangler.jsonc"` | Wrangler config file | -| `requiresSecrets` | `[]` | _(Optional)_ Extra secrets not in wrangler.jsonc (e.g. build-time) | +| `wranglerConfig` | `"wrangler.example.jsonc"` | Wrangler config file (tracked template; user copy is gitignored) | +| `requiresSecrets` | `[]` | _(Optional)_ Extra secrets not in wrangler config (e.g. build-time) | -> **SSOT:** Placeholders in `wrangler.jsonc` `env.production` / `env.preview` are auto-detected. `requiresSecrets` is +> **SSOT:** Placeholders in `wrangler.example.jsonc` `env.production` / `env.preview` are auto-detected. `requiresSecrets` is > only for secrets that don't appear in wrangler. ### Minimal examples @@ -87,13 +87,13 @@ Already configured; push to `main` or open PRs as usual. "buildCommand": "build", "outputDirectory": "dist", "verifyPaths": ["dist", "cloudflare-worker.ts"], - "wranglerConfig": "wrangler.jsonc" + "wranglerConfig": "wrangler.example.jsonc" } ``` ### Wrangler placeholders -In `wrangler.jsonc`, `ALL_CAPS_SNAKE_CASE` placeholder values in `env.production` and `env.preview` are +In `wrangler.example.jsonc`, `ALL_CAPS_SNAKE_CASE` placeholder values in `env.production` and `env.preview` are **auto-detected** by `substitute-wrangler-secrets.py` and substituted from GitHub Secrets. No explicit key list or per-secret workflow wiring needed — just set the placeholder and the secret. @@ -204,9 +204,9 @@ pnpm preview # if available | Issue | Check | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| App not discovered | `deployable: true` in `cloudflare-config.json`; `package.json` has required scripts; `wrangler.jsonc` present if no cloudflare-config | -| Build fails | Actions logs; locally: `pnpm --filter=@ottabase/my-app run build` | -| Deploy fails | Required secrets set; `wrangler.jsonc` valid; no unsubstituted placeholders in generated config | +| App not discovered | `deployable: true` in `cloudflare-config.json`; `package.json` has required scripts; `wrangler.example.jsonc` present if no cloudflare-config | +| Build fails | Actions logs; locally: `pnpm --filter=@ottabase/my-app run build` | +| Deploy fails | Required secrets set; `wrangler.example.jsonc` valid; no unsubstituted placeholders in generated config | | Preview not created | PR without `#skippr` / `#skipdeploy`; secrets set; app in `APPS_TO_DEPLOY` or default | Errors in workflows include what failed, why, and how to fix (e.g. missing secrets with links to Cloudflare). diff --git a/.github/instructions/agent.instructions.md b/.github/instructions/agent.instructions.md index a23a0577a..726fed2f1 100644 --- a/.github/instructions/agent.instructions.md +++ b/.github/instructions/agent.instructions.md @@ -147,7 +147,7 @@ export const { useUpdate: useUpdateTodo, useDelete: useDeleteTodo, useInfiniteList: useTodosInfinite, -} = createModelHooks({ entity: 'todos' }); +} = createModelHooks({ entityName: 'todos' }); // Usage: const { data: todo } = useTodoBySlug("slug", "my-todo-slug"); ``` @@ -297,7 +297,7 @@ export { myTable } from '@ottabase/mypackage/schema'; // ottabase/hooks/useMyModel.ts import { createModelHooks } from '@ottabase/ottaorm/client'; -export const { useList, useCreate, useUpdate, useDelete } = createModelHooks({ entity: 'mytable' }); +export const { useList, useCreate, useUpdate, useDelete } = createModelHooks({ entityName: 'mytable' }); ``` ### 5. Run migrations diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 71f5eb243..1e6989226 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -200,9 +200,9 @@ jobs: if [ -f "$CONFIG_JSON" ]; then CONFIG=$(cat "$CONFIG_JSON") else - # Check for wrangler.jsonc as fallback - if [ ! -f "${APP_PATH}/wrangler.jsonc" ]; then - echo "⚠️ Warning: No cloudflare-config.json or wrangler.jsonc for ${app_folder}, skipping" + # Check for wrangler config as fallback (.example is the tracked template) + if [ ! -f "${APP_PATH}/wrangler.example.jsonc" ] && [ ! -f "${APP_PATH}/wrangler.jsonc" ]; then + echo "⚠️ Warning: No cloudflare-config.json or wrangler config for ${app_folder}, skipping" continue fi # Use TanStack defaults (default template app is TanStack) @@ -213,7 +213,7 @@ jobs: "workerBuildCommand": null, "outputDirectory": "dist", "verifyPaths": ["dist", "cloudflare-worker.ts"], - "wranglerConfig": "wrangler.jsonc", + "wranglerConfig": "wrangler.example.jsonc", "wranglerEnv": "production", "healthCheckPath": "/" }' @@ -322,10 +322,10 @@ jobs: run: | echo "🔍 Verifying required secrets for ${{ matrix.name }}..." - # Base secrets (wrangler-action needs these; not in wrangler.jsonc) + # Base secrets (wrangler-action needs these; not in wrangler config) BASE="CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID" - # Derive placeholders from wrangler.jsonc env.production (single source of truth; no drift) + # Derive placeholders from wrangler config env.production (single source of truth; no drift) PLACEHOLDERS=$(python ../../.github/scripts/substitute-wrangler-secrets.py --list-only) # Optional extra from cloudflare-config.json requiresSecrets @@ -393,7 +393,7 @@ jobs: key: ${{ runner.os }}-nextjs-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}-${{ hashFiles('apps/${{ - matrix.folder }}/wrangler.jsonc', 'apps/${{ matrix.folder }}/open-next.config.ts', 'apps/${{ + matrix.folder }}/wrangler.example.jsonc', 'apps/${{ matrix.folder }}/open-next.config.ts', 'apps/${{ matrix.folder }}/next.config.js') }} restore-keys: | ${{ runner.os }}-nextjs-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}- @@ -410,7 +410,7 @@ jobs: key: ${{ runner.os }}-tanstack-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}-${{ hashFiles('apps/${{ - matrix.folder }}/wrangler.jsonc', 'apps/${{ matrix.folder }}/vite.config.ts') }} + matrix.folder }}/wrangler.example.jsonc', 'apps/${{ matrix.folder }}/vite.config.ts') }} restore-keys: | ${{ runner.os }}-tanstack-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}- ${{ runner.os }}-tanstack-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}- diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 72380f90a..177e98253 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -127,11 +127,11 @@ jobs: if [ -f "$CONFIG_JSON" ]; then CONFIG=$(cat "$CONFIG_JSON") else - if [ ! -f "${APP_PATH}/wrangler.jsonc" ]; then - echo "⚠️ Warning: No cloudflare-config.json or wrangler.jsonc for ${APP_FOLDER}, skipping" + if [ ! -f "${APP_PATH}/wrangler.example.jsonc" ] && [ ! -f "${APP_PATH}/wrangler.jsonc" ]; then + echo "⚠️ Warning: No cloudflare-config.json or wrangler config for ${APP_FOLDER}, skipping" continue fi - CONFIG='{"deployable":true,"appType":"tanstack","buildCommand":"build","workerBuildCommand":null,"outputDirectory":"dist","verifyPaths":["dist","cloudflare-worker.ts"],"wranglerConfig":"wrangler.jsonc","wranglerEnv":"production","healthCheckPath":"/"}' + CONFIG='{"deployable":true,"appType":"tanstack","buildCommand":"build","workerBuildCommand":null,"outputDirectory":"dist","verifyPaths":["dist","cloudflare-worker.ts"],"wranglerConfig":"wrangler.example.jsonc","wranglerEnv":"production","healthCheckPath":"/"}' fi DEPLOYABLE=$(echo "$CONFIG" | jq -r '.deployable // true') @@ -222,7 +222,7 @@ jobs: echo "📋 Configuration loaded for ${{ matrix.name }} (preview)" - # Secrets derived from wrangler.jsonc env.preview + base + optional extra (single source of truth; no drift) + # Secrets derived from wrangler config env.preview + base + optional extra (single source of truth; no drift) - name: Verify required secrets working-directory: apps/${{ matrix.folder }} env: @@ -235,7 +235,7 @@ jobs: # Base secrets (wrangler-action needs these) BASE="CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID" - # Derive placeholders from wrangler.jsonc env.preview (single source of truth; no drift) + # Derive placeholders from wrangler config env.preview (single source of truth; no drift) PLACEHOLDERS=$(python ../../.github/scripts/substitute-wrangler-secrets.py --list-only) # Optional extra from cloudflare-config.json requiresPreviewSecrets @@ -296,7 +296,7 @@ jobs: key: ${{ runner.os }}-nextjs-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}-${{ hashFiles('apps/${{ - matrix.folder }}/wrangler.jsonc', 'apps/${{ matrix.folder }}/open-next.config.ts', 'apps/${{ + matrix.folder }}/wrangler.example.jsonc', 'apps/${{ matrix.folder }}/open-next.config.ts', 'apps/${{ matrix.folder }}/next.config.js') }} restore-keys: | ${{ runner.os }}-nextjs-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}- @@ -313,7 +313,7 @@ jobs: key: ${{ runner.os }}-tanstack-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}-${{ hashFiles('apps/${{ - matrix.folder }}/wrangler.jsonc', 'apps/${{ matrix.folder }}/vite.config.ts') }} + matrix.folder }}/wrangler.example.jsonc', 'apps/${{ matrix.folder }}/vite.config.ts') }} restore-keys: | ${{ runner.os }}-tanstack-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/${{ matrix.folder }}/**/*.{ts,tsx,js,jsx}') }}- ${{ runner.os }}-tanstack-${{ matrix.folder }}-${{ hashFiles('pnpm-lock.yaml') }}- diff --git a/AGENTS.MD b/AGENTS.MD index 33459393b..fc58a2eac 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -148,7 +148,7 @@ export const { useUpdate: useUpdateTodo, useDelete: useDeleteTodo, useInfiniteList: useTodosInfinite, -} = createModelHooks({ entity: 'todos' }); +} = createModelHooks({ entityName: 'todos' }); // Usage: // const { data: todo } = useTodoBySlug("slug", "my-todo-slug"); @@ -312,7 +312,7 @@ export { myTable } from '@ottabase/mypackage/schema'; // ottabase/hooks/useMyModel.ts import { createModelHooks } from '@ottabase/ottaorm/client'; -export const { useList, useCreate, useUpdate, useDelete } = createModelHooks({ entity: 'mytable' }); +export const { useList, useCreate, useUpdate, useDelete } = createModelHooks({ entityName: 'mytable' }); ``` ### 5. Run migrations diff --git a/CLOUDFLARE_CONFIGURATION_GUIDE.md b/CLOUDFLARE_CONFIGURATION_GUIDE.md index 143ea79d3..6de6234cd 100644 --- a/CLOUDFLARE_CONFIGURATION_GUIDE.md +++ b/CLOUDFLARE_CONFIGURATION_GUIDE.md @@ -68,7 +68,7 @@ Create `apps/ottabase-template-app-tanstack/.env.local` with the following (if u # Generate with: openssl rand -base64 32 AUTH_SECRET=your-32-character-secret-here NEXTAUTH_SECRET=your-32-character-secret-here -NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_URL=http://localhost:3003 # Enable auth providers (true/false) AUTH_LOGIN_CREDENTIALS=true @@ -192,7 +192,7 @@ multi-app: same placeholder name = shared resource; different names = isolated ( } ``` -### 3. `apps/ottabase-template-app-tanstack/types/cloudflare.d.ts` +### 2. `apps/ottabase-template-app-tanstack/cloudflare-env.d.ts` **Status:** ✅ Already configured @@ -225,7 +225,7 @@ export interface CloudflareEnv { } ``` -### 4. `apps/ottabase-template-app-tanstack/cloudflare-worker.ts` +### 3. `apps/ottabase-template-app-tanstack/cloudflare-worker.ts` **Status:** ✅ Already configured @@ -242,22 +242,24 @@ export { RealtimeActor } from '@ottabase/cf-realtime/server'; ### Using Drizzle with D1 -The app uses `@ottabase/db` package with Drizzle adapter for D1. +The app uses `@ottabase/db` package with Drizzle adapter for D1. Access `env` directly from your Worker fetch handler: ```typescript -import { getCloudflareContext } from '@opennextjs/cloudflare'; import { createD1Driver } from '@ottabase/db/drizzle-d1'; +import { setDriver } from '@ottabase/ottaorm'; -export async function GET() { - const { env } = await getCloudflareContext(); +// cloudflare-worker.ts +export default { + async fetch(request: Request, env: CloudflareEnv) { + const driver = createD1Driver(env.OBCF_D1); + setDriver(driver); - const driver = createD1Driver(env.OBCF_D1); - const db = driver.getDb(); + const db = driver.getDb(); + const users = await db.select().from(usersTable); - const users = await db.select().from(usersTable); - - return Response.json(users); -} + return Response.json(users); + }, +}; ``` --- @@ -285,22 +287,7 @@ export const authConfig = createOttabaseAuthConfig({ }); ``` -### 4. Configure Auth - -```typescript -// app/auth.ts -import { createOttabaseAuthConfig, createGoogleProvider } from '@ottabase/auth'; - -export const authConfig = createOttabaseAuthConfig({ - d1: env.OBCF_D1, - providers: [ - createGoogleProvider(env), - // Add more providers - ], -}); -``` - -### 5. Set Environment Variables +### 3. Set Environment Variables Add to `.env.local`: @@ -318,25 +305,25 @@ AUTH_GOOGLE_SECRET=your-google-client-secret - [ ] **Cloudflare Resources Created** - [ ] D1 Database exists: `wrangler d1 list` - - [ ] KV Namespace exists: `wrangler kv:namespace list` + - [ ] KV Namespace exists: `wrangler kv namespace list` - [ ] R2 Bucket exists: `wrangler r2 bucket list` - [ ] Queue exists: `wrangler queues list` - [ ] **Configuration Files Updated** - [ ] GitHub Secrets set for production: `D1_DATABASE_ID`, `KV_NAMESPACE_ID` - [ ] GitHub Secrets set for PR preview: `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` - - [ ] `types/cloudflare.d.ts` includes all OBCF\_\* bindings + - [ ] `cloudflare-env.d.ts` includes all OBCF\_\* bindings - [ ] **Environment Variables Set** - [ ] `.env.local` created for local development - [ ] Production secrets set via `wrangler secret put` - [ ] **Database Schema Generated** - - [ ] Migrations applied to D1: `wrangler d1 migrations apply` + - [ ] Migrations applied via OttaORM: `curl -X POST http://localhost:3004/api/ottaorm/init` - [ ] **Build & Deploy** - [ ] Local build works: `pnpm build` - - [ ] Worker build works: `pnpm build:worker` + - [ ] Worker build works: `pnpm build` (TanStack) or `pnpm build:worker` (Next.js) - [ ] Preview works: `pnpm preview` - [ ] Deploy successful: `pnpm deploy` @@ -359,28 +346,26 @@ curl https://your-app.workers.dev/api/cloudflare/r2/list ## 🔍 Accessing Cloudflare Bindings in Code -### App Router (Server Components & Route Handlers) +### Cloudflare Worker (Fetch Handler) ```typescript -import { getCloudflareContext } from '@opennextjs/cloudflare'; - -export async function GET() { - const { env } = await getCloudflareContext(); - - // Access bindings with OBCF_* names - const db = env.OBCF_D1; // D1 Database - const kv = env.OBCF_KV; // KV Namespace - const r2 = env.OBCF_R2; // R2 Bucket - const queue = env.OBCF_QUEUE; // Queue - const realtime = env.OBCF_REALTIME; // Durable Object - - // Use with @ottabase packages - // D1 via OttaORM (preferred): - // import { createD1Driver } from '@ottabase/db/drizzle-d1'; - // const driver = createD1Driver(db); setDriver(driver); - const kvClient = createKVClient({ namespace: kv }); - const r2Client = createR2Client({ bucket: r2 }); -} +// cloudflare-worker.ts +export default { + async fetch(request: Request, env: CloudflareEnv) { + // Access bindings with OBCF_* names + const db = env.OBCF_D1; // D1 Database + const kv = env.OBCF_KV; // KV Namespace + const r2 = env.OBCF_R2; // R2 Bucket + const queue = env.OBCF_QUEUE; // Queue + const realtime = env.OBCF_REALTIME; // Durable Object + + // D1 via OttaORM (preferred): + // import { createD1Driver } from '@ottabase/db/drizzle-d1'; + // const driver = createD1Driver(db); setDriver(driver); + const kvClient = createKVClient({ namespace: kv }); + const r2Client = createR2Client({ bucket: r2 }); + }, +}; ``` ### Package Usage @@ -494,14 +479,15 @@ secret: set the placeholder in `wrangler.jsonc`, add the secret to GitHub. CI au **Cause:** D1 database doesn't have schema. -**Solution:** +**Solution:** Ottabase uses OttaORM auto-init, not wrangler migrations: ```bash -# Local -wrangler d1 migrations apply ottabase-db --local +# Local (with dev server running on port 3004) +curl -X POST http://localhost:3004/api/ottaorm/init -# Production -wrangler d1 migrations apply ottabase-db --remote +# Production (requires MIGRATION_SECRET) +curl -X POST https://your-app.workers.dev/api/ottaorm/init \ + -H "Authorization: Bearer ${MIGRATION_SECRET}" ``` ### "Type errors with CloudflareEnv" diff --git a/CLOUDFLARE_DEPLOY.md b/CLOUDFLARE_DEPLOY.md index e40a4cd55..8b4bb6c33 100644 --- a/CLOUDFLARE_DEPLOY.md +++ b/CLOUDFLARE_DEPLOY.md @@ -215,11 +215,11 @@ pnpm build && pnpm wrangler deploy --env production wrangler tail ottabase-template-app-tanstack # Execute D1 commands -wrangler d1 execute ottabase-db --remote --command="SELECT * FROM User LIMIT 5" +wrangler d1 execute ottabase-db --remote --command="SELECT * FROM users LIMIT 5" # List resources wrangler d1 list -wrangler kv:namespace list +wrangler kv namespace list wrangler r2 bucket list wrangler queues list ``` diff --git a/README.md b/README.md index b13b10392..531ef262f 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,47 @@ Durable Objects. ``` ottabase/ ├── apps/ -│ └── ottabase-template-app-tanstack/ # TanStack Router + Vite + Workers (primary) +│ ├── ottabase-template-app-tanstack/ # TanStack Router + Vite + Workers (primary) +│ └── ottabase-template-app-nextjs-homepage/ # Next.js + OpenNext (homepage/landing) ├── packages/ -│ ├── ottaorm/ # Fat models, auto-migrations, CRUD -│ ├── db/ # Drizzle D1 driver -│ ├── cf/ # Cloudflare bindings (D1, KV, R2, Queues) -│ ├── queue/ # Job queue (Laravel-style dispatch/handlers) -│ ├── auth/ # Auth.js v5 with D1 -│ ├── rbac/ # Role-based access control with KV caching -│ ├── audit/ # Audit logging middleware & utilities -│ ├── logger/ # Structured logging with context -│ ├── state/ # Global state (Jotai) -│ ├── ui-shadcn/ # shadcn/ui components -│ ├── ui-mantine/ # Mantine provider + themes -│ ├── ottaupload/ # File uploads (R2, CF Images) -│ ├── ottaeditor/ # EditorJS wrapper -│ ├── cf-realtime/ # WebSocket pub/sub (Durable Objects) -│ ├── shortlinks/ # URL shortener schema -│ ├── referrals/ # Referral tracking -│ └── utils/ # Utilities (timezone, string, file, etc.) +│ ├── ottaorm/ # Fat models, auto-migrations, CRUD, RLS +│ ├── db/ # Drizzle D1 driver +│ ├── cf/ # Cloudflare bindings (D1, KV, R2, Queues, Cache Keys) +│ ├── cf-realtime/ # WebSocket pub/sub (Durable Objects) +│ ├── queue/ # Job queue (dispatch, handlers, priority) +│ ├── auth/ # Auth.js v5 with D1 +│ ├── rbac/ # Role-based access control with KV caching +│ ├── audit/ # Audit logging with change tracking +│ ├── analytics/ # Cloudflare Analytics Engine (WAE) +│ ├── notifications/ # Multi-channel notifications (email, WebSocket) +│ ├── shortlinks/ # URL shortener with interstitial + WAE tracking +│ ├── referrals/ # Referral tracking (first-touch, WAE) +│ ├── brand-engine/ # Design tokens, preset expansion, CSS injection +│ ├── brand-engine-react/ # BrandProvider, LayoutResolver, useBrand() +│ ├── ottalayout/ # Layout types, presets, path resolver, React slots +│ ├── ottablog/ # Blog/CMS (Post, Category, Tag, Series, Studio) +│ ├── email/ # Email sending (Resend, SES, MailChannels, SMTP) +│ ├── cron/ # Cron handlers (static + DB scheduler) +│ ├── logger/ # Structured logging (multi-transport) +│ ├── config/ # App config, env vars, storage keys +│ ├── scripts/ # CLI: cf:setup, cf:validate, cf:login, clean:*, db:* +│ ├── state/ # Jotai atoms (theme, user, sidebar) +│ ├── ui-shadcn/ # shadcn/ui components +│ ├── ui-mantine/ # Mantine provider + themes +│ ├── ui-components/ # Shared components (DarkModeToggle, Logo) +│ ├── ui-code-highlight/ # Code syntax highlighting +│ ├── ui-split-pane/ # Resizable split pane +│ ├── ottaeditor/ # EditorJS wrapper with 15+ plugins +│ ├── ottaupload/ # File uploads (R2, CF Images) +│ ├── ottarenderer/ # EditorJS block renderer +│ ├── ottaselect/ # Headless select/combobox +│ ├── cropper/ # Vanilla JS image cropper (~3-4 KB) +│ ├── spotlight/ # Command palette +│ ├── docs/ # Markdown doc viewer +│ ├── forms/ # Auto-generated CRUD forms from OttaORM models +│ ├── i18n/ # i18next wrapper (en, es, fr, de) +│ ├── api/ # Type-safe fetch wrapper +│ └── utils/ # Timezone, string, file, URL utilities └── turbo.json ``` @@ -152,7 +174,7 @@ export const { useCreate: useCreateTodo, useUpdate: useUpdateTodo, useDelete: useDeleteTodo, -} = createModelHooks({ entity: 'todos' }); +} = createModelHooks({ entityName: 'todos' }); ``` ```tsx @@ -164,43 +186,69 @@ createTodo.mutate({ title: 'New Todo' }); ## Packages -### Database & ORM - -| Package | Purpose | -| ------------------- | ------------------------------------------------------------------------ | -| `@ottabase/ottaorm` | Fat models, CRUD, relationships, auto-migrations | -| `@ottabase/db` | Drizzle D1 driver (`createD1Driver`) | -| `@ottabase/cf` | D1, KV, R2, Queues, Rate Limiting, Cache Keys, read-through cache | -| `@ottabase/queue` | Job queue system (dispatch, handlers, deduplication, chaining, priority) | -| `@ottabase/auth` | Auth.js v5 with D1 adapter | -| `@ottabase/rbac` | Role-based access control with per-org KV caching | -| `@ottabase/audit` | Audit logging middleware with event tracking | -| `@ottabase/logger` | Structured logging with context (replaces console.log) | - -### UI - -| Package | Purpose | -| ---------------------- | ------------------------------------- | -| `@ottabase/ui-shadcn` | shadcn/ui components, ShadcnProviders | -| `@ottabase/ui-mantine` | Mantine provider, pre-built themes | -| `@ottabase/ui-base` | Framework-agnostic base styles | -| `@ottabase/ottaeditor` | EditorJS with 15 plugins | -| `@ottabase/ottaupload` | File upload (R2, CF Images) | - -### State & Utils - -| Package | Purpose | -| ----------------- | ------------------------------------------ | -| `@ottabase/state` | Jotai atoms (theme, user, sidebar) | -| `@ottabase/utils` | timezone, string, file, url, git utilities | - -### Features - -| Package | Purpose | -| ----------------------- | ----------------------------------- | -| `@ottabase/shortlinks` | URL shortener schema + model | -| `@ottabase/referrals` | Referral tracking system | -| `@ottabase/cf-realtime` | WebSocket pub/sub (Durable Objects) | +### Database, Auth & Infrastructure + +| Package | Purpose | +| --------------------- | ---------------------------------------------------------------------- | +| `@ottabase/ottaorm` | Fat models, CRUD, relationships, RLS, auto-migrations | +| `@ottabase/db` | Drizzle D1 driver (`createD1Driver`) | +| `@ottabase/cf` | D1, KV, R2, Queues, Rate Limiting, Cache Keys, read-through KV cache | +| `@ottabase/queue` | Job queue (dispatch, handlers, deduplication, chaining, priority) | +| `@ottabase/auth` | Auth.js v5 with D1 adapter, OAuth, Credentials, Magic Link | +| `@ottabase/rbac` | Role-based access control with per-org KV caching | +| `@ottabase/audit` | Audit logging with change tracking and RBAC context | +| `@ottabase/logger` | Structured logging (Console, HTTP, Sentry, Memory, Buffer transports) | +| `@ottabase/analytics` | Cloudflare Analytics Engine (WAE) — write events, query, funnel, top-K | +| `@ottabase/config` | App config, env vars, storage key utilities | +| `@ottabase/cron` | Cron handlers — static code-defined and DB scheduler (Laravel-style) | +| `@ottabase/scripts` | CLI tools: `cf:login`, `cf:setup`, `cf:validate`, `clean:*`, `db:*` | + +### UI Components + +| Package | Purpose | +| ----------------------------- | -------------------------------------------------------- | +| `@ottabase/ui-shadcn` | shadcn/ui components, ShadcnProviders | +| `@ottabase/ui-mantine` | Mantine provider, pre-built themes | +| `@ottabase/ui-base` | Framework-agnostic base styles | +| `@ottabase/ui-components` | Shared components: DarkModeToggle, Logo | +| `@ottabase/ui-code-highlight` | Code syntax highlighting (Prism/Shiki) | +| `@ottabase/ui-split-pane` | Resizable split-pane layout component | +| `@ottabase/ottaeditor` | EditorJS wrapper with 15+ plugins (Spoiler, CTA, Review) | +| `@ottabase/ottaupload` | File upload component (R2, Cloudflare Images) | +| `@ottabase/ottarenderer` | EditorJS block renderer for React | +| `@ottabase/ottaselect` | Headless select/combobox component | +| `@ottabase/cropper` | Vanilla JS image cropper (~3-4 KB, zero deps) | +| `@ottabase/spotlight` | Spotlight/command palette component | +| `@ottabase/docs` | Markdown doc viewer with layout themes | +| `@ottabase/forms` | Auto-generated CRUD forms from OttaORM models | + +### Brand, Layout & Content + +| Package | Purpose | +| ------------------------------ | -------------------------------------------------------------------- | +| `@ottabase/brand-engine` | Design tokens, preset expansion, CSS injection, email branding | +| `@ottabase/brand-engine-react` | `BrandProvider`, `LayoutResolver`, `useBrand()` React bindings | +| `@ottabase/ottalayout` | Layout types, 10 presets, path resolver, React slots, LayoutMeta | +| `@ottabase/ottablog` | Blog/CMS models (Post, Category, Tag, Series, Version) + Blog Studio | + +### Features & Realtime + +| Package | Purpose | +| ------------------------- | ------------------------------------------------------------ | +| `@ottabase/cf-realtime` | WebSocket pub/sub via Durable Objects (Pusher alternative) | +| `@ottabase/shortlinks` | URL shortener: short codes, interstitial, expiry, WAE clicks | +| `@ottabase/referrals` | Referral tracking — first-touch attribution, WAE clicks | +| `@ottabase/notifications` | Multi-channel notifications (email, WebSocket, system) | + +### Utilities & Integrations + +| Package | Purpose | +| ----------------- | ----------------------------------------------------- | +| `@ottabase/state` | Jotai atoms (theme, user, sidebar, org) | +| `@ottabase/utils` | Timezone, string, file, URL, git utilities | +| `@ottabase/api` | Type-safe fetch wrapper with deduping and error types | +| `@ottabase/email` | Email sending (Resend, SES, MailChannels, Nodemailer) | +| `@ottabase/i18n` | i18next wrapper (en, es, fr, de) | ## Multi-App Database Sharing @@ -243,11 +291,24 @@ All models include a nullable `appId` column: ## Creating New Apps +**Full-stack SPA** (TanStack Router, OttaORM, Auth, RBAC, all CF bindings): + ```bash -cp -r apps/ottabase-template-app-tanstack apps/my-app +# Unix/macOS: cp -r apps/ottabase-template-app-tanstack apps/my-app +# Windows: xcopy /E /I apps\ottabase-template-app-tanstack apps\my-app cd apps/my-app # Update package.json name -# Delete src/pages/demo/ +# Delete src/pages/demo/ (optional — remove demo pages) +``` + +**Marketing homepage** (Next.js, OpenNext, Brand Engine): + +```bash +# Unix/macOS: cp -r apps/ottabase-template-app-nextjs-homepage apps/my-homepage +# Windows: xcopy /E /I apps\ottabase-template-app-nextjs-homepage apps\my-homepage +cd apps/my-homepage +# Update package.json name +# Edit config/brand.config.ts to customize theme ``` ## Package Fat Model Pattern @@ -285,7 +346,7 @@ export { shortlinksTable } from '@ottabase/shortlinks'; ```typescript // ottabase/hooks/useShortlink.ts import { createModelHooks } from "@ottabase/ottaorm/client"; -export const { useList, useCreate, ... } = createModelHooks({ entity: "shortlinks" }); +export const { useList, useCreate, ... } = createModelHooks({ entityName: "shortlinks" }); ``` ## Commands diff --git a/apps/ottabase-template-app-nextjs-homepage/README.md b/apps/ottabase-template-app-nextjs-homepage/README.md index a1253cb91..85109611a 100644 --- a/apps/ottabase-template-app-nextjs-homepage/README.md +++ b/apps/ottabase-template-app-nextjs-homepage/README.md @@ -1,32 +1,27 @@ # Ottabase Next.js Homepage Template -A barebone Next.js homepage template with OpenNext and Cloudflare Workers deployment. Perfect for creating beautiful, -dynamic homepages with modern web technologies. +A barebone Next.js homepage template with OpenNext and Cloudflare Workers deployment. Perfect for marketing homepages, +landing pages, and company websites. -## ✨ Features +## Features -- **Next.js 16** with App Router and React Server Components +- **Next.js App Router** with React Server Components - **OpenNext** for seamless Cloudflare Workers deployment -- **Brand Engine** - Configuration-driven theming with 8+ built-in presets +- **Brand Engine** — Configuration-driven theming with 8+ built-in presets - **TypeScript** for type safety - **Tailwind CSS** for rapid styling - **Dark Mode** support out of the box - **Fully Responsive** design -- **Production Ready** configuration -## 🚀 Quick Start +## Quick Start ```bash -# Install dependencies pnpm install - -# Start dev server pnpm dev - # Visit http://localhost:3000 ``` -## 📦 Project Structure +## Project Structure ``` apps/ottabase-template-app-nextjs-homepage/ @@ -45,26 +40,27 @@ apps/ottabase-template-app-nextjs-homepage/ └── next.config.js # Next.js configuration ``` -## 🎨 Brand Customization +## Brand Customization -This template includes Brand Engine integration for easy theming. Edit `config/brand.config.ts` to customize your brand: +Edit `config/brand.config.ts` to set your theme preset and color overrides: ```typescript +import type { BrandTheme } from '@ottabase/brand-engine'; + // config/brand.config.ts export const brandConfig: Partial = { name: 'my-brand', // Customize colors, typography, spacing, etc. }; -// Choose from built-in presets +// Choose from 8 built-in presets: +// 'default', 'neo', 'crisp', 'funky', 'artisan', 'midnight', 'rose', 'verdant' export const themePreset = 'default'; -// Available: 'default', 'neo', 'crisp', 'funky', 'artisan', 'midnight', 'rose', 'verdant' ``` -The Brand Engine is integrated at the worker level, so themes are automatically registered and available throughout your -app. +The Brand Engine registers and applies the theme at the worker level via CSS custom properties. -## 🛠️ Scripts +## Scripts | Command | Description | | ----------------- | ---------------------------------------------- | @@ -76,142 +72,70 @@ app. | `pnpm test` | Run tests | | `pnpm lint` | Run ESLint | -## 🌐 Deployment +## Deployment ### Local Development +No Cloudflare account needed for local development: + ```bash -# No Cloudflare account needed for local development pnpm dev ``` ### Production Deployment -#### 1. Login to Cloudflare - ```bash pnpm wrangler login -``` - -#### 2. Deploy - -```bash -# Build and deploy to Cloudflare Workers pnpm deploy ``` Your app will be deployed to `https://ottabase-template-app-nextjs-homepage.your-subdomain.workers.dev` -### CI/CD Deployment - -The template includes GitHub Actions workflow support. Configure the following secrets in your repository: +### CI/CD -- `CLOUDFLARE_API_TOKEN` - Your Cloudflare API token -- `CLOUDFLARE_ACCOUNT_ID` - Your Cloudflare account ID +Configure these secrets in your GitHub repository for automatic deployments on push to main: -The app will be automatically deployed when changes are pushed to the main branch. +- `CLOUDFLARE_API_TOKEN` — API token with Edit Workers permissions +- `CLOUDFLARE_ACCOUNT_ID` — Your Cloudflare account ID -## 📝 Customization Guide +## Customization -### 1. Update Content +### Update Content -Edit the following files to customize your homepage: +- `app/page.tsx` — Homepage content +- `app/about/page.tsx` — About page content +- `app/layout.tsx` — Site metadata (title, description, etc.) -- `app/page.tsx` - Homepage content -- `app/about/page.tsx` - About page content -- `app/layout.tsx` - Site metadata (title, description, etc.) - -### 2. Configure Brand - -Edit `config/brand.config.ts` to set your brand theme: - -```typescript -export const themePreset = 'neo'; // Choose your preset -``` - -### 3. Add New Pages - -Create new pages in the `app/` directory: +### Add New Pages ```bash -# Create a new page mkdir -p app/contact echo "export default function Contact() { return
Contact
}" > app/contact/page.tsx ``` -### 4. Update Metadata - -Edit `app/layout.tsx` to update site metadata: +### Update Metadata ```typescript +// app/layout.tsx export const metadata: Metadata = { title: 'Your Site Title', description: 'Your site description', - // ... more metadata }; ``` -## 🔧 Configuration - -### Next.js Configuration +## Configuration Notes -The template uses Next.js standalone output mode for optimal Cloudflare Workers deployment. See `next.config.js` for -configuration details. +- **Next.js standalone output** — Used for optimal Cloudflare Workers deployment (see `next.config.js`) +- **OpenNext** — Converts Next.js output to Cloudflare Workers format (see `open-next.config.ts`) +- **wrangler.jsonc** — Cloudflare Workers config with asset serving and compatibility flags -### OpenNext Configuration +## Related Templates -OpenNext converts Next.js apps to Cloudflare Workers format. The configuration is in `open-next.config.ts`. +- [Ottabase Template App (TanStack)](../ottabase-template-app-tanstack) — Full-featured SPA with OttaORM, Auth, RBAC, + and all Cloudflare bindings -### Wrangler Configuration +## Documentation -Cloudflare Workers configuration is in `wrangler.jsonc`. The template includes: - -- Asset serving -- Environment variables -- Compatibility flags for Node.js APIs - -## 🎯 Use Cases - -This template is perfect for: - -- Marketing homepages -- Product landing pages -- Portfolio websites -- Company websites -- Documentation homepages -- Project showcase pages - -## 🧪 Testing - -```bash -# Run tests -pnpm test - -# Run tests with coverage -pnpm test:coverage -``` - -## 📚 Documentation - -- [Next.js Documentation](https://nextjs.org/docs) +- [Brand Engine](../../packages/brand-engine/README.md) - [OpenNext Documentation](https://opennext.js.org/) - [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers/) -- [Brand Engine Documentation](../../packages/brand-engine/README.md) -- [Tailwind CSS Documentation](https://tailwindcss.com/docs) - -## 🤝 Contributing - -This template is part of the Ottabase monorepo. For contributions, please refer to the main repository guidelines. - -## 📄 License - -Open Source - See the main repository for license details. - -## 🔗 Related Templates - -- [Ottabase Template App (TanStack)](../ottabase-template-app-tanstack) - Full-featured SPA template -- [Ottabase Template App](../ottabase-template-app) - Legacy Next.js template (deprecated) - ---- - -**Built with ❤️ by the Ottabase team** diff --git a/apps/ottabase-template-app-nextjs-homepage/vitest.config.ts b/apps/ottabase-template-app-nextjs-homepage/vitest.config.ts index 83e4b09f2..278747d90 100644 --- a/apps/ottabase-template-app-nextjs-homepage/vitest.config.ts +++ b/apps/ottabase-template-app-nextjs-homepage/vitest.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ ], }, include: ['app/**/*.{test,spec}.{ts,tsx}', '__tests__/**/*.{test,spec}.{ts,tsx}'], - testTimeout: 10000, + testTimeout: 30000, }, resolve: { alias: { diff --git a/apps/ottabase-template-app-tanstack/.env.example b/apps/ottabase-template-app-tanstack/.env.example index dd85ab90e..d92891996 100644 --- a/apps/ottabase-template-app-tanstack/.env.example +++ b/apps/ottabase-template-app-tanstack/.env.example @@ -5,6 +5,23 @@ # This file documents all the environment variables needed for # production-ready authentication in the TanStack template app. # +# ┌─────────────────────────────────────────────────────────┐ +# │ NON-SECRET APP CONFIGURATION → ottabase.config.ts │ +# │ │ +# │ The following have been MOVED to ottabase.config.ts │ +# │ and should NO LONGER be set as env vars: │ +# │ │ +# │ • APP_ID → appId │ +# │ • EMAIL_FROM → email.from │ +# │ • AWS_REGION → email.sesRegion │ +# │ • AUTH_SESSION_MAX_AGE → features.authBehavior.sessionMaxAge │ +# │ • AUTH_REQUIRE_EMAIL_VERIFIED → features.authBehavior.requireEmailVerified │ +# │ • AUTH_DISABLE_CREDENTIALS → features.authBehavior.disableCredentials │ +# │ • AUTH_VERBOSE → features.authBehavior.verbose │ +# │ │ +# │ Edit ottabase.config.ts for these settings. │ +# └─────────────────────────────────────────────────────────┘ +# # For local development: # 1. Copy this file to .env.local # 2. Fill in the values for the providers you want to use @@ -81,18 +98,23 @@ AUTH0_CLIENT_SECRET= AUTH0_ISSUER= # ============================================================ -# Email Provider (Magic Link - Optional) +# Email Provider – API keys / credentials (secrets only) # ============================================================ +# The "From" address is set in ottabase.config.ts → email.from +# The AWS SES region is set in ottabase.config.ts → email.sesRegion # Option 1: Resend (Recommended) # Get API key: https://resend.com/ EMAIL_RESEND_API_KEY= -EMAIL_FROM=noreply@yourdomain.com # Option 2: Nodemailer / SMTP # Format: smtp://username:password@smtp.example.com:587 # EMAIL_SERVER=smtp://user:pass@smtp.gmail.com:587 -# EMAIL_FROM=noreply@yourdomain.com + +# Option 3: AWS SES +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# (region is set in ottabase.config.ts → email.sesRegion) # ============================================================ # Cloudflare Bindings (Configured in wrangler.jsonc) @@ -115,7 +137,7 @@ EMAIL_FROM=noreply@yourdomain.com # Or navigate to: /api/ottaorm/init in your browser # ============================================================ -# Platform Kill Switches +# Platform Kill Switches (operational – change without redeploy) # ============================================================ # Set to true to block all POST/PUT/PATCH/DELETE (read-only mode) KILLSWITCH_READONLY_MODE=false @@ -123,7 +145,7 @@ KILLSWITCH_READONLY_MODE=false KILLSWITCH_LOCKDOWN=false -# Feature gates / flags +# Feature gates / flags (operational – change without redeploy) # By default destructive migrations are disabled. Set to '1' or 'true' to enable. MIGRATION_ALLOW_DESTRUCTIVE=0 diff --git a/apps/ottabase-template-app-tanstack/.gitignore b/apps/ottabase-template-app-tanstack/.gitignore new file mode 100644 index 000000000..ae61e8343 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/.gitignore @@ -0,0 +1,20 @@ +# ============================================================ +# User-owned files — NOT tracked by git. +# Copy the corresponding .example / .template on first setup: +# +# cp ottabase.config.example.ts ottabase.config.ts +# cp wrangler.example.jsonc wrangler.jsonc +# cp -r ottabase.template/ ottabase/ +# +# See README.md § "First-Time Setup" for details. +# ============================================================ + +# Single user config file (edit freely — never overwritten by git pull) +ottabase.config.ts + +# Cloudflare Workers config (local dev overrides) +wrangler.jsonc + +# User-zone: models, schemas, migrations, queue handlers +# (ottabase.template/ is the tracked reference copy) +ottabase/ diff --git a/apps/ottabase-template-app-tanstack/README.md b/apps/ottabase-template-app-tanstack/README.md index 92b8aec99..3fea6816e 100644 --- a/apps/ottabase-template-app-tanstack/README.md +++ b/apps/ottabase-template-app-tanstack/README.md @@ -13,12 +13,33 @@ TanStack Router + Query template with automated OttaORM migrations and Cloudflar - **Mantine + shadcn/ui** - Flexible UI component libraries - **Jotai** - Global state management -## Quick Start +## First-Time Setup + +After cloning (or downloading) the repo, create your local working copies from the tracked templates: ```bash -# Install -pnpm install +# 1. Copy the example/template files +cp ottabase.config.example.ts ottabase.config.ts +cp wrangler.example.jsonc wrangler.jsonc +cp -r ottabase.template/ ottabase/ + +# 2. Install dependencies & build internal packages +pnpm install && pnpm build:pkg + +# 3. Start dev server +pnpm dev:worker +# 4. Initialize database +curl -X POST http://localhost:3004/api/ottaorm/init + +# Done! Visit http://localhost:3004 +``` + +These three files/directories are **gitignored** so your customisations are never overwritten by `git pull`. + +## Quick Start + +```bash # Start Vite dev server (fast) pnpm dev @@ -28,9 +49,134 @@ pnpm dev:worker # Initialize database (creates all tables automatically) curl -X POST http://localhost:3004/api/ottaorm/init -# Done! Visit http://localhost:3004 +# Done! Visit http://localhost:3003 (frontend) or http://localhost:3004 (when using dev:worker only) +``` + +## Customising Your App + +All user-owned settings live in a **single file**: `ottabase.config.ts` at the app root. Edit it to set your app +identity, toggle packages, add premium features, and tune UI defaults. + +```typescript +// ottabase.config.ts +import { defineOttabaseConfig } from '@ottabase/config'; + +export default defineOttabaseConfig({ + appId: 'my-saas-app', + appName: 'My SaaS App', + packages: { ottablog: true, shortlinks: true, referrals: true, brandEngine: false }, + features: { referrals: { enabled: true, expiryDays: 90 } }, + theme: { colorDefault: 'tremorBlue' }, +}); +``` + +`defineOttabaseConfig()` validates your config at startup — it **throws** on missing required fields (`appId`, +`appName`) and **warns** about unrecognised keys (likely typos) so they don't silently fall to defaults. Example output: + +``` +[ottabase] Unknown key "packges" — possible typo (will be ignored) +``` + +### File Ownership + +| File / Directory | Owner | Notes | +| ---------------------------- | --------- | ---------------------------------------------------------------- | +| `ottabase.config.ts` | **You** | Your app config (gitignored) | +| `ottabase/` | **You** | Models, schemas, migrations, routes, queue handlers (gitignored) | +| `wrangler.jsonc` | **You** | Local Cloudflare Workers config (gitignored) | +| `ottabase.config.example.ts` | Framework | Template for `ottabase.config.ts` | +| `wrangler.example.jsonc` | Framework | Template for `wrangler.jsonc` (also used by CI) | +| `ottabase.template/` | Framework | Template for `ottabase/` — diff on updates | +| `packages/` | Framework | Shared packages — update via `git pull` | +| `worker/routes/` | Framework | API route handlers | +| `cloudflare-worker.ts` | Framework | Worker entry point | + +### Updating the Framework + +Your user-owned files (`ottabase.config.ts`, `wrangler.jsonc`, `ottabase/`) are **gitignored**, so framework updates +never overwrite them. + +```bash +# Fork / git workflow — your files stay untouched +git pull upstream main +pnpm install && pnpm build:pkg + +# Check if ottabase.template/ changed (merge new framework defaults into your copy) +diff -rq ottabase.template/ ottabase/ +# If there are differences, review and merge manually. ``` +```bash +# Zip workflow +# 1. Unzip new release over the repo (ottabase.config.ts, wrangler.jsonc, +# and ottabase/ are gitignored — they won't be in the zip) +# 2. Diff ottabase.template/ against your ottabase/ for any new framework defaults +# 3. Run: pnpm install && pnpm build:pkg +``` + +### Adding a Premium Package + +1. Install: `pnpm add --filter @myorg/premium-feature` +2. Register tables in `ottabase/config.migrations.ts` (user section at the top): + ```typescript + import { premiumTable } from '@myorg/premium-feature/schema'; + const USER_PACKAGE_REGISTRY: Record = { + premiumFeature: { tables: { premiumTable }, migrations: [] }, + }; + ``` +3. Register routes in `ottabase/config.routes.ts`: + + ```typescript + import { handlePremiumDashboard } from '@myorg/premium-feature'; + + export async function handleCustomRoutes(context) { + if (context.route === '/api/premium/dashboard' && context.method === 'GET') { + return handlePremiumDashboard(context); + } + return null; + } + ``` + +4. Enable in `ottabase.config.ts` (by key only—no schema imports in this file): + ```typescript + customPackages: { premiumFeature: true }, + ``` +5. Run migrations: `curl -X POST http://localhost:3004/api/ottaorm/init` + +### Custom Routes (`ottabase/config.routes.ts`) + +Custom and premium packages can register API routes without editing framework files. The framework router calls +`handleCustomRoutes(context)` **after** all built-in routes, giving your code the same `ApiRouteContext` that framework +handlers use. + +```typescript +// ottabase/config.routes.ts +import type { ApiRouteContext } from '../worker/routes/types'; + +export async function handleCustomRoutes(context: ApiRouteContext): Promise { + const { route, method } = context; + + // Simple route + if (route === '/api/my-feature' && method === 'GET') { + return new Response(JSON.stringify({ hello: 'world' }), { + headers: { 'Content-Type': 'application/json', ...context.corsHeaders }, + }); + } + + // Prefix-based (delegate to a package handler) + if (route.startsWith('/api/premium/')) { + return handlePremiumRoutes(context); + } + + return null; // fall through — not handled +} +``` + +**Route resolution order:** Built-in (method-specific) → Built-in (method-agnostic) → **Custom routes** → 404 + +`ApiRouteContext` fields: `request`, `env` (Cloudflare bindings), `url`, `route` (normalized pathname), `method`, +`corsHeaders`, `withAuthCors(response)`. + ## Authentication This template ships with Auth.js + D1 integration and tighter session handling: @@ -255,26 +401,30 @@ See [@ottabase/brand-engine](../../packages/brand-engine/README.md) for detailed ``` apps/ottabase-template-app-tanstack/ -├── cloudflare-worker.ts # Cloudflare Worker entry (API routes) -├── ottabase/ # Server-side code -│ ├── migrations/ # Database migrations -│ ├── models/ # OttaORM models (Todo, etc.) -│ └── db/schema.ts # Drizzle table schemas -├── src/ # React application -│ ├── main.tsx # App entry point -│ ├── router.tsx # TanStack Router configuration -│ ├── ottabase/ # Client-side config -│ │ ├── config/ # App configuration -│ │ ├── hooks/ # Custom hooks -│ │ ├── providers/ # React providers -│ │ └── state/ # Jotai atoms -│ ├── pages/ # Page components -│ │ └── demo/ # Demo pages -│ └── providers/ # App providers wrapper -├── index.html # HTML template -├── vite.config.ts # Vite configuration -├── wrangler.jsonc # Cloudflare Workers config (template; CI substitutes placeholders) -└── tailwind.config.cjs # Tailwind CSS config +├── ottabase.config.example.ts # [tracked] Template for user config +├── ottabase.config.ts # [gitignored] YOUR config (copy from .example) +├── wrangler.example.jsonc # [tracked] Template for Cloudflare config (CI uses this) +├── wrangler.jsonc # [gitignored] YOUR Cloudflare config (copy from .example) +├── ottabase.template/ # [tracked] Template for user-zone code +│ ├── config.migrations.ts # Custom/premium package table registration +│ ├── config.routes.ts # Custom/premium package route registration +│ ├── db/ # Drizzle table schemas +│ ├── helpers/ # Domain helpers +│ ├── migrations/ # Database migrations +│ ├── models/ # OttaORM models (Todo, etc.) +│ └── queue/ # Queue job handlers +├── ottabase/ # [gitignored] YOUR working copy (cp -r from .template/) +├── cloudflare-worker.ts # Cloudflare Worker entry (API routes) +├── worker/ # Worker route handlers, bootstrap, lib +├── src/ # React application +│ ├── main.tsx # App entry point +│ ├── router.tsx # TanStack Router configuration +│ ├── ottabase/ # Client-side config, hooks, state +│ ├── pages/ # Page components +│ └── providers/ # App providers wrapper +├── index.html # HTML template +├── vite.config.ts # Vite configuration +└── tailwind.config.cjs # Tailwind CSS config ``` ## Routes @@ -364,38 +514,43 @@ pnpm dev:worker #### 1. Create Cloudflare Resources -```bash -# Login -pnpm wrangler login +Use the automated setup script (recommended): -# Create D1 database -pnpm wrangler d1 create ottabase-db +```bash +pnpm cf:login # authenticate +pnpm cf:setup # creates D1, KV, R2, Queue — prints IDs for GitHub Secrets +pnpm cf:validate +``` -# Create KV namespace -pnpm wrangler kv:namespace create OTTABASE_KV +Or manually: -# Create R2 bucket +```bash +pnpm wrangler login +pnpm wrangler d1 create ottabase-db +pnpm wrangler kv namespace create OBCF_KV pnpm wrangler r2 bucket create ottabase-bucket - -# Create Queue pnpm wrangler queues create ottabase-queue ``` -#### 2. Update wrangler.jsonc +#### 2. Configure wrangler.jsonc + GitHub Secrets + +- **Local (gitignored)**: Copy `wrangler.example.jsonc` to `wrangler.jsonc` and replace the placeholders with your + actual D1/KV/R2/Queue/DO IDs for local dev. +- **CI/CD**: `wrangler.example.jsonc` keeps `ALL_CAPS` placeholders that the deploy workflow substitutes from GitHub + Secrets. Set these in your repository → Settings → Secrets → Actions: -Update the IDs in `wrangler.jsonc` with your actual: +- `D1_DATABASE_ID`, `KV_NAMESPACE_ID` (production) +- `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` (PR previews) +- `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` -- D1 database ID -- KV namespace ID -- R2 bucket name -- Queue name +See [CLOUDFLARE_DEPLOY.md](../../CLOUDFLARE_DEPLOY.md) for the full setup guide. #### 3. Analytics (optional) Shortlink and referral click tracking uses **Cloudflare Analytics Engine** (WAE). Clicks are written automatically; the unified analytics page at `/analytics` requires: -1. **CLOUDFLARE_ACCOUNT_ID** – Set in `wrangler.jsonc` vars (32-char account ID from Cloudflare dashboard). +1. **CLOUDFLARE_ACCOUNT_ID** – Set in your local `wrangler.jsonc` vars (32-char account ID from Cloudflare dashboard). 2. **CLOUDFLARE_ANALYTICS_API_TOKEN** – Create a token with **Account | Account Analytics | Read**: diff --git a/apps/ottabase-template-app-tanstack/cloudflare-config.json b/apps/ottabase-template-app-tanstack/cloudflare-config.json index 0b3dc80c9..689aa8099 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-config.json +++ b/apps/ottabase-template-app-tanstack/cloudflare-config.json @@ -7,7 +7,7 @@ "workerBuildCommand": "", "outputDirectory": "dist", "verifyPaths": ["dist", "cloudflare-worker.ts"], - "wranglerConfig": "wrangler.jsonc", + "wranglerConfig": "wrangler.example.jsonc", "wranglerEnv": "production", "healthCheckPath": "/", "requiresSecrets": [], diff --git a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts index cb205b13d..0cebf0904 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts +++ b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts @@ -6,6 +6,7 @@ import { handleBootstrapRoute, interceptIfNotReady, resolvePlatformState } from import { injectBrandCriticalCSS } from './worker/lib/brand-html-inject'; import { initDbConnection } from './worker/lib/db-utils'; import { checkKillSwitches } from './worker/lib/killswitch'; +import { PACKAGES } from './worker/lib/worker-config'; import { resolveApiRoute } from './worker/routes/router'; import { handleShortlinkFallback } from './worker/routes/shortlinks'; @@ -113,9 +114,11 @@ export default { return apiResponse; } - const shortlinkFallbackResponse = await handleShortlinkFallback({ request, env, url }); - if (shortlinkFallbackResponse) { - return shortlinkFallbackResponse; + if (PACKAGES.shortlinks) { + const shortlinkFallbackResponse = await handleShortlinkFallback({ request, env, url }); + if (shortlinkFallbackResponse) { + return shortlinkFallbackResponse; + } } if (!env.OBCF_ASSETS) { diff --git a/apps/ottabase-template-app-tanstack/ottabase.config.example.ts b/apps/ottabase-template-app-tanstack/ottabase.config.example.ts new file mode 100644 index 000000000..01dedf338 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase.config.example.ts @@ -0,0 +1,134 @@ +// ============================================================ +// OTTABASE USER CONFIG — EXAMPLE / TEMPLATE +// ============================================================ +// +// FIRST-TIME SETUP +// ───────────────── +// Copy this file to create your own config: +// cp ottabase.config.example.ts ottabase.config.ts +// +// ottabase.config.ts is gitignored — your customisations are +// never overwritten by `git pull` or zip-replace. +// +// HOW UPDATES WORK +// ───────────────── +// 1. Pull the latest framework: +// git pull upstream main (fork) OR +// unzip ottabase-latest.zip -d . (zip download) +// 2. Your ottabase.config.ts is untouched (gitignored). +// 3. Run `pnpm install && pnpm build:pkg && pnpm dev`. +// +// PREMIUM / CUSTOM PACKAGES +// ────────────────────────── +// Install the package, then: +// 1. Register tables in ottabase/config.migrations.ts +// 2. Register routes in ottabase/config.routes.ts +// 3. Add the key to `customPackages` below. +// Table/route imports are server-only so client bundles stay lean. +// +// VALIDATION +// ────────── +// defineOttabaseConfig() validates at startup: +// • Throws on missing required fields (appId, appName). +// • Warns on unrecognised keys (likely typos) so they +// don't silently fall to defaults. +// ============================================================ + +import { defineOttabaseConfig } from '@ottabase/config'; + +export default defineOttabaseConfig({ + // ── App Identity ────────────────────────────────────────── + appId: 'ottabase-template-app', + appName: 'Ottabase Template App (TanStack)', + + // ── App Metadata (SEO, copyright, social) ───────────────── + meta: { + description: 'A minimal TanStack + Cloudflare Workers template app in the Ottabase monorepo', + author: '@thinkdj', + keywords: + 'Ottabase, TanStack Router, TanStack Query, Vite, Tailwind, Shadcn, Cloudflare Workers, TypeScript, React', + companyName: 'Ottabase', + }, + + // ── Theme ───────────────────────────────────────────────── + theme: { + colorDefault: 'tremorBlue', + }, + + // ── Storage ─────────────────────────────────────────────── + storage: { + prefix: 'ottabase', + }, + + // ── Built-in Package Toggles ────────────────────────────── + // Set a package to `false` (or remove it) to exclude its + // database tables and API routes from your app. + packages: { + ottablog: true, + shortlinks: true, + referrals: true, + brandEngine: true, + }, + + // ── Custom / Premium Packages ───────────────────────────── + // After installing a premium package: + // 1. Register tables in ottabase/config.migrations.ts + // 2. Register routes in ottabase/config.routes.ts + // 3. Add the key here: + // + // Example: + // customPackages: { + // myPremiumFeature: true, + // }, + customPackages: {}, + + // ── Feature Configuration ───────────────────────────────── + features: { + referrals: { + enabled: true, + trackClicks: true, + expiryDays: 90, + }, + spotlight: { + enabled: true, + shortcuts: ['/'], + }, + pagination: { + defaultPageSize: 10, + maxPageSize: 100, + sizeOptions: [5, 10, 20, 50, 100], + }, + crudHub: { + apiBaseUrl: '/api/crudhub', + urlBase: 'crudhub', + urlBaseListing: 'browse', + }, + // ── Auth behaviour (non-secret flags) ────────────────── + // These replace the AUTH_SESSION_MAX_AGE, AUTH_REQUIRE_EMAIL_VERIFIED, + // AUTH_DISABLE_CREDENTIALS, and AUTH_VERBOSE env vars. + // Secrets (AUTH_SECRET, OAuth credentials) still go in env vars. + authBehavior: { + sessionMaxAge: 30 * 24 * 60 * 60, // 30 days in seconds + requireEmailVerified: false, + disableCredentials: false, + verbose: false, + }, + }, + + // ── Email (non-secret settings) ─────────────────────────── + // Replaces EMAIL_FROM and AWS_REGION env vars. + // Secrets (EMAIL_RESEND_API_KEY, AWS_ACCESS_KEY_ID, etc.) stay in env vars. + email: { + from: 'noreply@example.com', // Change to your domain: noreply@yourdomain.com + sesRegion: 'us-east-1', // AWS SES region (only needed when using SES) + }, + + // ── UI ──────────────────────────────────────────────────── + ui: { + preventFOUC: false, + preventFOUCInsideIframe: false, + debounceMs: 500, + layout: { minWidth: 320, maxWidth: 1280 }, + enforceGoogleFonts: true, + }, +}); diff --git a/apps/ottabase-template-app-tanstack/ottabase.template/config.migrations.ts b/apps/ottabase-template-app-tanstack/ottabase.template/config.migrations.ts new file mode 100644 index 000000000..23b27f3b9 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase.template/config.migrations.ts @@ -0,0 +1,165 @@ +// ============================================================ +// PACKAGE MIGRATION CONFIGURATION (Framework-managed) +// ============================================================ +// This file wires up the built-in PACKAGE_REGISTRY and merges +// any custom/premium packages declared in `../ottabase.config.ts` +// (customPackages is KEY-ONLY; schemas live here in USER_PACKAGE_REGISTRY). +// +// ── To enable/disable a built-in package ──────────────────── +// Edit `packages` in ottabase.config.ts (no change needed here). +// +// ── To add a custom or premium package ────────────────────── +// 1. Install the package and import its table schema below. +// 2. Add it to USER_PACKAGE_REGISTRY with { tables, migrations } (server-only). +// 3. Register its API routes in `config.routes.ts`. +// 4. Toggle it on in `customPackages` in ottabase.config.ts (boolean flag only). +// +// Example: +// import { myPremiumTable } from '@myorg/premium-feature/schema'; +// const USER_PACKAGE_REGISTRY: Record = { +// myPremiumFeature: { +// tables: { myPremiumTable }, +// migrations: [], +// }, +// }; +// ============================================================ + +import { brandKitsTable, layoutRouteMappingsTable, layoutTemplatesTable } from '@ottabase/brand-engine/persistence'; +import { + categoriesTable, + ottablogPluginsTable, + ottablogThemesTable, + postTagLinksTable, + postTagsTable, + postVersionsTable, + postsTable, + seriesTable, +} from '@ottabase/ottablog'; +import type { Migration } from '@ottabase/ottaorm'; +import { referralTrackingTable } from '@ottabase/referrals'; +import { shortlinksTable } from '@ottabase/shortlinks'; +import userConfig from '../ottabase.config'; + +// ── Internal types ─────────────────────────────────────────────────────────── + +interface PackageEntry { + tables: Record; + migrations: Migration[]; +} + +// ── 1. BUILT-IN REGISTRY (framework-maintained) ────────────────────────────── +// Do not edit this section. Add new built-in packages here when the framework +// ships new packages. + +const BUILTIN_PACKAGE_REGISTRY: Record = { + ottablog: { + tables: { + seriesTable, + categoriesTable, + postsTable, + postTagsTable, + postTagLinksTable, + postVersionsTable, + ottablogPluginsTable, + ottablogThemesTable, + }, + migrations: [], + }, + shortlinks: { + tables: { shortlinksTable }, + migrations: [], + }, + referrals: { + tables: { referralTrackingTable }, + migrations: [], + }, + brandEngine: { + tables: { + brandKitsTable, + layoutTemplatesTable, + layoutRouteMappingsTable, + }, + migrations: [], + }, +}; + +// ── 2. USER / PREMIUM REGISTRY (add custom packages here) ──────────────────── +// Import your premium package table schemas above, then register them here. +// The key must match an entry in `customPackages` in ottabase.config.ts. +// +// Example: +// import { premiumTable } from '@myorg/premium-feature/schema'; +// const USER_PACKAGE_REGISTRY: Record = { +// myPremiumFeature: { tables: { premiumTable }, migrations: [] }, +// }; + +const USER_PACKAGE_REGISTRY: Record = {}; + +// ── 3. COMBINED REGISTRY ───────────────────────────────────────────────────── + +const PACKAGE_REGISTRY: Record = { + ...BUILTIN_PACKAGE_REGISTRY, + ...USER_PACKAGE_REGISTRY, +}; + +// ── 4. TOGGLES (read from ottabase.config.ts) ──────────────────────────────── +// Built-in packages: sourced from `packages` in ottabase.config.ts. +// Custom packages: sourced from `customPackages` keys (all enabled when listed). + +export type MigrationPackageName = string; + +function buildMigrationConfig(): Record { + const cfg: Record = {}; + + // Built-in packages + const pkgs = userConfig.packages ?? {}; + for (const name of Object.keys(BUILTIN_PACKAGE_REGISTRY)) { + cfg[name] = (pkgs as Record)[name] ?? false; + } + + // Custom/premium packages (present in USER_PACKAGE_REGISTRY → enabled) + const custom = userConfig.customPackages ?? {}; + for (const name of Object.keys(USER_PACKAGE_REGISTRY)) { + cfg[name] = name in custom ? true : false; + } + + return cfg; +} + +export const migrationConfig: Record = buildMigrationConfig(); + +// ============================================================ +// PRIVATE UTILITY (Do not edit below this line) +// ============================================================ + +/** + * Merges tables from all enabled packages into a single object. + * Used by `schema.ts` (Drizzle Kit) and the runtime migration init. + */ +export function getEnabledPackageTables(): Record { + const tables: Record = {}; + + for (const [pkgName, entry] of Object.entries(PACKAGE_REGISTRY)) { + if (migrationConfig[pkgName]) { + Object.assign(tables, entry.tables); + } + } + + return tables; +} + +/** + * Merges migrations from all enabled packages into a single array. + * Used by the migration runner for package-specific SQL migrations. + */ +export function getEnabledPackageMigrations(): Migration[] { + const migrations: Migration[] = []; + + for (const [pkgName, entry] of Object.entries(PACKAGE_REGISTRY)) { + if (migrationConfig[pkgName]) { + migrations.push(...entry.migrations); + } + } + + return migrations; +} diff --git a/apps/ottabase-template-app-tanstack/ottabase.template/config.routes.ts b/apps/ottabase-template-app-tanstack/ottabase.template/config.routes.ts new file mode 100644 index 000000000..0640cb839 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase.template/config.routes.ts @@ -0,0 +1,56 @@ +// ============================================================ +// CUSTOM ROUTE REGISTRATION (User-zone) +// ============================================================ +// Register API routes for your custom or premium packages here. +// This file is called by the framework router AFTER all built-in +// routes. Return a Response to handle a route, or null to skip. +// +// ── How it works ───────────────────────────────────────────── +// The framework router tries built-in routes first. If none match, +// it calls `handleCustomRoutes(context)`. Your handler receives the +// same ApiRouteContext that all framework route handlers use. +// +// ── Adding a custom route ──────────────────────────────────── +// 1. Import your handler (from a premium package or local file). +// 2. Match on `context.route` and `context.method`. +// 3. Return a Response, or null to fall through. +// +// Example: +// import { handlePremiumDashboard } from '@myorg/premium-dashboard'; +// +// export async function handleCustomRoutes(context: ApiRouteContext): Promise { +// const { route, method } = context; +// +// if (route === '/api/premium/dashboard' && method === 'GET') { +// return handlePremiumDashboard(context); +// } +// +// if (route.startsWith('/api/premium/reports')) { +// return handlePremiumReports(context); +// } +// +// return null; +// } +// ============================================================ + +import type { ApiRouteContext } from '../worker/routes/types'; + +/** + * Handle custom / premium package API routes. + * + * Called by the framework router after all built-in routes. + * Return a Response to handle the route, or null to skip. + */ +export async function handleCustomRoutes(context: ApiRouteContext): Promise { + // const { route, method } = context; + + // Add your custom route handlers here. + // Example: + // if (route === '/api/my-feature' && method === 'GET') { + // return new Response(JSON.stringify({ hello: 'world' }), { + // headers: { 'Content-Type': 'application/json' }, + // }); + // } + + return null; +} diff --git a/apps/ottabase-template-app-tanstack/ottabase.template/db/schema.ts b/apps/ottabase-template-app-tanstack/ottabase.template/db/schema.ts new file mode 100644 index 000000000..0886ffb50 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase.template/db/schema.ts @@ -0,0 +1,34 @@ +// ============================================================ +// Database Schema (ottabase-template-app-tanstack) +// ============================================================ +// +// Exports all Drizzle table schemas for the application. +// Used by drizzle-kit (pnpm db:push / db:studio) and runtime autoInit. +// +// Tables come from THREE sources: +// 1. CORE — auth/user tables from @ottabase/ottaorm +// 2. APP — your app-specific models (e.g. Todo) +// 3. PKG — enabled packages (governed by ottabase.config.ts) +// +// The getAllSchemas() helper in schemas-helper.ts combines all three. +// This file re-exports everything drizzle-kit needs as named exports. +// ============================================================ + +import { getEnabledPackageTables } from '../config.migrations'; + +// ── Core tables (always included) ──────────────────────────── +export { + accountsTable, + authenticatorsTable, + sessionsTable, + usersTable, + verificationTokensTable, +} from '@ottabase/ottaorm'; + +// ── App-specific tables ────────────────────────────────────── +export { todosTable } from '../models/Todo'; + +// ── Package tables (config-driven) ────────────────────────── +// Collected dynamically from enabled packages in ottabase.config.ts. +// drizzle-kit picks these up via the spread; runtime uses getAllSchemas(). +export const packageTables = getEnabledPackageTables(); diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts b/apps/ottabase-template-app-tanstack/ottabase.template/db/schemas-helper.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/db/schemas-helper.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts b/apps/ottabase-template-app-tanstack/ottabase.template/helpers/__tests__/referral-attribution.test.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/helpers/__tests__/referral-attribution.test.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts b/apps/ottabase-template-app-tanstack/ottabase.template/helpers/referral-attribution.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/helpers/referral-attribution.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase.template/migrations/README.md b/apps/ottabase-template-app-tanstack/ottabase.template/migrations/README.md new file mode 100644 index 000000000..c29f822ce --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase.template/migrations/README.md @@ -0,0 +1,336 @@ +# Migration System Documentation + +## Overview + +The OttaBase migration system provides a **simple, automatic, and comprehensive** approach to database migrations that +works across your entire monorepo. It handles: + +1. **Core schemas** (from `@ottabase/ottaorm`) - users, auth, posts, etc. +2. **App-specific schemas** - custom tables for your app (like `todos`) +3. **Package schemas** - tables from enabled packages (like `@ottabase/shortlinks`) + +## How It Works + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ /api/ottaorm/init │ +│ (Migration Endpoint) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ schemas-helper.ts │ +│ Collects all table schemas: │ +│ • Core (from @ottabase/ottaorm/schema) │ +│ • App (from ottabase/db/schema.ts) │ +│ • Packages (from config.migrations.ts) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ autoInit() │ +│ (from @ottabase/ottaorm) │ +│ • Creates missing tables │ +│ • Adds new columns │ +│ • Runs custom migrations │ +│ • Tracks history in _ottabase_migrations │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Migration Status Page │ +│ Shows detailed migration results │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +1. **`ottabase/config.migrations.ts`** + - Single source of truth for enabled packages + - Configure which packages are active + - Each package can provide tables + custom migrations + +2. **`ottabase/db/schemas-helper.ts`** + - Collects all schemas from different sources + - Combines core, app, and package schemas + - Used by the migration endpoint + +3. **`ottabase/db/schema.ts`** + - Exports all table schemas + - Core tables from `@ottabase/ottaorm` + - App-specific tables + - Package tables (via `config.migrations.ts`) + +4. **`ottabase/migrations/index.ts`** + - Custom migrations registry + - Combines core, app, and package migrations + - Executed after table creation + +5. **`cloudflare-worker.ts` (`/api/ottaorm/init`)** + - Migration endpoint + - Calls `autoInit()` with all schemas + - Returns detailed migration results + +## Usage + +### Running Migrations + +#### Development (wrangler dev) + +```bash +curl -X POST http://127.0.0.1:3004/api/ottaorm/init +``` + +Or navigate to: + +``` +http://127.0.0.1:3004/migration-status +``` + +#### Production + +```bash +curl -X POST https://your-app.workers.dev/api/ottaorm/init \ + -H "Authorization: Bearer YOUR_MIGRATION_SECRET" +``` + +Or with query parameter: + +```bash +curl -X POST https://your-app.workers.dev/api/ottaorm/init?secret=YOUR_MIGRATION_SECRET +``` + +### Adding a New Table (App-Specific) + +1. **Create the Model** (`ottabase/models/YourModel.ts`): + +```typescript +import { BaseModel } from '@ottabase/ottaorm'; +import { text, integer } from 'drizzle-orm/sqlite-core'; +import { sqliteTable } from 'drizzle-orm/sqlite-core'; + +export const yourTable = sqliteTable('your_table', { + id: text('id').primaryKey(), + name: text('name').notNull(), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), +}); + +export class YourModel extends BaseModel { + static entity = 'your_table'; + static table = yourTable; +} +``` + +2. **Export from schema** (`ottabase/db/schema.ts`): + +```typescript +export { yourTable } from '../models/YourModel'; +``` + +3. **Run migrations**: + +```bash +curl -X POST http://127.0.0.1:3004/api/ottaorm/init +``` + +That's it! The table will be automatically created. + +### Adding a Package + +1. **Enable the package** in `ottabase/config.migrations.ts`: + +```typescript +import { shortlinksTable } from '@ottabase/shortlinks'; +import { shortlinkMigrations } from '@ottabase/shortlinks/migrations'; + +const PACKAGE_REGISTRY = { + shortlinks: { + tables: { shortlinksTable }, + migrations: shortlinkMigrations, // Optional custom migrations + }, + // Add your package here: + myPackage: { + tables: { myPackageTable }, + migrations: myPackageMigrations, + }, +} as const; + +export const migrationConfig: Record = { + shortlinks: true, + myPackage: true, // Enable it +}; +``` + +2. **Run migrations** - the package tables will be created automatically! + +### Adding Custom Migrations + +For data seeding, indexes, or other custom SQL that can't be expressed in Models: + +**In `ottabase/migrations/index.ts`**: + +```typescript +const appSpecificMigrations: Migration[] = [ + { + name: '0001_seed_admin_user', + up: async (db) => { + await db.executeRaw(` + INSERT OR IGNORE INTO users (id, name, email, created_at, updated_at) + VALUES ( + 'admin-001', + 'Admin', + 'admin@example.com', + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000 + ) + `); + }, + }, + { + name: '0002_create_custom_index', + up: async (db) => { + await db.executeRaw(` + CREATE INDEX IF NOT EXISTS idx_users_email + ON users(email) + `); + }, + }, +]; +``` + +Migrations are tracked in the `_ottabase_migrations` table and will only run once. + +## Migration Tracking + +### History Table + +All executed migrations are tracked in `_ottabase_migrations`: + +```sql +CREATE TABLE _ottabase_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + executed_at INTEGER NOT NULL, + driver_type TEXT DEFAULT 'd1-drizzle' +); +``` + +### Checking Migration Status + +Navigate to `/migration-status` in your app to see: + +- All detected tables (core + app + packages) +- Tables created vs. skipped +- Columns added +- Custom migrations run +- Any errors + +## Production Deployment + +### Setting Up Migration Secret + +1. **In Cloudflare Dashboard** or via `wrangler`: + +```bash +wrangler secret put MIGRATION_SECRET +``` + +2. **Set environment variable**: + +```bash +export MIGRATION_SECRET="your-secret-here" +``` + +3. **Run migrations** with the secret: + +```bash +curl -X POST https://your-app.workers.dev/api/ottaorm/init \ + -H "Authorization: Bearer your-secret-here" +``` + +### Deployment Workflow + +1. **Deploy your app**: + +```bash +pnpm deploy +``` + +2. **Run migrations** (after deployment): + +```bash +curl -X POST https://your-app.workers.dev/api/ottaorm/init?secret=$MIGRATION_SECRET +``` + +3. **Verify** by checking `/migration-status` + +## FAQ + +### Q: Do I need to write SQL migrations? + +**A:** No! Tables are auto-created from your Models. Only write custom migrations for data seeding, indexes, or other +special SQL. + +### Q: How do I add a new column? + +**A:** Just add it to your Model and run `/api/ottaorm/init`. It will be automatically added to existing tables. + +### Q: What about drizzle-kit? + +**A:** You can still use `drizzle-kit push` for schema changes, but `autoInit()` works without any CLI tools - perfect +for serverless. + +### Q: Can I use this in local development? + +**A:** Yes! It works with both `wrangler dev` (local D1) and production Cloudflare D1. + +### Q: What if a migration fails? + +**A:** Check the `/migration-status` page for detailed error messages. Failed migrations won't be marked as complete and +will retry next time. + +### Q: How do I rollback? + +**A:** Currently, there's no automatic rollback. For schema changes, you'd need to manually run SQL to reverse changes. +For data migrations, implement a `down` function in your custom migration. + +## Best Practices + +1. **Test migrations locally first** with `wrangler dev` +2. **Use version-prefixed names** for custom migrations: `0001_description`, `0002_description` +3. **Check migration status** after deployment +4. **Keep migrations idempotent** - use `INSERT OR IGNORE`, `CREATE TABLE IF NOT EXISTS`, etc. +5. **Never edit executed migrations** - create a new migration instead + +## Troubleshooting + +### Tables not appearing + +- Check that the table is exported from `ottabase/db/schema.ts` +- Verify the table name ends with `Table` (convention) +- Check `/migration-status` for errors + +### Package tables not created + +- Verify the package is enabled in `config.migrations.ts` +- Check that the table is imported correctly +- Look for errors in `/migration-status` + +### Migrations running multiple times + +- Each migration should have a unique name +- Check the `_ottabase_migrations` table for duplicate entries +- Ensure migration names don't change after deployment + +## Examples + +See the following files for complete examples: + +- `ottabase/models/Todo.ts` - Simple app-specific model +- `@ottabase/shortlinks` package - Shortlinks model in package +- `ottabase/migrations/index.ts` - Custom migrations setup +- `ottabase/config.migrations.ts` - Package configuration diff --git a/apps/ottabase-template-app-tanstack/ottabase/migrations/custom/.gitkeep b/apps/ottabase-template-app-tanstack/ottabase.template/migrations/custom/.gitkeep similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/migrations/custom/.gitkeep rename to apps/ottabase-template-app-tanstack/ottabase.template/migrations/custom/.gitkeep diff --git a/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts b/apps/ottabase-template-app-tanstack/ottabase.template/migrations/index.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/migrations/index.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/Todo.schema.ts b/apps/ottabase-template-app-tanstack/ottabase.template/models/Todo.schema.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/models/Todo.schema.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/models/Todo.schema.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/Todo.ts b/apps/ottabase-template-app-tanstack/ottabase.template/models/Todo.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/models/Todo.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/models/Todo.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase/queue/handlers.ts b/apps/ottabase-template-app-tanstack/ottabase.template/queue/handlers.ts similarity index 96% rename from apps/ottabase-template-app-tanstack/ottabase/queue/handlers.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/queue/handlers.ts index 4d9df0a43..12c361682 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/queue/handlers.ts +++ b/apps/ottabase-template-app-tanstack/ottabase.template/queue/handlers.ts @@ -22,9 +22,11 @@ import { createResendMailer } from '@ottabase/email/providers/resend'; import { createSESMailer } from '@ottabase/email/providers/ses'; import type { JobHandler } from '@ottabase/queue'; import type { CloudflareEnv } from '../../cloudflare-env'; +import { EMAIL_FROM_DEFAULT, EMAIL_SES_REGION } from '../../worker/lib/worker-config'; function getMailer(env: CloudflareEnv): { mailer: Mailer | null; from: string } { - const from = (env as any).EMAIL_FROM || 'noreply@example.com'; + // Email "from" and SES region are now configured in ottabase.config.ts + const from = EMAIL_FROM_DEFAULT; if ((env as any).EMAIL_RESEND_API_KEY) { return { mailer: createResendMailer({ apiKey: (env as any).EMAIL_RESEND_API_KEY }), from }; } @@ -33,7 +35,7 @@ function getMailer(env: CloudflareEnv): { mailer: Mailer | null; from: string } mailer: createSESMailer({ accessKeyId: (env as any).AWS_ACCESS_KEY_ID, secretAccessKey: (env as any).AWS_SECRET_ACCESS_KEY, - region: (env as any).AWS_REGION || 'us-east-1', + region: EMAIL_SES_REGION, }), from, }; diff --git a/apps/ottabase-template-app-tanstack/ottabase/queue/index.ts b/apps/ottabase-template-app-tanstack/ottabase.template/queue/index.ts similarity index 100% rename from apps/ottabase-template-app-tanstack/ottabase/queue/index.ts rename to apps/ottabase-template-app-tanstack/ottabase.template/queue/index.ts diff --git a/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts b/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts deleted file mode 100644 index 40d80a864..000000000 --- a/apps/ottabase-template-app-tanstack/ottabase/config.migrations.ts +++ /dev/null @@ -1,112 +0,0 @@ -// ============================================================ -// PACKAGE MIGRATION CONFIGURATION -// ============================================================ -// Register external packages (like @ottabase/shortlinks) here to -// automatically include their database tables and migrations. -// -// HOW TO ADD A PACKAGE: -// 1. Import the package's table schema and migrations -// 2. Add it to `PACKAGE_REGISTRY` below -// 3. Enable it in `migrationConfig` -// ============================================================ - -import { brandKitsTable, layoutRouteMappingsTable, layoutTemplatesTable } from '@ottabase/brand-engine/persistence'; -import { - categoriesTable, - ottablogPluginsTable, - ottablogThemesTable, - postTagLinksTable, - postTagsTable, - postVersionsTable, - postsTable, - seriesTable, -} from '@ottabase/ottablog'; -import type { Migration } from '@ottabase/ottaorm'; -import { referralTrackingTable } from '@ottabase/referrals'; -import { shortlinksTable } from '@ottabase/shortlinks'; - -/** - * 1. REGISTRY - * Map package names to their table definitions and optional migrations. - */ -const PACKAGE_REGISTRY = { - ottablog: { - tables: { - seriesTable, - categoriesTable, - postsTable, - postTagsTable, - postTagLinksTable, - postVersionsTable, - ottablogPluginsTable, - ottablogThemesTable, - }, - migrations: [] as Migration[], - }, - shortlinks: { - tables: { shortlinksTable }, - migrations: [] as Migration[], // Add package-specific migrations here if any - }, - referrals: { - tables: { referralTrackingTable }, - migrations: [] as Migration[], - }, - brandEngine: { - tables: { - brandKitsTable, - layoutTemplatesTable, - layoutRouteMappingsTable, - }, - migrations: [] as Migration[], - }, -} as const; - -/** - * 2. CONFIGURATION - * Toggle packages ON (true) or OFF (false). - * Only enabled packages will have their tables created/migrated. - */ -export type MigrationPackageName = keyof typeof PACKAGE_REGISTRY; - -export const migrationConfig: Record = { - ottablog: true, - shortlinks: true, - referrals: true, - brandEngine: true, -}; - -// ============================================================ -// PRIVATE UTILITY (Do not edit below this line) -// ============================================================ - -/** - * Merges tables from all enabled packages into a single object. - * Used by `schema.ts` (Drizzle Kit) and `config.migrations.ts` (Runtime). - */ -export function getEnabledPackageTables() { - const tables: Record = {}; - - for (const [pkgName, config] of Object.entries(PACKAGE_REGISTRY)) { - if (migrationConfig[pkgName as MigrationPackageName]) { - Object.assign(tables, config.tables); - } - } - - return tables; -} - -/** - * Merges migrations from all enabled packages into a single array. - * Used by migration runner to execute package-specific migrations. - */ -export function getEnabledPackageMigrations(): Migration[] { - const migrations: Migration[] = []; - - for (const [pkgName, config] of Object.entries(PACKAGE_REGISTRY)) { - if (migrationConfig[pkgName as MigrationPackageName]) { - migrations.push(...config.migrations); - } - } - - return migrations; -} diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts deleted file mode 100644 index aa2aefc8d..000000000 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schema.ts +++ /dev/null @@ -1,67 +0,0 @@ -// ============================================================ -// Database Schema (ottabase-template-app-tanstack) -// ============================================================ -// -// This file exports all Drizzle table schemas for the application. -// It combines CORE tables from @ottabase/ottaorm + APP-SPECIFIC tables. -// -// Usage with drizzle-kit push (codebase first approach): -// pnpm db:push - Push schema changes to D1 -// pnpm db:studio - Open Drizzle Studio for database browsing -// -// The TypeScript schema is the single source of truth. -// No SQL migration files needed - drizzle-kit handles everything. -// ============================================================ - -// ============================================================ -// CORE TABLES (from @ottabase/ottaorm) -// ============================================================ -import { - accountsTable, - authenticatorsTable, - sessionsTable, - usersTable, - verificationTokensTable, -} from '@ottabase/ottaorm'; -import { - categoriesTable, - ottablogPluginsTable, - ottablogThemesTable, - postTagLinksTable, - postTagsTable, - postVersionsTable, - postsTable, - seriesTable, -} from '@ottabase/ottablog'; -import { referralTrackingTable } from '@ottabase/referrals'; -import { shortlinksTable } from '@ottabase/shortlinks'; - -export { accountsTable, authenticatorsTable, sessionsTable, usersTable, verificationTokensTable }; - -// ============================================================ -// APP-SPECIFIC TABLES -// ============================================================ -export { todosTable } from '../models/Todo'; - -// ============================================================ -// PACKAGE TABLES (from enabled packages) -// ============================================================ -export { - categoriesTable, - ottablogPluginsTable, - ottablogThemesTable, - postTagLinksTable, - postTagsTable, - postVersionsTable, - postsTable, - seriesTable, - referralTrackingTable, - shortlinksTable, -}; - -// ============================================================ -// DYNAMIC PACKAGE TABLES (Configured in config.migrations.ts) -// ============================================================ -import { getEnabledPackageTables } from '../config.migrations'; - -export const packageTables = getEnabledPackageTables(); diff --git a/apps/ottabase-template-app-tanstack/ottabase/migrations/README.md b/apps/ottabase-template-app-tanstack/ottabase/migrations/README.md index bf11289ce..c29f822ce 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/migrations/README.md +++ b/apps/ottabase-template-app-tanstack/ottabase/migrations/README.md @@ -120,8 +120,8 @@ export const yourTable = sqliteTable('your_table', { }); export class YourModel extends BaseModel { + static entity = 'your_table'; static table = yourTable; - static tableName = 'your_table'; } ``` diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 2e3ea254e..07b26ffb1 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -4,9 +4,12 @@ "type": "module", "private": true, "scripts": { + "setup": "node scripts/setup-user-zone.js", + "predev": "node scripts/setup-user-zone.js", "dev": "vite --open", "dev:worker": "wrangler dev", "dev:full": "pnpm build && pnpm dev:worker", + "prebuild": "node scripts/setup-user-zone.js", "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build", "preview": "pnpm build && wrangler dev", "deploy": "pnpm build && wrangler deploy", diff --git a/apps/ottabase-template-app-tanstack/scripts/setup-user-zone.js b/apps/ottabase-template-app-tanstack/scripts/setup-user-zone.js new file mode 100644 index 000000000..bb93c5bf7 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/scripts/setup-user-zone.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +/** + * Ensures user-zone files exist before build/dev. + * Copies from tracked templates only if the target doesn't already exist. + * + * Runs automatically via the "setup" npm script (called by predev / prebuild). + */ + +import { cpSync, existsSync, readdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(__dirname, '..'); + +const copies = [ + // Single user config file (app identity, package toggles, feature flags) + { from: 'ottabase.config.example.ts', to: 'ottabase.config.ts', directory: false }, + // User-zone directory (models, schemas, migrations, queue, routes) + { from: 'ottabase.template', to: 'ottabase', directory: true }, + // Wrangler config (local dev overrides) + { from: 'wrangler.example.jsonc', to: 'wrangler.jsonc', directory: false }, +]; + +/** + * For directories: check that key files from the template are present in dest. + * If the directory exists but is missing essential files, merge them in. + */ +function ensureDirectoryComplete(src, dest) { + if (!existsSync(src) || !existsSync(dest)) return; + + const srcEntries = readdirSync(src, { withFileTypes: true }); + let copied = 0; + for (const entry of srcEntries) { + const destPath = resolve(dest, entry.name); + if (!existsSync(destPath)) { + const srcPath = resolve(src, entry.name); + cpSync(srcPath, destPath, { recursive: entry.isDirectory() }); + copied++; + } + } + if (copied > 0) { + console.log(`[setup] Merged ${copied} missing entries from ${src} into existing ${dest}/`); + } +} + +for (const { from, to, directory } of copies) { + const src = resolve(appDir, from); + const dest = resolve(appDir, to); + + if (existsSync(dest)) { + // For directories, verify they have all expected top-level entries + if (directory) { + ensureDirectoryComplete(src, dest); + } + continue; + } + + if (!existsSync(src)) { + console.warn(`[setup] Source "${from}" not found — skipping.`); + continue; + } + + cpSync(src, dest, { recursive: directory }); + console.log(`[setup] Copied ${from} → ${to}`); +} diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts index 75a19bb5e..0a101565f 100644 --- a/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts +++ b/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts @@ -57,7 +57,7 @@ describe('Cloudflare Worker API', () => { const data = (await resp.json()) as any; expect(resp.status).toBe(200); expect(data.ok).toBe(true); - expect(data.name).toBe('ottabase-template-app-tanstack'); + expect(data.name).toBe('Ottabase Template App (TanStack)'); }); }); diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/components/__tests__/BrandLayout.test.tsx b/apps/ottabase-template-app-tanstack/src/ottabase/components/__tests__/BrandLayout.test.tsx index 7f55789b8..49f271607 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/components/__tests__/BrandLayout.test.tsx +++ b/apps/ottabase-template-app-tanstack/src/ottabase/components/__tests__/BrandLayout.test.tsx @@ -69,6 +69,9 @@ vi.mock('@/hooks/useLocalStorage', () => ({ vi.mock('@/ottabase/config/app.config', () => ({ APP_META: { appName: 'Test App' }, APP_ID: 'test-app', + APP_NAME: 'Test App', + appConfig: { storage: { prefix: 'test' } }, + REFERRALS_CONFIG: { enabled: false }, })); vi.mock('@/ottabase/config/i18n.config', () => ({ i18nConfig: { enabledLanguages: ['en'] }, @@ -195,13 +198,14 @@ describe('BrandLayout', () => { it('renders footer when footer is true', () => { setLayout({ footer: true }); render(); - expect(screen.getByText('Built with Ottabase')).toBeTruthy(); + // Footer text is split across elements: "Built with" + APP_NAME + expect(screen.getByText(/Built with/)).toBeTruthy(); }); it('does not render footer when footer is false', () => { setLayout({ footer: false }); render(); - expect(screen.queryByText('Built with Ottabase')).toBeNull(); + expect(screen.queryByText(/Built with/)).toBeNull(); }); }); diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/BrandFooter.tsx b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/BrandFooter.tsx index b2008b768..18119bd00 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/BrandFooter.tsx +++ b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/BrandFooter.tsx @@ -1,10 +1,11 @@ import { memo } from 'react'; +import { APP_NAME } from '@/ottabase/config/app.config'; export const BrandFooter = memo(function BrandFooter({ containerClass }: { containerClass: string }) { return (
- Built with Ottabase + Built with {APP_NAME}
); diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/config/app.config.ts b/apps/ottabase-template-app-tanstack/src/ottabase/config/app.config.ts index 08022a1c2..59e6a4efd 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/config/app.config.ts +++ b/apps/ottabase-template-app-tanstack/src/ottabase/config/app.config.ts @@ -1,60 +1,31 @@ -import { createAppConfig, createThemeColors, DEFAULT_THEME_COLORS } from '@ottabase/config'; +import { + createAppConfig, + createThemeColors, + DEFAULT_THEME_COLORS, + OttabaseUserConfig, + userConfigToOptions, +} from '@ottabase/config'; +import userConfig from '../../../ottabase.config'; +// Cast through OttabaseUserConfig to allow optional `colors` access on the +// narrowed type returned by defineOttabaseConfig. +const typedConfig = userConfig as OttabaseUserConfig; +const options = userConfigToOptions(typedConfig); + +// Convert the root user config into createAppConfig options. +// userConfigToOptions bridges OttabaseUserConfig → ConfigOptions. export const appConfig = createAppConfig({ - appName: 'Ottabase Template App (TanStack)', - appId: 'ottabase-template-app', + ...options, defaults: { - meta: { - author: '@thinkdj', - description: 'A minimal TanStack + Cloudflare Workers template app in the Ottabase monorepo', - keywords: - 'Ottabase, TanStack Router, TanStack Query, Vite, Tailwind, Shadcn, Cloudflare Workers, TypeScript, React', - companyName: 'Ottabase', - }, - uiFramework: 'mantine', - ui: { - preventFOUC: false, - preventFOUCInsideIframe: false, - debounceMs: 500, - layout: { - minWidth: 320, - maxWidth: 1280, - }, - enforceGoogleFonts: true, - }, + ...options.defaults, + // Merge in the full theme color palette (not settable via env vars) theme: { - colorDefault: 'tremorBlue', + colorDefault: typedConfig.theme?.colorDefault ?? 'tremorBlue', colors: createThemeColors({ ...DEFAULT_THEME_COLORS, + ...typedConfig.theme?.colors, }), }, - storage: { - prefix: 'ottabase', - }, - api: { - serverErrorHttpCode: 500, - }, - features: { - spotlight: { - enabled: true, - shortcuts: ['/'], - }, - referrals: { - enabled: true, - trackClicks: true, // Set to false to disable click tracking (only track conversions) - expiryDays: 90, // How long stored referral codes are valid by default - }, - crudHub: { - apiBaseUrl: '/api/crudhub', - urlBase: 'crudhub', - urlBaseListing: 'browse', - }, - pagination: { - defaultPageSize: 10, - maxPageSize: 100, - sizeOptions: [5, 10, 20, 50, 100], - }, - }, model: { defaultRelKey: 'defaults', }, @@ -107,6 +78,12 @@ export const SPOTLIGHT_CONFIG = appConfig.features.spotlight; // Referrals export const REFERRALS_CONFIG = appConfig.features.referrals; +// Auth behaviour +export const AUTH_BEHAVIOR_CONFIG = appConfig.features.authBehavior; + +// Email (non-secret) +export const EMAIL_CONFIG = appConfig.email; + // Theme / Colors export const THEME_COLOR_DEFAULT = appConfig.theme.colorDefault; export const THEME_COLORS = appConfig.theme.colors; diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/config/log.config.ts b/apps/ottabase-template-app-tanstack/src/ottabase/config/log.config.ts index 43a55571c..7ee64efcf 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/config/log.config.ts +++ b/apps/ottabase-template-app-tanstack/src/ottabase/config/log.config.ts @@ -1,5 +1,6 @@ import type { LogConfig } from '@ottabase/logger'; import { LogLevelEnum } from '@ottabase/logger'; +import { APP_ID } from './app.config'; // Safe access for non-Vite environments (SSR, tests) where import.meta.env may be undefined const env = typeof import.meta !== 'undefined' ? import.meta.env : undefined; @@ -26,7 +27,7 @@ export const logConfig: LogConfig = { // Global context to include in all logs context: { - app: 'ottabase-template-app-tanstack', + app: APP_ID, version: env?.VITE_APP_VERSION ?? '0.0.0', }, @@ -135,7 +136,7 @@ export const logConfig: LogConfig = { export const devLogConfig: LogConfig = { level: LogLevelEnum.DEBUG, context: { - app: 'ottabase-template-app-tanstack', + app: APP_ID, environment: 'development', }, server: { @@ -157,7 +158,7 @@ export const devLogConfig: LogConfig = { export const prodLogConfig: LogConfig = { level: LogLevelEnum.INFO, context: { - app: 'ottabase-template-app-tanstack', + app: APP_ID, environment: 'production', }, server: { diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/state/appState.ts b/apps/ottabase-template-app-tanstack/src/ottabase/state/appState.ts index 6012d48a1..8afd703ba 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/state/appState.ts +++ b/apps/ottabase-template-app-tanstack/src/ottabase/state/appState.ts @@ -2,7 +2,7 @@ * App Global State * Central state management for ottabase-template-app-tanstack */ -import { APP_ID } from '@/ottabase/config/app.config'; +import { APP_ID, APP_NAME } from '@/ottabase/config/app.config'; import { createAppState, type BaseUser, type SidebarState } from '@ottabase/state'; import { createStore } from 'jotai'; @@ -15,7 +15,7 @@ export interface AppUser extends BaseUser { // Create app state with appName const { appStateAtom, atoms, createAtom } = createAppState({ - appName: 'Ottabase', + appName: APP_NAME, initialState: { appId: APP_ID, organizationId: null, diff --git a/apps/ottabase-template-app-tanstack/src/pages/docs/docs.config.ts b/apps/ottabase-template-app-tanstack/src/pages/docs/docs.config.ts index 6e5460563..e31188ec1 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/docs/docs.config.ts +++ b/apps/ottabase-template-app-tanstack/src/pages/docs/docs.config.ts @@ -1,3 +1,4 @@ +import { APP_NAME } from '@/ottabase/config/app.config'; import type { DocsConfig, DocsSource } from '@ottabase/docs'; import { extractTitle, fileNameToSlug, slugToTitle } from '@ottabase/docs'; @@ -81,7 +82,7 @@ const packageModules = import.meta.glob('/../../packages/*/README.md', { }) as Record Promise>; export const docsConfig: DocsConfig = { - title: 'Ottabase Docs', + title: `${APP_NAME} Docs`, basePath: '/docs', theme: 'spacious', codeRenderMode: 'ui-code-highlight', diff --git a/apps/ottabase-template-app-tanstack/tsconfig.json b/apps/ottabase-template-app-tanstack/tsconfig.json index 69970cb7e..ef17d5de1 100644 --- a/apps/ottabase-template-app-tanstack/tsconfig.json +++ b/apps/ottabase-template-app-tanstack/tsconfig.json @@ -21,6 +21,16 @@ }, "types": ["@cloudflare/workers-types"] }, - "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "cloudflare-worker.ts", "cloudflare-env.d.ts"], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "vite.config.ts", + "cloudflare-worker.ts", + "cloudflare-env.d.ts", + "ottabase.config.ts", + "ottabase/**/*.ts", + "ottabase.template/**/*.ts", + "worker/**/*.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/apps/ottabase-template-app-tanstack/worker/bootstrap/pages.ts b/apps/ottabase-template-app-tanstack/worker/bootstrap/pages.ts index 66b3342aa..2ca423ccf 100644 --- a/apps/ottabase-template-app-tanstack/worker/bootstrap/pages.ts +++ b/apps/ottabase-template-app-tanstack/worker/bootstrap/pages.ts @@ -293,7 +293,7 @@ export function renderWizardPage(state: PlatformStateResult): string {

Email (Optional)

- +
EMAIL_RESEND_API_KEYAPI key from Resend (recommended)
EMAIL_FROMSender address (default: noreply@example.com)
Sender address & SES region are configured in ottabase.config.tsemail

Security (Recommended)

diff --git a/apps/ottabase-template-app-tanstack/worker/bootstrap/routes.ts b/apps/ottabase-template-app-tanstack/worker/bootstrap/routes.ts index f1339af86..c71a74f62 100644 --- a/apps/ottabase-template-app-tanstack/worker/bootstrap/routes.ts +++ b/apps/ottabase-template-app-tanstack/worker/bootstrap/routes.ts @@ -27,6 +27,7 @@ import type { CloudflareEnv } from '../../cloudflare-env'; import { getAllSchemas } from '../../ottabase/db/schemas-helper'; import { appMigrations } from '../../ottabase/migrations'; import { ensureAppBrandDefaults, provisionDefaultOrganizationForUser } from '../lib/user-provisioning'; +import { APP_ID, APP_NAME } from '../lib/worker-config'; import { renderBindingsErrorPage, renderLockedPage, renderMaintenancePage, renderWizardPage } from './pages'; import { ensureMetaTable, probeBindings, writeDBState, writeKVState } from './state-resolver'; import type { PlatformStateResult } from './types'; @@ -382,8 +383,8 @@ async function handleSeed(context: BootstrapContext): Promise { ensureOrmConnection(env); // Seed default brand kit + route mappings for current app (brand kits are always app-scoped) - const appId = (env as { APP_ID?: string }).APP_ID ?? 'ottabase-template-app'; - await ensureAppBrandDefaults('Ottabase', appId); + // APP_ID and APP_NAME are now configured in ottabase.config.ts + await ensureAppBrandDefaults(APP_NAME, APP_ID); // Seed default roles (owner, admin, editor, viewer, member) const createdRoles = await Role.ensureDefaultRoles(); @@ -490,7 +491,7 @@ async function handleCreateOwner(context: BootstrapContext): Promise { organizationRole: 'owner', assignedBy: 'system', roleFallbacks: ['owner'], - appId: (env as { APP_ID?: string }).APP_ID ?? 'ottabase-template-app', + appId: APP_ID, }); organizationId = provisioned.organizationId; assignedRole = provisioned.assignedRole; diff --git a/apps/ottabase-template-app-tanstack/worker/lib/auth-utils.ts b/apps/ottabase-template-app-tanstack/worker/lib/auth-utils.ts index 487c884cf..90cb121da 100644 --- a/apps/ottabase-template-app-tanstack/worker/lib/auth-utils.ts +++ b/apps/ottabase-template-app-tanstack/worker/lib/auth-utils.ts @@ -3,11 +3,44 @@ import { invalidateCacheByPrefix } from '@ottabase/cf/kv-cache'; import { createResendMailer, createSESMailer } from '@ottabase/email'; import { SecurityContext } from '@ottabase/ottaorm'; import { Account, Organization, OrganizationMember, VerificationToken } from '@ottabase/ottaorm/models'; -import type { CloudflareEnv } from '../cloudflare-env'; +import type { CloudflareEnv } from '../../cloudflare-env'; import { createSecureToken } from './utils'; +import { + AUTH_DISABLE_CREDENTIALS, + AUTH_REQUIRE_EMAIL_VERIFIED, + AUTH_SESSION_MAX_AGE, + AUTH_VERBOSE, + EMAIL_FROM_DEFAULT, + EMAIL_SES_REGION, +} from './worker-config'; + +function parseBoolOverride(value: unknown): boolean | undefined { + if (value === undefined || value === null) return undefined; + const v = String(value).trim().toLowerCase(); + if (v === 'true' || v === '1' || v === 'yes') return true; + if (v === 'false' || v === '0' || v === 'no') return false; + return undefined; +} + +function resolveSessionMaxAge(env: CloudflareEnv): number { + const envVal = Number((env as any).AUTH_SESSION_MAX_AGE); + if (Number.isFinite(envVal) && envVal > 0) return envVal; + return AUTH_SESSION_MAX_AGE; +} + +export function resolveAuthBehavior(env: CloudflareEnv) { + return { + sessionMaxAge: resolveSessionMaxAge(env), + requireEmailVerified: + parseBoolOverride((env as any).AUTH_REQUIRE_EMAIL_VERIFIED) ?? AUTH_REQUIRE_EMAIL_VERIFIED, + disableCredentials: parseBoolOverride((env as any).AUTH_DISABLE_CREDENTIALS) ?? AUTH_DISABLE_CREDENTIALS, + verbose: parseBoolOverride((env as any).AUTH_VERBOSE) ?? AUTH_VERBOSE, + }; +} export async function resolveMailer(env: CloudflareEnv) { - const from = env.EMAIL_FROM || 'noreply@example.com'; + // Prefer env override for backward compatibility; fall back to config default + const from = env.EMAIL_FROM && env.EMAIL_FROM.trim().length > 0 ? env.EMAIL_FROM : EMAIL_FROM_DEFAULT; let mailer: any = null; let provider: 'resend' | 'ses' | 'nodemailer' | null = null; @@ -18,7 +51,8 @@ export async function resolveMailer(env: CloudflareEnv) { mailer = createSESMailer({ accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - region: env.AWS_REGION || 'us-east-1', + // Prefer env override for backward compatibility; fall back to config default + region: env.AWS_REGION || EMAIL_SES_REGION, }); provider = 'ses'; } else if (env.EMAIL_SERVER) { @@ -58,6 +92,8 @@ export async function createVerificationToken( } export function getAuthOptions(env: CloudflareEnv): CreateAuthConfigOptions { + const behavior = resolveAuthBehavior(env); + const options: CreateAuthConfigOptions = { authConfig: { pages: { @@ -67,23 +103,17 @@ export function getAuthOptions(env: CloudflareEnv): CreateAuthConfigOptions { }, }; - const maxAge = Number(env.AUTH_SESSION_MAX_AGE); - if (Number.isFinite(maxAge) && maxAge > 0) { - options.sessionMaxAge = maxAge; - } + options.sessionMaxAge = behavior.sessionMaxAge; - const requireVerified = env.AUTH_REQUIRE_EMAIL_VERIFIED === 'true' || env.AUTH_REQUIRE_EMAIL_VERIFIED === '1'; - if (requireVerified) { + if (behavior.requireEmailVerified) { options.requireVerifiedEmail = true; } - const disableCredentials = env.AUTH_DISABLE_CREDENTIALS === 'true' || env.AUTH_DISABLE_CREDENTIALS === '1'; - if (disableCredentials) { + if (behavior.disableCredentials) { options.disableCredentials = true; } - const verbose = env.AUTH_VERBOSE === 'true' || env.AUTH_VERBOSE === '1'; - if (verbose) { + if (behavior.verbose) { options.verbose = true; } diff --git a/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts b/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts index c4ecd995e..5b24bdae7 100644 --- a/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts +++ b/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts @@ -26,9 +26,10 @@ import { import { ReferralTracking } from '@ottabase/referrals'; import { Shortlink } from '@ottabase/shortlinks'; import { errorResponse } from '@ottabase/utils/http-errors'; +import type { CloudflareEnv } from '../../cloudflare-env'; import { Todo } from '../../ottabase/models/Todo'; -import type { CloudflareEnv } from '../cloudflare-env'; import { readJson } from './utils'; +import { PACKAGES } from './worker-config'; export function initAdminCron(env: CloudflareEnv): Response | null { if (!env.OBCF_D1) { @@ -74,39 +75,38 @@ export function initDbConnection(env: CloudflareEnv): void { } registerConnection('default', createD1Driver(env.OBCF_D1)); - registerModels([ - // Core models + + // Core models (always registered) + const models: any[] = [ Account, Authenticator, Session, VerificationToken, ScheduledTask, - // Multi-tenant models Organization, OrganizationMember, - // RBAC models Role, UserRole, Permission, - // Blog models - Post, - PostTag, - PostTagLink, - PostCategory, - PostSeries, - PostVersion, - OttablogPlugin, - OttablogTheme, - // Package models - Shortlink, - ReferralTracking, - // Brand engine models - BrandKit, - LayoutTemplate, - LayoutRouteMapping, // App models Todo, - ]); + ]; + + // Package models (only registered when enabled in ottabase.config.ts) + if (PACKAGES.ottablog) { + models.push(Post, PostTag, PostTagLink, PostCategory, PostSeries, PostVersion, OttablogPlugin, OttablogTheme); + } + if (PACKAGES.shortlinks) { + models.push(Shortlink); + } + if (PACKAGES.referrals) { + models.push(ReferralTracking); + } + if (PACKAGES.brandEngine) { + models.push(BrandKit, LayoutTemplate, LayoutRouteMapping); + } + + registerModels(models); initRLS(); } diff --git a/apps/ottabase-template-app-tanstack/worker/lib/rate-limiting.ts b/apps/ottabase-template-app-tanstack/worker/lib/rate-limiting.ts index 96d8b4800..368acb94b 100644 --- a/apps/ottabase-template-app-tanstack/worker/lib/rate-limiting.ts +++ b/apps/ottabase-template-app-tanstack/worker/lib/rate-limiting.ts @@ -1,8 +1,8 @@ +import { globalKey, userKey } from '@ottabase/cf/cache-keys'; import { createKVClient } from '@ottabase/cf/kv'; -import { userKey, globalKey } from '@ottabase/cf/cache-keys'; import { createRateLimitingClient } from '@ottabase/cf/rate-limiting'; import { errorResponse } from '@ottabase/utils/http-errors'; -import type { CloudflareEnv } from '../cloudflare-env'; +import type { CloudflareEnv } from '../../cloudflare-env'; /** * Build scoped rate limit key diff --git a/apps/ottabase-template-app-tanstack/worker/lib/worker-config.ts b/apps/ottabase-template-app-tanstack/worker/lib/worker-config.ts new file mode 100644 index 000000000..f1d08b355 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/lib/worker-config.ts @@ -0,0 +1,77 @@ +// ============================================================ +// WORKER CONFIG ACCESSOR +// ============================================================ +// Single import point for worker/server code to read config +// values that live in `ottabase.config.ts`. +// +// These are BUILD-TIME constants bundled into the Worker by +// wrangler. They are NOT read from env vars at runtime. +// +// Env vars should only be used for: +// - Secrets (API keys, OAuth secrets, JWT secrets) +// - Infra IDs (DB IDs, KV namespace IDs, account IDs) +// - Operational kill-switches (KILLSWITCH_* vars) +// - Deployment flags (MIGRATION_ALLOW_DESTRUCTIVE) +// +// Everything else goes here via ottabase.config.ts. +// ============================================================ + +import type { OttabaseUserConfig } from '@ottabase/config'; +import userConfig from '../../ottabase.config'; + +const cfg = userConfig as OttabaseUserConfig; + +// ── App identity ────────────────────────────────────────────── +/** Unique app ID used in brand kits, org provisioning, headers */ +export const APP_ID: string = cfg.appId; + +/** Human-readable app name */ +export const APP_NAME: string = cfg.appName; + +// ── Email (non-secret settings) ─────────────────────────────── +/** + * Default "From" address for outbound emails. + * The secret credentials (API key, SMTP password) still come from env vars. + */ +export const EMAIL_FROM_DEFAULT: string = cfg.email?.from ?? 'noreply@example.com'; + +/** + * AWS region for SES emails. + * Not a secret – it's a regional preference. + */ +export const EMAIL_SES_REGION: string = cfg.email?.sesRegion ?? 'us-east-1'; + +// ── Auth behaviour (non-secret flags) ───────────────────────── +/** + * Session cookie max-age in seconds. + * Default: 2_592_000 (30 days) + */ +export const AUTH_SESSION_MAX_AGE: number = cfg.features?.authBehavior?.sessionMaxAge ?? 30 * 24 * 60 * 60; + +/** + * When true, users must verify their email before they can log in. + * Default: false + */ +export const AUTH_REQUIRE_EMAIL_VERIFIED: boolean = cfg.features?.authBehavior?.requireEmailVerified ?? false; + +/** + * When true, credentials (email/password) login is disabled. + * Only OAuth providers will be available. + * Default: false + */ +export const AUTH_DISABLE_CREDENTIALS: boolean = cfg.features?.authBehavior?.disableCredentials ?? false; + +/** + * Enable verbose auth logging. + * Default: false + */ +export const AUTH_VERBOSE: boolean = cfg.features?.authBehavior?.verbose ?? false; + +// ── Package toggles ────────────────────────────────────────── +/** Built-in package enabled/disabled flags from ottabase.config.ts */ +export const PACKAGES = { + ottablog: cfg.packages?.ottablog ?? false, + shortlinks: cfg.packages?.shortlinks ?? false, + referrals: cfg.packages?.referrals ?? false, + brandEngine: cfg.packages?.brandEngine ?? false, +} as const; diff --git a/apps/ottabase-template-app-tanstack/worker/routes/auth.ts b/apps/ottabase-template-app-tanstack/worker/routes/auth.ts index a57890384..6b06290b5 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/auth.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/auth.ts @@ -12,10 +12,17 @@ import { isValidUrl } from '@ottabase/utils/url'; import type { CloudflareEnv } from '../../cloudflare-env'; import { processReferralAttribution } from '../../ottabase/helpers/referral-attribution'; import { registerAppEmailTemplates } from '../../src/email/templates'; -import { createVerificationToken, getAuthOptions, getUserLinkedAccounts, resolveMailer } from '../lib/auth-utils'; +import { + createVerificationToken, + getAuthOptions, + getUserLinkedAccounts, + resolveAuthBehavior, + resolveMailer, +} from '../lib/auth-utils'; import { enforceRateLimit } from '../lib/rate-limiting'; import { provisionDefaultOrganizationForUser } from '../lib/user-provisioning'; import { getClientIpAddress, isStrongPassword, normalizeEmail, readJson } from '../lib/utils'; +import { APP_ID } from '../lib/worker-config'; export interface AuthRouteContext { request: Request; @@ -320,10 +327,11 @@ export async function handlePasswordResetConfirm(context: AuthRouteContext): Pro await VerificationToken.deleteByIdentifierAndToken(identifier, token); if (env.OBCF_KV) { + const authBehavior = resolveAuthBehavior(env); try { const revokedAt = Math.floor(Date.now() / 1000); await env.OBCF_KV.put(userKey('auth', String(user.get('id')), 'revoked'), String(revokedAt), { - expirationTtl: Number(env.AUTH_SESSION_MAX_AGE) || 30 * 24 * 60 * 60, + expirationTtl: authBehavior.sessionMaxAge, }); } catch { // ignore revocation errors @@ -405,10 +413,11 @@ export async function handleUserProfile(context: AuthRouteContext): Promise(request); - const from = env.EMAIL_FROM || 'noreply@example.com'; + // Prefer env.EMAIL_FROM for backward compatibility; fall back to ottabase.config.ts default + const from = env.EMAIL_FROM || EMAIL_FROM_DEFAULT; const recipients = body.recipients || []; if (!recipients.length) { @@ -74,7 +79,8 @@ export async function handleEmailTest(context: EmailRouteContext): Promise Response; - corsHeaders: Record; +import type { ApiRouteContext } from './types'; + +// Dynamic import with fallback for user-zone custom routes. +// ottabase/ is user-owned (gitignored); ottabase.template/ is the tracked reference. +// If the user hasn't set up ottabase/ yet, fall back to a no-op handler. +let _handleCustomRoutes: ((ctx: ApiRouteContext) => Promise) | undefined; +async function handleCustomRoutes(ctx: ApiRouteContext): Promise { + if (_handleCustomRoutes === undefined) { + try { + const mod = await import('../../ottabase/config.routes'); + _handleCustomRoutes = mod.handleCustomRoutes; + } catch { + // User-zone file not found — fall back to no-op + _handleCustomRoutes = async () => null; + } + } + return _handleCustomRoutes(ctx); } +export type { ApiRouteContext } from './types'; type MethodHandler = (context: ApiRouteContext) => Promise | Response | null; export async function resolveApiRoute(context: ApiRouteContext): Promise { @@ -107,7 +116,17 @@ export async function resolveApiRoute(context: ApiRouteContext): Promise = { @@ -123,6 +142,7 @@ async function handleGetRoutes(context: ApiRouteContext): Promise Response; + /** Pre-built CORS headers object for use in custom Response constructors. */ + corsHeaders: Record; +} + +/** A function that handles an API route, returning a Response or null to skip. */ +export type RouteHandler = (context: ApiRouteContext) => Promise | Response | null; diff --git a/apps/ottabase-template-app-tanstack/wrangler.jsonc b/apps/ottabase-template-app-tanstack/wrangler.example.jsonc similarity index 96% rename from apps/ottabase-template-app-tanstack/wrangler.jsonc rename to apps/ottabase-template-app-tanstack/wrangler.example.jsonc index 1fc0d077c..9cde2f33d 100644 --- a/apps/ottabase-template-app-tanstack/wrangler.jsonc +++ b/apps/ottabase-template-app-tanstack/wrangler.example.jsonc @@ -1,7 +1,11 @@ { - // ─── SINGLE SOURCE OF TRUTH FOR SECRETS ─── - // This file drives CI secret detection. The env.production / env.preview sections - // contain ALL_CAPS_SNAKE_CASE placeholder values (e.g. "D1_DATABASE_ID") that are: + // ─── WRANGLER CONFIG — EXAMPLE / TEMPLATE ─── + // Copy to create your local config: cp wrangler.example.jsonc wrangler.jsonc + // wrangler.jsonc is gitignored — your local overrides are never committed. + // + // CI uses this .example file directly (via cloudflare-config.json → wranglerConfig). + // The env.production / env.preview sections contain ALL_CAPS_SNAKE_CASE placeholder + // values (e.g. "D1_DATABASE_ID") that are: // 1. Auto-detected by substitute-wrangler-secrets.py at deploy time // 2. Verified against GitHub Secrets (missing → CI fails early with clear error) // 3. Substituted into wrangler.production.jsonc / wrangler.preview.jsonc diff --git a/docs/cloudflare-features.md b/docs/cloudflare-features.md index d8c8dba3b..bfb71706c 100644 --- a/docs/cloudflare-features.md +++ b/docs/cloudflare-features.md @@ -44,7 +44,7 @@ Copy the returned `database_id` and update `wrangler.jsonc`: ```jsonc "d1_databases": [{ - "binding: "OBCF_D1", + "binding": "OBCF_D1", "database_name": "ottabase-db", "database_id": "YOUR_D1_DATABASE_ID" }] @@ -141,7 +141,7 @@ pnpm wrangler secret put CF_API_TOKEN ### Local Development (with HMR) ```bash -cd apps/ottabase-template-app +cd apps/ottabase-template-app-tanstack pnpm dev ``` @@ -429,7 +429,7 @@ export const runtime = 'edge'; export async function GET(request: Request) { const { env } = await getCloudflareContext(); const limiter = createRateLimitingClient({ - rateLimiter: env.RATE_LIMITER, + rateLimiter: env.OBCF_RATE_LIMITER, }); const ip = request.headers.get('cf-connecting-ip') || 'unknown'; @@ -540,7 +540,7 @@ Check `wrangler.jsonc` configuration and ensure bindings are created: ```bash pnpm wrangler d1 list -pnpm wrangler kv:namespace list +pnpm wrangler kv namespace list pnpm wrangler r2 bucket list ``` @@ -549,7 +549,7 @@ pnpm wrangler r2 bucket list Run type generation: ```bash -pnpm wrangler types +pnpm cf-typegen ``` ### Local development not working diff --git a/packages/brand-engine-react/README.md b/packages/brand-engine-react/README.md index 7f800589f..490babf6f 100644 --- a/packages/brand-engine-react/README.md +++ b/packages/brand-engine-react/README.md @@ -1,8 +1,17 @@ # @ottabase/brand-engine-react -React bindings for Ottabase Brand Engine. +React bindings for Ottabase Brand Engine — brand config provider, theme application, and layout resolution. -## Usage +## Install + +```bash +pnpm add @ottabase/brand-engine-react +``` + +## BrandProvider + useBrand + +`BrandProvider` fetches brand config from your API, applies the theme as CSS custom properties, and exposes it via +context. Wrap your app root with it: ```tsx import { BrandProvider, useBrand } from '@ottabase/brand-engine-react'; @@ -16,12 +25,90 @@ function App() { } function MyContent() { - const { config, isLoading } = useBrand(); + const { config, isLoading, error } = useBrand(); + if (isLoading) return
Loading...
; return config ?
{config.brandName}
: null; } ``` -## API +**`useBrand()` returns:** + +| Field | Type | Description | +| ----------- | ------------------- | ------------------------------- | +| `config` | `BrandConfig\|null` | Resolved brand config for route | +| `isLoading` | `boolean` | True during initial fetch | +| `error` | `Error\|null` | Fetch/parse error if any | +| `refresh` | `() => void` | Manually re-fetch brand config | + +**`BrandProvider` props:** + +- `apiEndpoint` — API path to fetch brand config from (e.g. `"/api/brand"`) +- `appId` — App identifier passed to the API +- `initialConfig` — Pre-fetched config for SSR/SSG (skips client fetch) +- `fallbackTheme` — Theme tokens used if the API fails (graceful degradation) +- `mode` — `'light'` | `'dark'` override (default: matches `prefers-color-scheme`) + +## LayoutResolver + +`LayoutResolver` reads route mappings from the brand config and renders the correct layout shell for the current path. +Must be inside `BrandProvider`. + +```tsx +import { LayoutResolver } from '@ottabase/brand-engine-react'; +import { tanstackRouterAdapter } from '@ottabase/brand-engine-react/routers'; +import type { LayoutComponentProps } from '@ottabase/brand-engine-react'; + +// Your layout shell — receives the resolved LayoutConfig +function AppShell({ config, children }: LayoutComponentProps) { + return ( +
+
...
+
{children}
+
+ ); +} + +function App() { + return ( + + + + + + ); +} +``` + +**Router adapters:** + +```typescript +// TanStack Router (built-in adapter) +import { tanstackRouterAdapter } from '@ottabase/brand-engine-react/routers'; + +// Custom adapter — any object with a usePathname hook +const myAdapter = { usePathname: () => useMyRouter().pathname }; +``` + +## BrandPathSync + +For SSR/server-driven pathname sync (e.g. Next.js App Router where router hooks aren't available in Server Components), +render `BrandPathSync` to push the current path into the brand context: + +```tsx +import { BrandPathSync } from '@ottabase/brand-engine-react'; + +// Render in your layout (client boundary): +; +``` + +## Architecture + +``` +@ottabase/brand-engine ← design tokens, CSS injection, API handlers (no React) +@ottabase/brand-engine-react ← BrandProvider, LayoutResolver, useBrand() +@ottabase/ottalayout ← LayoutConfig types, presets, route resolver, React slots +``` -- **BrandProvider** – Fetches brand config from API, applies theme, injects custom CSS -- **useBrand()** – Consumes brand config from context +`BrandProvider` fetches one `GET /api/brand` and resolves the per-route config client-side using the route mappings in +the response. See [`@ottabase/brand-engine`](../brand-engine/README.md) for server-side API handlers and token +architecture. diff --git a/packages/cf-realtime/README.md b/packages/cf-realtime/README.md index ed61e0937..51621898c 100644 --- a/packages/cf-realtime/README.md +++ b/packages/cf-realtime/README.md @@ -5,14 +5,14 @@ applications with WebSocket support, offline message queuing, and TypeScript-fir ## Features -- 🚀 **Real-time WebSocket connections** with auto-reconnect -- 📡 **Channel-based pub/sub** (subscribe to `org-1201`, `user-22`, `system`, etc.) -- 💾 **Offline message queuing** - messages are delivered when clients come back online -- 🔒 **TypeScript-first** with full type safety -- ⚡ **Powered by Cloudflare Actors** - built on Durable Objects for global scale -- 🎯 **Simple API** - Pusher-like interface for easy migration -- 🔄 **Server-side broadcasting** to all online subscribers -- 📊 **Built-in stats and monitoring** +- **Real-time WebSocket connections** with auto-reconnect +- **Channel-based pub/sub** (subscribe to `org-1201`, `user-22`, `system`, etc.) +- **Offline message queuing** — messages are delivered when clients reconnect +- **TypeScript-first** with full type safety +- **Powered by Cloudflare Durable Objects** for global scale +- **Pusher-like API** for easy migration +- **Server-side broadcasting** to all online subscribers +- **Built-in stats and monitoring** ## Installation @@ -24,72 +24,129 @@ pnpm add @ottabase/cf-realtime ### 1. Server Setup (Cloudflare Worker) -Create a Cloudflare Worker with the RealtimeActor: +Export `RealtimeActor` from your worker entry point so Cloudflare registers the Durable Object class: ```typescript -// worker.ts -import { RealtimeActor, RealtimeBroadcaster } from '@ottabase/cf-realtime/server'; -import { handler } from '@cloudflare/actors'; +// cloudflare-worker.ts +import { RealtimeBroadcaster } from '@ottabase/cf-realtime/server'; -export { RealtimeActor }; -export default handler(RealtimeActor); +// Re-export the Durable Object class (required by Cloudflare) +export { RealtimeActor } from '@ottabase/cf-realtime/server'; -// Environment bindings -export interface Env { - REALTIME: DurableObjectNamespace; +interface CloudflareEnv { + OBCF_REALTIME: DurableObjectNamespace; + API_KEYS: KVNamespace; } -// Example: Handle requests export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: CloudflareEnv): Promise { const url = new URL(request.url); - // WebSocket upgrade - connect to the Actor + // WebSocket upgrade — forward to the Durable Object if (url.pathname === '/realtime' && request.headers.get('Upgrade') === 'websocket') { const id = env.OBCF_REALTIME.idFromName('global'); const stub = env.OBCF_REALTIME.get(id); return stub.fetch(request); } - // REST API endpoint to broadcast messages + // Broadcast endpoint — requires authentication + channel-level authorization if (url.pathname === '/api/broadcast' && request.method === 'POST') { + const publisher = await validatePublisher(request, env); + if (!publisher) { + return new Response('Unauthorized', { status: 401 }); + } + + const body = await request.json(); + const channels = Array.isArray(body.channels) ? body.channels : []; + if (channels.length === 0) { + return new Response('channels array is required', { status: 400 }); + } + + const event = typeof body.event === 'string' ? body.event.trim() : ''; + if (!event) { + return new Response('event is required', { status: 400 }); + } + + const allowedChannels = new Set(publisher.allowedChannels); + + // Enforce per-channel publish permissions derived from the caller's session/API key + if (channels.some((channel) => !allowedChannels.has(channel))) { + return new Response('Forbidden', { status: 403 }); + } + const broadcaster = new RealtimeBroadcaster(env.OBCF_REALTIME); - const body = await request.json(); const result = await broadcaster.broadcast({ - channels: body.channels, - event: body.event, + channels, + event, data: body.data, - persistForOffline: body.persistForOffline || false, + persistForOffline: body.persistForOffline ?? false, }); - return new Response(JSON.stringify(result), { - headers: { 'Content-Type': 'application/json' }, - }); + return Response.json(result); } return new Response('Not Found', { status: 404 }); }, }; + +type Publisher = { allowedChannels: string[] }; + +// Validate an API key/session and return which channels the caller may publish to +async function validatePublisher(request: Request, env: CloudflareEnv): Promise { + const authHeader = request.headers.get('Authorization'); + if (!authHeader) return null; + + const trimmed = authHeader.trim(); + const [scheme, ...rest] = trimmed.split(/\s+/); + if (!scheme || scheme.toLowerCase() !== 'bearer') return null; + + const token = rest.join(' '); + if (!token) return null; + + // Example: look up an API key that encodes allowed channels (KV/DB/custom auth) + // Return null to reject callers without publish permissions. + const apiKey = await env.API_KEYS?.get(token, 'json'); + return apiKey ?? null; +} ``` ### 2. Wrangler Configuration -```toml -# wrangler.toml -name = "cf-realtime-worker" -main = "src/worker.ts" -compatibility_date = "2024-01-01" +```jsonc +// wrangler.jsonc +{ + "durable_objects": { + "bindings": [{ "name": "OBCF_REALTIME", "class_name": "RealtimeActor" }], + }, + "kv_namespaces": [{ "binding": "API_KEYS", "id": "your-api-keys-kv" }], + "migrations": [{ "tag": "v1", "new_classes": ["RealtimeActor"] }], +} +``` + +Provision a KV namespace for publisher API keys (each value should include `allowedChannels`): + +```bash +wrangler kv namespace create API_KEYS +wrangler kv key put --binding=API_KEYS "server-1" '{"allowedChannels":["org-1201","system"]}' +``` -[[durable_objects.bindings]] -name = "OBCF_REALTIME" -class_name = "RealtimeActor" +Then call the broadcast endpoint from your backend with the API key: -[[migrations]] -tag = "v1" -new_classes = ["RealtimeActor"] +```typescript +await fetch('https://your-worker.workers.dev/api/broadcast', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.REALTIME_PUBLISH_KEY}`, + }, + body: JSON.stringify({ channels: ['org-1201'], event: 'update', data: {} }), +}); ``` +> **Never expose `/api/broadcast` without authentication and channel-level authorization.** Anyone who can reach the +> endpoint could inject arbitrary events into any channel unless you enforce publish scopes. + ### 3. Client Usage (Browser/Node.js) ```typescript @@ -397,25 +454,6 @@ await broadcaster.send(`document:${docId}`, 'update', { }); ``` -## Deployment - -1. **Install Wrangler CLI**: - - ```bash - pnpm add -g wrangler - ``` - -2. **Login to Cloudflare**: - - ```bash - wrangler login - ``` - -3. **Deploy**: - ```bash - wrangler deploy - ``` - ## Migration from Pusher cf-realtime provides a Pusher-like API, making migration straightforward: @@ -435,25 +473,6 @@ cf-realtime provides a Pusher-like API, making migration straightforward: - **Auto-scaling**: Scales automatically with your traffic - **Low Latency**: Messages delivered in milliseconds -## Pricing - -Based on Cloudflare Durable Objects pricing: - -- **Requests**: $0.15 per million requests -- **Duration**: $12.50 per million GB-seconds -- **WebSocket Messages**: Included in request pricing - -See [Cloudflare Pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/) for details. - ## License MIT - -## Contributing - -Contributions welcome! Please open an issue or PR. - -## Support - -- GitHub Issues: [Report a bug](https://github.com/thinkdj/ottabase/issues) -- Documentation: [Cloudflare Actors](https://github.com/cloudflare/actors) diff --git a/packages/cf-realtime/_GUIDE.md b/packages/cf-realtime/_GUIDE.md index 37a24cc3f..5bf2a532b 100644 --- a/packages/cf-realtime/_GUIDE.md +++ b/packages/cf-realtime/_GUIDE.md @@ -51,19 +51,15 @@ Create `src/worker.ts`: ```typescript import { RealtimeActor, RealtimeBroadcaster } from '@ottabase/cf-realtime/server'; -import { handler } from '@cloudflare/actors'; -// Export Actor +// Re-export the Durable Object class (required by Cloudflare) export { RealtimeActor }; -// Export default handler -export default handler(RealtimeActor); - export interface Env { - REALTIME: DurableObjectNamespace; + OBCF_REALTIME: DurableObjectNamespace; + BROADCAST_SECRET: string; } -// Main worker export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); @@ -75,8 +71,13 @@ export default { return stub.fetch(request); } - // Broadcast API + // Broadcast API — server-to-server only, requires a shared secret if (url.pathname === '/api/broadcast' && request.method === 'POST') { + const token = request.headers.get('Authorization'); + if (!token || token !== `Bearer ${env.BROADCAST_SECRET}`) { + return new Response('Unauthorized', { status: 401 }); + } + const broadcaster = new RealtimeBroadcaster(env.OBCF_REALTIME); const body = await request.json(); @@ -115,6 +116,12 @@ tag = "v1" new_classes = ["RealtimeActor"] ``` +Set the broadcast secret: + +```bash +wrangler secret put BROADCAST_SECRET +``` + ## Step 5: Login and Deploy ```bash @@ -159,9 +166,13 @@ In your backend API (Node.js, Next.js API routes, etc.): ```typescript // Send a broadcast to all subscribers +// Use a shared BROADCAST_SECRET (set via `wrangler secret put BROADCAST_SECRET`) await fetch('https://my-realtime-worker.your-subdomain.workers.dev/api/broadcast', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.BROADCAST_SECRET}`, + }, body: JSON.stringify({ channels: ['org-1201'], event: 'notification', diff --git a/packages/cf-realtime/examples/worker.example.ts b/packages/cf-realtime/examples/worker.example.ts index 9eca02d17..53449a4ce 100644 --- a/packages/cf-realtime/examples/worker.example.ts +++ b/packages/cf-realtime/examples/worker.example.ts @@ -18,7 +18,7 @@ export default handler(RealtimeActor); * Environment bindings */ export interface Env { - REALTIME: DurableObjectNamespace; + OBCF_REALTIME: DurableObjectNamespace; // Add other bindings like KV, R2, etc. as needed } @@ -110,7 +110,8 @@ async function handleBroadcast(request: Request, env: Env): Promise { }); } - if (!body.event) { + const event = typeof body.event === 'string' ? body.event.trim() : ''; + if (!event) { return new Response(JSON.stringify({ error: 'event is required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, @@ -121,7 +122,7 @@ async function handleBroadcast(request: Request, env: Env): Promise { const result = await broadcaster.broadcast({ channels: body.channels, - event: body.event, + event, data: body.data, persistForOffline: body.persistForOffline || false, metadata: body.metadata, diff --git a/packages/cf-realtime/examples/wrangler.toml b/packages/cf-realtime/examples/wrangler.toml index e7602bf9a..5f2a87916 100644 --- a/packages/cf-realtime/examples/wrangler.toml +++ b/packages/cf-realtime/examples/wrangler.toml @@ -13,7 +13,7 @@ compatibility_date = "2024-01-01" # Durable Objects binding [[durable_objects.bindings]] -name = "REALTIME" +name = "OBCF_REALTIME" class_name = "RealtimeActor" # Migration to create the Durable Object class diff --git a/packages/cf/README.md b/packages/cf/README.md index ca14640bb..ded5df885 100644 --- a/packages/cf/README.md +++ b/packages/cf/README.md @@ -24,36 +24,9 @@ pnpm add @ottabase/cf ## Usage -### Prisma D1 (Recommended) - -Use Prisma ORM with Cloudflare D1 for type-safe database access: - -```typescript -import { createPrismaD1Client, getPrismaD1Client } from '@ottabase/cf/d1-prisma'; - -export default { - async fetch(request: Request, env: Env) { - // Create a new client - const prisma = createPrismaD1Client(env.DB); - - // Or use cached client (recommended for multiple queries) - const prisma = getPrismaD1Client(env.DB); - - // Type-safe queries with Prisma - const users = await prisma.user.findMany({ - where: { email: { contains: '@example.com' } }, - include: { posts: true }, - }); - - return Response.json(users); - }, -}; -``` - -**Requirements:** - -- `@prisma/client` and `@prisma/adapter-d1` as peer dependencies -- Generated Prisma client (run `pnpm db:generate`) +> **Note:** For database access in Ottabase apps, use `@ottabase/db/drizzle-d1` with `@ottabase/ottaorm` (the standard +> path). The raw D1 client and other helpers in this package are for direct Cloudflare binding access when OttaORM is +> not being used. ### D1 Raw SQL diff --git a/packages/config/README.md b/packages/config/README.md index bcf960831..581558eb5 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -4,207 +4,96 @@ Shared configuration utilities for Ottabase applications with environment variab ## Features -- **Environment Variable Support**: Automatically reads from set environment variables -- **Type-Safe Configuration**: Full TypeScript support with strict typing -- **Flexible Defaults**: Override defaults per app or use global defaults -- **Storage Utilities**: Helper functions for consistent storage key naming -- **Multiple UI Framework Support**: Built-in support for Mantine, Shadcn, Chakra, and MUI +- **`defineOttabaseConfig`** – Single user config file with full TypeScript autocomplete +- **Environment Variable Support** – Automatically reads from set environment variables +- **Type-Safe Configuration** – Full TypeScript support with strict typing +- **Flexible Defaults** – Override defaults per app or use global defaults +- **Storage Utilities** – Helper functions for consistent storage key naming -## Installation +## Usage -```bash -pnpm add @ottabase/config -``` +### `defineOttabaseConfig` (recommended) -## Usage +The recommended pattern for apps in this monorepo. Create a single `ottabase.config.ts` at your app root and import it +everywhere. + +```typescript +// ottabase.config.ts ← single user-owned config file +import { defineOttabaseConfig } from '@ottabase/config'; + +export default defineOttabaseConfig({ + appId: 'my-saas-app', + appName: 'My SaaS App', + + meta: { + description: 'Built on Ottabase', + author: 'Your Name', + companyName: 'My Company', + }, + + // Toggle built-in packages + packages: { + ottablog: true, + shortlinks: true, + referrals: true, + brandEngine: false, + }, + + // Enable custom / premium packages by key only (no schemas here) + // Schemas stay in ottabase/config.migrations.ts + customPackages: { + // premiumFeature: true, + }, + + features: { + referrals: { enabled: true, trackClicks: true, expiryDays: 90 }, + spotlight: { enabled: true, shortcuts: ['/'] }, + }, -### Basic Usage + theme: { colorDefault: 'tremorBlue' }, + storage: { prefix: 'my-app' }, +}); +``` + +### `createAppConfig` (low-level) ```typescript import { createAppConfig } from '@ottabase/config'; -// Create config with defaults (single-app mode) const config = createAppConfig({ appName: 'My Awesome App', appId: 'my-awesome-app', }); -// For multi-app database sharing, enable the feature flag -const multiAppConfig = createAppConfig({ - appName: 'My Awesome App', - appId: 'my-awesome-app', - defaults: { - features: { scopeByAppId: true }, - }, -}); -// IMP NOTE: Use the same `appId` for multi-app database sharing of Core Models - -console.log(config.features.scopeByAppId); // false (default) console.log(config.appId); // "my-awesome-app" ``` -### With Custom Defaults +### `userConfigToOptions` + +Bridge between `OttabaseUserConfig` and `createAppConfig` options: ```typescript -import { createAppConfig } from '@ottabase/config'; +import { createAppConfig, userConfigToOptions } from '@ottabase/config'; +import userConfig from '../ottabase.config'; -const config = createAppConfig({ - appName: 'My App', - defaults: { - meta: { - author: 'Custom Author', - description: 'Custom description', - }, - uiFramework: 'shadcn', - features: { - darkMode: false, - analytics: true, - }, - }, -}); +export const appConfig = createAppConfig(userConfigToOptions(userConfig)); ``` ### Environment Variables -The package reads environment variables with optional prefix (default: no prefix): - ```bash -# App Identity -APP_ID="my-app" # Unique identifier for multi-app database sharing -SCOPE_BY_APP_ID="true" # Enable appId scoping for DB queries - -# App Meta APP_NAME="My App" -APP_TITLE="My App Title" APP_DESCRIPTION="App description" -APP_LOGO_URL="/custom-logo.png" APP_AUTHOR="Your Name" -APP_KEYWORDS="react,tanstack,typescript" -APP_ROBOTS="index,follow" -APP_COPYRIGHT_TEXT="© 2024 Your Company" -APP_COMPANY_NAME="Your Company" - -# UI Framework -UI_FRAMEWORK="mantine" # mantine | shadcn | chakra | mui - -# Storage STORAGE_PREFIX="my-app" +UI_FRAMEWORK="mantine" # mantine | shadcn | chakra | mui ``` -> **Note**: Use `envPrefix` option if you need prefixed vars (e.g., for Vite: `envPrefix: "VITE_"`). - ### Storage Utilities ```typescript import { createAppConfig, createStorageKey, STORAGE_KEYS } from '@ottabase/config'; const config = createAppConfig({ appName: 'My App' }); - -// Create prefixed storage keys -const themeKey = createStorageKey(config, STORAGE_KEYS.THEME); -// Result: "my-app-theme" - -const customKey = createStorageKey(config, 'user-settings'); -// Result: "my-app-user-settings" - -// Use in localStorage -localStorage.setItem(themeKey, 'dark'); -``` - -### TypeScript Types - -```typescript -import type { AppConfig, AppMeta, SupportedUIFramework } from '@ottabase/config'; - -// Use the types in your app -function useAppConfig(): AppConfig { - return createAppConfig({ appName: 'My App' }); -} - -// Type-safe UI framework -const framework: SupportedUIFramework = 'mantine'; -``` - -## Configuration Structure - -```typescript -interface AppConfig { - meta: { - appName: string; - logoUrl: string; - title: string; - author: string; - description: string; - keywords: string; - robots: string; - copyrightText: string; - companyName: string; - }; - uiFramework: 'mantine' | 'shadcn' | 'chakra' | 'mui'; - features: { - darkMode: boolean; - analytics: boolean; - notifications: boolean; - }; - api: { - baseUrl: string; - timeout: number; - }; - storage: { - prefix: string; - }; -} -``` - -## Examples - -### Next.js App Router - -```typescript -// app/layout.tsx -import { createAppConfig } from '@ottabase/config'; - -const config = createAppConfig({ - appName: 'My Next.js App', -}); - -export const metadata = { - title: config.meta.title, - description: config.meta.description, - keywords: config.meta.keywords, - robots: config.meta.robots, -}; - -export default function RootLayout({ children }) { - return ( - - {children} - - ); -} -``` - -### Custom Hook - -```typescript -// hooks/useAppConfig.ts -import { createAppConfig } from '@ottabase/config'; -import { useMemo } from 'react'; - -export function useAppConfig() { - return useMemo( - () => - createAppConfig({ - appName: 'My App', - defaults: { - features: { - darkMode: true, - analytics: false, - notifications: true, - }, - }, - }), - [], - ); -} +const themeKey = createStorageKey(config, STORAGE_KEYS.THEME); // "my-app-theme" ``` diff --git a/packages/config/src/__tests__/config.test.ts b/packages/config/src/__tests__/config.test.ts index 35051aa46..dda627b77 100644 --- a/packages/config/src/__tests__/config.test.ts +++ b/packages/config/src/__tests__/config.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { createAppConfig, defineOttabaseConfig, userConfigToOptions } from '../index'; +import { validateOttabaseConfig } from '../createAppConfig'; import * as config from '../index'; describe('Configuration Utilities', () => { @@ -35,4 +37,304 @@ describe('Configuration Utilities', () => { expect(config).toBeDefined(); }); }); + + describe('defineOttabaseConfig', () => { + it('should be a pass-through identity function', () => { + const userConfig = defineOttabaseConfig({ + appId: 'test-app', + appName: 'Test App', + }); + expect(userConfig.appId).toBe('test-app'); + expect(userConfig.appName).toBe('Test App'); + }); + + it('should preserve all user config fields', () => { + const userConfig = defineOttabaseConfig({ + appId: 'my-saas', + appName: 'My SaaS', + packages: { ottablog: true, shortlinks: false }, + features: { + referrals: { enabled: true, trackClicks: true, expiryDays: 90 }, + spotlight: { enabled: true, shortcuts: ['/'] }, + }, + }); + expect(userConfig.packages?.ottablog).toBe(true); + expect(userConfig.packages?.shortlinks).toBe(false); + expect(userConfig.features?.referrals?.enabled).toBe(true); + expect(userConfig.features?.spotlight?.shortcuts).toEqual(['/']); + }); + + it('should accept email config (non-secret)', () => { + const userConfig = defineOttabaseConfig({ + appId: 'email-test', + appName: 'Email Test', + email: { from: 'noreply@my-app.com', sesRegion: 'eu-west-1' }, + }); + expect(userConfig.email?.from).toBe('noreply@my-app.com'); + expect(userConfig.email?.sesRegion).toBe('eu-west-1'); + }); + + it('should accept authBehavior flags (non-secret)', () => { + const userConfig = defineOttabaseConfig({ + appId: 'auth-test', + appName: 'Auth Test', + features: { + authBehavior: { + sessionMaxAge: 7 * 24 * 3600, + requireEmailVerified: true, + disableCredentials: false, + verbose: false, + }, + }, + }); + expect(userConfig.features?.authBehavior?.sessionMaxAge).toBe(7 * 24 * 3600); + expect(userConfig.features?.authBehavior?.requireEmailVerified).toBe(true); + }); + }); + + describe('userConfigToOptions', () => { + it('should convert OttabaseUserConfig to ConfigOptions', () => { + const userConfig = defineOttabaseConfig({ + appId: 'my-app', + appName: 'My App', + meta: { description: 'A test app', author: 'Tester' }, + features: { referrals: { enabled: false, trackClicks: false, expiryDays: 30 } }, + }); + const options = userConfigToOptions(userConfig); + expect(options.appId).toBe('my-app'); + expect(options.appName).toBe('My App'); + expect(options.defaults?.meta?.description).toBe('A test app'); + expect(options.defaults?.features?.referrals?.enabled).toBe(false); + }); + + it('should produce a valid AppConfig via createAppConfig', () => { + const userConfig = defineOttabaseConfig({ + appId: 'verify-app', + appName: 'Verify App', + storage: { prefix: 'verify' }, + }); + const appConfig = createAppConfig(userConfigToOptions(userConfig)); + expect(appConfig.appId).toBe('verify-app'); + expect(appConfig.meta.appName).toBe('Verify App'); + expect(appConfig.storage.prefix).toBe('verify'); + }); + }); + + describe('createAppConfig – email defaults and env overrides', () => { + it('should use hardcoded defaults when no config or env vars are provided', () => { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.email.from).toBe('noreply@example.com'); + expect(appConfig.email.sesRegion).toBe('us-east-1'); + }); + + it('should use values from defaults when provided via userConfig', () => { + const userConfig = defineOttabaseConfig({ + appId: 'email-app', + appName: 'Email App', + email: { from: 'hello@myapp.com', sesRegion: 'eu-west-1' }, + }); + const appConfig = createAppConfig(userConfigToOptions(userConfig)); + expect(appConfig.email.from).toBe('hello@myapp.com'); + expect(appConfig.email.sesRegion).toBe('eu-west-1'); + }); + + it('should override email.from with EMAIL_FROM env var', () => { + process.env['EMAIL_FROM'] = 'override@env.com'; + try { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.email.from).toBe('override@env.com'); + } finally { + delete process.env['EMAIL_FROM']; + } + }); + + it('should override email.sesRegion with AWS_REGION env var', () => { + process.env['AWS_REGION'] = 'ap-southeast-1'; + try { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.email.sesRegion).toBe('ap-southeast-1'); + } finally { + delete process.env['AWS_REGION']; + } + }); + }); + + describe('createAppConfig – authBehavior defaults and env overrides', () => { + it('should use hardcoded defaults when no config or env vars are provided', () => { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.features.authBehavior.sessionMaxAge).toBe(30 * 24 * 60 * 60); + expect(appConfig.features.authBehavior.requireEmailVerified).toBe(false); + expect(appConfig.features.authBehavior.disableCredentials).toBe(false); + expect(appConfig.features.authBehavior.verbose).toBe(false); + }); + + it('should use values from defaults when provided via userConfig', () => { + const userConfig = defineOttabaseConfig({ + appId: 'auth-app', + appName: 'Auth App', + features: { + authBehavior: { + sessionMaxAge: 7 * 24 * 3600, + requireEmailVerified: true, + disableCredentials: true, + verbose: true, + }, + }, + }); + const appConfig = createAppConfig(userConfigToOptions(userConfig)); + expect(appConfig.features.authBehavior.sessionMaxAge).toBe(7 * 24 * 3600); + expect(appConfig.features.authBehavior.requireEmailVerified).toBe(true); + expect(appConfig.features.authBehavior.disableCredentials).toBe(true); + expect(appConfig.features.authBehavior.verbose).toBe(true); + }); + + it('should override sessionMaxAge with AUTH_SESSION_MAX_AGE env var', () => { + process.env['AUTH_SESSION_MAX_AGE'] = '3600'; + try { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.features.authBehavior.sessionMaxAge).toBe(3600); + } finally { + delete process.env['AUTH_SESSION_MAX_AGE']; + } + }); + + it('should override requireEmailVerified with AUTH_REQUIRE_EMAIL_VERIFIED env var', () => { + process.env['AUTH_REQUIRE_EMAIL_VERIFIED'] = 'true'; + try { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.features.authBehavior.requireEmailVerified).toBe(true); + } finally { + delete process.env['AUTH_REQUIRE_EMAIL_VERIFIED']; + } + }); + + it('should override disableCredentials and verbose with env vars', () => { + process.env['AUTH_DISABLE_CREDENTIALS'] = 'true'; + process.env['AUTH_VERBOSE'] = 'true'; + try { + const appConfig = createAppConfig({ appId: 'test', appName: 'Test' }); + expect(appConfig.features.authBehavior.disableCredentials).toBe(true); + expect(appConfig.features.authBehavior.verbose).toBe(true); + } finally { + delete process.env['AUTH_DISABLE_CREDENTIALS']; + delete process.env['AUTH_VERBOSE']; + } + }); + }); + + describe('validateOttabaseConfig', () => { + it('should throw if appId is missing', () => { + expect(() => validateOttabaseConfig({ appName: 'Test' })).toThrow('"appId" is required'); + }); + + it('should throw if appName is missing', () => { + expect(() => validateOttabaseConfig({ appId: 'test' })).toThrow('"appName" is required'); + }); + + it('should throw if appId is empty string', () => { + expect(() => validateOttabaseConfig({ appId: '', appName: 'Test' })).toThrow('"appId" is required'); + }); + + it('should return no warnings for a valid config', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + packages: { ottablog: true }, + features: { authBehavior: { sessionMaxAge: 3600 } }, + email: { from: 'hi@test.com' }, + }); + expect(warnings).toEqual([]); + }); + + it('should warn on unknown top-level keys (typos)', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + packges: { ottablog: true }, // typo + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Unknown key "packges"/); + }); + + it('should warn on unknown nested keys in packages', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + packages: { ottablog: true, shortlink: true }, // typo: should be "shortlinks" + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Unknown key "packages\.shortlink"/); + }); + + it('should warn on unknown nested keys in features', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + features: { authBehaviour: { verbose: true } }, // typo: should be "authBehavior" + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Unknown key "features\.authBehaviour"/); + }); + + it('should warn on unknown deep-nested keys in features.authBehavior', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + features: { authBehavior: { sessionmaxage: 3600 } }, // typo: should be "sessionMaxAge" + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Unknown key "features\.authBehavior\.sessionmaxage"/); + }); + + it('should warn on unknown email keys', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + email: { from: 'hi@test.com', region: 'us-east-1' }, // typo: should be "sesRegion" + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/Unknown key "email\.region"/); + }); + + it('should collect multiple warnings', () => { + const warnings = validateOttabaseConfig({ + appId: 'test', + appName: 'Test', + packges: {}, // typo + fetures: {}, // typo + }); + expect(warnings).toHaveLength(2); + }); + }); + + describe('defineOttabaseConfig – validation integration', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should log warnings for unknown keys', () => { + defineOttabaseConfig({ + appId: 'test', + appName: 'Test', + packges: { ottablog: true }, // typo + } as any); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown key "packges"')); + }); + + it('should throw on missing required fields', () => { + expect(() => defineOttabaseConfig({ appId: '', appName: 'Test' } as any)).toThrow('"appId" is required'); + }); + + it('should still return the config object on success', () => { + const cfg = defineOttabaseConfig({ appId: 'ok', appName: 'OK' }); + expect(cfg.appId).toBe('ok'); + expect(cfg.appName).toBe('OK'); + }); + }); }); diff --git a/packages/config/src/createAppConfig.ts b/packages/config/src/createAppConfig.ts index 8085eed2e..4490c9768 100644 --- a/packages/config/src/createAppConfig.ts +++ b/packages/config/src/createAppConfig.ts @@ -1,4 +1,5 @@ -import { AppConfig, AppMeta, ConfigOptions, SupportedUIFramework, ThemeColors } from './types'; +import { AppConfig, AppMeta, ConfigOptions, OttabaseUserConfig, SupportedUIFramework, ThemeColors } from './types'; +import { DEFAULT_AUTH_BEHAVIOR_CONFIG, DEFAULT_EMAIL_CONFIG } from './defaults'; /** * Creates app configuration by merging environment variables with defaults @@ -156,6 +157,31 @@ export function createAppConfig(options: ConfigOptions = {}): AppConfig { trackClicks: getBoolEnv('REFERRALS_TRACK_CLICKS', defaults.features?.referrals?.trackClicks ?? true), expiryDays: getNumberEnv('REFERRALS_EXPIRY_DAYS', defaults.features?.referrals?.expiryDays ?? 30), }, + authBehavior: { + sessionMaxAge: getNumberEnv( + 'AUTH_SESSION_MAX_AGE', + defaults.features?.authBehavior?.sessionMaxAge ?? DEFAULT_AUTH_BEHAVIOR_CONFIG.sessionMaxAge, + ), + requireEmailVerified: getBoolEnv( + 'AUTH_REQUIRE_EMAIL_VERIFIED', + defaults.features?.authBehavior?.requireEmailVerified ?? + DEFAULT_AUTH_BEHAVIOR_CONFIG.requireEmailVerified, + ), + disableCredentials: getBoolEnv( + 'AUTH_DISABLE_CREDENTIALS', + defaults.features?.authBehavior?.disableCredentials ?? + DEFAULT_AUTH_BEHAVIOR_CONFIG.disableCredentials, + ), + verbose: getBoolEnv( + 'AUTH_VERBOSE', + defaults.features?.authBehavior?.verbose ?? DEFAULT_AUTH_BEHAVIOR_CONFIG.verbose, + ), + }, + }, + + email: { + from: getEnv('EMAIL_FROM', defaults.email?.from ?? DEFAULT_EMAIL_CONFIG.from), + sesRegion: getEnv('AWS_REGION', defaults.email?.sesRegion ?? DEFAULT_EMAIL_CONFIG.sesRegion), }, model: { @@ -219,3 +245,146 @@ export function createThemeColors(colors: ThemeColors = {}): ThemeColors { return { ...defaultColors, ...colors }; } + +// ── Config validation ──────────────────────────────────────────────────────── + +/** Known keys at each level of OttabaseUserConfig. `true` = open-ended (no nested check). */ +const VALID_TOP_KEYS = new Set([ + 'appId', + 'appName', + 'meta', + 'ui', + 'theme', + 'storage', + 'packages', + 'customPackages', + 'features', + 'email', +]); + +const VALID_NESTED_KEYS: Record> = { + meta: new Set(['description', 'author', 'keywords', 'robots', 'copyrightText', 'companyName', 'logoUrl', 'title']), + ui: new Set(['preventFOUC', 'preventFOUCInsideIframe', 'debounceMs', 'layout', 'enforceGoogleFonts']), + 'ui.layout': new Set(['minWidth', 'maxWidth']), + theme: new Set(['colorDefault', 'colors']), + storage: new Set(['prefix']), + packages: new Set(['ottablog', 'shortlinks', 'referrals', 'brandEngine']), + features: new Set(['referrals', 'spotlight', 'pagination', 'crudHub', 'auth', 'authBehavior']), + 'features.referrals': new Set(['enabled', 'trackClicks', 'expiryDays']), + 'features.spotlight': new Set(['enabled', 'shortcuts']), + 'features.pagination': new Set(['defaultPageSize', 'maxPageSize', 'sizeOptions']), + 'features.crudHub': new Set(['apiBaseUrl', 'urlBase', 'urlBaseListing']), + 'features.auth': new Set(['signInUrl', 'signOutUrl', 'preLaunchOptIn']), + 'features.authBehavior': new Set(['sessionMaxAge', 'requireEmailVerified', 'disableCredentials', 'verbose']), + email: new Set(['from', 'sesRegion']), +}; + +function checkUnknownKeys(obj: Record, validKeys: Set, path: string): string[] { + const warnings: string[] = []; + for (const key of Object.keys(obj)) { + if (!validKeys.has(key)) { + warnings.push(`Unknown key "${path ? path + '.' : ''}${key}" — possible typo (will be ignored)`); + } + } + return warnings; +} + +/** + * Validates an OttabaseUserConfig at runtime. + * Throws on missing required fields; returns warnings for unknown keys. + */ +export function validateOttabaseConfig(config: Record): string[] { + const warnings: string[] = []; + + // ── Required fields ────────────────────────────────────── + if (typeof config.appId !== 'string' || config.appId.trim() === '') { + throw new Error('ottabase.config.ts: "appId" is required and must be a non-empty string'); + } + if (typeof config.appName !== 'string' || config.appName.trim() === '') { + throw new Error('ottabase.config.ts: "appName" is required and must be a non-empty string'); + } + + // ── Top-level unknown keys ─────────────────────────────── + warnings.push(...checkUnknownKeys(config, VALID_TOP_KEYS, '')); + + // ── Nested unknown keys (2 levels deep) ────────────────── + for (const [topKey, value] of Object.entries(config)) { + if (value && typeof value === 'object' && !Array.isArray(value) && VALID_NESTED_KEYS[topKey]) { + warnings.push(...checkUnknownKeys(value as Record, VALID_NESTED_KEYS[topKey], topKey)); + + // Go one more level for features.* and ui.layout + for (const [nestedKey, nestedValue] of Object.entries(value as Record)) { + const deepPath = `${topKey}.${nestedKey}`; + if ( + nestedValue && + typeof nestedValue === 'object' && + !Array.isArray(nestedValue) && + VALID_NESTED_KEYS[deepPath] + ) { + warnings.push( + ...checkUnknownKeys( + nestedValue as Record, + VALID_NESTED_KEYS[deepPath], + deepPath, + ), + ); + } + } + } + } + + return warnings; +} + +/** + * Helper for `ottabase.config.ts`. + * Provides TypeScript autocomplete and **runtime validation** — throws on + * missing required fields and warns on unrecognised keys (likely typos). + * + * @example + * ```ts + * // ottabase.config.ts + * import { defineOttabaseConfig } from '@ottabase/config'; + * + * export default defineOttabaseConfig({ + * appId: 'my-app', + * appName: 'My SaaS App', + * packages: { ottablog: true, shortlinks: true, referrals: true }, + * }); + * ``` + */ +export function defineOttabaseConfig(config: T): T { + const warnings = validateOttabaseConfig(config as unknown as Record); + for (const w of warnings) { + console.warn(`[ottabase] ${w}`); + } + return config; +} + +/** + * Converts an `OttabaseUserConfig` into `ConfigOptions` accepted by `createAppConfig`. + * Use this inside `src/ottabase/config/app.config.ts` to bridge the two. + */ +export function userConfigToOptions(userConfig: OttabaseUserConfig): ConfigOptions { + return { + appId: userConfig.appId, + appName: userConfig.appName, + defaults: { + meta: userConfig.meta, + ui: userConfig.ui, + theme: userConfig.theme, + storage: userConfig.storage, + features: userConfig.features + ? { + referrals: userConfig.features.referrals, + spotlight: userConfig.features.spotlight, + pagination: userConfig.features.pagination, + crudHub: userConfig.features.crudHub, + auth: userConfig.features.auth, + authBehavior: userConfig.features.authBehavior, + } + : undefined, + email: userConfig.email, + }, + }; +} diff --git a/packages/config/src/defaults.ts b/packages/config/src/defaults.ts new file mode 100644 index 000000000..9a7f3cb63 --- /dev/null +++ b/packages/config/src/defaults.ts @@ -0,0 +1,40 @@ +import type { ThemeColors } from './types'; + +export const DEFAULT_THEME_COLORS: ThemeColors = { + primary: [ + '#f7eefb', + '#ebdaf2', + '#d6b0e6', + '#c085dc', + '#ae60d2', + '#a349cc', + '#9e3dca', + '#8a30b3', + '#7b29a0', + '#6b218d', + ], + tremorBlue: [ + '#e5f3ff', + '#cee2ff', + '#9ec2fd', + '#6aa1fa', + '#3e84f6', + '#2272f5', + '#0d69f5', + '#0058db', + '#004ec5', + '#0043af', + ], +}; + +export const DEFAULT_EMAIL_CONFIG = { + from: 'noreply@example.com', + sesRegion: 'us-east-1', +} as const; + +export const DEFAULT_AUTH_BEHAVIOR_CONFIG = { + sessionMaxAge: 30 * 24 * 60 * 60, // 30 days + requireEmailVerified: false, + disableCredentials: false, + verbose: false, +} as const; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 4414921f8..63dd69f2c 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -2,9 +2,13 @@ export type { AppConfig, AppMeta, + AuthBehaviorConfig, AuthConfig, + BuiltinPackageName, ConfigOptions, CrudHubConfig, + EmailConfig, + OttabaseUserConfig, PaginationConfig, ReferralsConfig, SpotlightConfig, @@ -14,41 +18,25 @@ export type { } from './types'; // Export main functions -export { createAppConfig, createStorageKey, createThemeColors, getCurrentYear } from './createAppConfig'; +export { + createAppConfig, + createStorageKey, + createThemeColors, + defineOttabaseConfig, + getCurrentYear, + userConfigToOptions, + validateOttabaseConfig, +} from './createAppConfig'; // Import for internal use import type { SupportedUIFramework, ThemeColors } from './types'; +import { DEFAULT_AUTH_BEHAVIOR_CONFIG, DEFAULT_EMAIL_CONFIG, DEFAULT_THEME_COLORS } from './defaults'; // Export constants and defaults export const DEFAULT_UI_FRAMEWORK: SupportedUIFramework = 'mantine'; export const DEFAULT_APP_ID = 'ottabase-template-app'; -export const DEFAULT_THEME_COLORS: ThemeColors = { - primary: [ - '#f7eefb', - '#ebdaf2', - '#d6b0e6', - '#c085dc', - '#ae60d2', - '#a349cc', - '#9e3dca', - '#8a30b3', - '#7b29a0', - '#6b218d', - ], - tremorBlue: [ - '#e5f3ff', - '#cee2ff', - '#9ec2fd', - '#6aa1fa', - '#3e84f6', - '#2272f5', - '#0d69f5', - '#0058db', - '#004ec5', - '#0043af', - ], -}; +export { DEFAULT_THEME_COLORS }; export const DEFAULT_UI_CONFIG = { preventFOUC: false, @@ -90,6 +78,8 @@ export const DEFAULT_REFERRALS_CONFIG = { expiryDays: 30, } as const; +export { DEFAULT_EMAIL_CONFIG, DEFAULT_AUTH_BEHAVIOR_CONFIG }; + // Common storage keys export const STORAGE_KEYS = { THEME: 'theme', @@ -155,4 +145,14 @@ export const ENV_KEYS = { // Storage STORAGE_PREFIX: 'STORAGE_PREFIX', + + // Email (non-secret – from/region now in ottabase.config.ts, env var override kept) + EMAIL_FROM: 'EMAIL_FROM', + AWS_REGION: 'AWS_REGION', + + // Auth behaviour (non-secret – now in ottabase.config.ts, env var override kept) + AUTH_SESSION_MAX_AGE: 'AUTH_SESSION_MAX_AGE', + AUTH_REQUIRE_EMAIL_VERIFIED: 'AUTH_REQUIRE_EMAIL_VERIFIED', + AUTH_DISABLE_CREDENTIALS: 'AUTH_DISABLE_CREDENTIALS', + AUTH_VERBOSE: 'AUTH_VERBOSE', } as const; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index d6f51962f..8421d085f 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -39,6 +39,26 @@ export interface AuthConfig { preLaunchOptIn: boolean; } +/** Email/mailer configuration (non-secret settings only – API keys stay in env vars) */ +export interface EmailConfig { + /** Default "From" address for outbound emails. e.g. "noreply@yourdomain.com" */ + from: string; + /** AWS region used for SES. Default: "us-east-1" */ + sesRegion: string; +} + +/** Server-side auth behaviour flags (non-secret – values that don't require a secret) */ +export interface AuthBehaviorConfig { + /** Session cookie max-age in seconds. Default: 2592000 (30 days) */ + sessionMaxAge: number; + /** Require email verification before login is allowed. Default: false */ + requireEmailVerified: boolean; + /** Disable credentials (email/password) login. Default: false */ + disableCredentials: boolean; + /** Enable verbose auth logging. Default: false */ + verbose: boolean; +} + export interface PaginationConfig { defaultPageSize: number; maxPageSize: number; @@ -91,8 +111,13 @@ export interface AppConfig { auth: AuthConfig; pagination: PaginationConfig; referrals: ReferralsConfig; + /** Server-side auth behaviour (session length, verification gate, etc.) */ + authBehavior: AuthBehaviorConfig; }; + // Email Configuration (non-secret settings) + email: EmailConfig; + // Model Configuration model: { defaultRelKey: string; @@ -118,8 +143,82 @@ export interface ConfigOptions { auth?: Partial; pagination?: Partial; referrals?: Partial; + /** Server-side auth behaviour flags */ + authBehavior?: Partial; }; + /** Email/mailer settings (non-secret) */ + email?: Partial; model?: Partial; }; envPrefix?: string; } + +// ============================================================ +// OttabaseUserConfig – the shape of `ottabase.config.ts` +// ============================================================ + +/** Built-in feature packages shipped with the monorepo */ +export type BuiltinPackageName = 'ottablog' | 'shortlinks' | 'referrals' | 'brandEngine'; + +/** + * Top-level user configuration for the Ottabase monorepo app. + * Lives in `ottabase.config.ts` at the app root. + * + * This is the SINGLE file users edit. Framework files can be freely + * updated (git pull / zip replace) without touching this file. + */ +export interface OttabaseUserConfig { + /** Unique app identifier (used for storage prefix, API headers, etc.) */ + appId: string; + + /** Human-readable app name */ + appName: string; + + /** SEO / branding metadata */ + meta?: Partial>; + + /** UI / layout defaults */ + ui?: Partial; + + /** Theme defaults */ + theme?: { + /** Default active color name (must match a key in `theme.colors`) */ + colorDefault?: string; + /** Custom Mantine-style 10-shade color palettes */ + colors?: ThemeColors; + }; + + /** Storage key prefix for localStorage/sessionStorage */ + storage?: Partial; + + /** + * Toggle built-in packages on/off. + * Missing keys default to `false` (disabled). + */ + packages?: Partial>; + + /** + * Enable custom or premium packages. + * Keys are package names; values toggle enablement. + * Server-only resources (e.g., Drizzle table schemas) must be registered in + * server-only files such as `ottabase/config.migrations.ts`. + */ + customPackages?: Record; + + /** Fine-grained feature configuration */ + features?: { + referrals?: Partial; + spotlight?: Partial; + pagination?: Partial; + crudHub?: Partial; + auth?: Partial; + /** Server-side auth behaviour (session length, verification gate, etc.) */ + authBehavior?: Partial; + }; + + /** + * Email/mailer settings (non-secret). + * Secrets (API keys, SMTP passwords) must remain in environment variables. + */ + email?: Partial; +} diff --git a/packages/ottaeditor/README.md b/packages/ottaeditor/README.md index 20709f695..b369ebafb 100644 --- a/packages/ottaeditor/README.md +++ b/packages/ottaeditor/README.md @@ -4,11 +4,11 @@ A flexible EditorJS wrapper with typesafe plugin management for React applicatio ## Features -- 🔌 **15 pre-installed EditorJS plugins** -- ✨ **Typesafe plugin selection** with autocomplete -- 📦 Full TypeScript support -- 🎯 Easy custom plugin integration -- 🎁 Zero configuration required +- **21 pre-installed plugins** (15 Editor.js + 6 custom blocks) +- **Type-safe plugin selection** with autocomplete +- **TypeScript support** +- **Custom plugin integration** +- **Zero-config defaults** ## Installation @@ -18,14 +18,22 @@ pnpm add @ottabase/ottaeditor ## Default Plugins -15 EditorJS plugins included: Header, Paragraph, List, Checklist, Code, Quote, Table, Warning, Delimiter, Link, Embed, +15 Editor.js plugins included: Header, Paragraph, List, Checklist, Code, Quote, Table, Warning, Delimiter, Link, Embed, Raw HTML, Marker, Underline, Inline Code. ### Custom Block Plugins -- **Spoiler** - Collapsible spoiler content -- **CTA** - Call-to-action button with style variants -- **Review** - Product/service review block with image, rating, pros/cons, and summary +- **Spoiler** – Collapsible spoiler content +- **CTA** – Call-to-action button with alignment (left/center/right) and four style variants (primary, secondary, + outline, ghost) +- **Review** – Product/service review block with image, rating, pros/cons, and summary +- **Map** – Embeddable map block (OpenStreetMap, Google Maps) +- **Layout** – Multi-column layout with six preset splits; each column hosts a full nested editor +- **Disclosure** – Transparency block with AI usage disclosure (slight/mid/high/custom %) and sponsored-content + disclaimer (preset or custom wording) + +CTA and Disclosure generate instance-scoped input IDs/names so multiple blocks can coexist without DOM ID or radio-group +collisions. ## Quick Start @@ -39,7 +47,6 @@ const { editorRef, save, hasUnsavedChanges } = useOttaEditor({ placeholder: 'Start writing...', }); -// Use hasUnsavedChanges to control save button state ; @@ -79,7 +86,8 @@ const { editorRef } = useOttaEditor({ Use these names with `defaultPlugins`: `'header'`, `'paragraph'`, `'list'`, `'checklist'`, `'code'`, `'quote'`, `'table'`, `'warning'`, `'delimiter'`, -`'linkTool'`, `'embed'`, `'raw'`, `'Marker'`, `'underline'`, `'inlineCode'`, `'spoiler'`, `'cta'`, `'review'` +`'linkTool'`, `'embed'`, `'raw'`, `'Marker'`, `'underline'`, `'inlineCode'`, `'spoiler'`, `'cta'`, `'review'`, `'map'`, +`'layout'`, `'disclosure'` ## API @@ -114,10 +122,67 @@ Use these names with `defaultPlugins`: } ``` +## Plugin Reference + +### CTA + +```typescript +// Saved data shape +interface CTAData { + text: string; // Button label + url: string; // Destination URL + style: 'primary' | 'secondary' | 'outline' | 'ghost'; + alignment: 'left' | 'center' | 'right'; + openInNewTab: boolean; + icon?: string; // Optional SVG string +} +``` + +### Disclosure + +```typescript +// Saved data shape +interface DisclosureData { + aiEnabled: boolean; + aiLevel: 'none' | 'slight' | 'mid' | 'high' | 'custom'; + aiPercent?: number; // 1–100, used when aiLevel === 'custom' + sponsoredEnabled: boolean; + sponsoredType: 'preset' | 'custom'; + sponsoredText?: string; // Used when sponsoredType === 'custom' +} + +// Standard AI wording presets +// slight → "AI tools were used to assist in light editing and proofreading…" +// mid → "AI tools were significantly used in drafting and editing…" +// high → "This content was primarily generated with AI assistance…" +// custom → "Approximately {n}% of this content was created with AI assistance." + +// Standard sponsored preset +// "This content was created in partnership with a sponsor. Our editorial standards remain independent." +``` + +### Layout + +```typescript +// Saved data shape +interface LayoutData { + preset: '1-1' | '1-3' | '3-1' | '1-2' | '2-1' | '1-1-1'; + columns: Array<{ content: OutputData }>; +} +``` + ## Types ```typescript -import type { DefaultPluginName, OttaEditorPlugin, OutputData } from '@ottabase/ottaeditor'; +import type { + AIDisclosureLevel, + DefaultPluginName, + DisclosureData, + LayoutData, + LayoutPreset, + OttaEditorPlugin, + OutputData, +} from '@ottabase/ottaeditor'; ``` ## License diff --git a/packages/ottaeditor/src/__tests__/editor.test.tsx b/packages/ottaeditor/src/__tests__/editor.test.tsx index d8a691d2a..9cf091d21 100644 --- a/packages/ottaeditor/src/__tests__/editor.test.tsx +++ b/packages/ottaeditor/src/__tests__/editor.test.tsx @@ -1,7 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import React from 'react'; -import * as editor from '../index.ts'; +import { describe, expect, it, vi } from 'vitest'; +import * as editor from '../index'; // Mock EditorJS wrapper component const MockEditor = ({ onSave }: { onSave?: (data: any) => void }) => ( diff --git a/packages/ottaeditor/src/defaultPlugins.ts b/packages/ottaeditor/src/defaultPlugins.ts index beb973444..a8d60069a 100644 --- a/packages/ottaeditor/src/defaultPlugins.ts +++ b/packages/ottaeditor/src/defaultPlugins.ts @@ -1,6 +1,5 @@ // @ts-nocheck - EditorJS plugins have inconsistent type definitions import CheckList from '@editorjs/checklist'; -import CodeTool from './tools/CodeTool/CodeTool'; import Delimiter from '@editorjs/delimiter'; import Embed from '@editorjs/embed'; import Header from '@editorjs/header'; @@ -14,7 +13,12 @@ import Raw from '@editorjs/raw'; import Table from '@editorjs/table'; import Underline from '@editorjs/underline'; import Warning from '@editorjs/warning'; +import AdvancedImageTool from './tools/AdvancedImageTool/AdvancedImageTool'; +import CodeTool from './tools/CodeTool/CodeTool'; import CTATool from './tools/CTATool/CTATool'; +import DisclosureTool from './tools/DisclosureTool/DisclosureTool'; +import LayoutTool from './tools/LayoutTool/LayoutTool'; +import MapTool from './tools/MapTool/MapTool'; import ReviewTool from './tools/ReviewTool/ReviewTool'; import SpoilerTool from './tools/SpoilerTool/SpoilerTool'; import type { OttaEditorPlugin } from './types'; @@ -41,6 +45,9 @@ export const DEFAULT_PLUGIN_NAMES = { SPOILER: 'spoiler', CTA: 'cta', REVIEW: 'review', + MAP: 'map', + LAYOUT: 'layout', + DISCLOSURE: 'disclosure', } as const; /** @@ -48,6 +55,23 @@ export const DEFAULT_PLUGIN_NAMES = { */ export type DefaultPluginName = (typeof DEFAULT_PLUGIN_NAMES)[keyof typeof DEFAULT_PLUGIN_NAMES]; +/** + * Build the tools config for nested Layout editors. + * Includes all default block tools except Layout itself (prevents infinite nesting). + */ +function buildLayoutNestedTools(): Record { + return { + paragraph: { class: Paragraph, config: { placeholder: 'Start writing…' } }, + header: { class: Header, config: { levels: [1, 2, 3, 4, 5, 6], defaultLevel: 2 } }, + image: { class: AdvancedImageTool }, + delimiter: { class: Delimiter }, + code: { class: CodeTool, config: { placeholder: 'Enter your code here…' } }, + list: { class: NestedList, config: { defaultStyle: 'unordered' } }, + checklist: { class: CheckList }, + table: { class: Table, config: { rows: 2, cols: 3 } }, + }; +} + /** * Default EditorJS plugins configuration * These plugins are pre-installed with ottaeditor and ready to use @@ -171,6 +195,27 @@ export const defaultPlugins: OttaEditorPlugin[] = [ tool: ReviewTool as any, config: {} as any, }, + { + name: DEFAULT_PLUGIN_NAMES.MAP, + tool: MapTool as any, + config: { + defaultProvider: 'openstreetmap', + defaultHeight: 400, + defaultTheme: 'default', + } as any, + }, + { + name: DEFAULT_PLUGIN_NAMES.LAYOUT, + tool: LayoutTool as any, + config: { + tools: buildLayoutNestedTools(), + } as any, + }, + { + name: DEFAULT_PLUGIN_NAMES.DISCLOSURE, + tool: DisclosureTool as any, + config: {} as any, + }, ]; /** @@ -204,10 +249,13 @@ export { CodeTool, CTATool, Delimiter, + DisclosureTool, Embed, Header, InlineCode, + LayoutTool, LinkTool, + MapTool, Marker, NestedList, Paragraph, diff --git a/packages/ottaeditor/src/editorjs-brandkit-theme.css b/packages/ottaeditor/src/editorjs-brandkit-theme.css index 0b43513c6..7219014f7 100644 --- a/packages/ottaeditor/src/editorjs-brandkit-theme.css +++ b/packages/ottaeditor/src/editorjs-brandkit-theme.css @@ -38,6 +38,16 @@ fill: currentColor !important; } +/* Keep EditorJS menus above custom block UIs (e.g. Layout headers/toolbars) */ +.ce-toolbar, +.ce-inline-toolbar, +.ce-conversion-toolbar, +.ce-popover, +.ce-settings, +.ce-toolbox { + z-index: 60 !important; +} + /* Popover Item Hover - Brand color with transparency */ .ce-popover__item:hover, .ce-popover-item:hover { diff --git a/packages/ottaeditor/src/index.ts b/packages/ottaeditor/src/index.ts index 8eb0abff8..543b9cb7f 100644 --- a/packages/ottaeditor/src/index.ts +++ b/packages/ottaeditor/src/index.ts @@ -12,12 +12,16 @@ export { useOttaEditor } from './useOttaEditor'; export { CheckList, CodeTool, + CTATool, DEFAULT_PLUGIN_NAMES, Delimiter, + DisclosureTool, Embed, Header, InlineCode, + LayoutTool, LinkTool, + MapTool, Marker, NestedList, Paragraph, @@ -38,6 +42,9 @@ export { default as AdvancedImageRenderer } from './tools/AdvancedImageTool/Adva export { default as AdvancedImageTool } from './tools/AdvancedImageTool/AdvancedImageTool'; export type { AdvancedImageData } from './tools/AdvancedImageTool/types'; export { default as MediaLibraryTool } from './tools/MediaLibraryTool/MediaLibraryTool'; +export type { DisclosureData, AIDisclosureLevel } from './tools/DisclosureTool/DisclosureTool'; +export type { LayoutData, LayoutPreset, LayoutColumnData, LayoutToolConfig } from './tools/LayoutTool/LayoutTool'; +export type { MapData, MapProvider, MapTheme, MapToolConfig } from './tools/MapTool/MapTool'; export type { DefaultPluginName } from './defaultPlugins'; diff --git a/packages/ottaeditor/src/tools/CTATool/CTATool.css b/packages/ottaeditor/src/tools/CTATool/CTATool.css index 665a28f9e..51d00185e 100644 --- a/packages/ottaeditor/src/tools/CTATool/CTATool.css +++ b/packages/ottaeditor/src/tools/CTATool/CTATool.css @@ -17,9 +17,11 @@ margin: 0; } -/* Ensure checkbox group has same spacing as other rows */ -.cdx-cta__form > div:has(input[type='checkbox']) { - margin: 0; +/* Rows: flex containers for side-by-side fields */ +.cdx-cta__row { + display: flex; + gap: 8px; + align-items: flex-end; } .cdx-cta__input-group { @@ -46,6 +48,7 @@ font-family: inherit; background: hsl(var(--background)); color: hsl(var(--foreground)); + box-sizing: border-box; } .cdx-cta__input:focus, @@ -55,6 +58,41 @@ box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); } +/* Alignment button group */ +.cdx-cta__align-group { + display: inline-flex; + gap: 2px; + border: 1px solid hsl(var(--border)); + border-radius: 4px; + padding: 2px; + background: hsl(var(--background)); +} + +.cdx-cta__align-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 26px; + border: none; + border-radius: 3px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: all 0.15s ease; + padding: 0; +} + +.cdx-cta__align-btn:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} + +.cdx-cta__align-btn--active { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); +} + .cdx-cta__checkbox-group { display: flex; align-items: center; @@ -67,6 +105,7 @@ height: 14px; cursor: pointer; margin: 0; + accent-color: hsl(var(--primary)); } .cdx-cta__checkbox-label { @@ -80,12 +119,11 @@ margin-top: 6px; padding-top: 8px; border-top: 1px solid hsl(var(--border)); - text-align: center; } .cdx-cta__preview-button { display: inline-block; - padding: 8px 16px; + padding: 8px 18px; border-radius: 6px; font-size: 13px; font-weight: 500; @@ -106,13 +144,13 @@ } .cdx-cta__preview-button--secondary { - background: hsl(var(--muted)); - color: hsl(var(--muted-foreground)); - border-color: hsl(var(--muted)); + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + border-color: hsl(var(--secondary)); } .cdx-cta__preview-button--secondary:hover { - opacity: 0.8; + opacity: 0.85; } .cdx-cta__preview-button--outline { @@ -125,3 +163,14 @@ background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); } + +.cdx-cta__preview-button--ghost { + background: transparent; + color: hsl(var(--foreground)); + border-color: hsl(var(--border)); +} + +.cdx-cta__preview-button--ghost:hover { + background: hsl(var(--muted)); + border-color: hsl(var(--muted)); +} diff --git a/packages/ottaeditor/src/tools/CTATool/CTATool.test.ts b/packages/ottaeditor/src/tools/CTATool/CTATool.test.ts index 19c7b12a3..7c3ae710a 100644 --- a/packages/ottaeditor/src/tools/CTATool/CTATool.test.ts +++ b/packages/ottaeditor/src/tools/CTATool/CTATool.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import CTATool from './CTATool'; +// Mock CSS import +vi.mock('./CTATool.css', () => ({})); + // Mock EditorJS API const createMockAPI = () => ({ blocks: { @@ -49,6 +52,7 @@ describe('CTATool', () => { expect(saved.text).toBe('Get Started'); expect(saved.url).toBe(''); expect(saved.style).toBe('primary'); + expect(saved.alignment).toBe('center'); expect(saved.openInNewTab).toBe(false); }); @@ -58,6 +62,7 @@ describe('CTATool', () => { text: 'Sign Up', url: 'https://example.com', style: 'secondary', + alignment: 'left', openInNewTab: true, }, config: {}, @@ -68,6 +73,7 @@ describe('CTATool', () => { expect(saved.text).toBe('Sign Up'); expect(saved.url).toBe('https://example.com'); expect(saved.style).toBe('secondary'); + expect(saved.alignment).toBe('left'); expect(saved.openInNewTab).toBe(true); }); @@ -81,6 +87,17 @@ describe('CTATool', () => { const saved = toolWithConfig.save(); expect(saved.style).toBe('outline'); }); + + it('should use config default alignment', () => { + const toolWithConfig = new CTATool({ + data: {}, + config: { defaultAlignment: 'right' }, + api: mockAPI as any, + }); + + const saved = toolWithConfig.save(); + expect(saved.alignment).toBe('right'); + }); }); describe('Rendering', () => { @@ -96,10 +113,16 @@ describe('CTATool', () => { const form = element.querySelector('.cdx-cta__form'); expect(form).toBeTruthy(); - expect(element.querySelector('#cta-text')).toBeTruthy(); - expect(element.querySelector('#cta-url')).toBeTruthy(); - expect(element.querySelector('#cta-style')).toBeTruthy(); - expect(element.querySelector('#cta-new-tab')).toBeTruthy(); + expect(element.querySelector('[id^="cta-text-"]')).toBeTruthy(); + expect(element.querySelector('[id^="cta-url-"]')).toBeTruthy(); + expect(element.querySelector('[id^="cta-style-"]')).toBeTruthy(); + expect(element.querySelector('[id^="cta-new-tab-"]')).toBeTruthy(); + }); + + it('should render alignment buttons', () => { + const element = tool.render(); + const alignBtns = element.querySelectorAll('.cdx-cta__align-btn'); + expect(alignBtns.length).toBe(3); }); it('should render preview button', () => { @@ -113,7 +136,7 @@ describe('CTATool', () => { it('should update preview when text changes', () => { const element = tool.render(); - const textInput = element.querySelector('#cta-text') as HTMLInputElement; + const textInput = element.querySelector('[id^="cta-text-"]') as HTMLInputElement; const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; textInput.value = 'New Button Text'; @@ -124,7 +147,7 @@ describe('CTATool', () => { it('should update preview when URL changes', () => { const element = tool.render(); - const urlInput = element.querySelector('#cta-url') as HTMLInputElement; + const urlInput = element.querySelector('[id^="cta-url-"]') as HTMLInputElement; const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; urlInput.value = 'https://example.com'; @@ -135,7 +158,7 @@ describe('CTATool', () => { it('should update preview when style changes', () => { const element = tool.render(); - const styleSelect = element.querySelector('#cta-style') as HTMLSelectElement; + const styleSelect = element.querySelector('[id^="cta-style-"]') as HTMLSelectElement; const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; styleSelect.value = 'outline'; @@ -146,7 +169,7 @@ describe('CTATool', () => { it('should update target when checkbox changes', () => { const element = tool.render(); - const checkbox = element.querySelector('#cta-new-tab') as HTMLInputElement; + const checkbox = element.querySelector('[id^="cta-new-tab-"]') as HTMLInputElement; const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; checkbox.checked = true; @@ -155,13 +178,35 @@ describe('CTATool', () => { expect(previewButton.target).toBe('_blank'); expect(previewButton.rel).toBe('noopener noreferrer'); }); + + it('should update alignment when alignment button clicked', () => { + const element = tool.render(); + const leftBtn = element.querySelector('.cdx-cta__align-btn[data-align="left"]') as HTMLButtonElement; + + leftBtn.click(); + + const saved = tool.save(); + expect(saved.alignment).toBe('left'); + }); + + it('should mark active alignment button', () => { + const element = tool.render(); + const centerBtn = element.querySelector('.cdx-cta__align-btn[data-align="center"]') as HTMLButtonElement; + // Default is center + expect(centerBtn.classList.contains('cdx-cta__align-btn--active')).toBe(true); + + const rightBtn = element.querySelector('.cdx-cta__align-btn[data-align="right"]') as HTMLButtonElement; + rightBtn.click(); + expect(rightBtn.classList.contains('cdx-cta__align-btn--active')).toBe(true); + expect(centerBtn.classList.contains('cdx-cta__align-btn--active')).toBe(false); + }); }); describe('Save', () => { it('should save current data', () => { const element = tool.render(); - const textInput = element.querySelector('#cta-text') as HTMLInputElement; - const urlInput = element.querySelector('#cta-url') as HTMLInputElement; + const textInput = element.querySelector('[id^="cta-text-"]') as HTMLInputElement; + const urlInput = element.querySelector('[id^="cta-url-"]') as HTMLInputElement; textInput.value = 'Click Here'; textInput.dispatchEvent(new Event('input')); @@ -173,6 +218,15 @@ describe('CTATool', () => { expect(saved.text).toBe('Click Here'); expect(saved.url).toBe('https://test.com'); }); + + it('should save alignment', () => { + const element = tool.render(); + const leftBtn = element.querySelector('.cdx-cta__align-btn[data-align="left"]') as HTMLButtonElement; + leftBtn.click(); + + const saved = tool.save(); + expect(saved.alignment).toBe('left'); + }); }); describe('Validation', () => { @@ -199,7 +253,6 @@ describe('CTATool', () => { api: mockAPI as any, }); - // Override default text to test empty validation const saved = invalidTool.save(); saved.text = ''; expect(invalidTool.validate(saved)).toBe(false); @@ -234,42 +287,51 @@ describe('CTATool', () => { describe('Style Options', () => { it('should support primary style', () => { - const tool = new CTATool({ - data: { style: 'primary' }, - config: {}, - api: mockAPI as any, - }); - - const element = tool.render(); + const t = new CTATool({ data: { style: 'primary' }, config: {}, api: mockAPI as any }); + const element = t.render(); const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; - expect(previewButton.classList.contains('cdx-cta__preview-button--primary')).toBe(true); }); it('should support secondary style', () => { - const tool = new CTATool({ - data: { style: 'secondary' }, - config: {}, - api: mockAPI as any, - }); - - const element = tool.render(); + const t = new CTATool({ data: { style: 'secondary' }, config: {}, api: mockAPI as any }); + const element = t.render(); const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; - expect(previewButton.classList.contains('cdx-cta__preview-button--secondary')).toBe(true); }); it('should support outline style', () => { - const tool = new CTATool({ - data: { style: 'outline' }, - config: {}, - api: mockAPI as any, - }); + const t = new CTATool({ data: { style: 'outline' }, config: {}, api: mockAPI as any }); + const element = t.render(); + const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; + expect(previewButton.classList.contains('cdx-cta__preview-button--outline')).toBe(true); + }); - const element = tool.render(); + it('should support ghost style', () => { + const t = new CTATool({ data: { style: 'ghost' }, config: {}, api: mockAPI as any }); + const element = t.render(); const previewButton = element.querySelector('.cdx-cta__preview-button') as HTMLAnchorElement; + expect(previewButton.classList.contains('cdx-cta__preview-button--ghost')).toBe(true); + }); + }); - expect(previewButton.classList.contains('cdx-cta__preview-button--outline')).toBe(true); + describe('Alignment Options', () => { + it('should support left alignment', () => { + const t = new CTATool({ data: { alignment: 'left' }, config: {}, api: mockAPI as any }); + const saved = t.save(); + expect(saved.alignment).toBe('left'); + }); + + it('should support center alignment', () => { + const t = new CTATool({ data: { alignment: 'center' }, config: {}, api: mockAPI as any }); + const saved = t.save(); + expect(saved.alignment).toBe('center'); + }); + + it('should support right alignment', () => { + const t = new CTATool({ data: { alignment: 'right' }, config: {}, api: mockAPI as any }); + const saved = t.save(); + expect(saved.alignment).toBe('right'); }); }); }); diff --git a/packages/ottaeditor/src/tools/CTATool/CTATool.ts b/packages/ottaeditor/src/tools/CTATool/CTATool.ts index 2f1ddb02c..56d3d6887 100644 --- a/packages/ottaeditor/src/tools/CTATool/CTATool.ts +++ b/packages/ottaeditor/src/tools/CTATool/CTATool.ts @@ -1,15 +1,17 @@ -import type { API, BlockTool, BlockToolConstructorOptions } from '@editorjs/editorjs'; +import type { API, BlockTool } from '@editorjs/editorjs'; import './CTATool.css'; interface CTAToolConfig { placeholder?: string; - defaultStyle?: 'primary' | 'secondary' | 'outline'; + defaultStyle?: 'primary' | 'secondary' | 'outline' | 'ghost'; + defaultAlignment?: 'left' | 'center' | 'right'; } interface CTAData { text: string; url: string; - style: 'primary' | 'secondary' | 'outline'; + style: 'primary' | 'secondary' | 'outline' | 'ghost'; + alignment: 'left' | 'center' | 'right'; openInNewTab: boolean; icon?: string; } @@ -19,28 +21,35 @@ export default class CTATool implements BlockTool { private data: CTAData; private config: CTAToolConfig; private wrapper: HTMLElement | null = null; + private instanceId: string; + + private static idSeed = 0; static get CSS() { return { baseClass: 'cdx-cta', wrapper: 'cdx-cta__wrapper', form: 'cdx-cta__form', + row: 'cdx-cta__row', inputGroup: 'cdx-cta__input-group', label: 'cdx-cta__label', input: 'cdx-cta__input', - textarea: 'cdx-cta__textarea', select: 'cdx-cta__select', checkbox: 'cdx-cta__checkbox', checkboxLabel: 'cdx-cta__checkbox-label', preview: 'cdx-cta__preview', previewButton: 'cdx-cta__preview-button', + alignBtnGroup: 'cdx-cta__align-group', + alignBtn: 'cdx-cta__align-btn', + alignBtnActive: 'cdx-cta__align-btn--active', }; } static get toolbox() { return { title: 'Call to Action', - icon: '', + // Tabler-style CTA button icon with improved spacing + icon: '', }; } @@ -48,18 +57,39 @@ export default class CTATool implements BlockTool { return true; } - constructor({ data, config, api }: BlockToolConstructorOptions) { + constructor({ + data, + config, + api, + block, + }: { + data?: Partial; + config?: CTAToolConfig; + api: API; + block?: { id?: string }; + }) { this.api = api; this.config = config || {}; + this.instanceId = block?.id || CTATool.nextId(); this.data = { text: data?.text || 'Get Started', url: data?.url || '', style: data?.style || this.config.defaultStyle || 'primary', + alignment: data?.alignment || this.config.defaultAlignment || 'center', openInNewTab: data?.openInNewTab ?? false, icon: data?.icon || '', }; } + private static nextId(): string { + CTATool.idSeed += 1; + return `cta-${CTATool.idSeed}`; + } + + private domId(name: string): string { + return `${name}-${this.instanceId}`; + } + render(): HTMLElement { const wrapper = document.createElement('div'); wrapper.classList.add(CTATool.CSS.baseClass, CTATool.CSS.wrapper); @@ -67,32 +97,29 @@ export default class CTATool implements BlockTool { const form = document.createElement('div'); form.classList.add(CTATool.CSS.form); - // Compact layout: Text and Style in one row - const topRow = document.createElement('div'); - topRow.style.display = 'grid'; - topRow.style.gridTemplateColumns = '1fr 120px'; - topRow.style.gap = '8px'; - topRow.style.alignItems = 'end'; + // Row 1: Text input + Style select + const row1 = document.createElement('div'); + row1.classList.add(CTATool.CSS.row); // Text input const textGroup = document.createElement('div'); textGroup.classList.add(CTATool.CSS.inputGroup); + textGroup.style.flex = '1'; const textLabel = document.createElement('label'); textLabel.classList.add(CTATool.CSS.label); textLabel.textContent = 'Button Text'; - textLabel.setAttribute('for', 'cta-text'); + const textInputId = this.domId('cta-text'); + textLabel.setAttribute('for', textInputId); const textInput = document.createElement('input'); - textInput.id = 'cta-text'; + textInput.id = textInputId; textInput.type = 'text'; textInput.classList.add(CTATool.CSS.input); textInput.placeholder = this.config.placeholder || 'Enter button text...'; textInput.value = this.data.text; - textInput.addEventListener('input', (event) => { - const target = event.target as HTMLInputElement; - this.data.text = target.value; + this.data.text = (event.target as HTMLInputElement).value; this.updatePreview(); }); @@ -102,20 +129,23 @@ export default class CTATool implements BlockTool { // Style select const styleGroup = document.createElement('div'); styleGroup.classList.add(CTATool.CSS.inputGroup); + styleGroup.style.width = '110px'; const styleLabel = document.createElement('label'); styleLabel.classList.add(CTATool.CSS.label); styleLabel.textContent = 'Style'; - styleLabel.setAttribute('for', 'cta-style'); + const styleSelectId = this.domId('cta-style'); + styleLabel.setAttribute('for', styleSelectId); const styleSelect = document.createElement('select'); - styleSelect.id = 'cta-style'; + styleSelect.id = styleSelectId; styleSelect.classList.add(CTATool.CSS.select); const styles: Array<{ value: CTAData['style']; label: string }> = [ { value: 'primary', label: 'Primary' }, { value: 'secondary', label: 'Secondary' }, { value: 'outline', label: 'Outline' }, + { value: 'ghost', label: 'Ghost' }, ]; styles.forEach((style) => { @@ -127,70 +157,131 @@ export default class CTATool implements BlockTool { }); styleSelect.addEventListener('change', (event) => { - const target = event.target as HTMLSelectElement; - this.data.style = target.value as CTAData['style']; + this.data.style = (event.target as HTMLSelectElement).value as CTAData['style']; this.updatePreview(); }); styleGroup.appendChild(styleLabel); styleGroup.appendChild(styleSelect); - topRow.appendChild(textGroup); - topRow.appendChild(styleGroup); + row1.appendChild(textGroup); + row1.appendChild(styleGroup); - // URL input + // Row 2: URL input const urlGroup = document.createElement('div'); urlGroup.classList.add(CTATool.CSS.inputGroup); const urlLabel = document.createElement('label'); urlLabel.classList.add(CTATool.CSS.label); urlLabel.textContent = 'URL'; - urlLabel.setAttribute('for', 'cta-url'); + const urlInputId = this.domId('cta-url'); + urlLabel.setAttribute('for', urlInputId); const urlInput = document.createElement('input'); - urlInput.id = 'cta-url'; + urlInput.id = urlInputId; urlInput.type = 'url'; urlInput.classList.add(CTATool.CSS.input); urlInput.placeholder = 'https://example.com'; urlInput.value = this.data.url; - urlInput.addEventListener('input', (event) => { - const target = event.target as HTMLInputElement; - this.data.url = target.value; + this.data.url = (event.target as HTMLInputElement).value; this.updatePreview(); }); urlGroup.appendChild(urlLabel); urlGroup.appendChild(urlInput); - // Open in new tab checkbox - inline with label + // Row 3: Alignment + Open in new tab + const row3 = document.createElement('div'); + row3.classList.add(CTATool.CSS.row); + + // Alignment buttons + const alignGroup = document.createElement('div'); + alignGroup.classList.add(CTATool.CSS.inputGroup); + alignGroup.style.flex = '1'; + + const alignLabel = document.createElement('label'); + alignLabel.classList.add(CTATool.CSS.label); + alignLabel.textContent = 'Alignment'; + + const alignBtnGroup = document.createElement('div'); + alignBtnGroup.classList.add(CTATool.CSS.alignBtnGroup); + + const alignments: Array<{ value: CTAData['alignment']; icon: string; title: string }> = [ + { + value: 'left', + title: 'Left', + // Tabler icon: align-left + icon: '', + }, + { + value: 'center', + title: 'Center', + // Tabler icon: align-center + icon: '', + }, + { + value: 'right', + title: 'Right', + // Tabler icon: align-right + icon: '', + }, + ]; + + alignments.forEach(({ value, icon, title }) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.classList.add(CTATool.CSS.alignBtn); + if (value === this.data.alignment) btn.classList.add(CTATool.CSS.alignBtnActive); + btn.innerHTML = icon; + btn.title = title; + btn.setAttribute('data-align', value); + btn.addEventListener('click', () => { + this.data.alignment = value; + alignBtnGroup.querySelectorAll(`.${CTATool.CSS.alignBtn}`).forEach((b) => { + b.classList.toggle(CTATool.CSS.alignBtnActive, b.getAttribute('data-align') === value); + }); + this.updatePreview(); + }); + alignBtnGroup.appendChild(btn); + }); + + alignGroup.appendChild(alignLabel); + alignGroup.appendChild(alignBtnGroup); + + // Open in new tab checkbox const checkboxGroup = document.createElement('div'); - checkboxGroup.style.display = 'flex'; - checkboxGroup.style.alignItems = 'center'; + checkboxGroup.classList.add(CTATool.CSS.inputGroup); + checkboxGroup.style.justifyContent = 'flex-end'; + checkboxGroup.style.flexDirection = 'row'; + checkboxGroup.style.alignItems = 'flex-end'; checkboxGroup.style.gap = '6px'; + checkboxGroup.style.paddingBottom = '4px'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - checkbox.id = 'cta-new-tab'; + const newTabId = this.domId('cta-new-tab'); + checkbox.id = newTabId; checkbox.classList.add(CTATool.CSS.checkbox); checkbox.checked = this.data.openInNewTab; + checkbox.addEventListener('change', (event) => { + this.data.openInNewTab = (event.target as HTMLInputElement).checked; + this.updatePreview(); + }); const checkboxLabel = document.createElement('label'); checkboxLabel.classList.add(CTATool.CSS.checkboxLabel); - checkboxLabel.setAttribute('for', 'cta-new-tab'); - checkboxLabel.textContent = 'Open in new tab'; + checkboxLabel.setAttribute('for', newTabId); + checkboxLabel.textContent = 'New tab'; checkboxLabel.style.cursor = 'pointer'; checkboxLabel.style.margin = '0'; - checkbox.addEventListener('change', (event) => { - const target = event.target as HTMLInputElement; - this.data.openInNewTab = target.checked; - this.updatePreview(); - }); - checkboxGroup.appendChild(checkbox); checkboxGroup.appendChild(checkboxLabel); + row3.appendChild(alignGroup); + row3.appendChild(checkboxGroup); + // Preview const preview = document.createElement('div'); preview.classList.add(CTATool.CSS.preview); @@ -204,9 +295,9 @@ export default class CTATool implements BlockTool { preview.appendChild(previewButton); - form.appendChild(topRow); + form.appendChild(row1); form.appendChild(urlGroup); - form.appendChild(checkboxGroup); + form.appendChild(row3); form.appendChild(preview); wrapper.appendChild(form); @@ -233,12 +324,19 @@ export default class CTATool implements BlockTool { 'cdx-cta__preview-button--primary', 'cdx-cta__preview-button--secondary', 'cdx-cta__preview-button--outline', + 'cdx-cta__preview-button--ghost', ); previewButton.classList.add(`cdx-cta__preview-button--${this.data.style}`); + + // Update preview alignment + const preview = this.wrapper.querySelector(`.${CTATool.CSS.preview}`) as HTMLElement; + if (preview) { + preview.style.textAlign = this.data.alignment; + } } save(): CTAData { - return this.data; + return { ...this.data }; } validate(savedData: CTAData): boolean { diff --git a/packages/ottaeditor/src/tools/CodeTool/CodeTool.ts b/packages/ottaeditor/src/tools/CodeTool/CodeTool.ts index 838861cec..769bc727b 100644 --- a/packages/ottaeditor/src/tools/CodeTool/CodeTool.ts +++ b/packages/ottaeditor/src/tools/CodeTool/CodeTool.ts @@ -1,4 +1,4 @@ -import type { API, BlockTool, BlockToolConstructorOptions } from '@editorjs/editorjs'; +import type { API, BlockTool } from '@editorjs/editorjs'; import './CodeTool.css'; const LANGUAGE_OPTIONS = [ @@ -88,7 +88,7 @@ export default class CodeTool implements BlockTool { static get toolbox() { return { title: 'Code', - icon: '', + icon: '', }; } @@ -96,7 +96,7 @@ export default class CodeTool implements BlockTool { return true; } - constructor({ data, config, api }: BlockToolConstructorOptions) { + constructor({ data, config, api }: { data?: Partial; config?: CodeToolConfig; api: API }) { this.api = api; this.config = config || {}; this.data = { diff --git a/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.css b/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.css new file mode 100644 index 000000000..d7d4599bf --- /dev/null +++ b/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.css @@ -0,0 +1,172 @@ +/* Disclosure Tool Styles */ + +.cdx-disclosure__wrapper { + padding: 10px; + border-radius: 6px; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + gap: 0; +} + +.cdx-disclosure__section { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Section header: title + toggle on same row */ +.cdx-disclosure__section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; +} + +.cdx-disclosure__label { + font-size: 13px; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.cdx-disclosure__sublabel { + font-size: 12px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + white-space: nowrap; + flex-shrink: 0; +} + +.cdx-disclosure__section-body { + flex-direction: column; + gap: 6px; + padding-left: 2px; +} + +/* Rows: label + control side-by-side */ +.cdx-disclosure__row { + display: flex; + align-items: center; + gap: 8px; +} + +.cdx-disclosure__select, +.cdx-disclosure__input { + flex: 1; + padding: 5px 8px; + border: 1px solid hsl(var(--input)); + border-radius: 4px; + font-size: 13px; + font-family: inherit; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +.cdx-disclosure__input { + width: 72px; + flex: none; + text-align: center; +} + +.cdx-disclosure__select:focus, +.cdx-disclosure__input:focus { + outline: none; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); +} + +.cdx-disclosure__textarea { + flex: 1; + width: 100%; + padding: 5px 8px; + border: 1px solid hsl(var(--input)); + border-radius: 4px; + font-size: 13px; + font-family: inherit; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + resize: vertical; + box-sizing: border-box; +} + +.cdx-disclosure__textarea:focus { + outline: none; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); +} + +/* Preview text */ +.cdx-disclosure__preview { + font-size: 11px; + color: hsl(var(--muted-foreground)); + font-style: italic; + padding: 4px 6px; + border-left: 2px solid hsl(var(--primary) / 0.4); + background: hsl(var(--muted) / 0.3); + border-radius: 0 3px 3px 0; + word-break: break-word; +} + +/* Toggle switch */ +.cdx-disclosure__toggle { + display: inline-flex; + align-items: center; + cursor: pointer; + flex-shrink: 0; +} + +.cdx-disclosure__toggle-slider { + display: inline-block; + width: 32px; + height: 18px; + border-radius: 9px; + background: hsl(var(--muted)); + border: 1px solid hsl(var(--border)); + position: relative; + transition: background 0.2s ease; +} + +.cdx-disclosure__toggle-slider::after { + content: ''; + display: block; + width: 12px; + height: 12px; + border-radius: 50%; + background: hsl(var(--background)); + position: absolute; + top: 2px; + left: 2px; + transition: left 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.cdx-disclosure__toggle-slider--on { + background: hsl(var(--primary)); + border-color: hsl(var(--primary)); +} + +.cdx-disclosure__toggle-slider--on::after { + left: 16px; +} + +/* Radio group */ +.cdx-disclosure__radio-group { + display: flex; + gap: 12px; + align-items: center; +} + +.cdx-disclosure__radio-label { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: hsl(var(--foreground)); + cursor: pointer; +} + +.cdx-disclosure__radio { + accent-color: hsl(var(--primary)); + cursor: pointer; +} diff --git a/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.test.ts b/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.test.ts new file mode 100644 index 000000000..b1c0f0aae --- /dev/null +++ b/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import DisclosureTool, { AI_LEVEL_LABELS, AI_LEVEL_WORDING, SPONSORED_PRESET_TEXT } from './DisclosureTool'; + +// Mock CSS import +vi.mock('./DisclosureTool.css', () => ({})); + +const createMockAPI = () => ({ + blocks: { getCurrentBlockIndex: vi.fn(() => 0) }, + ui: { notifier: { show: vi.fn() } }, +}); + +describe('DisclosureTool', () => { + let mockAPI: ReturnType; + + beforeEach(() => { + mockAPI = createMockAPI(); + }); + + describe('Toolbox', () => { + it('should have correct toolbox title', () => { + expect(DisclosureTool.toolbox.title).toBe('Disclosure'); + }); + + it('should have a toolbox icon', () => { + expect(DisclosureTool.toolbox.icon).toBeTruthy(); + expect(DisclosureTool.toolbox.icon).toContain('svg'); + }); + + it('should enable line breaks', () => { + expect(DisclosureTool.enableLineBreaks).toBe(true); + }); + }); + + describe('Initialization – defaults', () => { + it('should initialize with safe defaults', () => { + const tool = new DisclosureTool({ data: {}, config: {}, api: mockAPI as any }); + const saved = tool.save(); + expect(saved.aiEnabled).toBe(false); + expect(saved.aiLevel).toBe('none'); + expect(saved.sponsoredEnabled).toBe(false); + expect(saved.sponsoredType).toBe('preset'); + }); + + it('should initialise from provided data', () => { + const tool = new DisclosureTool({ + data: { + aiEnabled: true, + aiLevel: 'mid', + sponsoredEnabled: true, + sponsoredType: 'custom', + sponsoredText: 'Paid partnership.', + }, + config: {}, + api: mockAPI as any, + }); + const saved = tool.save(); + expect(saved.aiEnabled).toBe(true); + expect(saved.aiLevel).toBe('mid'); + expect(saved.sponsoredEnabled).toBe(true); + expect(saved.sponsoredType).toBe('custom'); + expect(saved.sponsoredText).toBe('Paid partnership.'); + }); + + it('should respect config defaultAILevel', () => { + const tool = new DisclosureTool({ + data: {}, + config: { defaultAILevel: 'slight' }, + api: mockAPI as any, + }); + const saved = tool.save(); + expect(saved.aiEnabled).toBe(true); + expect(saved.aiLevel).toBe('slight'); + }); + }); + + describe('Rendering', () => { + it('should render a wrapper element', () => { + const tool = new DisclosureTool({ data: {}, config: {}, api: mockAPI as any }); + const el = tool.render(); + expect(el).toBeInstanceOf(HTMLElement); + expect(el.classList.contains('cdx-disclosure__wrapper')).toBe(true); + }); + + it('should render AI and Sponsored sections', () => { + const tool = new DisclosureTool({ data: {}, config: {}, api: mockAPI as any }); + const el = tool.render(); + const sections = el.querySelectorAll('.cdx-disclosure__section'); + expect(sections.length).toBe(2); + }); + + it('should render AI level select', () => { + const tool = new DisclosureTool({ data: {}, config: {}, api: mockAPI as any }); + const el = tool.render(); + const select = el.querySelector('[id^="disclosure-ai-level-"]') as HTMLSelectElement; + expect(select).toBeTruthy(); + // Should have options for each level (excluding 'none' from select) + expect(select.options.length).toBe(4); + }); + + it('should render sponsored type radio buttons', () => { + const tool = new DisclosureTool({ data: {}, config: {}, api: mockAPI as any }); + const el = tool.render(); + const radios = el.querySelectorAll('input[name^="disclosure-sponsored-type-"]'); + expect(radios.length).toBe(2); + }); + + it('should hide AI body when AI is disabled', () => { + const tool = new DisclosureTool({ data: { aiEnabled: false }, config: {}, api: mockAPI as any }); + const el = tool.render(); + const body = el.querySelector('.cdx-disclosure__section-body') as HTMLElement; + expect(body.style.display).toBe('none'); + }); + + it('should show AI body when AI is enabled', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'slight' }, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const bodies = el.querySelectorAll('.cdx-disclosure__section-body') as NodeListOf; + expect(bodies[0].style.display).toBe('flex'); + }); + + it('should show percent input only when level is custom', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'custom', aiPercent: 70 }, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const percentInput = el.querySelector('[id^="disclosure-ai-percent-"]') as HTMLInputElement; + expect(percentInput).toBeTruthy(); + expect(percentInput.value).toBe('70'); + }); + + it('should toggle AI body when toggle is clicked', () => { + const tool = new DisclosureTool({ data: { aiEnabled: false }, config: {}, api: mockAPI as any }); + const el = tool.render(); + const checkbox = el.querySelector('[id^="ai-enabled-"]') as HTMLInputElement; + + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + + const saved = tool.save(); + expect(saved.aiEnabled).toBe(true); + }); + + it('should toggle sponsored body when toggle is clicked', () => { + const tool = new DisclosureTool({ data: { sponsoredEnabled: false }, config: {}, api: mockAPI as any }); + const el = tool.render(); + const checkbox = el.querySelector('[id^="sponsored-enabled-"]') as HTMLInputElement; + + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + + const saved = tool.save(); + expect(saved.sponsoredEnabled).toBe(true); + }); + }); + + describe('AI Disclosure Text', () => { + it('should return empty string when AI is disabled', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: false, aiLevel: 'slight' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getAIDisclosureText()).toBe(''); + }); + + it('should return empty string when level is none', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'none' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getAIDisclosureText()).toBe(''); + }); + + it('should return slight wording', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'slight' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getAIDisclosureText()).toBe(AI_LEVEL_WORDING.slight); + }); + + it('should return mid wording', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'mid' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getAIDisclosureText()).toBe(AI_LEVEL_WORDING.mid); + }); + + it('should return high wording', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'high' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getAIDisclosureText()).toBe(AI_LEVEL_WORDING.high); + }); + + it('should return custom percent wording', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'custom', aiPercent: 75 }, + config: {}, + api: mockAPI as any, + }); + const text = tool.getAIDisclosureText(); + expect(text).toContain('75%'); + }); + }); + + describe('Sponsored Text', () => { + it('should return empty when sponsored is disabled', () => { + const tool = new DisclosureTool({ data: { sponsoredEnabled: false }, config: {}, api: mockAPI as any }); + expect(tool.getSponsoredText()).toBe(''); + }); + + it('should return preset text', () => { + const tool = new DisclosureTool({ + data: { sponsoredEnabled: true, sponsoredType: 'preset' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getSponsoredText()).toBe(SPONSORED_PRESET_TEXT); + }); + + it('should return custom text', () => { + const tool = new DisclosureTool({ + data: { sponsoredEnabled: true, sponsoredType: 'custom', sponsoredText: 'Paid post.' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getSponsoredText()).toBe('Paid post.'); + }); + + it('should return empty for custom with blank text', () => { + const tool = new DisclosureTool({ + data: { sponsoredEnabled: true, sponsoredType: 'custom', sponsoredText: ' ' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.getSponsoredText()).toBe(''); + }); + }); + + describe('Validation', () => { + it('should fail when both AI and sponsored are disabled', () => { + const tool = new DisclosureTool({ data: {}, config: {}, api: mockAPI as any }); + expect(tool.validate(tool.save())).toBe(false); + }); + + it('should fail when AI enabled but level is none', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'none' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.validate(tool.save())).toBe(false); + }); + + it('should pass when AI enabled with valid level', () => { + const tool = new DisclosureTool({ + data: { aiEnabled: true, aiLevel: 'slight' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.validate(tool.save())).toBe(true); + }); + + it('should pass when sponsored preset enabled', () => { + const tool = new DisclosureTool({ + data: { sponsoredEnabled: true, sponsoredType: 'preset' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.validate(tool.save())).toBe(true); + }); + + it('should fail when sponsored custom enabled with empty text', () => { + const tool = new DisclosureTool({ + data: { sponsoredEnabled: true, sponsoredType: 'custom', sponsoredText: '' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.validate(tool.save())).toBe(false); + }); + + it('should pass when sponsored custom enabled with valid text', () => { + const tool = new DisclosureTool({ + data: { sponsoredEnabled: true, sponsoredType: 'custom', sponsoredText: 'Paid partnership.' }, + config: {}, + api: mockAPI as any, + }); + expect(tool.validate(tool.save())).toBe(true); + }); + + it('should pass when both AI and sponsored are valid', () => { + const tool = new DisclosureTool({ + data: { + aiEnabled: true, + aiLevel: 'high', + sponsoredEnabled: true, + sponsoredType: 'preset', + }, + config: {}, + api: mockAPI as any, + }); + expect(tool.validate(tool.save())).toBe(true); + }); + }); + + describe('Constants', () => { + it('AI_LEVEL_LABELS should have entries for all levels', () => { + const levels = ['none', 'slight', 'mid', 'high', 'custom'] as const; + levels.forEach((l) => { + expect(AI_LEVEL_LABELS[l]).toBeTruthy(); + }); + }); + + it('SPONSORED_PRESET_TEXT should be a non-empty string', () => { + expect(SPONSORED_PRESET_TEXT.length).toBeGreaterThan(10); + }); + }); +}); diff --git a/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.ts b/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.ts new file mode 100644 index 000000000..00c2aa828 --- /dev/null +++ b/packages/ottaeditor/src/tools/DisclosureTool/DisclosureTool.ts @@ -0,0 +1,443 @@ +import type { API, BlockTool } from '@editorjs/editorjs'; +import './DisclosureTool.css'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AIDisclosureLevel = 'none' | 'slight' | 'mid' | 'high' | 'custom'; + +export interface DisclosureData { + /** Whether the AI disclosure section is enabled */ + aiEnabled: boolean; + /** Preset level for AI usage, or 'custom' for a percentage */ + aiLevel: AIDisclosureLevel; + /** Custom percentage value (1–100), used when aiLevel === 'custom' */ + aiPercent?: number; + /** Whether the sponsored disclosure section is enabled */ + sponsoredEnabled: boolean; + /** Preset sponsored text ('preset') or custom text */ + sponsoredType: 'preset' | 'custom'; + /** Custom sponsored text, used when sponsoredType === 'custom' */ + sponsoredText?: string; +} + +export interface DisclosureToolConfig { + defaultAILevel?: AIDisclosureLevel; +} + +// --------------------------------------------------------------------------- +// Standard wording presets +// --------------------------------------------------------------------------- + +export const AI_LEVEL_LABELS: Record = { + none: 'None', + slight: 'Slight AI help', + mid: 'Significant AI help', + high: 'Primarily AI-generated', + custom: 'Custom %', +}; + +export const AI_LEVEL_WORDING: Record, string> = { + slight: 'AI tools were used to assist in light editing and proofreading of this content.', + mid: 'AI tools were significantly used in drafting and editing this content.', + high: 'This content was primarily generated with AI assistance and reviewed by a human editor.', +}; + +export const SPONSORED_PRESET_TEXT = + 'This content was created in partnership with a sponsor. Our editorial standards remain independent.'; + +// --------------------------------------------------------------------------- +// Tool implementation +// --------------------------------------------------------------------------- + +export default class DisclosureTool implements BlockTool { + private api: API; + private data: DisclosureData; + private config: DisclosureToolConfig; + private wrapper: HTMLElement | null = null; + private instanceId: string; + + private static idSeed = 0; + + static get CSS() { + return { + baseClass: 'cdx-disclosure', + wrapper: 'cdx-disclosure__wrapper', + section: 'cdx-disclosure__section', + sectionHeader: 'cdx-disclosure__section-header', + sectionToggle: 'cdx-disclosure__section-toggle', + sectionBody: 'cdx-disclosure__section-body', + label: 'cdx-disclosure__label', + sublabel: 'cdx-disclosure__sublabel', + select: 'cdx-disclosure__select', + input: 'cdx-disclosure__input', + textarea: 'cdx-disclosure__textarea', + previewText: 'cdx-disclosure__preview', + row: 'cdx-disclosure__row', + toggle: 'cdx-disclosure__toggle', + toggleSlider: 'cdx-disclosure__toggle-slider', + radioGroup: 'cdx-disclosure__radio-group', + radio: 'cdx-disclosure__radio', + radioLabel: 'cdx-disclosure__radio-label', + }; + } + + static get toolbox() { + return { + title: 'Disclosure', + // Tabler icon: shield (disclosure/transparency feel) + icon: '', + }; + } + + static get enableLineBreaks() { + return true; + } + + constructor({ + data, + config, + api, + block, + }: { + data?: Partial; + config?: DisclosureToolConfig; + api: API; + block?: { id?: string }; + }) { + this.api = api; + this.config = config || {}; + this.instanceId = block?.id || DisclosureTool.nextId(); + + const defaultAI = this.config.defaultAILevel || 'none'; + + this.data = { + aiEnabled: data?.aiEnabled ?? defaultAI !== 'none', + aiLevel: data?.aiLevel || defaultAI, + aiPercent: data?.aiPercent ?? 50, + sponsoredEnabled: data?.sponsoredEnabled ?? false, + sponsoredType: data?.sponsoredType || 'preset', + sponsoredText: data?.sponsoredText || '', + }; + } + + private static nextId(): string { + DisclosureTool.idSeed += 1; + return `disclosure-${DisclosureTool.idSeed}`; + } + + private domId(name: string): string { + return `${name}-${this.instanceId}`; + } + + render(): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.classList.add(DisclosureTool.CSS.baseClass, DisclosureTool.CSS.wrapper); + + wrapper.appendChild(this.buildAISection()); + wrapper.appendChild(this.buildSponsoredSection()); + + this.wrapper = wrapper; + return wrapper; + } + + // ------------------------------------------------------------------------- + // AI Disclosure section + // ------------------------------------------------------------------------- + + private buildAISection(): HTMLElement { + const section = document.createElement('div'); + section.classList.add(DisclosureTool.CSS.section); + + // Header with toggle + const header = document.createElement('div'); + header.classList.add(DisclosureTool.CSS.sectionHeader); + + const titleGroup = document.createElement('div'); + // Tabler robot icon + titleGroup.innerHTML = + ''; + const titleSpan = document.createElement('span'); + titleSpan.classList.add(DisclosureTool.CSS.label); + titleSpan.textContent = 'AI Disclosure'; + titleGroup.appendChild(titleSpan); + titleGroup.style.display = 'inline-flex'; + titleGroup.style.alignItems = 'center'; + titleGroup.style.gap = '6px'; + + const aiEnabledId = this.domId('ai-enabled'); + const toggle = this.buildToggle(aiEnabledId, this.data.aiEnabled, (checked) => { + this.data.aiEnabled = checked; + body.style.display = checked ? 'flex' : 'none'; + }); + + header.appendChild(titleGroup); + header.appendChild(toggle); + section.appendChild(header); + + // Body (shown when toggle on) + const body = document.createElement('div'); + body.classList.add(DisclosureTool.CSS.sectionBody); + body.style.display = this.data.aiEnabled ? 'flex' : 'none'; + + // Level select + const levelGroup = document.createElement('div'); + levelGroup.classList.add(DisclosureTool.CSS.row); + + const levelLabel = document.createElement('label'); + levelLabel.classList.add(DisclosureTool.CSS.sublabel); + levelLabel.textContent = 'AI usage level'; + const aiLevelId = this.domId('disclosure-ai-level'); + levelLabel.setAttribute('for', aiLevelId); + + const levelSelect = document.createElement('select'); + levelSelect.id = aiLevelId; + levelSelect.classList.add(DisclosureTool.CSS.select); + + const levels: AIDisclosureLevel[] = ['slight', 'mid', 'high', 'custom']; + levels.forEach((level) => { + const opt = document.createElement('option'); + opt.value = level; + opt.textContent = AI_LEVEL_LABELS[level]; + opt.selected = level === this.data.aiLevel; + levelSelect.appendChild(opt); + }); + + // Custom percent input (shown only when 'custom' selected) + const percentGroup = document.createElement('div'); + percentGroup.classList.add(DisclosureTool.CSS.row); + percentGroup.style.display = this.data.aiLevel === 'custom' ? 'flex' : 'none'; + + const percentLabel = document.createElement('label'); + percentLabel.classList.add(DisclosureTool.CSS.sublabel); + percentLabel.textContent = 'AI usage %'; + const aiPercentId = this.domId('disclosure-ai-percent'); + percentLabel.setAttribute('for', aiPercentId); + + const percentInput = document.createElement('input'); + percentInput.id = aiPercentId; + percentInput.type = 'number'; + percentInput.min = '1'; + percentInput.max = '100'; + percentInput.classList.add(DisclosureTool.CSS.input); + percentInput.value = String(this.data.aiPercent ?? 50); + percentInput.placeholder = '1–100'; + percentInput.addEventListener('input', () => { + const val = parseInt(percentInput.value); + if (Number.isFinite(val) && val >= 1 && val <= 100) { + this.data.aiPercent = val; + this.updateAIPreview(previewEl); + } + }); + + percentGroup.appendChild(percentLabel); + percentGroup.appendChild(percentInput); + + // Preview text + const previewEl = document.createElement('div'); + previewEl.classList.add(DisclosureTool.CSS.previewText); + + levelSelect.addEventListener('change', () => { + this.data.aiLevel = levelSelect.value as AIDisclosureLevel; + percentGroup.style.display = this.data.aiLevel === 'custom' ? 'flex' : 'none'; + this.updateAIPreview(previewEl); + }); + + this.updateAIPreview(previewEl); + + levelGroup.appendChild(levelLabel); + levelGroup.appendChild(levelSelect); + + body.appendChild(levelGroup); + body.appendChild(percentGroup); + body.appendChild(previewEl); + + section.appendChild(body); + return section; + } + + private updateAIPreview(el: HTMLElement): void { + const text = this.getAIDisclosureText(); + el.textContent = text ? `Preview: "${text}"` : ''; + el.style.display = text ? 'block' : 'none'; + } + + getAIDisclosureText(): string { + if (!this.data.aiEnabled || this.data.aiLevel === 'none') return ''; + if (this.data.aiLevel === 'custom') { + const pct = this.data.aiPercent ?? 50; + return `Approximately ${pct}% of this content was created with AI assistance.`; + } + return AI_LEVEL_WORDING[this.data.aiLevel as keyof typeof AI_LEVEL_WORDING] || ''; + } + + // ------------------------------------------------------------------------- + // Sponsored Disclosure section + // ------------------------------------------------------------------------- + + private buildSponsoredSection(): HTMLElement { + const section = document.createElement('div'); + section.classList.add(DisclosureTool.CSS.section); + section.style.borderTop = '1px solid hsl(var(--border))'; + section.style.marginTop = '4px'; + section.style.paddingTop = '8px'; + + // Header with toggle + const header = document.createElement('div'); + header.classList.add(DisclosureTool.CSS.sectionHeader); + + const titleGroup = document.createElement('div'); + // Tabler tag icon (sponsored/commercial) + titleGroup.innerHTML = + ''; + const titleSpan = document.createElement('span'); + titleSpan.classList.add(DisclosureTool.CSS.label); + titleSpan.textContent = 'Sponsored Disclosure'; + titleGroup.appendChild(titleSpan); + titleGroup.style.display = 'inline-flex'; + titleGroup.style.alignItems = 'center'; + titleGroup.style.gap = '6px'; + + const sponsoredEnabledId = this.domId('sponsored-enabled'); + const toggle = this.buildToggle(sponsoredEnabledId, this.data.sponsoredEnabled, (checked) => { + this.data.sponsoredEnabled = checked; + body.style.display = checked ? 'flex' : 'none'; + }); + + header.appendChild(titleGroup); + header.appendChild(toggle); + section.appendChild(header); + + // Body + const body = document.createElement('div'); + body.classList.add(DisclosureTool.CSS.sectionBody); + body.style.display = this.data.sponsoredEnabled ? 'flex' : 'none'; + + // Type radio: Preset vs Custom + const typeGroup = document.createElement('div'); + typeGroup.classList.add(DisclosureTool.CSS.radioGroup); + + const previewEl = document.createElement('div'); + previewEl.classList.add(DisclosureTool.CSS.previewText); + + const customTextGroup = document.createElement('div'); + customTextGroup.classList.add(DisclosureTool.CSS.row); + customTextGroup.style.display = this.data.sponsoredType === 'custom' ? 'flex' : 'none'; + + const customLabel = document.createElement('label'); + customLabel.classList.add(DisclosureTool.CSS.sublabel); + customLabel.textContent = 'Custom disclaimer'; + const sponsoredTextId = this.domId('disclosure-sponsored-text'); + customLabel.setAttribute('for', sponsoredTextId); + + const customTextarea = document.createElement('textarea'); + customTextarea.id = sponsoredTextId; + customTextarea.classList.add(DisclosureTool.CSS.textarea); + customTextarea.placeholder = 'Enter your custom sponsored disclaimer...'; + customTextarea.value = this.data.sponsoredText || ''; + customTextarea.rows = 2; + customTextarea.addEventListener('input', () => { + this.data.sponsoredText = customTextarea.value; + this.updateSponsoredPreview(previewEl); + }); + + customTextGroup.appendChild(customLabel); + customTextGroup.appendChild(customTextarea); + + const radioTypes: Array<{ value: 'preset' | 'custom'; label: string }> = [ + { value: 'preset', label: 'Standard text' }, + { value: 'custom', label: 'Custom text' }, + ]; + + const sponsoredTypeName = this.domId('disclosure-sponsored-type'); + radioTypes.forEach(({ value, label }) => { + const radioWrapper = document.createElement('label'); + radioWrapper.classList.add(DisclosureTool.CSS.radioLabel); + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = sponsoredTypeName; + radio.classList.add(DisclosureTool.CSS.radio); + radio.value = value; + radio.checked = value === this.data.sponsoredType; + radio.addEventListener('change', () => { + this.data.sponsoredType = value; + customTextGroup.style.display = value === 'custom' ? 'flex' : 'none'; + this.updateSponsoredPreview(previewEl); + }); + + radioWrapper.appendChild(radio); + radioWrapper.appendChild(document.createTextNode(label)); + typeGroup.appendChild(radioWrapper); + }); + + this.updateSponsoredPreview(previewEl); + + body.appendChild(typeGroup); + body.appendChild(customTextGroup); + body.appendChild(previewEl); + + section.appendChild(body); + return section; + } + + private updateSponsoredPreview(el: HTMLElement): void { + const text = this.getSponsoredText(); + el.textContent = text ? `Preview: "${text}"` : ''; + el.style.display = text ? 'block' : 'none'; + } + + getSponsoredText(): string { + if (!this.data.sponsoredEnabled) return ''; + if (this.data.sponsoredType === 'custom') return this.data.sponsoredText?.trim() || ''; + return SPONSORED_PRESET_TEXT; + } + + // ------------------------------------------------------------------------- + // Shared: toggle switch + // ------------------------------------------------------------------------- + + private buildToggle(id: string, checked: boolean, onChange: (checked: boolean) => void): HTMLElement { + const label = document.createElement('label'); + label.classList.add(DisclosureTool.CSS.toggle); + label.setAttribute('for', id); + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = id; + input.checked = checked; + input.style.display = 'none'; + input.addEventListener('change', () => onChange(input.checked)); + + const slider = document.createElement('span'); + slider.classList.add(DisclosureTool.CSS.toggleSlider); + if (checked) slider.classList.add('cdx-disclosure__toggle-slider--on'); + + input.addEventListener('change', () => { + slider.classList.toggle('cdx-disclosure__toggle-slider--on', input.checked); + }); + + label.appendChild(input); + label.appendChild(slider); + return label; + } + + // ------------------------------------------------------------------------- + // EditorJS lifecycle + // ------------------------------------------------------------------------- + + save(): DisclosureData { + return { ...this.data }; + } + + validate(savedData: DisclosureData): boolean { + // Valid if at least one disclosure type is active and coherent + if (!savedData.aiEnabled && !savedData.sponsoredEnabled) return false; + if (savedData.aiEnabled && savedData.aiLevel === 'none') return false; + if (savedData.sponsoredEnabled && savedData.sponsoredType === 'custom') { + return (savedData.sponsoredText?.trim() || '') !== ''; + } + return true; + } +} diff --git a/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.css b/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.css new file mode 100644 index 000000000..3c6aaea2e --- /dev/null +++ b/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.css @@ -0,0 +1,182 @@ +/* Layout Tool Styles */ + +.cdx-layout__wrapper { + border-radius: 6px; + border: 1px solid hsl(var(--border)); +} + +/* Toolbar */ +.cdx-layout__toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: hsl(var(--muted) / 0.5); + border-bottom: 1px solid hsl(var(--border)); + border-radius: 6px 6px 0 0; + flex-wrap: wrap; +} + +.cdx-layout__toolbar-label { + font-size: 11px; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-right: 4px; +} + +/* Preset buttons */ +.cdx-layout__preset-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid hsl(var(--border)); + border-radius: 4px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.cdx-layout__preset-btn:hover { + border-color: hsl(var(--primary) / 0.5); + background: hsl(var(--primary) / 0.05); + color: hsl(var(--primary)); +} + +.cdx-layout__preset-btn--active { + border-color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + font-weight: 600; +} + +/* Preset icon (mini column diagram) */ +.cdx-layout__preset-icon { + display: inline-flex; + gap: 2px; + align-items: stretch; + height: 12px; +} + +.cdx-layout__preset-icon span { + display: block; + background: currentColor; + border-radius: 1px; + opacity: 0.8; + min-width: 4px; +} + +/* Columns container */ +.cdx-layout__columns { + display: flex; + gap: 0; + min-height: 80px; +} + +/* Individual column */ +.cdx-layout__column { + flex: 1; + min-width: 0; + position: relative; + border-right: 1px solid hsl(var(--border)); +} + +.cdx-layout__column:last-child { + border-right: none; +} + +/* Column header */ +.cdx-layout__col-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + background: hsl(var(--muted) / 0.3); + border-bottom: 1px solid hsl(var(--border) / 0.5); +} + +.cdx-layout__col-label { + font-size: 10px; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.cdx-layout__col-width { + font-size: 10px; + color: hsl(var(--muted-foreground)); + opacity: 0.7; +} + +.cdx-layout__col-meta { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.cdx-layout__col-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 1px solid transparent; + background: transparent; + color: hsl(var(--muted-foreground)); + border-radius: 4px; + padding: 0; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.cdx-layout__col-clear:hover { + border-color: hsl(var(--destructive) / 0.4); + background: hsl(var(--destructive) / 0.08); + color: hsl(var(--destructive)); +} + +/* Nested editor area */ +.cdx-layout__col-editor { + min-height: 80px; + padding: 4px; + outline: none; + position: relative; +} + +/* Style nested editor instances to look native */ +.cdx-layout__col-editor .codex-editor { + min-height: 60px; +} + +.cdx-layout__col-editor .codex-editor__redactor { + padding-bottom: 16px !important; +} + +.cdx-layout__col-editor .ce-block { + font-size: 14px; +} + +/* Make the nested editor placeholder smaller */ +.cdx-layout__col-editor .codex-editor__redactor:only-child::before { + font-size: 13px; +} + +/* Placeholder when editor not yet initialized */ +.cdx-layout__col-placeholder { + padding: 16px 12px; + color: hsl(var(--muted-foreground)); + font-size: 13px; + font-style: italic; + text-align: center; + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.test.ts b/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.test.ts new file mode 100644 index 000000000..b9bcb4d19 --- /dev/null +++ b/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.test.ts @@ -0,0 +1,367 @@ +// @ts-nocheck - EditorJS BlockToolConstructorOptions has inconsistent required fields across versions +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import LayoutTool from './LayoutTool'; + +// Mock CSS import +vi.mock('./LayoutTool.css', () => ({})); + +// Mock EditorJS – full nested editor init is not needed in unit tests +vi.mock('@editorjs/editorjs', () => { + const MockEditorJS = vi.fn().mockImplementation(() => ({ + isReady: Promise.resolve(), + save: vi.fn().mockResolvedValue({ blocks: [] }), + destroy: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + render: vi.fn().mockResolvedValue(undefined), + })); + return { default: MockEditorJS }; +}); + +// Mock optional nested tool imports (matches buildDefaultTools fallback list) +vi.mock('@editorjs/paragraph', () => ({ default: {} })); +vi.mock('@editorjs/header', () => ({ default: {} })); +vi.mock('@editorjs/delimiter', () => ({ default: {} })); +vi.mock('@editorjs/nested-list', () => ({ default: {} })); +vi.mock('@editorjs/checklist', () => ({ default: {} })); +vi.mock('@editorjs/table', () => ({ default: {} })); + +const createMockAPI = () => ({ + blocks: { getCurrentBlockIndex: vi.fn(() => 0) }, + ui: { notifier: { show: vi.fn() } }, +}); + +describe('LayoutTool', () => { + let mockAPI: ReturnType; + + beforeEach(() => { + mockAPI = createMockAPI(); + // Provide unique IDs so parallel tests don't conflict + vi.spyOn(Date, 'now').mockReturnValue(1000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Toolbox', () => { + it('should have correct toolbox configuration', () => { + expect(LayoutTool.toolbox.title).toBe('Layout'); + expect(LayoutTool.toolbox.icon).toBeTruthy(); + }); + + it('should enable line breaks', () => { + expect(LayoutTool.enableLineBreaks).toBe(true); + }); + }); + + describe('Initialization', () => { + it('should default to 1-1 (50/50) preset', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const saved = tool.save() as any; + return saved.then((data: any) => { + expect(data.preset).toBe('1-1'); + expect(data.columns).toHaveLength(2); + }); + }); + + it('should initialise with provided preset', () => { + const tool = new LayoutTool({ + data: { preset: '1-3', columns: [] } as any, + config: {}, + api: mockAPI as any, + }); + const saved = tool.save() as any; + return saved.then((data: any) => { + expect(data.preset).toBe('1-3'); + expect(data.columns).toHaveLength(2); + }); + }); + + it('should initialise 3-column preset', () => { + const tool = new LayoutTool({ + data: { preset: '1-1-1', columns: [] } as any, + config: {}, + api: mockAPI as any, + }); + const saved = tool.save() as any; + return saved.then((data: any) => { + expect(data.preset).toBe('1-1-1'); + expect(data.columns).toHaveLength(3); + }); + }); + + it('should preserve existing column content', () => { + const colContent = { blocks: [{ id: '1', type: 'paragraph', data: { text: 'Hello' } }] }; + const tool = new LayoutTool({ + data: { + preset: '1-1', + columns: [{ content: colContent }, { content: { blocks: [] } }], + } as any, + config: {}, + api: mockAPI as any, + }); + const saved = tool.save() as any; + return saved.then((data: any) => { + // column data preserved from initialisation (nested editors return empty in mock) + expect(data.columns[0]).toBeDefined(); + }); + }); + + it('should fall back to 1-1 preset for invalid preset key', () => { + const tool = new LayoutTool({ + data: { preset: 'invalid-key' as any, columns: [] } as any, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const cols = el.querySelectorAll('[data-col]'); + // Falls back to PRESETS[0] (1-1) which has 2 columns + expect(cols.length).toBe(2); + }); + }); + + describe('Rendering', () => { + it('should render wrapper element', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const el = tool.render(); + expect(el).toBeInstanceOf(HTMLElement); + expect(el.classList.contains('cdx-layout')).toBe(true); + }); + + it('should render preset toolbar buttons', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const el = tool.render(); + const buttons = el.querySelectorAll('.cdx-layout__preset-btn'); + // 6 presets + expect(buttons.length).toBe(6); + }); + + it('should mark active preset button', () => { + const tool = new LayoutTool({ + data: { preset: '1-3' } as any, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const activeButtons = el.querySelectorAll('.cdx-layout__preset-btn--active'); + expect(activeButtons.length).toBe(1); + expect(activeButtons[0].textContent).toContain('25 / 75'); + }); + + it('should render correct number of column divs', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const el = tool.render(); + const cols = el.querySelectorAll('[data-col]'); + expect(cols.length).toBe(2); + }); + + it('should render three columns for 1-1-1 preset', () => { + const tool = new LayoutTool({ + data: { preset: '1-1-1' } as any, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const cols = el.querySelectorAll('[data-col]'); + expect(cols.length).toBe(3); + }); + + it('should set correct flex-basis on columns for 1-3 preset', () => { + const tool = new LayoutTool({ + data: { preset: '1-3' } as any, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const cols = el.querySelectorAll('[data-col]') as NodeListOf; + expect(cols[0].style.flexBasis).toBe('25%'); + expect(cols[1].style.flexBasis).toBe('75%'); + }); + + it('should set correct flex-basis for all presets', () => { + const presetExpectations: Array<{ preset: string; widths: string[] }> = [ + { preset: '1-1', widths: ['50%', '50%'] }, + { preset: '3-1', widths: ['75%', '25%'] }, + { preset: '1-2', widths: ['33%', '67%'] }, + { preset: '2-1', widths: ['67%', '33%'] }, + { preset: '1-1-1', widths: ['33%', '33%', '34%'] }, + ]; + for (const { preset, widths } of presetExpectations) { + const tool = new LayoutTool({ + data: { preset } as any, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const cols = el.querySelectorAll('[data-col]') as NodeListOf; + expect(cols.length).toBe(widths.length); + widths.forEach((w, i) => { + expect(cols[i].style.flexBasis).toBe(w); + }); + } + }); + + it('should render column header labels and width text', () => { + const tool = new LayoutTool({ + data: { preset: '1-3' } as any, + config: {}, + api: mockAPI as any, + }); + const el = tool.render(); + const labels = el.querySelectorAll('.cdx-layout__col-label'); + const widths = el.querySelectorAll('.cdx-layout__col-width'); + expect(labels[0].textContent).toBe('Column 1'); + expect(labels[1].textContent).toBe('Column 2'); + expect(widths[0].textContent).toBe('25%'); + expect(widths[1].textContent).toBe('75%'); + }); + + it('should render placeholder text in editor holders', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const el = tool.render(); + const placeholders = el.querySelectorAll('.cdx-layout__col-placeholder'); + expect(placeholders.length).toBe(2); + expect(placeholders[0].textContent).toContain('Type or press'); + }); + + it('should render clear buttons for each column', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const el = tool.render(); + const clearButtons = el.querySelectorAll('.cdx-layout__col-clear'); + expect(clearButtons.length).toBe(2); + }); + }); + + describe('Validation', () => { + it('should pass validation with valid data', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect( + tool.validate({ + preset: '1-1', + columns: [{ content: { blocks: [] } }, { content: { blocks: [] } }], + }), + ).toBe(true); + }); + + it('should fail validation with only one column', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect( + tool.validate({ + preset: '1-1', + columns: [{ content: { blocks: [] } }], + }), + ).toBe(false); + }); + + it('should fail validation with missing preset', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect( + tool.validate({ + preset: '' as any, + columns: [{ content: { blocks: [] } }, { content: { blocks: [] } }], + }), + ).toBe(false); + }); + + it('should fail validation when column count mismatches preset', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + // 1-1-1 expects 3 columns, but only 2 provided + expect( + tool.validate({ + preset: '1-1-1', + columns: [{ content: { blocks: [] } }, { content: { blocks: [] } }], + }), + ).toBe(false); + // 1-1 expects 2 columns, but 3 provided + expect( + tool.validate({ + preset: '1-1', + columns: [{ content: { blocks: [] } }, { content: { blocks: [] } }, { content: { blocks: [] } }], + }), + ).toBe(false); + }); + + it('should pass validation for 3-column preset with 3 columns', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect( + tool.validate({ + preset: '1-1-1', + columns: [{ content: { blocks: [] } }, { content: { blocks: [] } }, { content: { blocks: [] } }], + }), + ).toBe(true); + }); + + it('should fail validation with unknown preset key', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect( + tool.validate({ + preset: 'unknown' as any, + columns: [{ content: { blocks: [] } }, { content: { blocks: [] } }], + }), + ).toBe(false); + }); + + it('should fail validation with non-array columns', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect( + tool.validate({ + preset: '1-1', + columns: 'not-an-array' as any, + }), + ).toBe(false); + }); + }); + + describe('Save', () => { + it('should return a shallow copy of data, not the same reference', async () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + const saved1 = await tool.save(); + const saved2 = await tool.save(); + expect(saved1).not.toBe(saved2); + expect(saved1.columns).not.toBe(saved2.columns); + }); + + it('should return correct preset and column count', async () => { + const tool = new LayoutTool({ + data: { preset: '2-1', columns: [] } as any, + config: {}, + api: mockAPI as any, + }); + const saved = await tool.save(); + expect(saved.preset).toBe('2-1'); + expect(saved.columns).toHaveLength(2); + }); + + it('should clear a column without affecting others', async () => { + const tool = new LayoutTool({ + data: { + preset: '1-1', + columns: [ + { content: { blocks: [{ id: 'a', type: 'paragraph', data: { text: 'A' } }] } }, + { content: { blocks: [{ id: 'b', type: 'paragraph', data: { text: 'B' } }] } }, + ], + } as any, + config: {}, + api: mockAPI as any, + } as any); + + await (tool as any).clearColumn(0); + const saved = await tool.save(); + expect(saved.columns[0].content.blocks).toHaveLength(0); + expect(saved.columns[1].content.blocks).toHaveLength(1); + }); + }); + + describe('Destroy', () => { + it('should not throw when calling destroy', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + tool.render(); + expect(() => tool.destroy()).not.toThrow(); + }); + + it('should not throw when calling destroy before render', () => { + const tool = new LayoutTool({ data: {} as any, config: {}, api: mockAPI as any } as any); + expect(() => tool.destroy()).not.toThrow(); + }); + }); +}); diff --git a/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.ts b/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.ts new file mode 100644 index 000000000..8c3a24562 --- /dev/null +++ b/packages/ottaeditor/src/tools/LayoutTool/LayoutTool.ts @@ -0,0 +1,415 @@ +import EditorJS, { + type API, + type BlockTool, + type BlockToolConstructorOptions, + type OutputData, +} from '@editorjs/editorjs'; +import './LayoutTool.css'; + +/** Column width preset keys */ +export type LayoutPreset = '1-1' | '1-3' | '3-1' | '1-2' | '2-1' | '1-1-1'; + +interface PresetDef { + key: LayoutPreset; + label: string; + /** Column widths as percentages, must sum to 100 */ + widths: number[]; +} + +const PRESETS: PresetDef[] = [ + { key: '1-1', label: '50 / 50', widths: [50, 50] }, + { key: '1-3', label: '25 / 75', widths: [25, 75] }, + { key: '3-1', label: '75 / 25', widths: [75, 25] }, + { key: '1-2', label: '33 / 67', widths: [33, 67] }, + { key: '2-1', label: '67 / 33', widths: [67, 33] }, + { key: '1-1-1', label: '33 / 33 / 33', widths: [33, 33, 34] }, +]; + +export interface LayoutColumnData { + content: OutputData; +} + +export interface LayoutData { + preset: LayoutPreset; + columns: LayoutColumnData[]; +} + +export interface LayoutToolConfig { + /** EditorJS tools to use inside nested column editors */ + tools?: Record; +} + +// Tabler icon: columns (layout-columns feel) +const TOOLBOX_ICON = + ''; + +// Tabler icon: trash (clear column) +const TRASH_ICON = + ''; + +/** + * LayoutTool – multi-column layout where each column contains a full + * nested EditorJS instance. Supports presets 50/50, 25/75, 75/25, + * 33/67, 67/33, and three equal columns. + */ +export default class LayoutTool implements BlockTool { + private api: API; + private data: LayoutData; + private config: LayoutToolConfig; + private wrapper: HTMLElement | null = null; + /** Nested EditorJS instances keyed by column index */ + private nestedEditors: Map = new Map(); + /** Block ID used to generate unique holder IDs */ + private blockId: string; + + static get CSS() { + return { + baseClass: 'cdx-layout', + wrapper: 'cdx-layout__wrapper', + toolbar: 'cdx-layout__toolbar', + toolbarLabel: 'cdx-layout__toolbar-label', + presetBtn: 'cdx-layout__preset-btn', + presetBtnActive: 'cdx-layout__preset-btn--active', + presetIcon: 'cdx-layout__preset-icon', + columns: 'cdx-layout__columns', + column: 'cdx-layout__column', + colHeader: 'cdx-layout__col-header', + colLabel: 'cdx-layout__col-label', + colWidth: 'cdx-layout__col-width', + colMeta: 'cdx-layout__col-meta', + colClearBtn: 'cdx-layout__col-clear', + colEditor: 'cdx-layout__col-editor', + colPlaceholder: 'cdx-layout__col-placeholder', + }; + } + + static get toolbox() { + return { + title: 'Layout', + icon: TOOLBOX_ICON, + }; + } + + static get enableLineBreaks() { + return true; + } + + constructor(options: BlockToolConstructorOptions) { + const { data, config, api } = options; + const block = (options as any).block; + this.api = api; + this.config = config || {}; + this.blockId = block?.id || `layout-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + + const defaultPreset: LayoutPreset = '1-1'; + const preset: LayoutPreset = data?.preset || defaultPreset; + const presetDef = PRESETS.find((p) => p.key === preset) || PRESETS[0]; + const columnCount = presetDef.widths.length; + + const columns: LayoutColumnData[] = []; + for (let i = 0; i < columnCount; i++) { + columns.push({ + content: data?.columns?.[i]?.content || { blocks: [] }, + }); + } + + this.data = { preset, columns }; + } + + render(): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.classList.add(LayoutTool.CSS.baseClass, LayoutTool.CSS.wrapper); + + wrapper.appendChild(this.buildToolbar()); + wrapper.appendChild(this.buildColumns()); + + this.wrapper = wrapper; + + requestAnimationFrame(() => this.initNestedEditors()); + + return wrapper; + } + + private buildToolbar(): HTMLElement { + const toolbar = document.createElement('div'); + toolbar.classList.add(LayoutTool.CSS.toolbar); + + const label = document.createElement('span'); + label.classList.add(LayoutTool.CSS.toolbarLabel); + label.textContent = 'Layout'; + toolbar.appendChild(label); + + PRESETS.forEach((preset) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.classList.add(LayoutTool.CSS.presetBtn); + btn.title = preset.label; + if (preset.key === this.data.preset) { + btn.classList.add(LayoutTool.CSS.presetBtnActive); + } + + btn.appendChild(this.buildPresetIcon(preset.widths)); + + const btnLabel = document.createElement('span'); + btnLabel.textContent = preset.label; + btn.appendChild(btnLabel); + + btn.addEventListener('click', () => this.switchPreset(preset.key)); + toolbar.appendChild(btn); + }); + + return toolbar; + } + + private buildPresetIcon(widths: number[]): HTMLElement { + const icon = document.createElement('span'); + icon.classList.add(LayoutTool.CSS.presetIcon); + icon.setAttribute('aria-hidden', 'true'); + const total = widths.reduce((s, w) => s + w, 0); + widths.forEach((w) => { + const bar = document.createElement('span'); + bar.style.width = `${Math.round((w / total) * 20)}px`; + icon.appendChild(bar); + }); + return icon; + } + + private buildColumns(): HTMLElement { + const columnsEl = document.createElement('div'); + columnsEl.classList.add(LayoutTool.CSS.columns); + columnsEl.setAttribute('data-key', 'columns'); + + const presetDef = PRESETS.find((p) => p.key === this.data.preset) || PRESETS[0]; + + presetDef.widths.forEach((width, idx) => { + columnsEl.appendChild(this.buildColumn(idx, width)); + }); + + return columnsEl; + } + + private buildColumn(idx: number, width: number): HTMLElement { + const col = document.createElement('div'); + col.classList.add(LayoutTool.CSS.column); + col.style.flexBasis = `${width}%`; + col.style.flexGrow = '0'; + col.style.flexShrink = '0'; + col.setAttribute('data-col', String(idx)); + + const header = document.createElement('div'); + header.classList.add(LayoutTool.CSS.colHeader); + + const colLabel = document.createElement('span'); + colLabel.classList.add(LayoutTool.CSS.colLabel); + colLabel.textContent = `Column ${idx + 1}`; + + const colMeta = document.createElement('div'); + colMeta.classList.add(LayoutTool.CSS.colMeta); + + const colWidth = document.createElement('span'); + colWidth.classList.add(LayoutTool.CSS.colWidth); + colWidth.textContent = `${width}%`; + colMeta.appendChild(colWidth); + + // Clear button with trash icon + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.classList.add(LayoutTool.CSS.colClearBtn); + clearBtn.title = 'Clear column'; + clearBtn.setAttribute('aria-label', `Clear column ${idx + 1}`); + clearBtn.innerHTML = TRASH_ICON; + clearBtn.addEventListener('click', () => this.clearColumn(idx)); + colMeta.appendChild(clearBtn); + + header.appendChild(colLabel); + header.appendChild(colMeta); + col.appendChild(header); + + const editorHolder = document.createElement('div'); + editorHolder.classList.add(LayoutTool.CSS.colEditor); + editorHolder.id = this.colHolderId(idx); + + const placeholder = document.createElement('div'); + placeholder.classList.add(LayoutTool.CSS.colPlaceholder); + placeholder.textContent = 'Type or press / to add blocks…'; + editorHolder.appendChild(placeholder); + + col.appendChild(editorHolder); + return col; + } + + private colHolderId(idx: number): string { + return `cdx-layout-col-${this.blockId}-${idx}`; + } + + private async initNestedEditors(): Promise { + this.destroyNestedEditors(); + + const presetDef = PRESETS.find((p) => p.key === this.data.preset) || PRESETS[0]; + + for (let idx = 0; idx < presetDef.widths.length; idx++) { + const holderId = this.colHolderId(idx); + const holderEl = document.getElementById(holderId); + if (!holderEl) continue; + + holderEl.innerHTML = ''; + + const colData = this.data.columns[idx]?.content || { blocks: [] }; + + try { + const editor = new EditorJS({ + holder: holderId, + data: colData, + tools: this.config.tools || this.buildDefaultTools(), + placeholder: 'Type or press / to add blocks…', + minHeight: 60, + logLevel: 'ERROR' as any, + }); + + await editor.isReady; + this.nestedEditors.set(idx, editor); + } catch { + if (holderEl) { + const ph = document.createElement('div'); + ph.classList.add(LayoutTool.CSS.colPlaceholder); + ph.textContent = 'Type or press / to add blocks…'; + holderEl.appendChild(ph); + } + } + } + } + + /** + * Default tools for nested editors. + * Excludes Layout to prevent infinite nesting. + */ + private buildDefaultTools(): Record { + const tools: Record = {}; + const toolImports: Array<{ name: string; module: string; config?: Record }> = [ + { name: 'paragraph', module: '@editorjs/paragraph' }, + { name: 'header', module: '@editorjs/header', config: { levels: [1, 2, 3, 4, 5, 6], defaultLevel: 2 } }, + { name: 'delimiter', module: '@editorjs/delimiter' }, + { name: 'list', module: '@editorjs/nested-list', config: { defaultStyle: 'unordered' } }, + { name: 'checklist', module: '@editorjs/checklist' }, + { name: 'table', module: '@editorjs/table', config: { rows: 2, cols: 3 } }, + ]; + + for (const { name, module, config } of toolImports) { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Tool = require(module).default || require(module); + tools[name] = config ? { class: Tool, config } : { class: Tool }; + } catch { + // Tool not available – skip silently + } + } + + return tools; + } + + private destroyNestedEditors(): void { + this.nestedEditors.forEach((editor) => { + try { + editor.destroy(); + } catch { + // ignore errors during cleanup + } + }); + this.nestedEditors.clear(); + } + + private async clearColumn(idx: number): Promise { + if (!this.data.columns[idx]) return; + + this.data.columns[idx] = { content: { blocks: [] } }; + + const editor = this.nestedEditors.get(idx) as any; + if (editor) { + try { + await (editor.clear?.() ?? editor.render?.({ blocks: [] })); + } catch { + /* ignore editor clearing errors */ + } + } + + const holderEl = document.getElementById(this.colHolderId(idx)); + if (holderEl && !editor) { + holderEl.innerHTML = ''; + const placeholder = document.createElement('div'); + placeholder.classList.add(LayoutTool.CSS.colPlaceholder); + placeholder.textContent = 'Type or press / to add blocks…'; + holderEl.appendChild(placeholder); + } + } + + private async switchPreset(preset: LayoutPreset): Promise { + if (preset === this.data.preset) return; + + await this.collectColumnData(); + + const newPresetDef = PRESETS.find((p) => p.key === preset) || PRESETS[0]; + const newColumnCount = newPresetDef.widths.length; + + const newColumns: LayoutColumnData[] = []; + for (let i = 0; i < newColumnCount; i++) { + newColumns.push({ + content: this.data.columns[i]?.content || { blocks: [] }, + }); + } + + this.data.preset = preset; + this.data.columns = newColumns; + + if (this.wrapper) { + this.wrapper.querySelectorAll(`.${LayoutTool.CSS.presetBtn}`).forEach((btn, i) => { + btn.classList.toggle(LayoutTool.CSS.presetBtnActive, PRESETS[i]?.key === preset); + }); + } + + const columnsEl = this.wrapper?.querySelector('[data-key="columns"]') as HTMLElement | null; + if (columnsEl) { + columnsEl.innerHTML = ''; + newPresetDef.widths.forEach((width, idx) => { + columnsEl.appendChild(this.buildColumn(idx, width)); + }); + } + + requestAnimationFrame(() => this.initNestedEditors()); + } + + private async collectColumnData(): Promise { + const savePromises: Array> = []; + this.nestedEditors.forEach((editor, idx) => { + savePromises.push( + editor + .save() + .then((outputData) => { + if (this.data.columns[idx]) { + this.data.columns[idx].content = outputData; + } + }) + .catch(() => {}), + ); + }); + await Promise.all(savePromises); + } + + async save(): Promise { + await this.collectColumnData(); + return { ...this.data, columns: this.data.columns.map((c) => ({ ...c })) }; + } + + validate(savedData: LayoutData): boolean { + const presetDef = PRESETS.find((p) => p.key === savedData.preset); + return ( + typeof savedData.preset === 'string' && + !!presetDef && + Array.isArray(savedData.columns) && + savedData.columns.length === presetDef.widths.length + ); + } + + destroy(): void { + this.destroyNestedEditors(); + } +} diff --git a/packages/ottaeditor/src/tools/MapTool/MapTool.css b/packages/ottaeditor/src/tools/MapTool/MapTool.css new file mode 100644 index 000000000..1a9217763 --- /dev/null +++ b/packages/ottaeditor/src/tools/MapTool/MapTool.css @@ -0,0 +1,136 @@ +/* Map Tool Styles */ + +.cdx-map__wrapper { + padding: 10px; + border-radius: 6px; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); +} + +.cdx-map__form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cdx-map__form > * { + margin: 0; +} + +.cdx-map__row { + display: grid; + gap: 8px; +} + +.cdx-map__row--3 { + grid-template-columns: 1fr 1fr 1fr; +} + +.cdx-map__row--2 { + grid-template-columns: 1fr 1fr; +} + +.cdx-map__input-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cdx-map__label { + font-size: 12px; + font-weight: 500; + color: hsl(var(--foreground)); + margin-bottom: 2px; +} + +.cdx-map__input, +.cdx-map__select { + display: block; + width: 100%; + padding: 6px 10px; + border: 1px solid hsl(var(--input)); + border-radius: 4px; + font-size: 13px; + font-family: inherit; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + box-sizing: border-box; +} + +.cdx-map__input:focus, +.cdx-map__select:focus { + outline: none; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); +} + +.cdx-map__hint { + font-size: 11px; + color: hsl(var(--muted-foreground)); + margin-top: 2px; +} + +/* Preview */ +.cdx-map__preview { + margin-top: 4px; + border-top: 1px solid hsl(var(--border)); + padding-top: 8px; +} + +.cdx-map__preview-label { + font-size: 11px; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.cdx-map__iframe-wrapper { + border-radius: 6px; + overflow: hidden; + border: 1px solid hsl(var(--border)); + position: relative; + background: hsl(var(--muted) / 0.3); +} + +.cdx-map__iframe-wrapper--dark { + filter: invert(90%) hue-rotate(180deg); +} + +.cdx-map__iframe { + display: block; + width: 100%; + border: none; +} + +.cdx-map__caption-preview { + font-size: 12px; + color: hsl(var(--muted-foreground)); + text-align: center; + padding: 4px 0; + font-style: italic; +} + +.cdx-map__placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 24px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + text-align: center; +} + +.cdx-map__placeholder-icon { + font-size: 28px; + line-height: 1; +} + +@media (max-width: 480px) { + .cdx-map__row--3 { + grid-template-columns: 1fr 1fr; + } +} diff --git a/packages/ottaeditor/src/tools/MapTool/MapTool.test.ts b/packages/ottaeditor/src/tools/MapTool/MapTool.test.ts new file mode 100644 index 000000000..282d390d7 --- /dev/null +++ b/packages/ottaeditor/src/tools/MapTool/MapTool.test.ts @@ -0,0 +1,191 @@ +// @ts-nocheck - EditorJS BlockToolConstructorOptions has inconsistent required fields across versions +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import MapTool from './MapTool'; + +// Mock CSS import +vi.mock('./MapTool.css', () => ({})); + +const createMockAPI = () => ({ + blocks: { getCurrentBlockIndex: vi.fn(() => 0) }, + ui: { notifier: { show: vi.fn() } }, +}); + +describe('MapTool', () => { + let tool: MapTool; + let mockAPI: ReturnType; + + beforeEach(() => { + mockAPI = createMockAPI(); + tool = new MapTool({ data: {} as any, config: {}, api: mockAPI as any }); + }); + + describe('Toolbox', () => { + it('should have correct toolbox configuration', () => { + expect(MapTool.toolbox.title).toBe('Map'); + expect(MapTool.toolbox.icon).toBeTruthy(); + }); + }); + + describe('Initialization', () => { + it('should initialize with default values', () => { + const saved = tool.save(); + expect(saved.url).toBe(''); + expect(saved.provider).toBe('openstreetmap'); + expect(saved.theme).toBe('default'); + expect(saved.height).toBe(400); + expect(saved.caption).toBe(''); + expect(saved.zoom).toBe(13); + }); + + it('should initialize with provided data', () => { + const customTool = new MapTool({ + data: { + url: 'https://www.openstreetmap.org/#map=13/51.5/-0.1', + provider: 'openstreetmap', + theme: 'dark', + height: 500, + caption: 'London', + zoom: 13, + } as any, + config: {}, + api: mockAPI as any, + }); + + const saved = customTool.save(); + expect(saved.url).toBe('https://www.openstreetmap.org/#map=13/51.5/-0.1'); + expect(saved.provider).toBe('openstreetmap'); + expect(saved.theme).toBe('dark'); + expect(saved.height).toBe(500); + expect(saved.caption).toBe('London'); + }); + + it('should respect config defaults', () => { + const configuredTool = new MapTool({ + data: {} as any, + config: { defaultProvider: 'gmaps', defaultHeight: 300, defaultTheme: 'satellite' }, + api: mockAPI as any, + }); + const saved = configuredTool.save(); + expect(saved.provider).toBe('gmaps'); + expect(saved.height).toBe(300); + expect(saved.theme).toBe('satellite'); + }); + }); + + describe('Rendering', () => { + it('should render wrapper element', () => { + const el = tool.render(); + expect(el).toBeInstanceOf(HTMLElement); + expect(el.classList.contains('cdx-map')).toBe(true); + expect(el.classList.contains('cdx-map__wrapper')).toBe(true); + }); + + it('should render URL input', () => { + const el = tool.render(); + const input = el.querySelector('input[type="url"]') as HTMLInputElement; + expect(input).toBeTruthy(); + }); + + it('auto-detects Google provider for maps.app.goo.gl links', () => { + const el = tool.render(); + const input = el.querySelector('input[type="url"]') as HTMLInputElement; + const providerSelect = el.querySelector('[data-key="provider"]') as HTMLSelectElement; + + input.value = 'https://maps.app.goo.gl/example'; + input.dispatchEvent(new Event('change')); + + expect(providerSelect.value).toBe('gmaps'); + expect(tool.save().provider).toBe('gmaps'); + }); + + it('should render provider select', () => { + const el = tool.render(); + const select = el.querySelector('[data-key="provider"]') as HTMLSelectElement; + expect(select).toBeTruthy(); + const options = Array.from(select.options).map((o) => o.value); + expect(options).toContain('openstreetmap'); + expect(options).toContain('gmaps'); + }); + + it('should render theme select', () => { + const el = tool.render(); + const select = el.querySelector('[data-key="theme"]') as HTMLSelectElement; + expect(select).toBeTruthy(); + const options = Array.from(select.options).map((o) => o.value); + expect(options).toContain('default'); + expect(options).toContain('dark'); + expect(options).toContain('satellite'); + expect(options).toContain('terrain'); + }); + + it('should render height input', () => { + const el = tool.render(); + const input = el.querySelector('input[type="number"]') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe('400'); + }); + + it('should show placeholder when no URL is provided', () => { + const el = tool.render(); + const placeholder = el.querySelector('.cdx-map__placeholder'); + expect(placeholder).toBeTruthy(); + }); + }); + + describe('Validation', () => { + it('should fail validation when URL is empty', () => { + expect(tool.validate(tool.save())).toBe(false); + }); + + it('should pass validation when URL is provided', () => { + const mapWithUrl = new MapTool({ + data: { url: 'https://www.openstreetmap.org/#map=13/51.5/-0.1' } as any, + config: {}, + api: mockAPI as any, + }); + expect(mapWithUrl.validate(mapWithUrl.save())).toBe(true); + }); + }); + + describe('toEmbedUrl', () => { + it('should pass through existing embed URLs unchanged', () => { + const embedUrl = 'https://www.openstreetmap.org/export/embed.html?bbox=1,2,3,4'; + expect(MapTool.toEmbedUrl(embedUrl, 'openstreetmap', 'default', 13)).toBe(embedUrl); + }); + + it('should convert Google Maps URL to embed format', () => { + const url = 'https://www.google.com/maps/place/London'; + const result = MapTool.toEmbedUrl(url, 'gmaps', 'default', 13); + expect(result).toContain('output=embed'); + }); + + it('should set satellite map type for Google Maps', () => { + const url = 'https://www.google.com/maps/place/London'; + const result = MapTool.toEmbedUrl(url, 'gmaps', 'satellite', 13); + expect(result).toContain('t=k'); + }); + + it('should set terrain map type for Google Maps', () => { + const url = 'https://www.google.com/maps/place/London'; + const result = MapTool.toEmbedUrl(url, 'gmaps', 'terrain', 13); + expect(result).toContain('t=p'); + }); + + it('should convert OSM hash URL to embed format', () => { + const url = 'https://www.openstreetmap.org/#map=13/51.5/-0.1'; + const result = MapTool.toEmbedUrl(url, 'openstreetmap', 'default', 13); + expect(result).toContain('openstreetmap.org/export/embed.html'); + expect(result).toContain('bbox='); + }); + + it('should apply dark layer for OpenStreetMap dark theme', () => { + const url = 'https://www.openstreetmap.org/#map=13/51.5/-0.1'; + const result = MapTool.toEmbedUrl(url, 'openstreetmap', 'dark', 13); + expect(result).toContain('layer=HOT'); + }); + + it('should return empty string for empty URL', () => { + expect(MapTool.toEmbedUrl('', 'openstreetmap', 'default', 13)).toBe(''); + }); + }); +}); diff --git a/packages/ottaeditor/src/tools/MapTool/MapTool.ts b/packages/ottaeditor/src/tools/MapTool/MapTool.ts new file mode 100644 index 000000000..dccdffe0e --- /dev/null +++ b/packages/ottaeditor/src/tools/MapTool/MapTool.ts @@ -0,0 +1,481 @@ +import type { API, BlockTool, BlockToolConstructorOptions } from '@editorjs/editorjs'; +import './MapTool.css'; + +export type MapProvider = 'gmaps' | 'openstreetmap'; +export type MapTheme = 'default' | 'dark' | 'satellite' | 'terrain'; + +export interface MapToolConfig { + defaultProvider?: MapProvider; + defaultHeight?: number; + defaultTheme?: MapTheme; +} + +export interface MapData { + url: string; + provider: MapProvider; + theme: MapTheme; + height: number; + caption: string; + zoom: number; +} + +/** + * MapTool – embed Google Maps or OpenStreetMap inside the editor. + * + * Paste a standard map URL or an embed URL; the tool converts it to an + * embeddable iframe src automatically. + */ +export default class MapTool implements BlockTool { + private api: API; + private data: MapData; + private config: MapToolConfig; + private wrapper: HTMLElement | null = null; + + static get CSS() { + return { + baseClass: 'cdx-map', + wrapper: 'cdx-map__wrapper', + form: 'cdx-map__form', + row2: 'cdx-map__row cdx-map__row--2', + row3: 'cdx-map__row cdx-map__row--3', + inputGroup: 'cdx-map__input-group', + label: 'cdx-map__label', + input: 'cdx-map__input', + select: 'cdx-map__select', + hint: 'cdx-map__hint', + preview: 'cdx-map__preview', + previewLabel: 'cdx-map__preview-label', + iframeWrapper: 'cdx-map__iframe-wrapper', + iframe: 'cdx-map__iframe', + captionPreview: 'cdx-map__caption-preview', + placeholder: 'cdx-map__placeholder', + placeholderIcon: 'cdx-map__placeholder-icon', + }; + } + + static get toolbox() { + return { + title: 'Map', + icon: '', + }; + } + + static get enableLineBreaks() { + return false; + } + + constructor({ data, config, api }: BlockToolConstructorOptions) { + this.api = api; + this.config = config || {}; + this.data = { + url: data?.url || '', + provider: data?.provider || this.config.defaultProvider || 'openstreetmap', + theme: data?.theme || this.config.defaultTheme || 'default', + height: data?.height || this.config.defaultHeight || 400, + caption: data?.caption || '', + zoom: data?.zoom ?? 13, + }; + } + + render(): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.classList.add(MapTool.CSS.baseClass, MapTool.CSS.wrapper); + + const form = document.createElement('div'); + form.classList.add(MapTool.CSS.form); + + // URL input (full width) + form.appendChild(this.createUrlInput()); + + // Provider / Theme / Height row + const row = document.createElement('div'); + row.className = MapTool.CSS.row3; + row.appendChild(this.createProviderSelect()); + row.appendChild(this.createThemeSelect()); + row.appendChild(this.createHeightInput()); + form.appendChild(row); + + // Caption + form.appendChild(this.createCaptionInput()); + + // Preview + form.appendChild(this.createPreview()); + + wrapper.appendChild(form); + this.wrapper = wrapper; + + return wrapper; + } + + private createUrlInput(): HTMLElement { + const group = document.createElement('div'); + group.classList.add(MapTool.CSS.inputGroup); + + const label = document.createElement('label'); + label.classList.add(MapTool.CSS.label); + label.textContent = 'Map URL'; + + const input = document.createElement('input'); + input.type = 'url'; + input.classList.add(MapTool.CSS.input); + input.placeholder = 'Paste a Google Maps or OpenStreetMap URL…'; + input.value = this.data.url; + + input.addEventListener('change', () => { + this.data.url = input.value.trim(); + + let detectedProvider: MapProvider | null = null; + try { + const parsedUrl = new URL(this.data.url); + const host = parsedUrl.hostname.toLowerCase(); + const path = parsedUrl.pathname.toLowerCase(); + + const isGoogleDomain = host === 'google.com' || host.endsWith('.google.com'); + const isShortGoogle = host === 'goo.gl'; + const isGoogleMaps = + host === 'maps.app.goo.gl' || ((isGoogleDomain || isShortGoogle) && path.startsWith('/maps')); + + const isOpenStreetMap = [ + 'openstreetmap.org', + 'www.openstreetmap.org', + 'osm.org', + 'www.osm.org', + ].includes(host); + + if (isGoogleMaps) { + detectedProvider = 'gmaps'; + } else if (isOpenStreetMap) { + detectedProvider = 'openstreetmap'; + } + } catch { + // ignore invalid URLs; leave provider unchanged + } + + if (detectedProvider) { + this.data.provider = detectedProvider; + const providerSelect = this.wrapper?.querySelector( + '.cdx-map__select[data-key="provider"]', + ) as HTMLSelectElement | null; + if (providerSelect) providerSelect.value = detectedProvider; + } + + this.refreshPreview(); + }); + + const hint = document.createElement('div'); + hint.classList.add(MapTool.CSS.hint); + hint.textContent = 'Supports Google Maps share links, embed URLs, or OpenStreetMap URLs.'; + + group.appendChild(label); + group.appendChild(input); + group.appendChild(hint); + + return group; + } + + private createProviderSelect(): HTMLElement { + const group = document.createElement('div'); + group.classList.add(MapTool.CSS.inputGroup); + + const label = document.createElement('label'); + label.classList.add(MapTool.CSS.label); + label.textContent = 'Provider'; + + const select = document.createElement('select'); + select.classList.add(MapTool.CSS.select); + select.setAttribute('data-key', 'provider'); + + const providers: Array<{ value: MapProvider; label: string }> = [ + { value: 'openstreetmap', label: 'OpenStreetMap' }, + { value: 'gmaps', label: 'Google Maps' }, + ]; + + providers.forEach(({ value, label: text }) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = text; + opt.selected = value === this.data.provider; + select.appendChild(opt); + }); + + select.addEventListener('change', () => { + this.data.provider = select.value as MapProvider; + this.refreshPreview(); + }); + + group.appendChild(label); + group.appendChild(select); + return group; + } + + private createThemeSelect(): HTMLElement { + const group = document.createElement('div'); + group.classList.add(MapTool.CSS.inputGroup); + + const label = document.createElement('label'); + label.classList.add(MapTool.CSS.label); + label.textContent = 'Theme'; + + const select = document.createElement('select'); + select.classList.add(MapTool.CSS.select); + select.setAttribute('data-key', 'theme'); + + const themes: Array<{ value: MapTheme; label: string }> = [ + { value: 'default', label: 'Default' }, + { value: 'dark', label: 'Dark' }, + { value: 'satellite', label: 'Satellite' }, + { value: 'terrain', label: 'Terrain' }, + ]; + + themes.forEach(({ value, label: text }) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = text; + opt.selected = value === this.data.theme; + select.appendChild(opt); + }); + + select.addEventListener('change', () => { + this.data.theme = select.value as MapTheme; + this.refreshPreview(); + }); + + group.appendChild(label); + group.appendChild(select); + return group; + } + + private createHeightInput(): HTMLElement { + const group = document.createElement('div'); + group.classList.add(MapTool.CSS.inputGroup); + + const label = document.createElement('label'); + label.classList.add(MapTool.CSS.label); + label.textContent = 'Height (px)'; + + const input = document.createElement('input'); + input.type = 'number'; + input.classList.add(MapTool.CSS.input); + input.min = '150'; + input.max = '800'; + input.step = '50'; + input.value = String(this.data.height); + + input.addEventListener('input', () => { + const val = parseInt(input.value, 10); + if (!isNaN(val) && val >= 150 && val <= 800) { + this.data.height = val; + this.refreshPreview(); + } + }); + + group.appendChild(label); + group.appendChild(input); + return group; + } + + private createCaptionInput(): HTMLElement { + const group = document.createElement('div'); + group.classList.add(MapTool.CSS.inputGroup); + + const label = document.createElement('label'); + label.classList.add(MapTool.CSS.label); + label.textContent = 'Caption (optional)'; + + const input = document.createElement('input'); + input.type = 'text'; + input.classList.add(MapTool.CSS.input); + input.placeholder = 'Map caption…'; + input.value = this.data.caption; + + input.addEventListener('input', () => { + this.data.caption = input.value; + this.refreshPreview(); + }); + + group.appendChild(label); + group.appendChild(input); + return group; + } + + private createPreview(): HTMLElement { + const preview = document.createElement('div'); + preview.classList.add(MapTool.CSS.preview); + preview.setAttribute('data-key', 'preview'); + + const previewLabel = document.createElement('div'); + previewLabel.classList.add(MapTool.CSS.previewLabel); + previewLabel.textContent = 'Preview'; + preview.appendChild(previewLabel); + + const iframeWrapper = document.createElement('div'); + iframeWrapper.classList.add(MapTool.CSS.iframeWrapper); + iframeWrapper.setAttribute('data-key', 'iframe-wrapper'); + preview.appendChild(iframeWrapper); + + this.renderPreviewContent(iframeWrapper); + + return preview; + } + + /** Convert a standard map URL to an embeddable iframe src */ + static toEmbedUrl(url: string, provider: MapProvider, theme: MapTheme, zoom: number): string { + if (!url) return ''; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return ''; + } + + const protocol = parsed.protocol.toLowerCase(); + if (protocol !== 'http:' && protocol !== 'https:') { + return ''; + } + + const host = parsed.hostname.toLowerCase(); + const isGMapsHost = [ + 'google.com', + 'www.google.com', + 'maps.google.com', + 'goo.gl', + 'www.goo.gl', + 'maps.app.goo.gl', + ].some((h) => host === h || host.endsWith(`.${h}`)); + const isOsmHost = ['openstreetmap.org', 'www.openstreetmap.org', 'osm.org', 'www.osm.org'].some( + (h) => host === h || host.endsWith(`.${h}`), + ); + + // Already an embed URL – allow only for known hosts + if (url.includes('/embed') || url.includes('output=embed') || url.includes('export/embed')) { + if ((provider === 'gmaps' && isGMapsHost) || (provider === 'openstreetmap' && isOsmHost)) { + return url; + } + return ''; + } + + if (provider === 'gmaps') { + if (!isGMapsHost) return ''; + + const mapType = theme === 'satellite' ? 'k' : theme === 'terrain' ? 'p' : 'm'; + + // https://goo.gl/maps/... short links – wrap in embed + if (url.includes('goo.gl/maps/')) { + return `https://maps.google.com/maps?q=${encodeURIComponent(url)}&output=embed&t=${mapType}`; + } + + // https://www.google.com/maps/place/.../@lat,lng,zoomz => embed without API key + const placeMatch = url.match(/\/maps\/place\/([^/@]+)\/@(-?[\d.]+),(-?[\d.]+)/); + if (placeMatch) { + return `https://maps.google.com/maps?q=${encodeURIComponent(placeMatch[1])}&output=embed&z=${zoom}&t=${mapType}`; + } + + // Fallback: wrap in standard iframe embed + return `https://maps.google.com/maps?q=${encodeURIComponent(url)}&output=embed&t=${mapType}`; + } + + if (provider === 'openstreetmap') { + if (!isOsmHost) return ''; + + // https://www.openstreetmap.org/?mlat=51.5&mlon=-0.1#map=13/51.5/-0.1 + const latMatch = url.match(/[?&#]mlat=([-\d.]+)/); + const lngMatch = url.match(/[?&#]mlon=([-\d.]+)/); + const mapMatch = url.match(/#map=(\d+)\/([-\d.]+)\/([-\d.]+)/); + + if (mapMatch) { + const [, z, lat, lng] = mapMatch; + const bbox = MapTool.bboxFromCenter(parseFloat(lat), parseFloat(lng), parseInt(z, 10)); + const layer = theme === 'satellite' || theme === 'terrain' ? 'C' : theme === 'dark' ? 'HOT' : 'M'; + return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=${layer}&marker=${lat}%2C${lng}`; + } + + if (latMatch && lngMatch) { + const lat = latMatch[1]; + const lng = lngMatch[1]; + const bbox = MapTool.bboxFromCenter(parseFloat(lat), parseFloat(lng), zoom); + const layer = theme === 'satellite' || theme === 'terrain' ? 'C' : theme === 'dark' ? 'HOT' : 'M'; + return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=${layer}&marker=${lat}%2C${lng}`; + } + + // Unknown OSM shape – refuse to embed + return ''; + } + + return ''; + } + + /** Compute a bounding-box string for OpenStreetMap embed from a center + zoom */ + private static bboxFromCenter(lat: number, lng: number, zoom: number): string { + // Approximate degrees per pixel at given zoom (web mercator) + const delta = (360 / Math.pow(2, zoom)) * 3; + const minLng = (lng - delta).toFixed(6); + const maxLng = (lng + delta).toFixed(6); + const minLat = (lat - delta * 0.5).toFixed(6); + const maxLat = (lat + delta * 0.5).toFixed(6); + return `${minLng}%2C${minLat}%2C${maxLng}%2C${maxLat}`; + } + + private renderPreviewContent(wrapper: HTMLElement): void { + wrapper.innerHTML = ''; + + const embedUrl = MapTool.toEmbedUrl(this.data.url, this.data.provider, this.data.theme, this.data.zoom); + + if (!embedUrl) { + const placeholder = document.createElement('div'); + placeholder.classList.add(MapTool.CSS.placeholder); + placeholder.innerHTML = `
🗺️
Paste a map URL above to see a preview
`; + wrapper.appendChild(placeholder); + return; + } + + // Apply theme modifier class + wrapper.className = MapTool.CSS.iframeWrapper; + if (this.data.theme === 'dark') { + wrapper.classList.add('cdx-map__iframe-wrapper--dark'); + } + + const iframe = document.createElement('iframe'); + iframe.classList.add(MapTool.CSS.iframe); + iframe.src = embedUrl; + iframe.height = String(this.data.height); + iframe.style.height = `${this.data.height}px`; + iframe.loading = 'lazy'; + iframe.allow = 'fullscreen'; + iframe.setAttribute('allowfullscreen', ''); + iframe.setAttribute('referrerpolicy', 'no-referrer-when-downgrade'); + wrapper.appendChild(iframe); + } + + private refreshPreview(): void { + if (!this.wrapper) return; + const iframeWrapper = this.wrapper.querySelector('[data-key="iframe-wrapper"]') as HTMLElement | null; + if (iframeWrapper) { + this.renderPreviewContent(iframeWrapper); + } + + // Update caption preview + const preview = this.wrapper.querySelector('[data-key="preview"]') as HTMLElement | null; + if (preview) { + let captionEl = preview.querySelector('.cdx-map__caption-preview') as HTMLElement | null; + if (this.data.caption) { + if (!captionEl) { + captionEl = document.createElement('div'); + captionEl.classList.add(MapTool.CSS.captionPreview); + preview.appendChild(captionEl); + } + captionEl.textContent = this.data.caption; + } else if (captionEl) { + captionEl.remove(); + } + } + } + + save(): MapData { + return { ...this.data }; + } + + validate(savedData: MapData): boolean { + return savedData.url.trim() !== ''; + } +} diff --git a/packages/ottaeditor/src/tools/ReviewTool/ReviewTool.ts b/packages/ottaeditor/src/tools/ReviewTool/ReviewTool.ts index 495bced96..eff79e52c 100644 --- a/packages/ottaeditor/src/tools/ReviewTool/ReviewTool.ts +++ b/packages/ottaeditor/src/tools/ReviewTool/ReviewTool.ts @@ -1,4 +1,4 @@ -import type { API, BlockTool, BlockToolConstructorOptions } from '@editorjs/editorjs'; +import type { API, BlockTool } from '@editorjs/editorjs'; import './ReviewTool.css'; export interface ReviewToolConfig { @@ -45,7 +45,7 @@ export default class ReviewTool implements BlockTool { static get toolbox() { return { title: 'Review', - icon: '', + icon: '', }; } @@ -53,7 +53,7 @@ export default class ReviewTool implements BlockTool { return true; } - constructor({ data, config, api }: BlockToolConstructorOptions) { + constructor({ data, config, api }: { data?: Partial; config?: ReviewToolConfig; api: API }) { this.api = api; this.config = config || {}; this.data = { diff --git a/packages/ottaorm/README.md b/packages/ottaorm/README.md index 3d253a90e..b240d30ef 100644 --- a/packages/ottaorm/README.md +++ b/packages/ottaorm/README.md @@ -168,7 +168,7 @@ Initialize database via your API endpoint: pnpm dev # Initialize database (creates all tables automatically) -curl -X POST http://localhost:3000/api/ottaorm/init +curl -X POST http://localhost:3004/api/ottaorm/init ``` ### Production Setup @@ -535,7 +535,7 @@ const tags = await post.tags({ ```bash # Development (no auth) -curl -X POST http://localhost:3000/api/ottaorm/init +curl -X POST http://localhost:3004/api/ottaorm/init # Production (requires MIGRATION_SECRET) curl -X POST https://your-app.com/api/ottaorm/init \ @@ -611,7 +611,7 @@ status: text('status').notNull(); ``` For complex schema changes, use custom migrations. See -[Migration READMEs](../../apps/ottabase-template-app/ottabase/migrations/README.md) for examples. +[Migration README](../../apps/ottabase-template-app-tanstack/ottabase/migrations/README.md) for examples. ## Type Casting diff --git a/packages/ottarenderer/README.md b/packages/ottarenderer/README.md index bcef7104b..3c5fb6642 100644 --- a/packages/ottarenderer/README.md +++ b/packages/ottarenderer/README.md @@ -5,9 +5,12 @@ React renderer for [Editor.js](https://editorjs.io/) content and generic HTML. ## Features - **Editor.js Support**: Renders blocks from Editor.js (headers, paragraphs, lists, images, etc.). -- **Custom Blocks**: Includes renderers for `@ottabase/ottaeditor` custom blocks (AdvancedImage, Spoiler, CTA, etc.). +- **Custom Blocks**: Includes renderers for `@ottabase/ottaeditor` custom blocks (AdvancedImage, CTA, Disclosure, + Layout, Review, Spoiler, etc.). - **HTML Renderer**: Safe HTML rendering utility. - **Tailwind Configured**: Styled with Tailwind CSS via `@ottabase/ui-base`. +- **Global Theming**: Components use semantic theme token classes (`bg-primary`, `text-foreground`, `border-border`) and + adapt to light/dark mode. - **Extensible**: Supports custom block injectors. ## Installation @@ -26,22 +29,19 @@ Code blocks use `@ottabase/ui-code-highlight`. Import its styles in your app (e. ### Rendering Editor.js Data -The `EditorJsRenderer` component takes the raw output data from Editor.js and renders it as React components. - ```tsx import { default as Renderer } from 'editorjs-blocks-react-renderer'; import { blockClass, customRenderers, defaultEJSRConfigs } from '@ottabase/ottarenderer'; function BlogPost({ content }) { - // content is the JSON object from Editor.js return (
); @@ -60,27 +60,79 @@ function SafeContent({ htmlString }) { ## Supported Blocks -The package includes default renderers for standard Editor.js blocks and custom components: +### Standard Editor.js Blocks + +Header, Paragraph, List, Quote, Code, Table, Delimiter, Attaches. -- **Standard**: Header, Paragraph, List, Quote, Code, Table, Delimiter, Attaches -- **Custom**: - - `AdvancedImageBlock`: Enhanced image with caption and layout options - - `Checklist`: Interactive checklist - - `CTA`: Call-to-action buttons - - `Review`: Product/service review with image, star rating, pros/cons, and summary - - `Spoiler`: Collapsible content - - `Warning`: Callout/Alert boxes +### Custom Blocks + +| Component | Description | +| -------------------- | ------------------------------------------------------------------------------------ | +| `AdvancedImageBlock` | Enhanced image with caption and layout options | +| `Checklist` | Interactive checklist items | +| `Code` | Syntax-highlighted code block | +| `CTA` | Call-to-action button with alignment (left/center/right) and four theme-aware styles | +| `Disclosure` | Transparency notice block: AI usage disclosure and/or sponsored-content disclaimer | +| `Layout` | Multi-column layout (6 presets) with recursive block rendering per column | +| `List` | Nested ordered/unordered list | +| `Map` | Embedded map (OpenStreetMap / Google Maps) | +| `Quote` | Styled pull-quote with attribution | +| `Review` | Product/service review card with star rating, pros/cons, CTA link, and verdict | +| `Spoiler` | Click-to-reveal blurred text | +| `Table` | Data table | +| `Warning` | Alert/callout box | ## Customization -You can extend or override the renderers by passing a `config` object to the underlying renderer or customizing -`customRenderers`. +Override or extend renderers: ```typescript import { customRenderers } from '@ottabase/ottarenderer'; -const myConfig = { +const myRenderers = { ...customRenderers, header: MyCustomHeaderComponent, }; ``` + +## Disclosure Block + +The `Disclosure` component renders a styled notice that can include: + +- **AI Disclosure**: Communicates how much AI was involved in producing the content. + - Presets: `slight`, `mid`, `high` — each with standardised wording + - Custom: percentage value (e.g. "Approximately 60% of this content was created with AI assistance.") +- **Sponsored Disclosure**: Notes commercial relationships. + - Preset standard wording or author-supplied custom text. + +```tsx +import { Disclosure } from '@ottabase/ottarenderer'; + +; +``` + +## CTA Block + +The `CTA` component uses semantic theme token classes (`bg-primary`, `text-primary-foreground`, `border-border`), so it +inherits app-level theming and light/dark mode. + +```tsx +import { CTA } from '@ottabase/ottarenderer'; + +; +``` diff --git a/packages/ottarenderer/src/EditorJsRenderer.tsx b/packages/ottarenderer/src/EditorJsRenderer.tsx index 277b7982c..5f8a05bed 100644 --- a/packages/ottarenderer/src/EditorJsRenderer.tsx +++ b/packages/ottarenderer/src/EditorJsRenderer.tsx @@ -1,101 +1,13 @@ import './styles.css'; -import AdvancedImageBlock from './components/AdvancedImage/AdvancedImage'; -import Checklist from './components/Checklist'; -import Code from './components/Code'; -import CTA from './components/CTA'; -import List from './components/List'; -import Quote from './components/Quote'; -import Review from './components/Review'; -import Spoiler from './components/Spoiler'; -import Table from './components/Table'; -import Warning from './components/Warning'; +import { baseRenderers } from './baseRenderers'; +import Layout from './components/Layout'; +import { blockClass, defaultEJSRConfigs, shouldRenderContentBlocks } from './rendererConfig'; +/** All block renderers including Layout (for top-level Blocks) */ export const customRenderers = { - checklist: Checklist, - // Images - image: AdvancedImageBlock, // Route legacy "image" blocks to AdvancedImage for backward compatibility - advancedImage: AdvancedImageBlock, - list: List, - table: Table, - code: Code, - warning: Warning, - quote: Quote, - spoiler: Spoiler, - cta: CTA, - review: Review, + ...baseRenderers, + layout: Layout, }; -export const blockClass = 'cdc-content-block'; - -export const defaultEJSRConfigs = { - code: { - className: `${blockClass} cdc-content-code`, - }, - checklist: { - className: `${blockClass} cdc-content-cl`, - }, - simpleImage: { - className: `${blockClass} cdc-content-simpleImage`, - }, - delimiter: { - className: `${blockClass} cdc-content-delimiter`, - }, - embed: { - className: `${blockClass} cdc-content-embed`, - rel: 'noreferer nofollower external', // Generates an if not able to receive an "embed" property - sandbox: undefined, - }, - header: { - className: `${blockClass} cdc-content-header mt-2 text-gray-700 dark:text-gray-200 text-3xl/10`, - }, - image: { - className: `${blockClass} image-block cdc-content-image`, - actionsClassNames: { - stretched: 'image-block--stretched', - withBorder: 'image-block--with-border', - withBackground: 'image-block--with-background', - featuredImage: 'image-block--featured-image', - }, - }, - advancedImage: { - className: `${blockClass} advanced-image-block cdc-content-advanced-image`, - actionsClassNames: { - stretched: 'advanced-image-block--stretched', - withBorder: 'advanced-image-block--with-border', - withBackground: 'advanced-image-block--with-background', - featuredImage: 'advanced-image-block--featured-image', - }, - }, - list: { - className: `${blockClass} cdc-content-list`, - }, - paragraph: { - className: `${blockClass} cdc-content-paragraph my-4 text-gray-700 dark:text-gray-200 text-md leading-relaxed`, - }, - quote: { - className: `${blockClass} cdc-content-quote`, - actionsClassNames: { - alignment: 'text-align-{alignment}', // This is a substitution placeholder: left or center. - }, - }, - table: { - className: `${blockClass} cdc-content-table`, - }, - warning: { - className: `${blockClass} cdc-content-warning`, - }, - spoiler: { - className: `${blockClass} cdc-content-spoiler`, - }, - cta: { - className: `${blockClass} cdc-content-cta`, - }, - review: { - className: `${blockClass} cdc-content-review`, - }, -}; - -export const shouldRenderContentBlocks = (contentBlocks: {} | null) => { - return contentBlocks !== null && Object.keys(contentBlocks).length > 0; -}; +export { blockClass, defaultEJSRConfigs, shouldRenderContentBlocks }; diff --git a/packages/ottarenderer/src/baseRenderers.ts b/packages/ottarenderer/src/baseRenderers.ts new file mode 100644 index 000000000..dd51029c1 --- /dev/null +++ b/packages/ottarenderer/src/baseRenderers.ts @@ -0,0 +1,33 @@ +/** + * Base renderers for EditorJS blocks (excluding Layout). + * Layout is added in EditorJsRenderer to avoid circular dependency: + * Layout needs these renderers for nested Blocks. + */ +import AdvancedImageBlock from './components/AdvancedImage/AdvancedImage'; +import Checklist from './components/Checklist'; +import Code from './components/Code'; +import CTA from './components/CTA'; +import Disclosure from './components/Disclosure'; +import List from './components/List'; +import Map from './components/Map'; +import Quote from './components/Quote'; +import Review from './components/Review'; +import Spoiler from './components/Spoiler'; +import Table from './components/Table'; +import Warning from './components/Warning'; + +export const baseRenderers = { + checklist: Checklist, + image: AdvancedImageBlock, + advancedImage: AdvancedImageBlock, + list: List, + table: Table, + code: Code, + warning: Warning, + quote: Quote, + spoiler: Spoiler, + cta: CTA, + disclosure: Disclosure, + review: Review, + map: Map, +}; diff --git a/packages/ottarenderer/src/components/CTA.test.tsx b/packages/ottarenderer/src/components/CTA.test.tsx index ed7c8b8df..1a17302be 100644 --- a/packages/ottarenderer/src/components/CTA.test.tsx +++ b/packages/ottarenderer/src/components/CTA.test.tsx @@ -23,30 +23,57 @@ describe('CTA Renderer', () => { }); }); - describe('Button Styles', () => { + describe('Button Styles (theme token classes)', () => { it('should apply primary style by default', () => { const { container } = render(); const link = container.querySelector('a'); - expect(link?.className).toContain('bg-blue-600'); - }); - - it('should apply primary style when specified', () => { - const { container } = render(); - const link = container.querySelector('a'); - expect(link?.className).toContain('bg-blue-600'); + expect(link?.className).toContain('bg-primary'); + expect(link?.className).toContain('text-primary-foreground'); }); it('should apply secondary style', () => { const { container } = render(); const link = container.querySelector('a'); - expect(link?.className).toContain('bg-gray-600'); + expect(link?.className).toContain('bg-secondary'); + expect(link?.className).toContain('text-secondary-foreground'); }); it('should apply outline style', () => { const { container } = render(); const link = container.querySelector('a'); expect(link?.className).toContain('bg-transparent'); - expect(link?.className).toContain('border-2'); + expect(link?.className).toContain('text-primary'); + }); + + it('should apply ghost style', () => { + const { container } = render(); + const link = container.querySelector('a'); + expect(link?.className).toContain('bg-transparent'); + expect(link?.className).toContain('text-foreground'); + expect(link?.className).toContain('border-border'); + }); + }); + + describe('Alignment', () => { + it('should default to center alignment', () => { + const { container } = render(); + const wrapper = container.querySelector('[data-alignment]'); + expect(wrapper?.getAttribute('data-alignment')).toBe('center'); + expect(wrapper?.className).toContain('justify-center'); + }); + + it('should apply left alignment', () => { + const { container } = render(); + const wrapper = container.querySelector('[data-alignment]'); + expect(wrapper?.getAttribute('data-alignment')).toBe('left'); + expect(wrapper?.className).toContain('justify-start'); + }); + + it('should apply right alignment', () => { + const { container } = render(); + const wrapper = container.querySelector('[data-alignment]'); + expect(wrapper?.getAttribute('data-alignment')).toBe('right'); + expect(wrapper?.className).toContain('justify-end'); }); }); @@ -55,14 +82,13 @@ describe('CTA Renderer', () => { render(); const link = screen.getByRole('link'); expect(link.getAttribute('target')).toBe('_self'); - expect(link.getAttribute('rel')).toBe(''); }); it('should open in new tab when specified', () => { render(); const link = screen.getByRole('link'); expect(link.getAttribute('target')).toBe('_blank'); - expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer nofollow'); }); }); @@ -96,8 +122,6 @@ describe('CTA Renderer', () => { it('should include noscript fallback', () => { const { container } = render(); - // Note: React Testing Library doesn't render noscript tags, but they exist in the HTML - // This test verifies the component structure includes noscript const html = container.innerHTML; expect(html).toContain('noscript'); }); @@ -109,13 +133,6 @@ describe('CTA Renderer', () => { const link = screen.getByRole('link'); expect(link.getAttribute('aria-label')).toBe('Sign Up'); }); - - it('should have focus styles', () => { - const { container } = render(); - const link = container.querySelector('a'); - expect(link?.className).toContain('focus:outline-none'); - expect(link?.className).toContain('focus:ring-2'); - }); }); describe('Icon Support', () => { @@ -134,7 +151,7 @@ describe('CTA Renderer', () => { }); describe('Custom ClassName', () => { - it('should apply custom className', () => { + it('should apply custom className to wrapper', () => { const { container } = render(); const wrapper = container.querySelector('.custom-class'); expect(wrapper).toBeTruthy(); @@ -153,10 +170,11 @@ describe('CTA Renderer', () => { expect(link.getAttribute('href')).toBe('#'); }); - it('should handle invalid style gracefully', () => { + it('should handle invalid style gracefully by using primary fallback', () => { const { container } = render(); const link = container.querySelector('a'); - expect(link).toBeTruthy(); + // Falls back to primary + expect(link?.className).toContain('bg-primary'); }); }); }); diff --git a/packages/ottarenderer/src/components/CTA.tsx b/packages/ottarenderer/src/components/CTA.tsx index 3f9ea1ba3..b60897240 100644 --- a/packages/ottarenderer/src/components/CTA.tsx +++ b/packages/ottarenderer/src/components/CTA.tsx @@ -4,63 +4,41 @@ import { useMemo } from 'react'; export interface CTAData { text?: string; url?: string; - style?: 'primary' | 'secondary' | 'outline'; + style?: 'primary' | 'secondary' | 'outline' | 'ghost'; + alignment?: 'left' | 'center' | 'right'; openInNewTab?: boolean; icon?: string; } +const alignmentClass: Record, string> = { + left: 'justify-start', + center: 'justify-center', + right: 'justify-end', +}; + +const buttonStyleClass: Record, string> = { + primary: 'bg-primary text-primary-foreground border-primary hover:opacity-90', + secondary: 'bg-secondary text-secondary-foreground border-secondary hover:opacity-90', + outline: 'bg-transparent text-primary border-primary hover:bg-muted/40', + ghost: 'bg-transparent text-foreground border-border hover:bg-muted', +}; + const CTA: RenderFn = ({ data, className = '' }) => { const buttonText = data?.text || 'Get Started'; const url = data?.url || '#'; const style = data?.style || 'primary'; + const alignment = data?.alignment || 'center'; const openInNewTab = data?.openInNewTab ?? false; const icon = data?.icon; - // Memoize button classes and inline styles - const { buttonClasses, buttonStyle } = useMemo(() => { - const baseClasses = - 'inline-flex items-center justify-center px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800'; - const styleClasses = { - primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500 shadow-md hover:shadow-lg', - secondary: 'bg-gray-600 hover:bg-gray-700 text-white focus:ring-gray-500 shadow-md hover:shadow-lg', - outline: - 'bg-transparent border-2 border-blue-600 text-blue-600 hover:bg-blue-600 hover:text-white dark:border-blue-400 dark:text-blue-400 dark:hover:bg-blue-400 dark:hover:text-gray-900 focus:ring-blue-500', - }; - - // Inline styles as fallback for when Tailwind isn't processed - const inlineStyles: Record = { - primary: { - backgroundColor: '#2563eb', - color: '#ffffff', - borderColor: '#2563eb', - borderWidth: '2px', - borderStyle: 'solid', - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', - }, - secondary: { - backgroundColor: '#4b5563', - color: '#ffffff', - borderColor: '#4b5563', - borderWidth: '2px', - borderStyle: 'solid', - boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', - }, - outline: { - backgroundColor: 'transparent', - color: '#2563eb', - borderColor: '#2563eb', - borderWidth: '2px', - borderStyle: 'solid', - }, - }; + const justify = alignmentClass[alignment] ?? 'justify-center'; - return { - buttonClasses: `${baseClasses} ${styleClasses[style]}`, - buttonStyle: inlineStyles[style], - }; + const buttonClass = useMemo(() => { + const baseClass = + 'inline-flex items-center gap-1.5 px-5 py-2.5 rounded-md font-semibold text-sm no-underline border-2 transition-colors duration-200 leading-tight cursor-pointer'; + return `${baseClass} ${buttonStyleClass[style] ?? buttonStyleClass.primary}`; }, [style]); - // Generate structured data for SEO const structuredData = useMemo(() => { if (!url || url === '#') return null; return { @@ -76,36 +54,30 @@ const CTA: RenderFn = ({ data, className = '' }) => { return ( <> - {/* Structured data for SEO */} {structuredData && (