From c7692446631e83a4d4c2094c3037fd3b099fabc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:38:20 +0000 Subject: [PATCH 01/19] Initial plan From ee7dc1e9bf1747bd72b2686d2ba343ac3246db2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:54:49 +0000 Subject: [PATCH 02/19] feat: add page content type and exposeToHomepage to ottablog + worker APIs + admin editor Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/046b035e-9b57-4779-b4ad-6fa561dfc03b --- .../pages/admin/blog/AdminBlogEditorPage.tsx | 26 ++++++- .../src/types/blog.ts | 1 + .../worker/routes/blog.ts | 71 +++++++++++++++++-- .../worker/routes/router.ts | 11 +++ packages/ottablog/src/index.ts | 1 + .../src/ottaorm-models/Post.schema.ts | 6 ++ packages/ottablog/src/ottaorm-models/Post.ts | 21 ++++++ packages/ottablog/src/types.ts | 12 +++- 8 files changed, 141 insertions(+), 8 deletions(-) diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx index 06cc1b1fc..0b46a3960 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogEditorPage.tsx @@ -107,6 +107,7 @@ interface BlogPost { allowComments: boolean; isProtected?: boolean; passwordHint?: string | null; + exposeToHomepage?: boolean; publishedAt: string | null; maxVersionsToKeep: number | null; wordCount: number | null; @@ -239,6 +240,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps const [isFeatured, setIsFeatured] = useState(initialData?.isFeatured || false); const [allowComments, setAllowComments] = useState(initialData?.allowComments ?? true); const [isProtected, setIsProtected] = useState(initialData?.isProtected ?? false); + const [exposeToHomepage, setExposeToHomepage] = useState(initialData?.exposeToHomepage ?? false); const [passwordHint, setPasswordHint] = useState(initialData?.passwordHint ?? ''); const [password, setPassword] = useState(''); // transient: only sent when setting/changing const [publishedAt, setPublishedAt] = useState( @@ -483,6 +485,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps isFeatured === initialData.isFeatured && allowComments === initialData.allowComments && isProtected === (initialData.isProtected ?? false) && + exposeToHomepage === (initialData.exposeToHomepage ?? false) && (passwordHint ?? '') === (initialData.passwordHint ?? '') && !password && (publishedAt || '') === @@ -519,6 +522,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps authorName, isFeatured, allowComments, + exposeToHomepage, publishedAt, seriesId, seriesOrder, @@ -881,6 +885,7 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps isFeatured, allowComments, isProtected, + exposeToHomepage: contentType === 'page' ? exposeToHomepage : false, passwordHint: passwordHint || undefined, ...(isProtected && password.trim() ? { password: password.trim() } : {}), publishedAt: publishNow && !publishedAt ? Date.now() : publishedAt || undefined, @@ -1350,7 +1355,12 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps id="contentType" aria-label="Content type" value={contentType} - onChange={(e) => setContentType(e.target.value as ContentType)} + onChange={(e) => { + const next = e.target.value as ContentType; + setContentType(next); + // Clear exposeToHomepage when switching away from 'page' + if (next !== 'page') setExposeToHomepage(false); + }} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" > {Object.entries(CONTENT_TYPES).map(([value, { label }]) => ( @@ -1409,6 +1419,20 @@ function BlogEditorForm({ postId, isEditMode, initialData }: BlogEditorFormProps + {contentType === 'page' && ( +
+ setExposeToHomepage(e.target.checked)} + className="rounded" + /> + +
+ )} +
{ + const { env, url } = context; + const d1Error = ensureD1(env); + if (d1Error) return d1Error; + registerConnection('default', createD1Driver(env.OBCF_D1)); + + const appId = url.searchParams.get('appId') || null; + const where: Record = { slug, status: 'published', contentType: 'page' }; + if (appId) where.appId = appId; + const record = await Post.first(where); + if (!record) { + return errorResponse('Page not found', 404, { code: 'NOT_FOUND' }); + } + + const data = await publicPostJson(record, { enrichTags: true, enrichCategory: true }); + return jsonResponse(data); +} + +/** + * GET /api/blog/pages/exposed + * Returns published pages with exposeToHomepage: true. + * Used by the marketing homepage to build navbar links. + */ +export async function handleExposedPages(context: BlogRouteContext): Promise { + const { env, url } = context; + const d1Error = ensureD1(env); + if (d1Error) return d1Error; + registerConnection('default', createD1Driver(env.OBCF_D1)); + + const appId = url.searchParams.get('appId') || null; + + try { + const where: Record = { + contentType: 'page', + status: 'published', + exposeToHomepage: true, + }; + if (appId) where.appId = appId; + + const pages = await Post.where(where, { orderBy: 'title', orderDirection: 'asc' }); + const exposedPages = pages.map((p) => ({ + slug: p.get('slug') as string, + title: p.get('title') as string, + })); + return jsonResponse({ exposedPages }); + } catch { + // Never hard-fail: return empty array + return jsonResponse({ exposedPages: [] }); + } +} + export async function handleBlogPostUnlock(context: BlogRouteContext): Promise { const { request, env, url } = context; const d1Error = ensureD1(env); @@ -390,7 +447,7 @@ export async function handleBlogPostUnlock(context: BlogRouteContext): Promise = { status: 'published', contentType: { $ne: 'changelog' } }; + // Sitemap: only blog articles (exclude changelog + page) + const where: Record = { status: 'published', contentType: { $in: BLOG_FEED_CONTENT_TYPES } }; if (appId) where.appId = appId; const posts = await Post.where(where, { diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts index 8dae78dc5..185cb8382 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -50,6 +50,7 @@ import { } from './auth'; import { handleBlogCategoryBySlug, + handleBlogPageBySlug, handleBlogPostBySlug, handleBlogPostUnlock, handleBlogPostsList, @@ -63,6 +64,7 @@ import { handleBlogStudioPluginEnable, handleBlogStudioState, handleBlogTagBySlug, + handleExposedPages, } from './blog'; import { handleChangelogEntriesList, handleChangelogEntryBySlug } from './changelog'; import { handleBrandApi } from './brand'; @@ -219,6 +221,15 @@ async function handleGetRoutes(context: ApiRouteContext): Promise Date: Mon, 30 Mar 2026 18:01:11 +0000 Subject: [PATCH 03/19] feat: add Next.js page route, navbar integration, and tests for exposed pages Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/046b035e-9b57-4779-b4ad-6fa561dfc03b --- .../.env.example | 3 + .../__tests__/components.test.tsx | 50 +++++++++++ .../app/{ => (site)}/page.tsx | 0 .../app/layout-shell.tsx | 44 ++++++++-- .../app/layout.tsx | 8 +- .../app/page/[slug]/page-content.tsx | 37 ++++++++ .../app/page/[slug]/page.tsx | 26 ++++++ .../lib/api.ts | 86 +++++++++++++++++++ .../package.json | 1 + packages/ottablog/src/__tests__/types.test.ts | 52 ++++++++++- pnpm-lock.yaml | 3 + 11 files changed, 301 insertions(+), 9 deletions(-) rename apps/ottabase-template-app-nextjs-homepage/app/{ => (site)}/page.tsx (100%) create mode 100644 apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx create mode 100644 apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx create mode 100644 apps/ottabase-template-app-nextjs-homepage/lib/api.ts diff --git a/apps/ottabase-template-app-nextjs-homepage/.env.example b/apps/ottabase-template-app-nextjs-homepage/.env.example index 9dbdc953c..82309a117 100644 --- a/apps/ottabase-template-app-nextjs-homepage/.env.example +++ b/apps/ottabase-template-app-nextjs-homepage/.env.example @@ -51,6 +51,9 @@ NODE_ENV="development" # Set to "true" to show on production, or leave unset to auto-show in dev only # NEXT_PUBLIC_SHOW_CONFIG_PANEL="true" +# Ottabase Worker API URL (for CMS pages, exposed pages) +# NEXT_PUBLIC_API_URL="http://localhost:3004" + # ============================================================ # Database Configuration (Prisma + D1) # ============================================================ diff --git a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx index 03c50152b..e7a5cbf4c 100644 --- a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx @@ -314,3 +314,53 @@ describe('ThemePresetSwitcher', () => { }); }); }); + +describe('LayoutShell navbar merge', () => { + let mergeNavLinks: any; + + beforeEach(async () => { + ({ mergeNavLinks } = await import('../app/layout-shell')); + }); + + it('returns base links when no exposed pages', () => { + const base = [ + { href: '/', label: 'Home' }, + { href: '/about', label: 'About' }, + ]; + const result = mergeNavLinks(base, []); + expect(result).toEqual(base); + }); + + it('appends exposed pages as /page/slug links', () => { + const base = [{ href: '/', label: 'Home' }]; + const exposedPages = [ + { slug: 'about-us', title: 'About Us' }, + { slug: 'pricing', title: 'Pricing' }, + ]; + const result = mergeNavLinks(base, exposedPages); + expect(result).toEqual([ + { href: '/', label: 'Home' }, + { href: '/page/about-us', label: 'About Us' }, + { href: '/page/pricing', label: 'Pricing' }, + ]); + }); + + it('deduplicates exposed pages by href', () => { + const base = [{ href: '/page/about-us', label: 'Existing About' }]; + const exposedPages = [ + { slug: 'about-us', title: 'About Us' }, + { slug: 'pricing', title: 'Pricing' }, + ]; + const result = mergeNavLinks(base, exposedPages); + // /page/about-us already exists in base, so only pricing is appended + expect(result).toEqual([ + { href: '/page/about-us', label: 'Existing About' }, + { href: '/page/pricing', label: 'Pricing' }, + ]); + }); + + it('handles empty base links with exposed pages', () => { + const result = mergeNavLinks([], [{ slug: 'faq', title: 'FAQ' }]); + expect(result).toEqual([{ href: '/page/faq', label: 'FAQ' }]); + }); +}); diff --git a/apps/ottabase-template-app-nextjs-homepage/app/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx similarity index 100% rename from apps/ottabase-template-app-nextjs-homepage/app/page.tsx rename to apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx index 1cd24f54e..0f7939a6e 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx @@ -2,13 +2,15 @@ import { ConfigPanel } from '../components/ConfigPanel'; import { SlotRenderer } from '../components/SlotRenderer'; +import type { NavLink } from '../components/variants/navbar/types'; const GITHUB_URL = 'https://github.com/thinkdj/ottabase'; -const NAVBAR_DATA = { - title: 'Ottabase', - githubUrl: GITHUB_URL, -}; +const BASE_NAVBAR_LINKS: NavLink[] = [ + { href: '/', label: 'Home' }, + { href: '/about', label: 'About' }, + { href: '/theme-demo', label: 'Themes' }, +]; const FOOTER_DATA = { siteName: 'Ottabase', @@ -21,10 +23,40 @@ const FOOTER_DATA = { ], }; -export function LayoutShell({ children }: { children: React.ReactNode }) { +/** Merge exposed CMS pages into navbar links, deduplicating by href. */ +export function mergeNavLinks(baseLinks: NavLink[], exposedPages: { slug: string; title: string }[]): NavLink[] { + const pageLinks: NavLink[] = exposedPages.map((p) => ({ + href: `/page/${p.slug}`, + label: p.title, + })); + const seen = new Set(baseLinks.map((l) => l.href)); + const merged = [...baseLinks]; + for (const link of pageLinks) { + if (!seen.has(link.href)) { + seen.add(link.href); + merged.push(link); + } + } + return merged; +} + +export interface LayoutShellProps { + children: React.ReactNode; + /** Exposed pages from the CMS, passed from server layout. */ + exposedPages?: { slug: string; title: string }[]; +} + +export function LayoutShell({ children, exposedPages = [] }: LayoutShellProps) { + const navbarLinks = mergeNavLinks(BASE_NAVBAR_LINKS, exposedPages); + const navbarData = { + title: 'Ottabase', + githubUrl: GITHUB_URL, + links: navbarLinks.length > 0 ? navbarLinks : undefined, + }; + return ( <> - +
{children}
diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx index 173a78777..adc3ed82f 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx @@ -1,5 +1,6 @@ import { buildCriticalCSS } from '@ottabase/brand-engine'; import type { Metadata } from 'next'; +import { fetchExposedPages } from '../lib/api'; import { generateBrandConfig } from '../lib/brand-server'; import './globals.css'; import { LayoutShell } from './layout-shell'; @@ -13,7 +14,7 @@ export const metadata: Metadata = { authors: [{ name: 'Ottabase' }], }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { // Generate brand config server-side (SSR) // Note: Using 'light' for initial SSR. BrandProvider will handle dynamic theme switching on client. const brandConfig = generateBrandConfig('light'); @@ -22,6 +23,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // Generate critical CSS for SSR (prevents FOUC) const criticalCSS = buildCriticalCSS(theme); + // Fetch exposed CMS pages for navbar + const exposedPages = await fetchExposedPages(); + return ( @@ -34,7 +38,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + {children} diff --git a/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx new file mode 100644 index 000000000..8d16b50f8 --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page-content.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { Blocks, customRenderers, defaultEJSRConfigs } from '@ottabase/ottarenderer'; +import '@ottabase/ottarenderer/styles'; +import type { PageData } from '../../../lib/api'; + +export function PageContent({ page }: { page: PageData }) { + const hasContent = page.content?.blocks && page.content.blocks.length > 0; + + return ( +
+
+

{page.title}

+ {page.excerpt &&

{page.excerpt}

} +
+ + {page.heroImage?.url && ( +
+ {page.heroImage.alt + {page.heroImage.caption && ( +

{page.heroImage.caption}

+ )} +
+ )} + + {hasContent && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx new file mode 100644 index 000000000..9a04f05d3 --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/app/page/[slug]/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { fetchPageBySlug } from '../../../lib/api'; +import { PageContent } from './page-content'; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const page = await fetchPageBySlug(slug); + if (!page) return { title: 'Not Found' }; + return { + title: page.seoMeta?.title || page.title, + description: page.seoMeta?.description || page.excerpt || undefined, + keywords: page.seoMeta?.keywords, + }; +} + +export default async function CmsPage({ params }: PageProps) { + const { slug } = await params; + const page = await fetchPageBySlug(slug); + if (!page) notFound(); + return ; +} diff --git a/apps/ottabase-template-app-nextjs-homepage/lib/api.ts b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts new file mode 100644 index 000000000..0d597ccfa --- /dev/null +++ b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts @@ -0,0 +1,86 @@ +/** + * API helpers for fetching data from the Ottabase worker backend. + * + * Requires NEXT_PUBLIC_API_URL to be set (e.g. http://localhost:3004). + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; + +/** EditorJS block data shape */ +export interface EditorJSData { + time?: number; + blocks: Array<{ + id?: string; + type: string; + data: Record; + }>; + version?: string; +} + +/** Public page data returned by /api/blog/pages/by-slug/:slug */ +export interface PageData { + id: string; + title: string; + slug: string; + excerpt: string | null; + content: EditorJSData | null; + contentType: string; + status: string; + heroImage: { + url: string; + alt?: string; + caption?: string; + } | null; + seoMeta: { + title?: string; + description?: string; + keywords?: string[]; + } | null; + authorName: string | null; + publishedAt: string | null; + createdAt: string; + updatedAt: string; +} + +/** Exposed page link for homepage navbar */ +export interface ExposedPage { + slug: string; + title: string; +} + +/** + * Fetch a published page by slug from the worker API. + * Returns null if not found or on error. + */ +export async function fetchPageBySlug(slug: string): Promise { + if (!API_URL) return null; + try { + const res = await fetch(`${API_URL}/api/blog/pages/by-slug/${encodeURIComponent(slug)}`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return null; + const data = await res.json(); + if (data.contentType !== 'page') return null; + return data as PageData; + } catch { + return null; + } +} + +/** + * Fetch exposed pages for the homepage navbar. + * Returns an empty array on error so the homepage never hard-fails. + */ +export async function fetchExposedPages(): Promise { + if (!API_URL) return []; + try { + const res = await fetch(`${API_URL}/api/blog/pages/exposed`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data.exposedPages) ? data.exposedPages : []; + } catch { + return []; + } +} diff --git a/apps/ottabase-template-app-nextjs-homepage/package.json b/apps/ottabase-template-app-nextjs-homepage/package.json index 76ebd4d44..ea453c28f 100644 --- a/apps/ottabase-template-app-nextjs-homepage/package.json +++ b/apps/ottabase-template-app-nextjs-homepage/package.json @@ -22,6 +22,7 @@ "@ottabase/brand-engine-react": "workspace:*", "@ottabase/config": "workspace:*", "@ottabase/ottalayout": "workspace:*", + "@ottabase/ottarenderer": "workspace:*", "@ottabase/ui-components": "workspace:*", "@ottabase/ui-shadcn": "workspace:*", "@radix-ui/react-slot": "^1.1.1", diff --git a/packages/ottablog/src/__tests__/types.test.ts b/packages/ottablog/src/__tests__/types.test.ts index 4acc1afd2..98c0a2b4c 100644 --- a/packages/ottablog/src/__tests__/types.test.ts +++ b/packages/ottablog/src/__tests__/types.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { calculateReadingTime, extractExcerpt, generateSlug, formatDate, formatShortDate } from '../types'; +import { + BLOG_FEED_CONTENT_TYPES, + CONTENT_TYPES, + calculateReadingTime, + extractExcerpt, + formatDate, + formatShortDate, + generateSlug, +} from '../types'; +import type { ContentType } from '../types'; describe('ottablog helpers', () => { describe('generateSlug', () => { @@ -108,4 +117,45 @@ describe('ottablog helpers', () => { expect(formatted).toMatch(/Dec 25, 2024/); }); }); + + describe('CONTENT_TYPES', () => { + it('includes all 6 content types', () => { + const keys = Object.keys(CONTENT_TYPES); + expect(keys).toEqual(['blog', 'changelog', 'docs', 'news', 'announcement', 'page']); + }); + + it('page content type has correct metadata', () => { + expect(CONTENT_TYPES.page).toEqual({ + label: 'Page', + description: 'Static/marketing page managed via CMS', + }); + }); + + it('every ContentType key has label and description', () => { + for (const [, meta] of Object.entries(CONTENT_TYPES)) { + expect(meta).toHaveProperty('label'); + expect(meta).toHaveProperty('description'); + expect(typeof meta.label).toBe('string'); + expect(typeof meta.description).toBe('string'); + } + }); + }); + + describe('BLOG_FEED_CONTENT_TYPES', () => { + it('includes blog, docs, news, announcement', () => { + expect(BLOG_FEED_CONTENT_TYPES).toEqual(['blog', 'docs', 'news', 'announcement']); + }); + + it('excludes changelog and page', () => { + expect(BLOG_FEED_CONTENT_TYPES).not.toContain('changelog'); + expect(BLOG_FEED_CONTENT_TYPES).not.toContain('page'); + }); + + it('is a subset of CONTENT_TYPES keys', () => { + const allTypes = Object.keys(CONTENT_TYPES); + for (const type of BLOG_FEED_CONTENT_TYPES) { + expect(allTypes).toContain(type); + } + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f191026a..e699ee2ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: '@ottabase/ottalayout': specifier: workspace:* version: link:../../packages/ottalayout + '@ottabase/ottarenderer': + specifier: workspace:* + version: link:../../packages/ottarenderer '@ottabase/ui-components': specifier: workspace:* version: link:../../packages/ui-components From 72478b60ffcf079ef11bf2d4e32b0ed8749b2112 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:07:12 +0000 Subject: [PATCH 04/19] fix: correct import path in (site)/page.tsx for route group, update ottablog README docs Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/046b035e-9b57-4779-b4ad-6fa561dfc03b --- .../app/(site)/page.tsx | 2 +- packages/ottablog/README.md | 23 ++++++++++++++++++- .../ottablog/src/__tests__/models.test.ts | 23 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx index 4dfb59eb5..e772ba669 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/(site)/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { Github, Palette, Rocket } from 'lucide-react'; -import { SlotRenderer } from '../components/SlotRenderer'; +import { SlotRenderer } from '../../components/SlotRenderer'; /** * Homepage content data — single source of truth. diff --git a/packages/ottablog/README.md b/packages/ottablog/README.md index 6bc75504e..445ea7212 100644 --- a/packages/ottablog/README.md +++ b/packages/ottablog/README.md @@ -102,7 +102,7 @@ const post = await Post.create({ content: { /* EditorJS JSON */ }, - contentType: 'blog', // blog, changelog, docs, news, announcement + contentType: 'blog', // blog, changelog, docs, news, announcement, page status: 'published', // draft, published, archived, scheduled categoryId: 'cat-123', seriesId: 'series-123', @@ -164,6 +164,7 @@ post.generateExcerpt(); // Auto-generate from content - `readingTimeMinutes`, `wordCount` - Auto-calculated stats - `viewCount` - View/hit counter (incremented via `trackView()`) - `isFeatured` - Pin to top +- `exposeToHomepage` - Show page link in marketing homepage navbar (only for `page` content type) - `allowComments` - Enable comments - `publishAt`, `publishedAt`, `postedAt` - Dates - `appId` - Multi-app identifier @@ -375,6 +376,7 @@ const customCats = await PostCategory.where({ - `docs` - Documentation - `changelog` - Change logs - `announcement` - Announcements +- `page` - Static/marketing pages managed via CMS - Custom types supported ## Relationships @@ -508,6 +510,25 @@ GET /api/blog/posts/by-slug/{slug} Returns a single post with tags, categories (via junction), and series title. View tracking is opt-in (call `trackView()` explicitly to avoid D1 write costs per page view). +> **Note:** Returns 404 for `changelog` and `page` content types — they have dedicated endpoints. + +### Page by Slug + +``` +GET /api/blog/pages/by-slug/{slug} +``` + +Returns a published page (`contentType: 'page'`). Used by the marketing homepage to render CMS-managed static pages. + +### Exposed Pages + +``` +GET /api/blog/pages/exposed +``` + +Returns `{ exposedPages: [{ slug, title }] }` — all published pages with `exposeToHomepage: true`, sorted by title. Used +by the marketing homepage to build navbar links. Returns `{ exposedPages: [] }` on failure. + ### Related Posts ``` diff --git a/packages/ottablog/src/__tests__/models.test.ts b/packages/ottablog/src/__tests__/models.test.ts index bbe7e0bab..7ca93c736 100644 --- a/packages/ottablog/src/__tests__/models.test.ts +++ b/packages/ottablog/src/__tests__/models.test.ts @@ -65,6 +65,29 @@ describe('ottablog models', () => { expect(fields.viewCount.editable).toBe(false); expect(fields.viewCount.sortable).toBe(true); }); + + it('should have exposeToHomepage in casts', () => { + expect(Post.casts).toHaveProperty('exposeToHomepage'); + expect(Post.casts.exposeToHomepage).toBe('boolean'); + }); + + it('should have exposeToHomepage in writable fields', () => { + expect(Post.writable.create).toContain('exposeToHomepage'); + expect(Post.writable.update).toContain('exposeToHomepage'); + }); + + it('should have exposeToHomepage field metadata', () => { + const fields = Post.getFields(); + expect(fields).toHaveProperty('exposeToHomepage'); + expect(fields.exposeToHomepage.type).toBe('boolean'); + expect(fields.exposeToHomepage.editable).toBe(true); + expect(fields.exposeToHomepage.filterable).toBe(true); + }); + + it('should have exposeToHomepage default to false', () => { + const defaults = (Post as any).defaults; + expect(defaults.exposeToHomepage).toBe(false); + }); }); describe('PostCategory model', () => { From 4db1801cd2fd6fc788ef903eaa326078c92a56db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:43:09 +0000 Subject: [PATCH 05/19] feat: add homepage DB models (Section, Feature, Action, DisplaySettings) + GET /api/homepage/data Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/b9bee764-b52d-4fdc-a63f-1aa2b58b0afb --- .../app/layout-shell.tsx | 73 ++++++- .../app/layout.tsx | 10 +- .../lib/api.ts | 60 ++++++ .../ottabase/db/schemas-helper.ts | 12 ++ .../ottabase/models/HomepageAction.schema.ts | 34 ++++ .../ottabase/models/HomepageAction.ts | 104 ++++++++++ .../models/HomepageDisplaySettings.schema.ts | 38 ++++ .../models/HomepageDisplaySettings.ts | 101 ++++++++++ .../ottabase/models/HomepageFeature.schema.ts | 30 +++ .../ottabase/models/HomepageFeature.ts | 92 +++++++++ .../ottabase/models/HomepageSection.schema.ts | 40 ++++ .../ottabase/models/HomepageSection.ts | 108 ++++++++++ .../worker/lib/db-utils.ts | 6 +- .../worker/routes/homepage.ts | 190 ++++++++++++++++++ .../worker/routes/router.ts | 6 + 15 files changed, 897 insertions(+), 7 deletions(-) create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.schema.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.schema.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.schema.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts create mode 100644 apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.ts create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/homepage.ts diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx index 0f7939a6e..11f6b002a 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx @@ -2,6 +2,7 @@ import { ConfigPanel } from '../components/ConfigPanel'; import { SlotRenderer } from '../components/SlotRenderer'; +import type { HomepageDataPayload } from '../lib/api'; import type { NavLink } from '../components/variants/navbar/types'; const GITHUB_URL = 'https://github.com/thinkdj/ottabase'; @@ -40,25 +41,93 @@ export function mergeNavLinks(baseLinks: NavLink[], exposedPages: { slug: string return merged; } +/** + * Build slot data overrides from the homepage API sections. + * Maps section data to the SlotRenderer data contracts. + */ +function buildSlotDataFromSections(sections: HomepageDataPayload['sections']) { + const dataBySlot: Record> = {}; + for (const section of sections) { + const slot = section.slot; + if (slot === 'hero') { + dataBySlot[slot] = { + title: section.title ?? '', + subtitle: section.subtitle ?? undefined, + body: section.body ?? undefined, + actions: section.actions.map((a) => ({ + label: a.label, + href: a.href, + variant: a.variant ?? 'default', + external: a.external, + })), + }; + } else if (slot === 'features') { + dataBySlot[slot] = { + title: section.title ?? undefined, + features: section.features, + }; + } else if (slot === 'cta') { + dataBySlot[slot] = { + title: section.title ?? '', + description: section.subtitle ?? undefined, + actions: section.actions.map((a) => ({ + label: a.label, + href: a.href, + variant: a.variant ?? 'default', + external: a.external, + })), + }; + } else if (slot === 'about') { + dataBySlot[slot] = { + title: section.title ?? undefined, + description: section.subtitle ?? undefined, + githubUrl: section.githubUrl ?? undefined, + }; + } else if (slot === 'navbar') { + dataBySlot[slot] = { + title: section.title ?? 'Ottabase', + githubUrl: section.githubUrl ?? undefined, + }; + } else if (slot === 'footer') { + dataBySlot[slot] = { + siteName: section.title ?? 'Ottabase', + tagline: section.subtitle ?? undefined, + }; + } + } + return dataBySlot; +} + export interface LayoutShellProps { children: React.ReactNode; /** Exposed pages from the CMS, passed from server layout. */ exposedPages?: { slug: string; title: string }[]; + /** Full homepage data payload from the API. */ + homepageData?: HomepageDataPayload; } -export function LayoutShell({ children, exposedPages = [] }: LayoutShellProps) { +export function LayoutShell({ children, exposedPages = [], homepageData }: LayoutShellProps) { const navbarLinks = mergeNavLinks(BASE_NAVBAR_LINKS, exposedPages); + + // Build slot data overrides from DB sections (if available) + const dbSlotData = homepageData?.sections?.length ? buildSlotDataFromSections(homepageData.sections) : {}; + const navbarData = { title: 'Ottabase', githubUrl: GITHUB_URL, + // Override with DB data if present (title/githubUrl from navbar section) + ...(dbSlotData.navbar ?? {}), + // Always include merged links (DB navbar data should not override links) links: navbarLinks.length > 0 ? navbarLinks : undefined, }; + const footerData = dbSlotData.footer ? { ...FOOTER_DATA, ...dbSlotData.footer } : FOOTER_DATA; + return ( <>
{children}
- + ); diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx index adc3ed82f..7c01d00a5 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout.tsx @@ -1,6 +1,6 @@ import { buildCriticalCSS } from '@ottabase/brand-engine'; import type { Metadata } from 'next'; -import { fetchExposedPages } from '../lib/api'; +import { fetchHomepageData } from '../lib/api'; import { generateBrandConfig } from '../lib/brand-server'; import './globals.css'; import { LayoutShell } from './layout-shell'; @@ -23,8 +23,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo // Generate critical CSS for SSR (prevents FOUC) const criticalCSS = buildCriticalCSS(theme); - // Fetch exposed CMS pages for navbar - const exposedPages = await fetchExposedPages(); + // Fetch full homepage data (sections, display settings, exposed pages) from the worker API + const homepageData = await fetchHomepageData(); return ( @@ -38,7 +38,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo - {children} + + {children} + diff --git a/apps/ottabase-template-app-nextjs-homepage/lib/api.ts b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts index 0d597ccfa..cc9b8cd7e 100644 --- a/apps/ottabase-template-app-nextjs-homepage/lib/api.ts +++ b/apps/ottabase-template-app-nextjs-homepage/lib/api.ts @@ -48,6 +48,38 @@ export interface ExposedPage { title: string; } +/** Homepage section from the public API */ +export interface HomepageSectionPayload { + id: string; + slot: string; + title: string | null; + subtitle: string | null; + body: string | null; + githubUrl: string | null; + sortOrder: number; + features: Array<{ title: string; description: string }>; + actions: Array<{ + label: string; + href: string; + variant: string | null; + external: boolean; + }>; +} + +/** Homepage display settings from the public API */ +export interface HomepageDisplayPayload { + variantBySlot: Record | null; + themePreset: string | null; + fallbackThemePresetId: string | null; +} + +/** Full homepage data payload from GET /api/homepage/data */ +export interface HomepageDataPayload { + sections: HomepageSectionPayload[]; + display: HomepageDisplayPayload; + exposedPages: ExposedPage[]; +} + /** * Fetch a published page by slug from the worker API. * Returns null if not found or on error. @@ -67,6 +99,34 @@ export async function fetchPageBySlug(slug: string): Promise { } } +/** + * Fetch the full homepage data payload from the worker API. + * Includes sections (with features + actions), display settings, and exposed pages. + * Returns safe defaults on error so the homepage never hard-fails. + */ +export async function fetchHomepageData(): Promise { + const fallback: HomepageDataPayload = { + sections: [], + display: { variantBySlot: null, themePreset: null, fallbackThemePresetId: null }, + exposedPages: [], + }; + if (!API_URL) return fallback; + try { + const res = await fetch(`${API_URL}/api/homepage/data`, { + next: { revalidate: 60 }, + }); + if (!res.ok) return fallback; + const data = await res.json(); + return { + sections: Array.isArray(data.sections) ? data.sections : [], + display: data.display ?? fallback.display, + exposedPages: Array.isArray(data.exposedPages) ? data.exposedPages : [], + }; + } catch { + return fallback; + } +} + /** * Fetch exposed pages for the homepage navbar. * Returns an empty array on error so the homepage never hard-fails. diff --git a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts b/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts index 0bd67a87f..23df84d12 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/db/schemas-helper.ts @@ -34,6 +34,10 @@ import { } from '@ottabase/ottaorm'; import { getEnabledPackageTables } from '../config.migrations'; import { changelogEntriesTable } from '../models/ChangelogEntry'; +import { homepageActionsTable } from '../models/HomepageAction'; +import { homepageDisplaySettingsTable } from '../models/HomepageDisplaySettings'; +import { homepageFeaturesTable } from '../models/HomepageFeature'; +import { homepageSectionsTable } from '../models/HomepageSection'; import { todosTable } from '../models/Todo'; /** @@ -63,6 +67,10 @@ export function getAllSchemas() { const appTables = { changelogEntriesTable, todosTable, + homepageSectionsTable, + homepageFeaturesTable, + homepageActionsTable, + homepageDisplaySettingsTable, }; // 3. Package schemas from enabled packages (ottablog, shortlinks, referrals, etc.) @@ -105,6 +113,10 @@ export function getSchemaSummary() { const appTables = { changelogEntriesTable, todosTable, + homepageSectionsTable, + homepageFeaturesTable, + homepageActionsTable, + homepageDisplaySettingsTable, }; const packageTables = getEnabledPackageTables(); diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.schema.ts new file mode 100644 index 000000000..e7a6cd572 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.schema.ts @@ -0,0 +1,34 @@ +// ============================================================ +// Homepage Action table (App-specific) +// ============================================================ + +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { homepageSectionsTable } from './HomepageSection.schema'; + +export const homepageActionsTable = sqliteTable('homepage_actions', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + /** Parent section (hero, cta, about, etc.) */ + sectionId: text('section_id') + .notNull() + .references(() => homepageSectionsTable.id, { onDelete: 'cascade' }), + label: text('label').notNull(), + href: text('href').notNull(), + /** Button style variant: default, secondary, outline, ghost */ + variant: text('variant').default('default'), + /** Whether the link opens in a new tab */ + external: integer('external', { mode: 'boolean' }).default(false), + /** Display order */ + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export type HomepageActionRow = typeof homepageActionsTable.$inferSelect; +export type NewHomepageActionRow = typeof homepageActionsTable.$inferInsert; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.ts new file mode 100644 index 000000000..d310e8080 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageAction.ts @@ -0,0 +1,104 @@ +// ============================================================ +// HomepageAction Model (App-specific fat model) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { homepageActionsTable } from './HomepageAction.schema'; + +export { homepageActionsTable, type HomepageActionRow, type NewHomepageActionRow } from './HomepageAction.schema'; + +/** + * A call-to-action button belonging to a homepage section (hero, cta, about, etc.). + */ +export class HomepageAction extends BaseModel { + static entity = 'homepage_actions'; + static table = homepageActionsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + external: 'boolean' as const, + sortOrder: 'number' as const, + createdAt: 'date' as const, + updatedAt: 'date' as const, + }; + + static writable = { + create: ['sectionId', 'label', 'href', 'variant', 'external', 'sortOrder'], + update: ['sectionId', 'label', 'href', 'variant', 'external', 'sortOrder'], + }; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false, uiConfig: { label: 'ID' } }, + sectionId: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'Section', description: 'Parent section ID' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 200 }, + validation: { rules: 'required', messages: { required: 'Section is required' } }, + }, + label: { + type: 'string', + editable: true, + searchable: true, + uiConfig: { label: 'Label', placeholder: 'Button text' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 'auto' }, + validation: { rules: 'required', messages: { required: 'Label is required' } }, + }, + href: { + type: 'string', + editable: true, + uiConfig: { label: 'URL', placeholder: '/path or https://...' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 200 }, + validation: { rules: 'required', messages: { required: 'URL is required' } }, + }, + variant: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'Style variant', description: 'default, secondary, outline, ghost' }, + formConfig: { visible: true, fieldType: 'select' }, + tableConfig: { visible: true, colWidth: 120 }, + }, + external: { + type: 'boolean', + editable: true, + uiConfig: { label: 'External link', description: 'Opens in new tab' }, + formConfig: { visible: true, fieldType: 'checkbox' }, + tableConfig: { visible: true, colWidth: 100 }, + }, + sortOrder: { + type: 'number', + editable: true, + sortable: true, + uiConfig: { label: 'Sort Order' }, + formConfig: { visible: true, fieldType: 'number' }, + tableConfig: { visible: true, colWidth: 100 }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created' }, + tableConfig: { visible: false }, + }, + updatedAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Updated' }, + tableConfig: { visible: false }, + }, + }; + + protected static validationRules = { + sectionId: { rules: 'required', fieldName: 'Section', messages: { required: 'Section is required' } }, + label: { rules: 'required', fieldName: 'Label', messages: { required: 'Label is required' } }, + href: { rules: 'required', fieldName: 'URL', messages: { required: 'URL is required' } }, + }; +} diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.schema.ts new file mode 100644 index 000000000..4daef09b4 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.schema.ts @@ -0,0 +1,38 @@ +// ============================================================ +// Homepage Display Settings table (App-specific) +// ============================================================ + +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +/** + * Single-row settings table storing the full homepage display state: + * - Variant selections per slot (JSON matching HomepageConfig) + * - Active theme preset name + * - Fallback theme preset ID + */ +export const homepageDisplaySettingsTable = sqliteTable('homepage_display_settings', { + id: text('id') + .primaryKey() + .$defaultFn(() => 'default'), + /** + * JSON object mapping slot names to active variant IDs. + * Shape: Record e.g. { hero: 'centered', features: 'grid', ... } + */ + variantBySlotJson: text('variant_by_slot_json', { mode: 'json' }).$type>(), + /** Active theme preset name (e.g. 'default', 'neo', 'crisp') */ + themePreset: text('theme_preset').default('default'), + /** Fallback theme preset ID for SSR */ + fallbackThemePresetId: text('fallback_theme_preset_id'), + /** Multi-app identifier */ + appId: text('app_id'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export type HomepageDisplaySettingsRow = typeof homepageDisplaySettingsTable.$inferSelect; +export type NewHomepageDisplaySettingsRow = typeof homepageDisplaySettingsTable.$inferInsert; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.ts new file mode 100644 index 000000000..3c6522449 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageDisplaySettings.ts @@ -0,0 +1,101 @@ +// ============================================================ +// HomepageDisplaySettings Model (App-specific fat model) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { homepageDisplaySettingsTable } from './HomepageDisplaySettings.schema'; + +export { + homepageDisplaySettingsTable, + type HomepageDisplaySettingsRow, + type NewHomepageDisplaySettingsRow, +} from './HomepageDisplaySettings.schema'; + +/** + * Single-row settings model for the homepage display state. + * Stores variant selections per slot and the active theme preset. + */ +export class HomepageDisplaySettings extends BaseModel { + static entity = 'homepage_display_settings'; + static table = homepageDisplaySettingsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + variantBySlotJson: 'json' as const, + createdAt: 'date' as const, + updatedAt: 'date' as const, + }; + + static writable = { + create: ['id', 'variantBySlotJson', 'themePreset', 'fallbackThemePresetId', 'appId'], + update: ['variantBySlotJson', 'themePreset', 'fallbackThemePresetId', 'appId'], + }; + + protected static defaults = { + themePreset: 'default', + }; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false, uiConfig: { label: 'ID' } }, + variantBySlotJson: { + type: 'json', + editable: true, + uiConfig: { + label: 'Variant selections', + description: 'JSON mapping slot names to variant IDs', + }, + formConfig: { visible: true, fieldType: 'textarea' }, + tableConfig: { visible: false }, + }, + themePreset: { + type: 'string', + editable: true, + uiConfig: { label: 'Theme preset', description: 'Active theme preset name' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 150 }, + }, + fallbackThemePresetId: { + type: 'string', + editable: true, + uiConfig: { label: 'Fallback theme', description: 'Theme preset for SSR fallback' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: false }, + }, + appId: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'App ID' }, + tableConfig: { visible: false }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created' }, + tableConfig: { visible: false }, + }, + updatedAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Updated' }, + tableConfig: { visible: false }, + }, + }; + + /** + * Get or create the default settings row. + */ + static async getOrCreateDefault(appId?: string | null): Promise { + const where: Record = { id: 'default' }; + if (appId) where.appId = appId; + + const existing = await this.first(where); + if (existing) return existing as HomepageDisplaySettings; + + return (await this.create({ id: 'default', appId: appId ?? undefined })) as HomepageDisplaySettings; + } +} diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.schema.ts new file mode 100644 index 000000000..64a4acf25 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.schema.ts @@ -0,0 +1,30 @@ +// ============================================================ +// Homepage Feature table (App-specific) +// ============================================================ + +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { homepageSectionsTable } from './HomepageSection.schema'; + +export const homepageFeaturesTable = sqliteTable('homepage_features', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + /** Parent section (typically the "features" slot) */ + sectionId: text('section_id') + .notNull() + .references(() => homepageSectionsTable.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description').notNull(), + /** Display order */ + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), +}); + +export type HomepageFeatureRow = typeof homepageFeaturesTable.$inferSelect; +export type NewHomepageFeatureRow = typeof homepageFeaturesTable.$inferInsert; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.ts new file mode 100644 index 000000000..61545a227 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageFeature.ts @@ -0,0 +1,92 @@ +// ============================================================ +// HomepageFeature Model (App-specific fat model) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { homepageFeaturesTable } from './HomepageFeature.schema'; + +export { homepageFeaturesTable, type HomepageFeatureRow, type NewHomepageFeatureRow } from './HomepageFeature.schema'; + +/** + * A feature item belonging to a homepage section (typically the "features" slot). + */ +export class HomepageFeature extends BaseModel { + static entity = 'homepage_features'; + static table = homepageFeaturesTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + sortOrder: 'number' as const, + createdAt: 'date' as const, + updatedAt: 'date' as const, + }; + + static writable = { + create: ['sectionId', 'title', 'description', 'sortOrder'], + update: ['sectionId', 'title', 'description', 'sortOrder'], + }; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false, uiConfig: { label: 'ID' } }, + sectionId: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'Section', description: 'Parent section ID' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 200 }, + validation: { rules: 'required', messages: { required: 'Section is required' } }, + }, + title: { + type: 'string', + editable: true, + searchable: true, + uiConfig: { label: 'Title', placeholder: 'Feature name' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 'auto' }, + validation: { rules: 'required', messages: { required: 'Title is required' } }, + }, + description: { + type: 'string', + editable: true, + uiConfig: { label: 'Description' }, + formConfig: { visible: true, fieldType: 'textarea' }, + tableConfig: { visible: true, colWidth: 'auto' }, + validation: { rules: 'required', messages: { required: 'Description is required' } }, + }, + sortOrder: { + type: 'number', + editable: true, + sortable: true, + uiConfig: { label: 'Sort Order' }, + formConfig: { visible: true, fieldType: 'number' }, + tableConfig: { visible: true, colWidth: 100 }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created' }, + tableConfig: { visible: false }, + }, + updatedAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Updated' }, + tableConfig: { visible: false }, + }, + }; + + protected static validationRules = { + sectionId: { rules: 'required', fieldName: 'Section', messages: { required: 'Section is required' } }, + title: { rules: 'required', fieldName: 'Title', messages: { required: 'Title is required' } }, + description: { + rules: 'required', + fieldName: 'Description', + messages: { required: 'Description is required' }, + }, + }; +} diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts new file mode 100644 index 000000000..7614d6501 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts @@ -0,0 +1,40 @@ +// ============================================================ +// Homepage Section table (App-specific) +// ============================================================ + +import { sql } from 'drizzle-orm'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const homepageSectionsTable = sqliteTable( + 'homepage_sections', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + /** Slot name: navbar, hero, features, cta, footer, about */ + slot: text('slot').notNull(), + title: text('title'), + subtitle: text('subtitle'), + body: text('body'), + /** Optional GitHub URL (used by navbar/about slots) */ + githubUrl: text('github_url'), + /** Display order for listing */ + sortOrder: integer('sort_order').notNull().default(0), + /** Multi-app identifier */ + appId: text('app_id'), + createdAt: integer('created_at') + .$defaultFn(() => Date.now()) + .notNull(), + updatedAt: integer('updated_at') + .$defaultFn(() => Date.now()) + .$onUpdateFn(() => Date.now()) + .notNull(), + }, + (table) => [ + index('homepage_sections_slot_idx').on(table.slot), + index('homepage_sections_app_slot_idx').on(table.appId, table.slot), + ], +); + +export type HomepageSectionRow = typeof homepageSectionsTable.$inferSelect; +export type NewHomepageSectionRow = typeof homepageSectionsTable.$inferInsert; diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.ts new file mode 100644 index 000000000..9e66b7b69 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.ts @@ -0,0 +1,108 @@ +// ============================================================ +// HomepageSection Model (App-specific fat model) +// ============================================================ + +import { BaseModel, ModelFields, type PackageType } from '@ottabase/ottaorm'; +import { homepageSectionsTable } from './HomepageSection.schema'; + +export { homepageSectionsTable, type HomepageSectionRow, type NewHomepageSectionRow } from './HomepageSection.schema'; + +/** + * A homepage section maps to a SlotRenderer slot (navbar, hero, features, cta, footer, about). + * Contains the title/subtitle/body for that slot. + * Features and actions are stored in child tables (HomepageFeature, HomepageAction). + */ +export class HomepageSection extends BaseModel { + static entity = 'homepage_sections'; + static table = homepageSectionsTable; + static primaryKey = 'id'; + static packageName = 'app'; + static packageType: PackageType = 'app'; + + static casts = { + sortOrder: 'number' as const, + createdAt: 'date' as const, + updatedAt: 'date' as const, + }; + + static writable = { + create: ['slot', 'title', 'subtitle', 'body', 'githubUrl', 'sortOrder', 'appId'], + update: ['slot', 'title', 'subtitle', 'body', 'githubUrl', 'sortOrder', 'appId'], + }; + + protected static fields: ModelFields = { + id: { type: 'id', primaryKey: true, editable: false, uiConfig: { label: 'ID' } }, + slot: { + type: 'string', + editable: true, + filterable: true, + sortable: true, + uiConfig: { label: 'Slot', description: 'Homepage slot name (hero, features, cta, etc.)' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 120 }, + validation: { rules: 'required', messages: { required: 'Slot is required' } }, + }, + title: { + type: 'string', + editable: true, + searchable: true, + uiConfig: { label: 'Title', placeholder: 'Section heading' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 'auto' }, + }, + subtitle: { + type: 'string', + editable: true, + uiConfig: { label: 'Subtitle' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: true, colWidth: 200 }, + }, + body: { + type: 'string', + editable: true, + uiConfig: { label: 'Body text' }, + formConfig: { visible: true, fieldType: 'textarea' }, + tableConfig: { visible: false }, + }, + githubUrl: { + type: 'string', + editable: true, + uiConfig: { label: 'GitHub URL' }, + formConfig: { visible: true, fieldType: 'input' }, + tableConfig: { visible: false }, + }, + sortOrder: { + type: 'number', + editable: true, + sortable: true, + uiConfig: { label: 'Sort Order' }, + formConfig: { visible: true, fieldType: 'number' }, + tableConfig: { visible: true, colWidth: 100 }, + }, + appId: { + type: 'string', + editable: true, + filterable: true, + uiConfig: { label: 'App ID' }, + tableConfig: { visible: false }, + }, + createdAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Created' }, + tableConfig: { visible: false }, + }, + updatedAt: { + type: 'date', + editable: false, + sortable: true, + uiConfig: { label: 'Updated' }, + tableConfig: { visible: false }, + }, + }; + + protected static validationRules = { + slot: { rules: 'required', fieldName: 'Slot', messages: { required: 'Slot is required' } }, + }; +} 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 f1926dfd9..aeb0d207f 100644 --- a/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts +++ b/apps/ottabase-template-app-tanstack/worker/lib/db-utils.ts @@ -39,6 +39,10 @@ import { errorResponse } from '@ottabase/utils/http-errors'; import { getOttabaseConfig } from '../../ottabase/config.loader'; import { ChangelogEntry } from '../../ottabase/models/ChangelogEntry'; import { changelogPolicy } from '../../ottabase/models/changelogPolicy'; +import { HomepageAction } from '../../ottabase/models/HomepageAction'; +import { HomepageDisplaySettings } from '../../ottabase/models/HomepageDisplaySettings'; +import { HomepageFeature } from '../../ottabase/models/HomepageFeature'; +import { HomepageSection } from '../../ottabase/models/HomepageSection'; import { Todo } from '../../ottabase/models/Todo'; import { mediaLibraryPolicy } from '../../ottabase/models/mediaLibraryPolicy'; import type { CloudflareEnv } from '../cloudflare-env'; @@ -127,7 +131,7 @@ export function initDbConnection(env: CloudflareEnv): void { registerPolicy(mediaLibraryPolicy); registerPolicy(changelogPolicy); - const appModels = [Todo, ChangelogEntry]; + const appModels = [Todo, ChangelogEntry, HomepageSection, HomepageFeature, HomepageAction, HomepageDisplaySettings]; registerModels([...coreModels, ...ottablogModels, ...packageModels, ...brandModels, ...appModels]); diff --git a/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts b/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts new file mode 100644 index 000000000..7b79ec95e --- /dev/null +++ b/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts @@ -0,0 +1,190 @@ +// ============================================================ +// Homepage public API route +// ============================================================ +// GET /api/homepage/data — returns all homepage content: +// sections (with features and actions), display settings, and exposed CMS pages. +// +// Pattern follows worker/routes/changelog.ts (public, read-only, no auth). +// ============================================================ + +import { BLOG_FEED_CONTENT_TYPES, Post } from '@ottabase/ottablog'; +import { createD1Driver } from '@ottabase/db/drizzle-d1'; +import { registerConnection } from '@ottabase/ottaorm'; +import { errorResponse } from '@ottabase/utils/http-errors'; +import { jsonResponse } from '@ottabase/utils/http-response'; +import type { CloudflareEnv } from '../../cloudflare-env'; +import { HomepageAction } from '../../ottabase/models/HomepageAction'; +import { HomepageDisplaySettings } from '../../ottabase/models/HomepageDisplaySettings'; +import { HomepageFeature } from '../../ottabase/models/HomepageFeature'; +import { HomepageSection } from '../../ottabase/models/HomepageSection'; + +export interface HomepageRouteContext { + request: Request; + env: CloudflareEnv; + url: URL; +} + +function ensureD1(env: CloudflareEnv): Response | null { + if (!env.OBCF_D1) { + return errorResponse('D1 database binding not configured', 500, { + code: 'CONFIG_ERROR', + }); + } + return null; +} + +/** Shape of a section in the public payload */ +interface PublicSection { + id: string; + slot: string; + title: string | null; + subtitle: string | null; + body: string | null; + githubUrl: string | null; + sortOrder: number; + features: Array<{ title: string; description: string }>; + actions: Array<{ + label: string; + href: string; + variant: string | null; + external: boolean; + }>; +} + +/** Shape of display settings in the public payload */ +interface PublicDisplay { + variantBySlot: Record | null; + themePreset: string | null; + fallbackThemePresetId: string | null; +} + +/** Shape of the full public payload */ +export interface HomepagePublicPayload { + sections: PublicSection[]; + display: PublicDisplay; + exposedPages: Array<{ slug: string; title: string }>; +} + +/** + * GET /api/homepage/data + * + * Returns the full homepage content payload for the Next.js consumer. + * All content comes from D1 via OttaORM; the Next.js app has no D1 binding. + */ +export async function handleHomepageData(context: HomepageRouteContext): Promise { + const { env, url } = context; + const d1Error = ensureD1(env); + if (d1Error) return d1Error; + registerConnection('default', createD1Driver(env.OBCF_D1)); + + const appId = url.searchParams.get('appId') || null; + + // ── 1. Sections with child features + actions ────────────────────── + let sections: PublicSection[] = []; + try { + const sectionWhere: Record = {}; + if (appId) sectionWhere.appId = appId; + + const sectionRecords = await HomepageSection.where(sectionWhere, { + orderBy: 'sortOrder', + orderDirection: 'asc', + }); + + // Batch-load all features and actions for these sections + const sectionIds = sectionRecords.map((s) => s.get('id') as string); + + let allFeatures: InstanceType[] = []; + let allActions: InstanceType[] = []; + + if (sectionIds.length > 0) { + allFeatures = (await HomepageFeature.where( + { sectionId: { $in: sectionIds } }, + { orderBy: 'sortOrder', orderDirection: 'asc' }, + )) as InstanceType[]; + + allActions = (await HomepageAction.where( + { sectionId: { $in: sectionIds } }, + { orderBy: 'sortOrder', orderDirection: 'asc' }, + )) as InstanceType[]; + } + + // Group by sectionId + const featuresBySectionId = new Map>(); + for (const f of allFeatures) { + const sid = f.get('sectionId') as string; + if (!featuresBySectionId.has(sid)) featuresBySectionId.set(sid, []); + featuresBySectionId.get(sid)!.push({ + title: f.get('title') as string, + description: f.get('description') as string, + }); + } + + const actionsBySectionId = new Map< + string, + Array<{ label: string; href: string; variant: string | null; external: boolean }> + >(); + for (const a of allActions) { + const sid = a.get('sectionId') as string; + if (!actionsBySectionId.has(sid)) actionsBySectionId.set(sid, []); + actionsBySectionId.get(sid)!.push({ + label: a.get('label') as string, + href: a.get('href') as string, + variant: (a.get('variant') as string) ?? null, + external: (a.get('external') as boolean) ?? false, + }); + } + + sections = sectionRecords.map((s) => { + const id = s.get('id') as string; + return { + id, + slot: s.get('slot') as string, + title: (s.get('title') as string) ?? null, + subtitle: (s.get('subtitle') as string) ?? null, + body: (s.get('body') as string) ?? null, + githubUrl: (s.get('githubUrl') as string) ?? null, + sortOrder: (s.get('sortOrder') as number) ?? 0, + features: featuresBySectionId.get(id) ?? [], + actions: actionsBySectionId.get(id) ?? [], + }; + }); + } catch { + // Non-fatal: return empty sections + sections = []; + } + + // ── 2. Display settings ──────────────────────────────────────────── + let display: PublicDisplay = { variantBySlot: null, themePreset: null, fallbackThemePresetId: null }; + try { + const settings = await HomepageDisplaySettings.getOrCreateDefault(appId); + display = { + variantBySlot: (settings.get('variantBySlotJson') as Record) ?? null, + themePreset: (settings.get('themePreset') as string) ?? null, + fallbackThemePresetId: (settings.get('fallbackThemePresetId') as string) ?? null, + }; + } catch { + // Non-fatal: keep defaults + } + + // ── 3. Exposed CMS pages (for navbar links) ─────────────────────── + let exposedPages: Array<{ slug: string; title: string }> = []; + try { + const pageWhere: Record = { + contentType: 'page', + status: 'published', + exposeToHomepage: true, + }; + if (appId) pageWhere.appId = appId; + + const pages = await Post.where(pageWhere, { orderBy: 'title', orderDirection: 'asc' }); + exposedPages = pages.map((p) => ({ + slug: p.get('slug') as string, + title: p.get('title') as string, + })); + } catch { + // Non-fatal: return empty + } + + const payload: HomepagePublicPayload = { sections, display, exposedPages }; + return jsonResponse(payload); +} diff --git a/apps/ottabase-template-app-tanstack/worker/routes/router.ts b/apps/ottabase-template-app-tanstack/worker/routes/router.ts index 185cb8382..1cb3bad2e 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/router.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/router.ts @@ -68,6 +68,7 @@ import { } from './blog'; import { handleChangelogEntriesList, handleChangelogEntryBySlug } from './changelog'; import { handleBrandApi } from './brand'; +import { handleHomepageData } from './homepage'; import { handleAIChat, handleAIGatewayChat, @@ -181,6 +182,11 @@ async function handleGetRoutes(context: ApiRouteContext): Promise Date: Mon, 30 Mar 2026 18:46:08 +0000 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20unused=20import,=20add=20error=20logging,?= =?UTF-8?q?=20fix=20empty=20array=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/b9bee764-b52d-4fdc-a63f-1aa2b58b0afb --- .../__tests__/components.test.tsx | 50 +++++++++++++++++++ .../app/layout-shell.tsx | 2 +- .../ottabase/models/HomepageSection.schema.ts | 1 - .../worker/routes/homepage.ts | 11 ++-- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx index e7a5cbf4c..30368f36e 100644 --- a/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/__tests__/components.test.tsx @@ -364,3 +364,53 @@ describe('LayoutShell navbar merge', () => { expect(result).toEqual([{ href: '/page/faq', label: 'FAQ' }]); }); }); + +describe('Homepage API types', () => { + it('fetchHomepageData returns safe fallback when API_URL is empty', async () => { + const { fetchHomepageData } = await import('../lib/api'); + // NEXT_PUBLIC_API_URL is not set in test env, so should return fallback + const result = await fetchHomepageData(); + expect(result).toEqual({ + sections: [], + display: { variantBySlot: null, themePreset: null, fallbackThemePresetId: null }, + exposedPages: [], + }); + }); + + it('fetchExposedPages returns empty array when API_URL is empty', async () => { + const { fetchExposedPages } = await import('../lib/api'); + const result = await fetchExposedPages(); + expect(result).toEqual([]); + }); + + it('fetchPageBySlug returns null when API_URL is empty', async () => { + const { fetchPageBySlug } = await import('../lib/api'); + const result = await fetchPageBySlug('test'); + expect(result).toBeNull(); + }); + + it('HomepageDataPayload types are correctly shaped', async () => { + const api = await import('../lib/api'); + // Verify the type shape exists by constructing a valid object + const payload: api.HomepageDataPayload = { + sections: [ + { + id: '1', + slot: 'hero', + title: 'Title', + subtitle: 'Sub', + body: null, + githubUrl: null, + sortOrder: 0, + features: [], + actions: [{ label: 'Go', href: '/go', variant: 'default', external: false }], + }, + ], + display: { variantBySlot: { hero: 'centered' }, themePreset: 'neo', fallbackThemePresetId: null }, + exposedPages: [{ slug: 'about', title: 'About' }], + }; + expect(payload.sections).toHaveLength(1); + expect(payload.display.themePreset).toBe('neo'); + expect(payload.exposedPages[0].slug).toBe('about'); + }); +}); diff --git a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx index 11f6b002a..01a2cf710 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/layout-shell.tsx @@ -110,7 +110,7 @@ export function LayoutShell({ children, exposedPages = [], homepageData }: Layou const navbarLinks = mergeNavLinks(BASE_NAVBAR_LINKS, exposedPages); // Build slot data overrides from DB sections (if available) - const dbSlotData = homepageData?.sections?.length ? buildSlotDataFromSections(homepageData.sections) : {}; + const dbSlotData = homepageData?.sections ? buildSlotDataFromSections(homepageData.sections) : {}; const navbarData = { title: 'Ottabase', diff --git a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts index 7614d6501..f6d8b367e 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/models/HomepageSection.schema.ts @@ -2,7 +2,6 @@ // Homepage Section table (App-specific) // ============================================================ -import { sql } from 'drizzle-orm'; import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; export const homepageSectionsTable = sqliteTable( diff --git a/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts b/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts index 7b79ec95e..19e7b6379 100644 --- a/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts +++ b/apps/ottabase-template-app-tanstack/worker/routes/homepage.ts @@ -7,7 +7,7 @@ // Pattern follows worker/routes/changelog.ts (public, read-only, no auth). // ============================================================ -import { BLOG_FEED_CONTENT_TYPES, Post } from '@ottabase/ottablog'; +import { Post } from '@ottabase/ottablog'; import { createD1Driver } from '@ottabase/db/drizzle-d1'; import { registerConnection } from '@ottabase/ottaorm'; import { errorResponse } from '@ottabase/utils/http-errors'; @@ -148,8 +148,9 @@ export async function handleHomepageData(context: HomepageRouteContext): Promise actions: actionsBySectionId.get(id) ?? [], }; }); - } catch { + } catch (err) { // Non-fatal: return empty sections + console.error('[homepage/data] Failed to load sections:', err); sections = []; } @@ -162,8 +163,9 @@ export async function handleHomepageData(context: HomepageRouteContext): Promise themePreset: (settings.get('themePreset') as string) ?? null, fallbackThemePresetId: (settings.get('fallbackThemePresetId') as string) ?? null, }; - } catch { + } catch (err) { // Non-fatal: keep defaults + console.error('[homepage/data] Failed to load display settings:', err); } // ── 3. Exposed CMS pages (for navbar links) ─────────────────────── @@ -181,8 +183,9 @@ export async function handleHomepageData(context: HomepageRouteContext): Promise slug: p.get('slug') as string, title: p.get('title') as string, })); - } catch { + } catch (err) { // Non-fatal: return empty + console.error('[homepage/data] Failed to load exposed pages:', err); } const payload: HomepagePublicPayload = { sections, display, exposedPages }; From a85fd4ff2dafff71ccebee1d48844e9a29724b61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:08:54 +0000 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20add=20admin=20UI=20for=20homepage?= =?UTF-8?q?=20management=20=E2=80=94=20sections,=20features,=20actions,=20?= =?UTF-8?q?display=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/thinkdj/ottabase/sessions/100ba360-6a73-4e2b-ad44-5369c4583ca8 --- .../src/hooks/homepageHooks.ts | 72 +++ .../src/pages/admin/AdminIndexPage.tsx | 7 + .../homepage/AdminHomepageDisplayPage.tsx | 262 ++++++++++ .../AdminHomepageSectionEditorPage.tsx | 485 ++++++++++++++++++ .../homepage/AdminHomepageSectionsPage.tsx | 427 +++++++++++++++ .../pages/admin/homepage/HomepageAdminNav.tsx | 38 ++ .../src/pages/admin/homepage/index.ts | 4 + .../src/router.tsx | 34 ++ 8 files changed, 1329 insertions(+) create mode 100644 apps/ottabase-template-app-tanstack/src/hooks/homepageHooks.ts create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageDisplayPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageSectionEditorPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageSectionsPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/homepage/HomepageAdminNav.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/homepage/index.ts diff --git a/apps/ottabase-template-app-tanstack/src/hooks/homepageHooks.ts b/apps/ottabase-template-app-tanstack/src/hooks/homepageHooks.ts new file mode 100644 index 000000000..043a0c5c4 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/hooks/homepageHooks.ts @@ -0,0 +1,72 @@ +/** + * Homepage Admin Hooks + * + * Pre-configured model hooks for homepage entities to avoid duplication across pages. + * Follows the same pattern as blogHooks.ts / mediaLibraryHooks.ts. + */ +import { createModelHooks } from '@ottabase/ottaorm/client'; + +// ── Row types ─────────────────────────────────────────────────────────────── + +export interface HomepageSectionRow { + id: string; + slot: string; + title: string | null; + subtitle: string | null; + body: string | null; + githubUrl: string | null; + sortOrder: number; + appId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface HomepageFeatureRow { + id: string; + sectionId: string; + title: string; + description: string; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface HomepageActionRow { + id: string; + sectionId: string; + label: string; + href: string; + variant: string | null; + external: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface HomepageDisplaySettingsRow { + id: string; + variantBySlotJson: Record | null; + themePreset: string | null; + fallbackThemePresetId: string | null; + appId: string | null; + createdAt: string; + updatedAt: string; +} + +// ── Hooks ─────────────────────────────────────────────────────────────────── + +export const homepageSectionHooks = createModelHooks({ + entityName: 'homepage_sections', +}); + +export const homepageFeatureHooks = createModelHooks({ + entityName: 'homepage_features', +}); + +export const homepageActionHooks = createModelHooks({ + entityName: 'homepage_actions', +}); + +export const homepageDisplaySettingsHooks = createModelHooks({ + entityName: 'homepage_display_settings', +}); diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx index 704fe3d40..578f85f55 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminIndexPage.tsx @@ -10,6 +10,7 @@ import { Clock, Database, FileText, + Home, Layers, Layout, Mail, @@ -87,6 +88,12 @@ const ADMIN_CATEGORIES: AdminCategory[] = [ href: '/admin/changelog', icon: FileText, }, + { + title: 'Homepage', + description: 'Manage homepage sections, features, actions, and display settings.', + href: '/admin/homepage', + icon: Home, + }, ...(MEDIA_LIBRARY_ENABLED ? [ { diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageDisplayPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageDisplayPage.tsx new file mode 100644 index 000000000..7fc834873 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageDisplayPage.tsx @@ -0,0 +1,262 @@ +/** + * Admin Homepage Display Settings + * + * Manages the homepage display state: + * - Variant selection per slot (maps to SLOT_REGISTRY on the Next.js side) + * - Theme preset + * + * Single-row settings model (id = 'default'). Uses upsert semantics via + * getOrCreateDefault on the API side. + */ +import { ADMIN_LIST_QUERY_CONFIG } from '@/config/queryConfig'; +import { homepageDisplaySettingsHooks, type HomepageDisplaySettingsRow } from '@/hooks/homepageHooks'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, +} from '@ottabase/ui-shadcn'; +import { Check, Monitor, Save } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { HomepageAdminNav } from './HomepageAdminNav'; + +/** Matches SLOT_NAMES + SLOT_REGISTRY from the Next.js homepage-config. */ +const SLOT_CONFIG = { + navbar: { + label: 'Navigation Bar', + variants: [ + { id: 'default', label: 'Default', desc: 'Logo left, links right, mobile hamburger.' }, + { id: 'centered', label: 'Centered', desc: 'Logo and links centered.' }, + { id: 'minimal', label: 'Minimal', desc: 'Logo and dark-mode toggle only.' }, + ], + default: 'default', + }, + hero: { + label: 'Hero Section', + variants: [ + { id: 'centered', label: 'Centered', desc: 'Large centered headline with buttons.' }, + { id: 'split', label: 'Split', desc: 'Text left, visual right.' }, + { id: 'minimal', label: 'Minimal', desc: 'Compact headline.' }, + ], + default: 'centered', + }, + features: { + label: 'Features Section', + variants: [ + { id: 'grid', label: 'Grid', desc: 'Two-column bordered list.' }, + { id: 'cards', label: 'Cards', desc: 'Card layout with hover effects.' }, + { id: 'list', label: 'List', desc: 'Vertical stacked list.' }, + ], + default: 'grid', + }, + cta: { + label: 'Call-to-Action', + variants: [ + { id: 'default', label: 'Default', desc: 'Centered text with buttons.' }, + { id: 'banner', label: 'Banner', desc: 'Full-width colored banner.' }, + { id: 'minimal', label: 'Minimal', desc: 'Compact inline CTA.' }, + ], + default: 'default', + }, + footer: { + label: 'Footer', + variants: [ + { id: 'default', label: 'Default', desc: 'Copyright and links row.' }, + { id: 'minimal', label: 'Minimal', desc: 'Single-line copyright.' }, + { id: 'columns', label: 'Columns', desc: 'Multi-column grouped links.' }, + ], + default: 'default', + }, + about: { + label: 'About Page', + variants: [ + { id: 'default', label: 'Default', desc: 'Full content with features, steps, CTA.' }, + { id: 'minimal', label: 'Minimal', desc: 'Concise single-section.' }, + { id: 'detailed', label: 'Detailed', desc: 'Card-based with tech-stack badges.' }, + ], + default: 'default', + }, +} as const; + +type SlotName = keyof typeof SLOT_CONFIG; +const SLOT_NAMES = Object.keys(SLOT_CONFIG) as SlotName[]; + +function getDefaults(): Record { + const d: Record = {}; + for (const [k, v] of Object.entries(SLOT_CONFIG)) { + d[k] = v.default; + } + return d; +} + +export function AdminHomepageDisplayPage() { + const { data, isLoading } = homepageDisplaySettingsHooks.useList({}, ADMIN_LIST_QUERY_CONFIG); + const createSettings = homepageDisplaySettingsHooks.useCreate(); + const updateSettings = homepageDisplaySettingsHooks.useUpdate(); + + // Normalize response: list may return array or paginated + const rows = (Array.isArray(data) ? data : []) as HomepageDisplaySettingsRow[]; + const existing = rows.find((r) => r.id === 'default') ?? null; + + const [variantBySlot, setVariantBySlot] = useState>(getDefaults()); + const [themePreset, setThemePreset] = useState('default'); + const [initialized, setInitialized] = useState(false); + + // Populate from DB when data loads + useEffect(() => { + if (existing && !initialized) { + setVariantBySlot({ ...getDefaults(), ...(existing.variantBySlotJson ?? {}) }); + setThemePreset(existing.themePreset ?? 'default'); + setInitialized(true); + } else if (!isLoading && !existing && !initialized) { + // No row yet — use defaults + setInitialized(true); + } + }, [existing, isLoading, initialized]); + + const isDirty = + initialized && + (JSON.stringify(variantBySlot) !== + JSON.stringify({ ...getDefaults(), ...(existing?.variantBySlotJson ?? {}) }) || + themePreset !== (existing?.themePreset ?? 'default')); + + const handleSave = useCallback(async () => { + const payload = { + variantBySlotJson: variantBySlot, + themePreset, + }; + if (existing) { + await updateSettings.mutateAsync({ id: 'default', data: payload }); + } else { + await createSettings.mutateAsync({ id: 'default', ...payload }); + } + }, [existing, variantBySlot, themePreset, updateSettings, createSettings]); + + const handleVariantChange = (slot: SlotName, variantId: string) => { + setVariantBySlot((prev) => ({ ...prev, [slot]: variantId })); + }; + + const handleResetDefaults = () => { + setVariantBySlot(getDefaults()); + setThemePreset('default'); + }; + + if (isLoading) { + return ( +
+ + + +

Loading display settings…

+
+
+
+ ); + } + + return ( +
+ + + {/* Header */} +
+
+

Display Settings

+

+ Choose which variant to display for each slot, and set the active theme preset. +

+
+
+ + +
+
+ + {/* Theme Preset */} + + + + + Theme Preset + + The theme preset name used for SSR and initial page load. + + +
+ + setThemePreset(e.target.value)} + placeholder="default" + className="max-w-xs" + /> +
+
+
+ + {/* Slot Variant Pickers */} + {SLOT_NAMES.map((slot) => { + const config = SLOT_CONFIG[slot]; + const selected = variantBySlot[slot] ?? config.default; + + return ( + + + {config.label} + + Select which variant to use for the{' '} + + {slot} + {' '} + slot. + + + +
+ {config.variants.map((v) => { + const isActive = selected === v.id; + return ( + + ); + })} +
+
+
+ ); + })} +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageSectionEditorPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageSectionEditorPage.tsx new file mode 100644 index 000000000..dbb64d867 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/homepage/AdminHomepageSectionEditorPage.tsx @@ -0,0 +1,485 @@ +/** + * Admin Homepage Section Editor + * + * Full CRUD editor for a single homepage section. Includes inline editing of + * child features and actions. Auto-saves on blur or button click. + * + * Pattern: follows AdminChangelogEditorPage (single-entity form with nested items). + */ +import { ADMIN_LIST_QUERY_CONFIG } from '@/config/queryConfig'; +import { + homepageActionHooks, + homepageFeatureHooks, + homepageSectionHooks, + type HomepageActionRow, + type HomepageFeatureRow, +} from '@/hooks/homepageHooks'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, + Separator, + Switch, + Textarea, +} from '@ottabase/ui-shadcn'; +import { useNavigate, useParams } from '@tanstack/react-router'; +import { ArrowLeft, ExternalLink, GripVertical, Plus, Save, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { HomepageAdminNav } from './HomepageAdminNav'; + +const SLOT_LABELS: Record = { + navbar: 'Navigation Bar', + hero: 'Hero Section', + features: 'Features Section', + cta: 'Call-to-Action', + footer: 'Footer', + about: 'About Page', +}; + +const ACTION_VARIANTS = [ + { value: 'default', label: 'Default' }, + { value: 'secondary', label: 'Secondary' }, + { value: 'outline', label: 'Outline' }, + { value: 'ghost', label: 'Ghost' }, +] as const; + +// ── Feature row editor ────────────────────────────────────────────────────── + +function FeatureEditor({ feature, onDelete }: { feature: HomepageFeatureRow; onDelete: (id: string) => void }) { + const updateFeature = homepageFeatureHooks.useUpdate(); + const [title, setTitle] = useState(feature.title); + const [description, setDescription] = useState(feature.description); + const dirty = title !== feature.title || description !== feature.description; + + const handleSave = useCallback(() => { + if (!dirty) return; + updateFeature.mutate({ id: feature.id, data: { title, description } }); + }, [dirty, feature.id, title, description, updateFeature]); + + return ( +
+ +
+ setTitle(e.target.value)} + onBlur={handleSave} + placeholder="Feature title" + className="h-8 text-sm font-medium" + /> +