From b2975650ec7cfcfbe82fa4038eb9bea5b1a07764 Mon Sep 17 00:00:00 2001 From: corsacca Date: Tue, 21 Apr 2026 14:41:43 +0100 Subject: [PATCH] phase 5 --- app/pages/[...slug].vue | 150 +++++++++++++++++++ app/pages/about/definitions.vue | 6 - app/pages/about/index.vue | 6 - app/pages/about/vision.vue | 6 - app/pages/privacy-policy.vue | 6 - app/pages/resources.vue | 6 - bun.lock | 39 +++++ migrations/005_create_pages_table.ts | 58 ++++++++ package.json | 13 ++ server/api/pages/[...slug].get.ts | 86 +++++++++++ server/database/pages.ts | 214 +++++++++++++++++++++++++++ server/database/schema.ts | 26 ++++ server/utils/renderTiptap.ts | 52 +++++++ 13 files changed, 638 insertions(+), 30 deletions(-) create mode 100644 app/pages/[...slug].vue delete mode 100644 app/pages/about/definitions.vue delete mode 100644 app/pages/about/index.vue delete mode 100644 app/pages/about/vision.vue delete mode 100644 app/pages/privacy-policy.vue delete mode 100644 app/pages/resources.vue create mode 100644 migrations/005_create_pages_table.ts create mode 100644 server/api/pages/[...slug].get.ts create mode 100644 server/database/pages.ts create mode 100644 server/utils/renderTiptap.ts diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue new file mode 100644 index 00000000..cfe17081 --- /dev/null +++ b/app/pages/[...slug].vue @@ -0,0 +1,150 @@ + + + diff --git a/app/pages/about/definitions.vue b/app/pages/about/definitions.vue deleted file mode 100644 index b8b5ec94..00000000 --- a/app/pages/about/definitions.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/app/pages/about/index.vue b/app/pages/about/index.vue deleted file mode 100644 index 1064092b..00000000 --- a/app/pages/about/index.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/app/pages/about/vision.vue b/app/pages/about/vision.vue deleted file mode 100644 index b4ea3e75..00000000 --- a/app/pages/about/vision.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/app/pages/privacy-policy.vue b/app/pages/privacy-policy.vue deleted file mode 100644 index 0fdd7337..00000000 --- a/app/pages/privacy-policy.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/app/pages/resources.vue b/app/pages/resources.vue deleted file mode 100644 index c48fe03b..00000000 --- a/app/pages/resources.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/bun.lock b/bun.lock index 5368b88c..7b750010 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,19 @@ "@nuxt/ui": "^4.6.1", "@nuxtjs/i18n": "^10.2.4", "@nuxtjs/turnstile": "^1.1.2", + "@tiptap/core": "^3.22.4", + "@tiptap/extension-color": "^3.22.4", + "@tiptap/extension-highlight": "^3.22.4", + "@tiptap/extension-image": "^3.22.4", + "@tiptap/extension-link": "^3.22.4", + "@tiptap/extension-subscript": "^3.22.4", + "@tiptap/extension-superscript": "^3.22.4", + "@tiptap/extension-text-align": "^3.22.4", + "@tiptap/extension-text-style": "^3.22.4", + "@tiptap/extension-typography": "^3.22.4", + "@tiptap/extension-youtube": "^3.22.4", + "@tiptap/html": "^3.22.4", + "@tiptap/starter-kit": "^3.22.4", "bcrypt": "^6.0.0", "fuse.js": "^7.3.0", "intl-tel-input": "^26.7.5", @@ -825,6 +838,8 @@ "@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4", "@tiptap/y-tiptap": "^3.0.2", "yjs": "^13" } }, "sha512-4g4DdpvXZyYYCcWs3cO4DwtzhukqI13waYUKfwOcNnQwZ4YOCR83ET/kgqMk/xMAfbymKghbyAZRCc33zD5xvw=="], + "@tiptap/extension-color": ["@tiptap/extension-color@3.22.4", "", { "peerDependencies": { "@tiptap/extension-text-style": "3.22.4" } }, "sha512-1vDuVsrOETshe4j4nZhWalbKYcWfNybRCe30h829ExX06XwFryUYLb/LgTIaGCr9beWZUldsK+vOkBWdDTGMTw=="], + "@tiptap/extension-document": ["@tiptap/extension-document@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw=="], "@tiptap/extension-drag-handle": ["@tiptap/extension-drag-handle@3.22.4", "", { "dependencies": { "@floating-ui/dom": "^1.6.13" }, "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/extension-collaboration": "3.22.4", "@tiptap/extension-node-range": "3.22.4", "@tiptap/pm": "3.22.4", "@tiptap/y-tiptap": "^3.0.2" } }, "sha512-VCheiqy0OGrcrxOYt7Kp7MXWMZoEaZp517HnRrdrvcCCO0j2hcrT5mbo1mMPzP/WvTUHkXCmqcLv+uIk3Bdo/g=="], @@ -841,6 +856,8 @@ "@tiptap/extension-heading": ["@tiptap/extension-heading@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q=="], + "@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-QItbgkWqGZ04rOdYfjrn1TaUTv9XLKQE9VVJm9f6bxCpacPREJpz4lzkdea6zISHBZjEXJzlkjRWZP0zQLXXFA=="], + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw=="], "@tiptap/extension-image": ["@tiptap/extension-image@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA=="], @@ -867,12 +884,26 @@ "@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="], + "@tiptap/extension-subscript": ["@tiptap/extension-subscript@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-95Uwi8jEYVr/qil7tGZhG87wR0SNIUBZXSFcDkDeFBzRdccofwD2XL2HU5FqWm2IPNuE21lQWPImVspXR0Kzyw=="], + + "@tiptap/extension-superscript": ["@tiptap/extension-superscript@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-VGNR67uTmdTfRC5+1HqtAfA+kK6Lbh9tXitcIs8Scwxlq89PBOUEs11RnzcXfwv5hYq94WjgQJF3A99Jc9Cg2w=="], + "@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="], + "@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-W7TnXWSyfDXSatGXp5y/CahE8G4btrQPb0/sy+eG+42FxdzYsqvh1ys3OE9j2XSuTrZ1q/tZA/NLPUkc7vw6Kw=="], + + "@tiptap/extension-text-style": ["@tiptap/extension-text-style@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-24DVBdySNKq3ovY+v9ERVxAyHStDa6ftUlyoHuZv0YXQ2amjUNOmqQtGEHBIULpCbBb1jZ+atHhv9MBZ0Ia9Pw=="], + + "@tiptap/extension-typography": ["@tiptap/extension-typography@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-LXcJbhLl7m8jQ+VFj8u6T5p8ylZLv8WUUZQal7JF4KgDLBhPGiS0O2HXXzcRfjuVzT+erqakwybnS+0bkTGcsQ=="], + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="], + "@tiptap/extension-youtube": ["@tiptap/extension-youtube@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-H5dKDEdVhGeE2JZ4z6AvurN9GNwZQBNige8wjUie3R+hkwX/0DDql0oqvNM8lO5WM1M90Sq/AwyPQ2K9auSbbw=="], + "@tiptap/extensions": ["@tiptap/extensions@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A=="], + "@tiptap/html": ["@tiptap/html@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4", "happy-dom": "^20.8.9" } }, "sha512-Y0ydX5ghuypurDRzU5YEVAXKJH2SWar7J7bDNslWHuXSmMnu2CBxFViGf7RKWw0BDMPv33wkuMxzkw1n0gOpgg=="], + "@tiptap/markdown": ["@tiptap/markdown@3.22.4", "", { "dependencies": { "marked": "^17.0.1" }, "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-gcoLOq5TBntw13QdeWMy7yc2X+b9yTplcFb5kpMQNgNoxlu0+jlX4LiBR+E6lOV+m+AtkprHHltTYyKG23bdOw=="], "@tiptap/pm": ["@tiptap/pm@3.22.4", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w=="], @@ -919,6 +950,10 @@ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], @@ -1499,6 +1534,8 @@ "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -2273,6 +2310,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "wheel-gestures": ["wheel-gestures@2.2.48", "", {}, "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA=="], diff --git a/migrations/005_create_pages_table.ts b/migrations/005_create_pages_table.ts new file mode 100644 index 00000000..ba1fbb85 --- /dev/null +++ b/migrations/005_create_pages_table.ts @@ -0,0 +1,58 @@ +import { Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('pages') + .ifNotExists() + .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`)) + .addColumn('slug', 'text', col => col.unique().notNull()) + .addColumn('parent_slug', 'text') + .addColumn('menu_order', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('created', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .execute() + + await db.schema + .createIndex('pages_parent_slug_idx') + .ifNotExists() + .on('pages') + .column('parent_slug') + .execute() + + await db.schema + .createTable('page_translations') + .ifNotExists() + .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`)) + .addColumn('page_id', 'uuid', col => col.notNull().references('pages.id').onDelete('cascade')) + .addColumn('locale', 'text', col => col.notNull()) + .addColumn('title', 'text', col => col.notNull()) + .addColumn('body_json', 'jsonb', col => col.notNull().defaultTo(sql`'{}'::jsonb`)) + .addColumn('excerpt', 'text') + .addColumn('featured_image', 'text') + .addColumn('meta_title', 'text') + .addColumn('meta_description', 'text') + .addColumn('og_image', 'text') + .addColumn('status', 'text', col => col.notNull().defaultTo('draft')) + .addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .execute() + + await db.schema + .createIndex('page_translations_page_locale_uidx') + .ifNotExists() + .on('page_translations') + .columns(['page_id', 'locale']) + .unique() + .execute() + + await db.schema + .createIndex('page_translations_status_idx') + .ifNotExists() + .on('page_translations') + .column('status') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('page_translations').ifExists().execute() + await db.schema.dropTable('pages').ifExists().execute() +} diff --git a/package.json b/package.json index 44e13595..659fa453 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,19 @@ "@nuxt/ui": "^4.6.1", "@nuxtjs/i18n": "^10.2.4", "@nuxtjs/turnstile": "^1.1.2", + "@tiptap/core": "^3.22.4", + "@tiptap/extension-color": "^3.22.4", + "@tiptap/extension-highlight": "^3.22.4", + "@tiptap/extension-image": "^3.22.4", + "@tiptap/extension-link": "^3.22.4", + "@tiptap/extension-subscript": "^3.22.4", + "@tiptap/extension-superscript": "^3.22.4", + "@tiptap/extension-text-align": "^3.22.4", + "@tiptap/extension-text-style": "^3.22.4", + "@tiptap/extension-typography": "^3.22.4", + "@tiptap/extension-youtube": "^3.22.4", + "@tiptap/html": "^3.22.4", + "@tiptap/starter-kit": "^3.22.4", "bcrypt": "^6.0.0", "fuse.js": "^7.3.0", "intl-tel-input": "^26.7.5", diff --git a/server/api/pages/[...slug].get.ts b/server/api/pages/[...slug].get.ts new file mode 100644 index 00000000..f30deb2e --- /dev/null +++ b/server/api/pages/[...slug].get.ts @@ -0,0 +1,86 @@ +// Public CMS page read endpoint. Mirrors the plan: +// - picks the translation for the requested locale +// - falls back to English if that locale isn't published +// - 404s if neither are published +// +// Returns the translation (title, excerpt, featured_image, meta_*, +// status) plus pre-rendered HTML body (so the public bundle doesn't +// have to ship the Tiptap renderer) and the list of published child +// pages for sidebar navigation. + +import { defineEventHandler, getRouterParam, getQuery, createError } from 'h3' +import { getPageBySlug, getChildTranslations } from '../../database/pages' +import { renderTiptap } from '../../utils/renderTiptap' + +const ENABLED_LOCALES = new Set(['en', 'es', 'fr', 'pt', 'ar', 'ru']) + +export default defineEventHandler(async (event) => { + const raw = getRouterParam(event, 'slug') + const slugParam = Array.isArray(raw) ? raw.join('/') : (raw ?? '') + const slug = slugParam.replace(/^\/+|\/+$/g, '') + if (!slug) { + throw createError({ statusCode: 400, statusMessage: 'Missing slug' }) + } + + const query = getQuery(event) + const requested = typeof query.locale === 'string' ? query.locale : 'en' + const locale = ENABLED_LOCALES.has(requested) ? requested : 'en' + + const result = await getPageBySlug(slug, locale, { fallback: 'en' }) + if (!result) { + throw createError({ statusCode: 404, statusMessage: 'Page not found' }) + } + + const { page, translation, resolvedLocale, requestedLocale } = result + const bodyHtml = renderTiptap(translation.body_json) + + // Menu parent = parent page if this is a child, or self if this is a + // top-level page. Sidebar items = children of that menu parent. + // Mirrors the WP page.php logic using `wp_get_post_parent_id()` and + // `get_pages(['child_of' => $menu_parent_id])`. + let menuParentSlug: string + let menuParentTitle: string + if (page.parent_slug) { + const parent = await getPageBySlug(page.parent_slug, resolvedLocale, { fallback: 'en' }) + if (parent) { + menuParentSlug = parent.page.slug + menuParentTitle = parent.translation.title + } else { + // Parent row exists but isn't published — fall back to self + menuParentSlug = page.slug + menuParentTitle = translation.title + } + } else { + menuParentSlug = page.slug + menuParentTitle = translation.title + } + + const siblings = await getChildTranslations(menuParentSlug, resolvedLocale, 'en') + + return { + slug: page.slug, + parent_slug: page.parent_slug, + menu_order: page.menu_order, + requested_locale: requestedLocale, + resolved_locale: resolvedLocale, + title: translation.title, + excerpt: translation.excerpt, + featured_image: translation.featured_image, + meta_title: translation.meta_title, + meta_description: translation.meta_description, + og_image: translation.og_image, + body_html: bodyHtml, + body_is_empty: bodyHtml.trim() === '', + menu_parent: { + slug: menuParentSlug, + title: menuParentTitle + }, + children: siblings.map(({ page: c, translation: t }) => ({ + slug: c.slug, + title: t.title, + excerpt: t.excerpt, + featured_image: t.featured_image, + menu_order: c.menu_order + })) + } +}) diff --git a/server/database/pages.ts b/server/database/pages.ts new file mode 100644 index 00000000..aa40392e --- /dev/null +++ b/server/database/pages.ts @@ -0,0 +1,214 @@ +// Query helpers for the `pages` + `page_translations` tables (migration +// 005). All functions use Kysely against the shared `db` instance. +// +// Lookup semantics (`getPageBySlug`) mirror the plan: prefer the +// requested locale's published translation, fall back to English if +// that locale is missing/draft, return null when neither is published. + +import { db } from '../utils/database' +import type { PagesTable, PageTranslationsTable } from './schema' +import type { Selectable } from 'kysely' + +export type Page = Selectable +export type PageTranslation = Selectable + +export interface LocalizedPage { + page: Page + translation: PageTranslation + resolvedLocale: string // locale actually rendered (may differ from requested when fallback kicked in) + requestedLocale: string +} + +export async function listPages(): Promise { + return db + .selectFrom('pages') + .selectAll() + .orderBy('parent_slug', 'asc') + .orderBy('menu_order', 'asc') + .orderBy('slug', 'asc') + .execute() +} + +export async function getPageChildren(parentSlug: string): Promise { + return db + .selectFrom('pages') + .selectAll() + .where('parent_slug', '=', parentSlug) + .orderBy('menu_order', 'asc') + .orderBy('slug', 'asc') + .execute() +} + +export async function getPageBySlug( + slug: string, + locale: string, + options: { fallback?: string } = {} +): Promise { + const fallbackLocale = options.fallback ?? 'en' + + const page = await db + .selectFrom('pages') + .selectAll() + .where('slug', '=', slug) + .executeTakeFirst() + if (!page) return null + + const translation = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', page.id) + .where('locale', '=', locale) + .where('status', '=', 'published') + .executeTakeFirst() + + if (translation) { + return { page, translation, resolvedLocale: locale, requestedLocale: locale } + } + + // Fall back to published English if the requested locale isn't published + if (locale !== fallbackLocale) { + const fallback = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', page.id) + .where('locale', '=', fallbackLocale) + .where('status', '=', 'published') + .executeTakeFirst() + if (fallback) { + return { page, translation: fallback, resolvedLocale: fallbackLocale, requestedLocale: locale } + } + } + + return null +} + +export async function getChildTranslations( + parentSlug: string, + locale: string, + fallbackLocale = 'en' +): Promise> { + const children = await getPageChildren(parentSlug) + if (children.length === 0) return [] + const ids = children.map(c => c.id) + + const preferred = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', 'in', ids) + .where('locale', '=', locale) + .where('status', '=', 'published') + .execute() + const haveLocale = new Set(preferred.map(p => p.page_id)) + + const missing = ids.filter(id => !haveLocale.has(id)) + let fallbacks: PageTranslation[] = [] + if (missing.length && locale !== fallbackLocale) { + fallbacks = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', 'in', missing) + .where('locale', '=', fallbackLocale) + .where('status', '=', 'published') + .execute() + } + + const byPageId = new Map() + for (const t of preferred) byPageId.set(t.page_id, t) + for (const t of fallbacks) byPageId.set(t.page_id, t) + + return children + .map(page => ({ page, translation: byPageId.get(page.id)! })) + .filter(entry => Boolean(entry.translation)) +} + +export async function createPage(input: { + slug: string + parent_slug?: string | null + menu_order?: number +}): Promise { + const row = await db + .insertInto('pages') + .values({ + slug: input.slug, + parent_slug: input.parent_slug ?? null, + menu_order: input.menu_order ?? 0 + }) + .returningAll() + .executeTakeFirstOrThrow() + return row +} + +export async function deletePage(id: string): Promise { + await db.deleteFrom('pages').where('id', '=', id).execute() +} + +export async function upsertTranslation(input: { + page_id: string + locale: string + title: string + body_json: Record + excerpt?: string | null + featured_image?: string | null + meta_title?: string | null + meta_description?: string | null + og_image?: string | null + status?: 'draft' | 'published' +}): Promise { + const existing = await db + .selectFrom('page_translations') + .selectAll() + .where('page_id', '=', input.page_id) + .where('locale', '=', input.locale) + .executeTakeFirst() + + if (existing) { + const updated = await db + .updateTable('page_translations') + .set({ + title: input.title, + body_json: input.body_json, + excerpt: input.excerpt ?? null, + featured_image: input.featured_image ?? null, + meta_title: input.meta_title ?? null, + meta_description: input.meta_description ?? null, + og_image: input.og_image ?? null, + ...(input.status ? { status: input.status } : {}), + updated: new Date() + }) + .where('id', '=', existing.id) + .returningAll() + .executeTakeFirstOrThrow() + return updated + } + + const inserted = await db + .insertInto('page_translations') + .values({ + page_id: input.page_id, + locale: input.locale, + title: input.title, + body_json: input.body_json, + excerpt: input.excerpt ?? null, + featured_image: input.featured_image ?? null, + meta_title: input.meta_title ?? null, + meta_description: input.meta_description ?? null, + og_image: input.og_image ?? null, + status: input.status ?? 'draft' + }) + .returningAll() + .executeTakeFirstOrThrow() + return inserted +} + +export async function setTranslationStatus( + pageId: string, + locale: string, + status: 'draft' | 'published' +): Promise { + await db + .updateTable('page_translations') + .set({ status, updated: new Date() }) + .where('page_id', '=', pageId) + .where('locale', '=', locale) + .execute() +} diff --git a/server/database/schema.ts b/server/database/schema.ts index 596ecb5a..d1137356 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -35,8 +35,34 @@ export interface ActivityLogsTable { metadata: Generated> } +export interface PagesTable { + id: Generated + slug: string + parent_slug: string | null + menu_order: Generated + created: ColumnType + updated: ColumnType +} + +export interface PageTranslationsTable { + id: Generated + page_id: string + locale: string + title: string + body_json: Generated> + excerpt: string | null + featured_image: string | null + meta_title: string | null + meta_description: string | null + og_image: string | null + status: Generated<'draft' | 'published'> + updated: ColumnType +} + export interface Database { users: UsersTable password_reset_requests: PasswordResetRequestsTable activity_logs: ActivityLogsTable + pages: PagesTable + page_translations: PageTranslationsTable } diff --git a/server/utils/renderTiptap.ts b/server/utils/renderTiptap.ts new file mode 100644 index 00000000..1afbe622 --- /dev/null +++ b/server/utils/renderTiptap.ts @@ -0,0 +1,52 @@ +// Server-side Tiptap JSON → HTML renderer. The extension set here must +// stay aligned with app/components/admin/RichTextEditor.vue (built in +// Phase 6) so content authored in the editor round-trips back out to +// the public page with the same tags/attributes. +// +// Keeping this on the server keeps the public bundle free of Tiptap +// runtime code — the renderer serializes once and we send HTML. + +import { generateHTML } from '@tiptap/html' +import StarterKit from '@tiptap/starter-kit' +import Link from '@tiptap/extension-link' +import Image from '@tiptap/extension-image' +import TextAlign from '@tiptap/extension-text-align' +import { TextStyle } from '@tiptap/extension-text-style' +import Color from '@tiptap/extension-color' +import Highlight from '@tiptap/extension-highlight' +import Typography from '@tiptap/extension-typography' +import Subscript from '@tiptap/extension-subscript' +import Superscript from '@tiptap/extension-superscript' +import Youtube from '@tiptap/extension-youtube' + +const EXTENSIONS = [ + StarterKit, + Link.configure({ openOnClick: false, autolink: true }), + Image, + TextAlign.configure({ types: ['heading', 'paragraph'] }), + TextStyle, + Color, + Highlight, + Typography, + Subscript, + Superscript, + Youtube +] + +function isEmptyDoc(doc: unknown): boolean { + if (!doc || typeof doc !== 'object') return true + const d = doc as { type?: string; content?: unknown[] } + if (d.type !== 'doc') return true + if (!Array.isArray(d.content) || d.content.length === 0) return true + return false +} + +export function renderTiptap(doc: unknown): string { + if (isEmptyDoc(doc)) return '' + try { + return generateHTML(doc as any, EXTENSIONS) + } catch (err) { + console.error('renderTiptap: failed to serialize doc', err) + return '' + } +}