From d3a8555a014de3c44073086ff761e78b6ddcffea Mon Sep 17 00:00:00 2001 From: devfive Date: Tue, 1 Sep 2026 15:58:01 +0900 Subject: [PATCH] feat: deploy interactive CMS demo to GitHub Pages --- .github/workflows/pages.yml | 64 ++ README.md | 4 +- packages/app/src/app/(auth)/settings/page.tsx | 15 +- .../src/app/(auth)/settings/plugins/page.tsx | 7 +- packages/app/src/app/(guest)/signin/page.tsx | 10 +- .../app/src/components/common/AppLink.tsx | 29 + .../content/ContentCollectionsHub.tsx | 11 +- packages/app/src/components/layout/Header.tsx | 33 +- .../app/src/components/layout/NavItem.tsx | 18 +- packages/app/src/components/providers.tsx | 4 + packages/app/src/lib/demo-path.ts | 21 + packages/app/src/lib/mock-api.ts | 691 ++++++++++++++++++ packages/app/vite.config.ts | 1 + 13 files changed, 873 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 packages/app/src/components/common/AppLink.tsx create mode 100644 packages/app/src/lib/demo-path.ts create mode 100644 packages/app/src/lib/mock-api.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..47ff54f --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,64 @@ +name: Pages Demo + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + name: Build demo + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + VITE_YEOLLIN_BASE_PATH: /yeollin-cms + VITE_YEOLLIN_DEMO: 'true' + + steps: + - uses: actions/checkout@v7 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build interactive demo + working-directory: apps/example-app + run: cargo run -p yeollin-cli -- build --skip-backend + + - name: Disable Jekyll processing + run: touch apps/example-app/.yeollin/app/out/.nojekyll + + - uses: actions/configure-pages@v6 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5 + with: + path: apps/example-app/.yeollin/app/out + + deploy: + name: Deploy demo + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/README.md b/README.md index 418075e..5e002d4 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ Yeollin CMS is a Tauri-inspired, plugin-based CMS *framework* rather than a finished CMS product. A plugin is a single Rust crate that bundles its Axum/Vespera API routes, a vinext (Vite + RSC, Next-compatible) frontend under `app/`, and optionally sea-orm models with vespertide JSON migrations. The `yeollin-cli` binary assembles the registered plugins into one application: it extracts the frontend template, merges each plugin's pages, statically exports the result, and embeds it into the release binary via `include_dir!`, so a deployment is a single executable that serves both the API and the UI. +Try the serverless [interactive demo](https://dev-five-git.github.io/yeollin-cms/). It includes the example app's plugins and keeps all mock data inside the browser tab. + ## Status > **Status: v0.1, pre-release. APIs change without notice. Not production ready.** > -> This repository is private and under active development. Treat every public +> This project is open source and under active development. Treat every public > interface, file layout, and CLI flag as unstable. ## Prerequisites diff --git a/packages/app/src/app/(auth)/settings/page.tsx b/packages/app/src/app/(auth)/settings/page.tsx index 83e2423..76a30c1 100644 --- a/packages/app/src/app/(auth)/settings/page.tsx +++ b/packages/app/src/app/(auth)/settings/page.tsx @@ -1,8 +1,9 @@ import { Box, Flex, Grid, Text, VStack } from '@devup-ui/react' import { readFile } from 'fs/promises' -import Link from 'next/link' import path from 'path' +import { AppLink } from '@/components/common/AppLink' + interface PluginInfo { description?: string name: string @@ -41,11 +42,7 @@ export default async function SettingsPage() { {configurablePlugins.map((plugin) => ( - + - + ))} - + - + diff --git a/packages/app/src/app/(auth)/settings/plugins/page.tsx b/packages/app/src/app/(auth)/settings/plugins/page.tsx index b101540..2f90993 100644 --- a/packages/app/src/app/(auth)/settings/plugins/page.tsx +++ b/packages/app/src/app/(auth)/settings/plugins/page.tsx @@ -1,8 +1,9 @@ import { Box, Flex, Grid, Text, VStack } from '@devup-ui/react' import { readFile } from 'fs/promises' -import Link from 'next/link' import path from 'path' +import { AppLink } from '@/components/common/AppLink' + interface PluginInfo { name: string version: string @@ -86,7 +87,7 @@ export default async function PluginsPage() { - + Settings - + / Plugins diff --git a/packages/app/src/app/(guest)/signin/page.tsx b/packages/app/src/app/(guest)/signin/page.tsx index 7033b90..27b9a97 100644 --- a/packages/app/src/app/(guest)/signin/page.tsx +++ b/packages/app/src/app/(guest)/signin/page.tsx @@ -4,6 +4,8 @@ import { Box, Flex, Text, VStack } from '@devup-ui/react' import { useRouter } from 'next/navigation' import { useState } from 'react' +import { DEMO_MODE, demoPageHref } from '@/lib/demo-path' + export default function SignInPage() { const router = useRouter() const [username, setUsername] = useState('') @@ -37,8 +39,12 @@ export default function SignInPage() { document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 7}` // 7 days // Redirect to dashboard - router.push('/') - router.refresh() + if (DEMO_MODE) { + window.location.assign(demoPageHref('/')) + } else { + router.push('/') + router.refresh() + } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred') } finally { diff --git a/packages/app/src/components/common/AppLink.tsx b/packages/app/src/components/common/AppLink.tsx new file mode 100644 index 0000000..3f39333 --- /dev/null +++ b/packages/app/src/components/common/AppLink.tsx @@ -0,0 +1,29 @@ +'use client' + +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { DEMO_MODE, demoPageHref } from '@/lib/demo-path' + +interface AppLinkProps { + children: ReactNode + href: string +} + +export function AppLink({ children, href }: AppLinkProps) { + const style = { textDecoration: 'none' } + + if (DEMO_MODE) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) +} diff --git a/packages/app/src/components/content/ContentCollectionsHub.tsx b/packages/app/src/components/content/ContentCollectionsHub.tsx index 27b6a28..f9087d2 100644 --- a/packages/app/src/components/content/ContentCollectionsHub.tsx +++ b/packages/app/src/components/content/ContentCollectionsHub.tsx @@ -1,5 +1,6 @@ import { Box, Grid, Text, VStack } from '@devup-ui/react' -import Link from 'next/link' + +import { AppLink } from '@/components/common/AppLink' interface ContentCollectionLink { label: string @@ -38,11 +39,7 @@ export function ContentCollectionsHub({ gridTemplateColumns={['1fr', 'repeat(2, minmax(0, 1fr))']} > {collections.map((collection) => ( - + - + ))} diff --git a/packages/app/src/components/layout/Header.tsx b/packages/app/src/components/layout/Header.tsx index 6c477c6..de257c2 100644 --- a/packages/app/src/components/layout/Header.tsx +++ b/packages/app/src/components/layout/Header.tsx @@ -3,6 +3,10 @@ import { Box, Flex, Text } from '@devup-ui/react' import { useRouter } from 'next/navigation' +import { resetMockApi } from '@/lib/mock-api' + +const DEMO_MODE = import.meta.env.VITE_YEOLLIN_DEMO === 'true' + /** * Header component with logout functionality. * Requires 'use client' due to cookie manipulation and useRouter. @@ -10,6 +14,11 @@ import { useRouter } from 'next/navigation' export function Header() { const router = useRouter() + const handleReset = () => { + resetMockApi() + window.location.reload() + } + const handleLogout = () => { // Clear auth cookies document.cookie = 'access_token=; path=/; max-age=0' @@ -26,21 +35,39 @@ export function Header() { borderBottom="1px solid $border" display="flex" h="56px" - justifyContent="flex-end" + justifyContent="space-between" px={6} w="100%" > + {DEMO_MODE ? ( + + + + Interactive demo + + + + Data stays in this browser tab + + + ) : ( + + )} - Logout + {DEMO_MODE ? 'Reset demo' : 'Logout'} ) diff --git a/packages/app/src/components/layout/NavItem.tsx b/packages/app/src/components/layout/NavItem.tsx index e43e665..9570c98 100644 --- a/packages/app/src/components/layout/NavItem.tsx +++ b/packages/app/src/components/layout/NavItem.tsx @@ -1,9 +1,11 @@ 'use client' import { Box, Flex, Text } from '@devup-ui/react' -import Link from 'next/link' import { usePathname } from 'next/navigation' +import { AppLink } from '@/components/common/AppLink' +import { normalizeDemoPath } from '@/lib/demo-path' + interface MenuItem { id: string label: string @@ -20,14 +22,14 @@ interface NavItemProps { * Requires 'use client' due to usePathname hook. */ export function NavItem({ item }: NavItemProps) { - const pathname = usePathname() + const pathname = normalizeDemoPath(usePathname()) const isActive = pathname === item.path const hasChildren = item.children && item.children.length > 0 const isChildActive = item.children?.some((child) => pathname === child.path) return ( - + {item.label} - + {hasChildren && (isActive || isChildActive) && ( {item.children!.map((child) => ( - + {child.label} - + ))} )} diff --git a/packages/app/src/components/providers.tsx b/packages/app/src/components/providers.tsx index 08c8ee6..bad8e13 100644 --- a/packages/app/src/components/providers.tsx +++ b/packages/app/src/components/providers.tsx @@ -3,6 +3,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' +import { installMockApi } from '@/lib/mock-api' + +if (import.meta.env.VITE_YEOLLIN_DEMO === 'true') installMockApi() + export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( () => diff --git a/packages/app/src/lib/demo-path.ts b/packages/app/src/lib/demo-path.ts new file mode 100644 index 0000000..06f5d8e --- /dev/null +++ b/packages/app/src/lib/demo-path.ts @@ -0,0 +1,21 @@ +export const DEMO_MODE = import.meta.env.VITE_YEOLLIN_DEMO === 'true' + +const DEMO_BASE_PATH = import.meta.env.VITE_YEOLLIN_BASE_PATH ?? '' + +export function demoPageHref(path: string): string { + if (!DEMO_MODE) return path + if (path === '/') return `${DEMO_BASE_PATH}/` + + return `${DEMO_BASE_PATH}${path}.html` +} + +export function normalizeDemoPath(pathname: string): string { + if (!DEMO_MODE) return pathname + + const withoutBase = pathname.startsWith(DEMO_BASE_PATH) + ? pathname.slice(DEMO_BASE_PATH.length) + : pathname + const withoutHtml = withoutBase.replace(/\.html$/, '') + + return withoutHtml || '/' +} diff --git a/packages/app/src/lib/mock-api.ts b/packages/app/src/lib/mock-api.ts new file mode 100644 index 0000000..72e0684 --- /dev/null +++ b/packages/app/src/lib/mock-api.ts @@ -0,0 +1,691 @@ +interface DemoState { + auditEvents: Array> + contentEntries: Array> + deliveries: Array> + forms: Array> + media: Array> + memos: Array> + redirects: Array> + settings: Record> + submissions: Record>> + users: Array> + webhooks: Array> +} + +const DEMO_NOW = '2026-09-01T04:30:00.000Z' +const DEMO_BASE_PATH = import.meta.env.VITE_YEOLLIN_BASE_PATH ?? '' + +let installed = false +let sequence = 100 +let state = createDemoState() + +function id(prefix: string): string { + sequence += 1 + return `${prefix}-${sequence}` +} + +function demoUrl(path: string): string { + return `${DEMO_BASE_PATH}${path}.html` +} + +function imageData(label: string, start: string, end: string): string { + const svg = `${label}` + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +function createDemoState(): DemoState { + const formFields = [ + { + id: 'email', + kind: 'email', + label: 'Work email', + options: [], + placeholder: 'you@example.com', + required: true, + }, + { + id: 'message', + kind: 'textarea', + label: 'How can we help?', + options: [], + placeholder: 'Tell us about your project', + required: true, + }, + ] + + return { + auditEvents: [ + { + createdAt: '2026-09-01T04:18:00.000Z', + id: 14, + name: 'content.pages.published', + payload: { actor: 'admin', slug: 'welcome-to-yeollin' }, + }, + { + createdAt: '2026-09-01T03:42:00.000Z', + id: 13, + name: 'auth.user.role_changed', + payload: { actor: 'admin', role: 'user', username: 'editor' }, + }, + { + createdAt: '2026-08-31T23:10:00.000Z', + id: 12, + name: 'media.asset.created', + payload: { mimeType: 'image/svg+xml', originalName: 'open-cms.svg' }, + }, + ], + contentEntries: [ + { + author: 'admin', + collection: 'pages', + createdAt: '2026-08-28T01:00:00.000Z', + fields: { + body: 'Yeollin CMS keeps backend routes, frontend pages, migrations, and metadata together in plugin crates.', + excerpt: 'A quick tour of an open and extensible Rust CMS.', + heroImage: 'media:11111111111111111111111111111111', + }, + id: 'page-welcome', + publishedAt: '2026-08-29T02:00:00.000Z', + slug: 'welcome-to-yeollin', + status: 'published', + title: 'Welcome to Yeollin CMS', + updatedAt: '2026-09-01T04:18:00.000Z', + }, + { + author: 'editor', + collection: 'pages', + createdAt: '2026-08-30T08:15:00.000Z', + fields: { + body: 'This draft demonstrates typed content fields and the publish workflow.', + excerpt: 'An unpublished page ready for editorial review.', + heroImage: null, + }, + id: 'page-roadmap', + publishedAt: null, + slug: 'product-roadmap', + status: 'draft', + title: 'Product roadmap', + updatedAt: '2026-09-01T01:12:00.000Z', + }, + ], + deliveries: [ + { + attempts: 1, + createdAt: '2026-09-01T04:18:01.000Z', + deliveredAt: '2026-09-01T04:18:02.000Z', + eventId: 14, + eventName: 'content.pages.published', + id: 'delivery-1', + lastError: null, + maxAttempts: 5, + responseStatus: 204, + status: 'delivered', + updatedAt: '2026-09-01T04:18:02.000Z', + webhookId: 'webhook-1', + }, + { + attempts: 3, + createdAt: '2026-09-01T03:42:01.000Z', + deliveredAt: null, + eventId: 13, + eventName: 'auth.user.role_changed', + id: 'delivery-2', + lastError: 'The demo endpoint returned HTTP 503.', + maxAttempts: 5, + responseStatus: 503, + status: 'dead_letter', + updatedAt: '2026-09-01T03:45:01.000Z', + webhookId: 'webhook-1', + }, + ], + forms: [ + { + createdAt: '2026-08-27T09:00:00.000Z', + createdBy: 'admin', + description: 'Collect questions from prospective Yeollin users.', + enabled: true, + fields: formFields, + id: 'contact-form', + maxSubmissionsPerHour: 100, + name: 'Contact us', + successMessage: 'Thanks - we will get back to you soon.', + updatedAt: '2026-08-31T11:25:00.000Z', + }, + ], + media: [ + { + createdAt: '2026-08-31T23:10:00.000Z', + id: '11111111111111111111111111111111', + mimeType: 'image/svg+xml', + originalName: 'open-cms.svg', + reference: 'media:11111111111111111111111111111111', + sizeBytes: 18432, + uploadedBy: 'admin', + url: imageData('OPEN CMS', '#0070f3', '#7c3aed'), + }, + { + createdAt: '2026-08-30T07:40:00.000Z', + id: '22222222222222222222222222222222', + mimeType: 'image/svg+xml', + originalName: 'plugin-workspace.svg', + reference: 'media:22222222222222222222222222222222', + sizeBytes: 22104, + uploadedBy: 'editor', + url: imageData('PLUGIN WORKSPACE', '#0f766e', '#06b6d4'), + }, + ], + memos: [ + { + content: + 'The GitHub Pages demo runs entirely in the browser. Try creating, editing, and deleting this memo.', + createdAt: '2026-09-01T02:10:00.000Z', + id: 1, + title: 'Welcome to the interactive demo', + updatedAt: '2026-09-01T02:10:00.000Z', + }, + { + content: + 'Plugins package Axum routes and vinext UI together in one Rust crate.', + createdAt: '2026-08-31T09:20:00.000Z', + id: 2, + title: 'Plugin architecture', + updatedAt: '2026-08-31T09:20:00.000Z', + }, + ], + redirects: [ + { + createdAt: '2026-08-29T04:00:00.000Z', + createdBy: 'admin', + destinationPath: '/welcome-to-yeollin', + enabled: true, + id: 'redirect-1', + sourcePath: '/getting-started', + updatedAt: '2026-08-29T04:00:00.000Z', + }, + { + createdAt: '2026-08-30T05:00:00.000Z', + createdBy: 'admin', + destinationPath: 'https://github.com/dev-five-git/yeollin-cms', + enabled: false, + id: 'redirect-2', + sourcePath: '/source', + updatedAt: '2026-08-31T05:00:00.000Z', + }, + ], + settings: { + '/api/example-plugin/settings': { + homepageMessage: 'Welcome to the Yeollin CMS demo', + maintenanceMode: false, + }, + }, + submissions: { + 'contact-form': [ + { + createdAt: '2026-09-01T00:20:00.000Z', + fields: formFields, + formId: 'contact-form', + formName: 'Contact us', + id: 'submission-1', + values: { + email: 'hello@example.com', + message: 'Can I build a multilingual publication with Yeollin?', + }, + }, + ], + }, + users: [ + { + createdAt: '2026-08-24T01:00:00.000Z', + id: 1, + role: 'admin', + username: 'admin', + }, + { + createdAt: '2026-08-28T06:30:00.000Z', + id: 2, + role: 'user', + username: 'editor', + }, + ], + webhooks: [ + { + allowPrivateNetworks: false, + createdAt: '2026-08-26T03:00:00.000Z', + enabled: true, + eventNames: ['content.pages.published', 'auth.user.role_changed'], + hasSecret: true, + id: 'webhook-1', + name: 'Editorial automation', + timeoutSeconds: 5, + updatedAt: '2026-08-31T08:20:00.000Z', + url: 'https://example.com/hooks/yeollin', + }, + ], + } +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + headers: { 'Content-Type': 'application/json' }, + status, + }) +} + +function bodyOf(init?: RequestInit): Record { + if (typeof init?.body !== 'string') return {} + try { + const value = JSON.parse(init.body) as unknown + return typeof value === 'object' && value !== null + ? (value as Record) + : {} + } catch { + return {} + } +} + +function pageOf(items: T[], url: URL, defaultPageSize: number) { + const page = Math.max(1, Number(url.searchParams.get('page') ?? 1)) + const pageSize = Math.max( + 1, + Number(url.searchParams.get('pageSize') ?? defaultPageSize), + ) + const start = (page - 1) * pageSize + return { items: items.slice(start, start + pageSize), page, pageSize } +} + +function handleUsers(path: string, method: string, init?: RequestInit) { + if (path === '/api/auth/login' && method === 'POST') { + return json({ + access_token: 'yeollin-demo-access-token', + expires_in: 3600, + refresh_token: 'yeollin-demo-refresh-token', + }) + } + if (path === '/api/auth/users' && method === 'GET') { + return json({ total: state.users.length, users: state.users }) + } + if (path === '/api/auth/users' && method === 'POST') { + const body = bodyOf(init) + state.users.push({ + createdAt: DEMO_NOW, + id: sequence + 1, + role: body.role === 'admin' ? 'admin' : 'user', + username: String(body.username ?? 'new-user'), + }) + sequence += 1 + return json(state.users.at(-1), 201) + } + const match = path.match(/^\/api\/auth\/users\/(\d+)(\/password)?$/) + if (!match) return null + const userId = Number(match[1]) + const user = state.users.find((entry) => entry.id === userId) + if (!user) return json({ error: 'User not found.' }, 404) + if (match[2] === '/password' && method === 'POST') return json({ ok: true }) + if (method === 'PATCH') Object.assign(user, bodyOf(init)) + if (method === 'DELETE') + state.users = state.users.filter((item) => item !== user) + return json(user) +} + +function handleMemos(path: string, method: string, init?: RequestInit) { + if (path === '/api/example-memo-plugin' && method === 'GET') { + return json({ memos: state.memos }) + } + if (path === '/api/example-memo-plugin' && method === 'POST') { + const memo = { + ...bodyOf(init), + createdAt: DEMO_NOW, + id: sequence + 1, + updatedAt: DEMO_NOW, + } + sequence += 1 + state.memos.unshift(memo) + return json(memo, 201) + } + const match = path.match(/^\/api\/example-memo-plugin\/(\d+)$/) + if (!match) return null + const memo = state.memos.find((entry) => entry.id === Number(match[1])) + if (!memo) return json({ error: 'Memo not found.' }, 404) + if (method === 'PATCH') + Object.assign(memo, bodyOf(init), { updatedAt: DEMO_NOW }) + if (method === 'DELETE') + state.memos = state.memos.filter((item) => item !== memo) + return json(memo) +} + +function handleRedirects(path: string, method: string, init?: RequestInit) { + if (path === '/api/redirects' && method === 'GET') { + return json({ redirects: state.redirects }) + } + if (path === '/api/redirects' && method === 'POST') { + const redirect = { + ...bodyOf(init), + createdAt: DEMO_NOW, + createdBy: 'demo-admin', + id: id('redirect'), + updatedAt: DEMO_NOW, + } + state.redirects.unshift(redirect) + return json(redirect, 201) + } + const match = path.match(/^\/api\/redirects\/([^/]+)$/) + if (!match) return null + const redirect = state.redirects.find((entry) => entry.id === match[1]) + if (!redirect) return json({ error: 'Redirect not found.' }, 404) + if (method === 'PUT') + Object.assign(redirect, bodyOf(init), { updatedAt: DEMO_NOW }) + if (method === 'DELETE') { + state.redirects = state.redirects.filter((item) => item !== redirect) + } + return json(redirect) +} + +function handleForms(path: string, method: string, init?: RequestInit) { + if (path === '/api/forms' && method === 'GET') + return json({ forms: state.forms }) + if (path === '/api/forms' && method === 'POST') { + const form = { + ...bodyOf(init), + createdAt: DEMO_NOW, + createdBy: 'demo-admin', + id: id('form'), + updatedAt: DEMO_NOW, + } + state.forms.unshift(form) + return json(form, 201) + } + const submissions = path.match(/^\/api\/forms\/([^/]+)\/submissions$/) + if (submissions && method === 'GET') { + const items = state.submissions[submissions[1]] ?? [] + return json({ + page: 1, + pageSize: 50, + submissions: items, + total: items.length, + }) + } + const match = path.match(/^\/api\/forms\/([^/]+)$/) + if (!match) return null + const form = state.forms.find((entry) => entry.id === match[1]) + if (!form) return json({ error: 'Form not found.' }, 404) + if (method === 'PUT') + Object.assign(form, bodyOf(init), { updatedAt: DEMO_NOW }) + if (method === 'DELETE') { + state.forms = state.forms.filter((item) => item !== form) + delete state.submissions[match[1]] + } + return json(form) +} + +function handleMedia( + path: string, + method: string, + init: RequestInit | undefined, + url: URL, +) { + if (path === '/api/media/settings' && method === 'GET') { + return json({ maxUploadMegabytes: 5 }) + } + if (path === '/api/media' && method === 'GET') { + const page = pageOf(state.media, url, 24) + return json({ + media: page.items, + page: page.page, + pageSize: page.pageSize, + total: state.media.length, + }) + } + if (path === '/api/media' && method === 'POST') { + const file = init?.body instanceof FormData ? init.body.get('file') : null + const mediaId = String(sequence + 1).padStart(32, '0') + sequence += 1 + const media = { + createdAt: DEMO_NOW, + id: mediaId, + mimeType: file instanceof File ? file.type : 'image/svg+xml', + originalName: file instanceof File ? file.name : 'demo-upload.svg', + reference: `media:${mediaId}`, + sizeBytes: file instanceof File ? file.size : 1024, + uploadedBy: 'demo-admin', + url: + file instanceof File + ? URL.createObjectURL(file) + : imageData('DEMO UPLOAD', '#9333ea', '#db2777'), + } + state.media.unshift(media) + return json(media, 201) + } + const match = path.match(/^\/api\/media\/([^/]+)$/) + if (!match || method !== 'DELETE') return null + state.media = state.media.filter((entry) => entry.id !== match[1]) + return json({ ok: true }) +} + +function handleAudit(path: string, method: string, url: URL) { + if (path !== '/api/audit-log' || method !== 'GET') return null + const eventName = url.searchParams.get('eventName') ?? '' + const filtered = + eventName === '' + ? state.auditEvents + : state.auditEvents.filter((event) => event.name === eventName) + const page = pageOf(filtered, url, 20) + return json({ + events: page.items, + page: page.page, + pageSize: page.pageSize, + retentionDays: 90, + total: filtered.length, + }) +} + +function handleSearch(path: string, method: string, url: URL) { + if (path !== '/api/search' || method !== 'GET') return null + const query = (url.searchParams.get('q') ?? '').toLowerCase() + const status = url.searchParams.get('status') ?? '' + const results = state.contentEntries + .filter((entry) => { + const haystack = + `${entry.title} ${entry.slug} ${JSON.stringify(entry.fields)}`.toLowerCase() + return ( + haystack.includes(query) && (status === '' || entry.status === status) + ) + }) + .map((entry) => ({ + collection: entry.collection, + excerpt: (entry.fields as Record).excerpt ?? '', + id: entry.id, + relevance: 1, + status: entry.status, + subject: `${entry.collection}:${entry.id}`, + title: entry.title, + updatedAt: entry.updatedAt, + url: demoUrl('/content/pages'), + })) + const page = pageOf(results, url, 20) + return json({ + page: page.page, + pageSize: page.pageSize, + query: url.searchParams.get('q') ?? '', + results: page.items, + total: results.length, + }) +} + +function handleWebhooks( + path: string, + method: string, + init: RequestInit | undefined, + url: URL, +) { + if (path === '/api/webhooks' && method === 'GET') { + return json({ webhooks: state.webhooks }) + } + if (path === '/api/webhooks' && method === 'POST') { + const webhook = { + ...bodyOf(init), + createdAt: DEMO_NOW, + hasSecret: true, + id: id('webhook'), + updatedAt: DEMO_NOW, + } + state.webhooks.unshift(webhook) + return json(webhook, 201) + } + if (path === '/api/webhooks/deliveries' && method === 'GET') { + const status = url.searchParams.get('status') ?? '' + const filtered = + status === '' + ? state.deliveries + : state.deliveries.filter((delivery) => delivery.status === status) + const page = pageOf(filtered, url, 25) + return json({ + deliveries: page.items, + page: page.page, + pageSize: page.pageSize, + total: filtered.length, + }) + } + const retry = path.match(/^\/api\/webhooks\/deliveries\/([^/]+)\/retry$/) + if (retry && method === 'POST') { + const delivery = state.deliveries.find((entry) => entry.id === retry[1]) + if (!delivery) return json({ error: 'Delivery not found.' }, 404) + Object.assign(delivery, { + attempts: 0, + lastError: null, + responseStatus: null, + status: 'pending', + updatedAt: DEMO_NOW, + }) + return json(delivery) + } + const match = path.match(/^\/api\/webhooks\/([^/]+)$/) + if (!match) return null + const webhook = state.webhooks.find((entry) => entry.id === match[1]) + if (!webhook) return json({ error: 'Webhook not found.' }, 404) + if (method === 'PUT') + Object.assign(webhook, bodyOf(init), { + hasSecret: true, + updatedAt: DEMO_NOW, + }) + if (method === 'DELETE') { + state.webhooks = state.webhooks.filter((item) => item !== webhook) + state.deliveries = state.deliveries.filter( + (item) => item.webhookId !== match[1], + ) + } + return json(webhook) +} + +function handleContent( + path: string, + method: string, + init: RequestInit | undefined, + url: URL, +) { + const root = '/api/content/pages' + if (path === root && method === 'GET') { + const status = url.searchParams.get('status') ?? '' + const filtered = + status === '' || status === 'all' + ? state.contentEntries + : state.contentEntries.filter((entry) => entry.status === status) + const page = pageOf(filtered, url, 20) + return json({ + entries: page.items, + page: page.page, + pageSize: page.pageSize, + total: filtered.length, + }) + } + if (path === root && method === 'POST') { + const entry = { + ...bodyOf(init), + author: 'demo-admin', + collection: 'pages', + createdAt: DEMO_NOW, + id: id('page'), + publishedAt: null, + status: 'draft', + updatedAt: DEMO_NOW, + } + state.contentEntries.unshift(entry) + return json(entry, 201) + } + const match = path.match( + /^\/api\/content\/pages\/([^/]+)(\/(publish|unpublish))?$/, + ) + if (!match) return null + const entry = state.contentEntries.find((item) => item.id === match[1]) + if (!entry) return json({ error: 'Content entry not found.' }, 404) + if (method === 'PUT') + Object.assign(entry, bodyOf(init), { updatedAt: DEMO_NOW }) + if (method === 'POST' && match[3]) { + Object.assign(entry, { + publishedAt: match[3] === 'publish' ? DEMO_NOW : null, + status: match[3] === 'publish' ? 'published' : 'draft', + updatedAt: DEMO_NOW, + }) + } + if (method === 'DELETE') { + state.contentEntries = state.contentEntries.filter((item) => item !== entry) + } + return json(entry) +} + +function handleSettings(path: string, method: string, init?: RequestInit) { + if (!path.endsWith('/settings')) return null + if (method === 'GET') return json(state.settings[path] ?? {}) + if (method === 'PUT') { + state.settings[path] = bodyOf(init) + return json(state.settings[path]) + } + return null +} + +function mockResponse(url: URL, method: string, init?: RequestInit): Response { + const path = url.pathname.replace(DEMO_BASE_PATH, '') + return ( + handleUsers(path, method, init) ?? + handleMemos(path, method, init) ?? + handleRedirects(path, method, init) ?? + handleForms(path, method, init) ?? + handleMedia(path, method, init, url) ?? + handleAudit(path, method, url) ?? + handleSearch(path, method, url) ?? + handleWebhooks(path, method, init, url) ?? + handleContent(path, method, init, url) ?? + handleSettings(path, method, init) ?? + json( + { error: `No demo response is registered for ${method} ${path}.` }, + 404, + ) + ) +} + +/** Installs the browser-only API simulator used by the public GitHub Pages demo. */ +export function installMockApi(): void { + if (installed || typeof window === 'undefined') return + installed = true + const nativeFetch = window.fetch.bind(window) + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const rawUrl = input instanceof Request ? input.url : String(input) + const url = new URL(rawUrl, window.location.origin) + const method = ( + init?.method ?? (input instanceof Request ? input.method : 'GET') + ).toUpperCase() + if ( + url.pathname === '/api' || + url.pathname.startsWith('/api/') || + url.pathname.startsWith(`${DEMO_BASE_PATH}/api/`) + ) { + return mockResponse(url, method, init) + } + return nativeFetch(input, init) + } +} + +/** Restores the deterministic seed data for another demo session. */ +export function resetMockApi(): void { + sequence = 100 + state = createDemoState() +} diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 59e82df..7640371 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -4,6 +4,7 @@ import vinext from 'vinext' import { defineConfig } from 'vite' export default defineConfig({ + base: process.env.VITE_YEOLLIN_BASE_PATH || '/', optimizeDeps: { exclude: ['@devup-ui/react'], },