Compare commits

...

12 Commits

53 changed files with 3541 additions and 449 deletions

View File

@@ -10,6 +10,11 @@ CMS_SUPPORT_EMAIL="support@cms.local"
CMS_SUPPORT_PASSWORD="change-me-support-password"
CMS_SUPPORT_NAME="Technical Support"
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
# Optional deterministic e2e admin user (seeded by `bun run test:e2e:prepare`)
# CMS_E2E_ADMIN_EMAIL="e2e-admin@cms.local"
# CMS_E2E_ADMIN_USERNAME="e2e-admin"
# CMS_E2E_ADMIN_PASSWORD="e2e-admin-password"
# CMS_E2E_ADMIN_NAME="E2E Admin"
CMS_MEDIA_STORAGE_PROVIDER="s3"
CMS_MEDIA_STORAGE_TENANT_ID="default"
CMS_MEDIA_UPLOAD_MAX_BYTES="26214400"

View File

@@ -55,7 +55,7 @@ jobs:
run: bun run commitlint
quality:
name: Lint Typecheck Unit E2E
name: Lint Typecheck (Testing Paused)
needs: governance
runs-on: node22-bun
services:
@@ -90,9 +90,6 @@ jobs:
echo "NEXT_PUBLIC_APP_VERSION=$version" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV"
- name: Install Playwright browser deps
run: bunx playwright install --with-deps chromium
- name: Lint and format checks
run: bun run check
@@ -101,9 +98,3 @@ jobs:
- name: Typecheck
run: bun run typecheck
- name: Unit and integration tests
run: bun run test
- name: E2E tests
run: bun run test:e2e

109
TODO.md
View File

@@ -59,15 +59,7 @@ This file is the single source of truth for roadmap and delivery progress.
### Testing
- [x] [P1] Vitest + Testing Library + MSW baseline
- [x] [P1] Playwright baseline with web/admin projects
- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [x] [P1] RBAC policy unit tests and permission regression suite
- [x] [P1] i18n unit tests (locale resolution, fallback, message key loading)
- [x] [P1] i18n integration tests (admin/public locale switch and persistence)
- [x] [P1] i18n e2e smoke tests (localized headings/content per route)
- [x] [P1] CRUD contract tests for shared service patterns
- [~] [P1] Testing workstream moved to `MVP 3: Testing and Quality` and temporarily paused to prioritize feature delivery
### Documentation
@@ -168,14 +160,14 @@ This file is the single source of truth for roadmap and delivery progress.
- [~] [P1] Dynamic page rendering from CMS page entities
- [~] [P1] Navigation rendering from managed menu structure
- [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [~] [P1] Media entity rendering with enrichment data
- [~] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [~] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
- [~] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow
- [~] [P1] Commission request submission flow
- [x] [P1] Header banner render logic and fallbacks
- [ ] [P1] Announcement render slots (homepage + optional global/top banner position)
- [x] [P1] Announcement render slots (homepage + optional global/top banner position)
### News / Blog (Secondary Track)
@@ -186,14 +178,7 @@ This file is the single source of truth for roadmap and delivery progress.
### Testing
- [x] [P1] Unit tests for content schemas and service logic
- [~] [P1] Component tests for admin forms (pages/media/navigation)
- [x] [P1] Integration tests for owner invariant and hidden support-user protection
- [x] [P1] Integration tests for registration allow/deny behavior
- [ ] [P1] Integration tests for translated content CRUD and locale-specific validation
- [~] [P1] E2E happy paths: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions
- [~] [P1] Testing workstream moved to `MVP 3: Testing and Quality` and temporarily paused to prioritize feature delivery
### Code Documentation And Handover
@@ -205,21 +190,39 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Add code-level diagrams (Mermaid) for service boundaries and data relationships
- [ ] [P2] Add route/action inventory for admin and public apps with linked source files
## MVP 1.5: UX/UI And Theming
## MVP 1.5: MVP1 Refinements (Planned)
### MVP1.5 Suggested Branch Order
### Scope
- [ ] [P1] `todo/mvp15-design-tokens-foundation`:
- [ ] [P1] Refine and harden all completed MVP1 modules (pages, navigation, media, portfolio, commissions, news)
- [ ] [P1] Resolve UX rough edges discovered during MVP1 implementation
- [ ] [P1] Improve admin workflows and reduce editor friction for daily use
- [ ] [P1] Stabilize public rendering behavior with better fallbacks and consistency
## MVP 2: MVP1 Quality Refinements (Planned)
### Scope
- [ ] [P1] Finish non-blocking enhancements postponed from MVP1 implementation
- [ ] [P1] Improve data modeling consistency and migration hygiene for MVP1 modules
- [ ] [P1] Consolidate reusable UI and domain primitives introduced during MVP1
- [ ] [P1] Address integration debt before moving to larger design/production phases
## MVP 3: UX/UI And Theming
### MVP3 Suggested Branch Order
- [ ] [P1] `todo/mvp3-design-tokens-foundation`:
establish shared design tokens (color, spacing, radius, typography scale, motion) in `@cms/ui` and app-level theme contracts
- [ ] [P1] `todo/mvp15-admin-layout-polish`:
- [ ] [P1] `todo/mvp3-admin-layout-polish`:
refine admin shell, navigation hierarchy, spacing rhythm, table/form visual consistency, empty/loading/error states
- [ ] [P1] `todo/mvp15-public-layout-and-templates`:
- [ ] [P1] `todo/mvp3-public-layout-and-templates`:
define public visual direction (hero/header/footer/content widths), page templates for home/content/news/portfolio
- [ ] [P2] `todo/mvp15-component-library-pass`:
- [ ] [P2] `todo/mvp3-component-library-pass`:
align shadcn-based primitives with CMS brand system (buttons, inputs, cards, badges, tabs, dialogs, toasts)
- [ ] [P2] `todo/mvp15-responsive-and-a11y-pass`:
- [ ] [P2] `todo/mvp3-responsive-and-a11y-pass`:
mobile/tablet breakpoints, keyboard flow, focus states, contrast checks, reduced-motion support
- [ ] [P2] `todo/mvp15-visual-regression-baseline`:
- [ ] [P2] `todo/mvp3-visual-regression-baseline`:
add screenshot baselines for critical admin/public routes to guard layout regressions
### Deliverables
@@ -229,7 +232,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P2] Shared UI primitives are consistent across admin and public apps
- [ ] [P2] Core routes have visual-regression coverage for the new layout baseline
## MVP 2: Production Readiness
## MVP 4: Production Readiness
### Admin App
@@ -268,6 +271,38 @@ This file is the single source of truth for roadmap and delivery progress.
### Testing
- [~] [P1] Testing workstream moved to `MVP 5: Testing and Quality` and temporarily paused to prioritize feature delivery
## MVP 5: Testing and Quality
### Status
- [~] [P1] Temporary freeze for active testing execution in local scripts and CI while MVP feature delivery is prioritized
- [ ] [P1] Re-enable root package test scripts (`test`, `test:*`) after MVP feature catch-up
- [ ] [P1] Re-enable CI quality test gates (unit + integration + e2e) in `.gitea/workflows/ci.yml`
### Baseline And Regression
- [x] [P1] Vitest + Testing Library + MSW baseline
- [x] [P1] Playwright baseline with web/admin projects
- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
- [x] [P1] RBAC policy unit tests and permission regression suite
- [x] [P1] i18n unit tests (locale resolution, fallback, message key loading)
- [x] [P1] i18n integration tests (admin/public locale switch and persistence)
- [x] [P1] i18n e2e smoke tests (localized headings/content per route)
- [x] [P1] CRUD contract tests for shared service patterns
- [x] [P1] Unit tests for content schemas and service logic
- [x] [P1] Component tests for admin forms (pages/media/navigation)
- [x] [P1] Integration tests for owner invariant and hidden support-user protection
- [x] [P1] Integration tests for registration allow/deny behavior
- [x] [P1] Integration tests for translated content CRUD and locale-specific validation
- [~] [P1] E2E happy paths: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions
### Advanced Quality Work
- [ ] [P2] Visual regression workflow for critical templates
- [ ] [P2] Load/perf tests for key public routes
- [ ] [P2] Flake tracking and quarantine policy for e2e
@@ -299,7 +334,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-11] Release workflow now publishes changelog-derived notes to Gitea releases and supports executable production rollback via SSH + compose tag switch.
- [2026-02-11] Branch protection verification checklist is now documented; final UI-level verification remains environment-specific.
- [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence.
- [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP2 covers advanced automation (watermark, palette extraction, media transform pipelines).
- [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP4 covers advanced automation (watermark, palette extraction, media transform pipelines).
- [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns.
- [2026-02-11] MVP1 media foundation started: portfolio domain models (`MediaAsset`, `Artwork`, galleries/albums/categories/tags, rendition links) plus initial admin `/media` and `/portfolio` data views.
- [2026-02-11] `prisma migrate dev --name media_foundation` can fail when DB endpoint is unreachable; apply this named migration once `DATABASE_URL` host is reachable again.
@@ -320,6 +355,14 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Added owner/support invariant integration tests for auth guards (`apps/admin/src/lib/auth/server.test.ts`), covering protected-user deletion blocking and one-owner repair/promotion rules.
- [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`).
- [2026-02-12] Added code handover documentation baseline: architecture map, critical invariants, request lifecycles, and onboarding playbook under `docs/product-engineering/`.
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
- [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback.
- [2026-02-12] Public rendering integration advanced with locale-aware navigation/news translations and a new public commission request entry route (`/[locale]/commissions`) that creates/reuses customer records and opens a `new` commission.
- [2026-02-12] Public portfolio baseline added with `/{locale}/portfolio` and `/{locale}/portfolio/{slug}`, including published-artwork filters (gallery/album/category/tag), rendition image streaming via web `/api/media/file/:id`, and media-aware artwork detail rendering.
- [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries.
- [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path).
- [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`.
## How We Use This File

View File

@@ -8,6 +8,7 @@
"build": "bun --env-file=../../.env next build",
"start": "bun --env-file=../../.env next start --port 3001",
"auth:seed:support": "bun --env-file=../../.env ./scripts/seed-support-user.ts",
"auth:seed:e2e-admin": "bun --env-file=../../.env ./scripts/seed-e2e-admin-user.ts",
"lint": "biome check src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},

View File

@@ -0,0 +1,11 @@
import { ensureE2EAdminBootstrap } from "../src/lib/auth/server"
async function main() {
await ensureE2EAdminBootstrap()
console.log("E2E admin bootstrap completed")
}
main().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -5,17 +5,23 @@ import {
listNavigationMenus,
listPages,
updateNavigationItem,
upsertNavigationItemTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { CreateMenuForm } from "@/components/navigation/create-menu-form"
import { CreateNavigationItemForm } from "@/components/navigation/create-navigation-item-form"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
@@ -51,6 +57,14 @@ function readInt(formData: FormData, field: string, fallback = 0): number {
return parsed
}
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
@@ -163,6 +177,31 @@ async function deleteItemAction(formData: FormData) {
redirectWithState({ notice: "Navigation item deleted." })
}
async function upsertItemTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertNavigationItemTranslation({
navigationItemId: readInputString(formData, "navigationItemId"),
locale,
label: readInputString(formData, "label"),
})
} catch {
redirectWithState({ error: "Failed to save item translation." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation item translation saved." })
}
export default async function NavigationManagementPage({
searchParams,
}: {
@@ -182,6 +221,7 @@ export default async function NavigationManagementPage({
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return (
<AdminShell
@@ -206,127 +246,32 @@ export default async function NavigationManagementPage({
<section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Menu</h2>
<form action={createMenuAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Name</span>
<input
name="name"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Location</span>
<input
name="location"
defaultValue="primary"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked
className="size-4"
/>
Visible
</label>
<Button type="submit">Create menu</Button>
</form>
<CreateMenuForm action={createMenuAction} />
</article>
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Navigation Item</h2>
<form action={createItemAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Menu</span>
<select
name="menuId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{menus.map((menu) => (
<option key={menu.id} value={menu.id}>
{menu.name} ({menu.location})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Custom href</span>
<input
name="href"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent item id</span>
<input
name="parentId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Sort order</span>
<input
name="sortOrder"
defaultValue="0"
type="number"
min={0}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked
className="size-4"
/>
Visible
</label>
<Button type="submit">Create item</Button>
</form>
<CreateNavigationItemForm action={createItemAction} menus={menus} pages={pages} />
</article>
</section>
<section className="space-y-4">
<div className="flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => (
<a
key={locale}
href={`/navigation?locale=${locale}`}
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
selectedLocale === locale
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
{locale.toUpperCase()}
</a>
))}
</div>
{menus.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No navigation menus yet.
@@ -347,94 +292,126 @@ export default async function NavigationManagementPage({
{menu.items.length === 0 ? (
<p className="text-sm text-neutral-600">No items in this menu.</p>
) : (
menu.items.map((item) => (
<form
key={item.id}
action={updateItemAction}
className="rounded-lg border border-neutral-200 p-3"
>
<input type="hidden" name="id" value={item.id} />
<div className="grid gap-3 md:grid-cols-5">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
defaultValue={item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Href</span>
<input
name="href"
defaultValue={item.href ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Sort</span>
<input
name="sortOrder"
type="number"
min={0}
defaultValue={item.sortOrder}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
menu.items.map((item) => {
const translation = item.translations.find(
(entry) => entry.locale === selectedLocale,
)
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
defaultValue={item.pageId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent id</span>
<input
name="parentId"
defaultValue={item.parentId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
return (
<div key={item.id} className="rounded-lg border border-neutral-200 p-3">
<form action={updateItemAction}>
<input type="hidden" name="id" value={item.id} />
<div className="grid gap-3 md:grid-cols-5">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
defaultValue={item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Href</span>
<input
name="href"
defaultValue={item.href ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Sort</span>
<input
name="sortOrder"
type="number"
min={0}
defaultValue={item.sortOrder}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
name="isVisible"
value="true"
defaultChecked={item.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save item
</Button>
<button
type="submit"
formAction={deleteItemAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
defaultValue={item.pageId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent id</span>
<input
name="parentId"
defaultValue={item.parentId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
name="isVisible"
value="true"
defaultChecked={item.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save item
</Button>
<button
type="submit"
formAction={deleteItemAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
<form
action={upsertItemTranslationAction}
className="mt-3 rounded border border-neutral-200 p-3"
>
<input type="hidden" name="navigationItemId" value={item.id} />
<input type="hidden" name="locale" value={selectedLocale} />
<p className="text-xs text-neutral-600">
Translation ({selectedLocale.toUpperCase()}) - saved locales:{" "}
{item.translations.length > 0
? item.translations
.map((entry) => entry.locale.toUpperCase())
.join(", ")
: "none"}
</p>
<div className="mt-2 flex gap-2">
<input
name="label"
defaultValue={translation?.label ?? item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit" size="sm" variant="secondary">
Save translation
</Button>
</div>
</form>
</div>
</form>
))
)
})
)}
</div>
</article>

View File

@@ -1,4 +1,10 @@
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
import {
createPost,
deletePost,
listPostsWithTranslations,
updatePost,
upsertPostTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
@@ -9,6 +15,9 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
@@ -28,6 +37,14 @@ function readNullableString(formData: FormData, field: string): string | undefin
return value.length > 0 ? value : undefined
}
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
@@ -115,6 +132,34 @@ async function deleteNewsAction(formData: FormData) {
redirectWithState({ notice: "Post deleted." })
}
async function upsertNewsTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertPostTranslation({
postId: readInputString(formData, "postId"),
locale,
title: readInputString(formData, "title"),
excerpt: readNullableString(formData, "excerpt") ?? null,
body: readInputString(formData, "body"),
})
} catch {
redirectWithState({ error: "Failed to save translation." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post translation saved." })
}
export default async function NewsManagementPage({
searchParams,
}: {
@@ -126,10 +171,14 @@ export default async function NewsManagementPage({
scope: "team",
})
const [resolvedSearchParams, posts] = await Promise.all([searchParams, listPosts()])
const [resolvedSearchParams, posts] = await Promise.all([
searchParams,
listPostsWithTranslations(),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return (
<AdminShell
@@ -204,72 +253,146 @@ export default async function NewsManagementPage({
</section>
<section className="space-y-3">
{posts.map((post) => (
<form
key={post.id}
action={updateNewsAction}
className="rounded-xl border border-neutral-200 p-6"
>
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
defaultValue={post.slug}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<select
name="status"
defaultValue={post.status}
className="rounded border border-neutral-300 px-3 py-2 text-sm"
<div className="flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => (
<a
key={locale}
href={`/news?locale=${locale}`}
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
selectedLocale === locale
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
{locale.toUpperCase()}
</a>
))}
</div>
{posts.map((post) => {
const translation = post.translations.find((entry) => entry.locale === selectedLocale)
return (
<div key={post.id} className="rounded-xl border border-neutral-200 p-6">
<form action={updateNewsAction}>
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
defaultValue={post.slug}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<select
name="status"
defaultValue={post.status}
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteNewsAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
<form
action={upsertNewsTranslationAction}
className="mt-4 rounded-lg border border-neutral-200 p-4"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteNewsAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
<input type="hidden" name="postId" value={post.id} />
<input type="hidden" name="locale" value={selectedLocale} />
<h3 className="text-sm font-medium">
Translation ({selectedLocale.toUpperCase()})
</h3>
<p className="mt-1 text-xs text-neutral-600">
Missing fields fall back to base post content on public pages.
</p>
{post.translations.length > 0 ? (
<p className="mt-2 text-xs text-neutral-600">
Saved locales:{" "}
{post.translations.map((entry) => entry.locale.toUpperCase()).join(", ")}
</p>
) : null}
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={translation?.title ?? post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={translation?.excerpt ?? post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={translation?.body ?? post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3">
<Button type="submit" size="sm">
Save translation
</Button>
</div>
</form>
</div>
</form>
))}
)
})}
</section>
</AdminShell>
)

View File

@@ -1,14 +1,23 @@
import { deletePage, getPageById, updatePage } from "@cms/db"
import {
deletePage,
getPageById,
listPageTranslations,
updatePage,
upsertPageTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { PageBlockEditor } from "@/components/pages/page-block-editor"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
type PageProps = {
params: Promise<{ id: string }>
@@ -48,6 +57,14 @@ function redirectWithState(pageId: string, params: { notice?: string; error?: st
redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`)
}
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
export default async function PageEditorPage({ params, searchParams }: PageProps) {
const role = await requirePermissionForRoute({
nextPath: "/pages",
@@ -57,7 +74,11 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const resolvedParams = await params
const pageId = resolvedParams.id
const [resolvedSearchParams, pageRecord] = await Promise.all([searchParams, getPageById(pageId)])
const [resolvedSearchParams, pageRecord, translations] = await Promise.all([
searchParams,
getPageById(pageId),
listPageTranslations(pageId),
])
if (!pageRecord) {
redirect("/pages?error=Page+not+found")
@@ -66,6 +87,8 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const page = pageRecord
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
const selectedTranslation = translations.find((entry) => entry.locale === selectedLocale)
async function updatePageAction(formData: FormData) {
"use server"
@@ -118,6 +141,34 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
redirect("/pages?notice=Page+deleted")
}
async function upsertPageTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertPageTranslation({
pageId,
locale,
title: readInputString(formData, "title"),
summary: readNullableString(formData, "summary"),
content: readInputString(formData, "content"),
seoTitle: readNullableString(formData, "seoTitle"),
seoDescription: readNullableString(formData, "seoDescription"),
})
} catch {
redirect(`/pages/${pageId}?error=Failed+to+save+translation.&locale=${locale}`)
}
redirect(`/pages/${pageId}?notice=Translation+saved.&locale=${locale}`)
}
return (
<AdminShell
role={role}
@@ -192,16 +243,7 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Content</span>
<textarea
name="content"
rows={10}
defaultValue={page.content}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<PageBlockEditor name="content" initialContent={page.content} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
@@ -226,6 +268,127 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-1">
<h3 className="text-xl font-medium">Translations</h3>
<p className="text-sm text-neutral-600">
Add locale-specific page content. Missing locales fall back to base page fields.
</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => {
const isActive = locale === selectedLocale
const hasTranslation = translations.some((entry) => entry.locale === locale)
return (
<Link
key={locale}
href={`/pages/${pageId}?locale=${locale}`}
className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-xs ${
isActive
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
<span>{locale.toUpperCase()}</span>
<span className={isActive ? "text-neutral-200" : "text-neutral-500"}>
{hasTranslation ? "saved" : "missing"}
</span>
</Link>
)
})}
</div>
{translations.length > 0 ? (
<div className="mt-4 rounded border border-neutral-200">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2">Locale</th>
<th className="px-3 py-2">Title</th>
<th className="px-3 py-2">Updated</th>
</tr>
</thead>
<tbody>
{translations.map((translation) => (
<tr key={translation.id} className="border-t border-neutral-200">
<td className="px-3 py-2">{translation.locale.toUpperCase()}</td>
<td className="px-3 py-2">{translation.title}</td>
<td className="px-3 py-2 text-neutral-600">
{translation.updatedAt.toLocaleDateString("en-US")}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<form action={upsertPageTranslationAction} className="mt-6 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Locale</span>
<select
name="locale"
defaultValue={selectedLocale}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{SUPPORTED_LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale.toUpperCase()}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={selectedTranslation?.title ?? page.title}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Summary</span>
<input
name="summary"
defaultValue={selectedTranslation?.summary ?? page.summary ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<PageBlockEditor
name="content"
initialContent={selectedTranslation?.content ?? page.content}
label="Translation Blocks"
/>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO title</span>
<input
name="seoTitle"
defaultValue={selectedTranslation?.seoTitle ?? page.seoTitle ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO description</span>
<input
name="seoDescription"
defaultValue={selectedTranslation?.seoDescription ?? page.seoDescription ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Save translation</Button>
</form>
</section>
<section className="rounded-xl border border-red-300 bg-red-50 p-6">
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
<p className="mt-1 text-sm text-red-700">

View File

@@ -1,10 +1,10 @@
import { createPage, listPages } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { CreatePageForm } from "@/components/pages/create-page-form"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
@@ -110,75 +110,7 @@ export default async function PagesManagementPage({
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Page</h2>
<form action={createPageAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Summary</span>
<input
name="summary"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Content</span>
<textarea
name="content"
rows={6}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO title</span>
<input
name="seoTitle"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO description</span>
<input
name="seoDescription"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Create page</Button>
</form>
<CreatePageForm action={createPageAction} />
</section>
<section className="rounded-xl border border-neutral-200 p-6">

View File

@@ -0,0 +1,20 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CreateMenuForm } from "./create-menu-form"
describe("CreateMenuForm", () => {
it("renders defaults for location and visibility", () => {
render(<CreateMenuForm action={vi.fn()} />)
const location = screen.getByLabelText("Location") as HTMLInputElement
expect(location.value).toBe("primary")
const visible = screen.getByLabelText("Visible") as HTMLInputElement
expect(visible.checked).toBe(true)
expect(screen.getByRole("button", { name: "Create menu" })).not.toBeNull()
})
})

View File

@@ -0,0 +1,41 @@
import { Button } from "@cms/ui/button"
type CreateMenuFormProps = {
action: (formData: FormData) => void | Promise<void>
}
export function CreateMenuForm({ action }: CreateMenuFormProps) {
return (
<form action={action} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Name</span>
<input
name="name"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Location</span>
<input
name="location"
defaultValue="primary"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input name="isVisible" type="checkbox" value="true" defaultChecked className="size-4" />
Visible
</label>
<Button type="submit">Create menu</Button>
</form>
)
}

View File

@@ -0,0 +1,32 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CreateNavigationItemForm } from "./create-navigation-item-form"
describe("CreateNavigationItemForm", () => {
it("renders menu/page options and defaults", () => {
render(
<CreateNavigationItemForm
action={vi.fn()}
menus={[{ id: "menu-1", name: "Primary", location: "header" }]}
pages={[{ id: "page-1", title: "Home", slug: "home" }]}
/>,
)
const menu = screen.getByLabelText("Menu") as HTMLSelectElement
expect(menu.options.length).toBe(1)
expect(menu.value).toBe("menu-1")
const page = screen.getByLabelText("Linked page") as HTMLSelectElement
expect(page.options.length).toBe(2)
expect(page.options[0]?.value).toBe("")
const sortOrder = screen.getByLabelText("Sort order") as HTMLInputElement
expect(sortOrder.value).toBe("0")
const visible = screen.getByLabelText("Visible") as HTMLInputElement
expect(visible.checked).toBe(true)
})
})

View File

@@ -0,0 +1,94 @@
import { Button } from "@cms/ui/button"
type MenuOption = {
id: string
name: string
location: string
}
type PageOption = {
id: string
title: string
slug: string
}
type CreateNavigationItemFormProps = {
action: (formData: FormData) => void | Promise<void>
menus: MenuOption[]
pages: PageOption[]
}
export function CreateNavigationItemForm({ action, menus, pages }: CreateNavigationItemFormProps) {
return (
<form action={action} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Menu</span>
<select
name="menuId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{menus.map((menu) => (
<option key={menu.id} value={menu.id}>
{menu.name} ({menu.location})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Custom href</span>
<input
name="href"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent item id</span>
<input
name="parentId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Sort order</span>
<input
name="sortOrder"
defaultValue="0"
type="number"
min={0}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input name="isVisible" type="checkbox" value="true" defaultChecked className="size-4" />
Visible
</label>
<Button type="submit">Create item</Button>
</form>
)
}

View File

@@ -0,0 +1,23 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { CreatePageForm } from "./create-page-form"
describe("CreatePageForm", () => {
it("renders required fields and draft default status", () => {
render(<CreatePageForm action={vi.fn()} />)
expect((screen.getByLabelText("Title") as HTMLInputElement).name).toBe("title")
expect((screen.getByLabelText("Slug") as HTMLInputElement).name).toBe("slug")
const contentField = document.querySelector('input[name="content"]') as HTMLInputElement | null
expect(contentField).not.toBeNull()
expect(contentField?.value.startsWith("[")).toBe(true)
const status = screen.getByLabelText("Status") as HTMLSelectElement
expect(status.value).toBe("draft")
expect(screen.getByRole("button", { name: "Create page" })).not.toBeNull()
})
})

View File

@@ -0,0 +1,83 @@
import { serializePageBlocks } from "@cms/content"
import { Button } from "@cms/ui/button"
import { PageBlockEditor } from "./page-block-editor"
type CreatePageFormProps = {
action: (formData: FormData) => void | Promise<void>
}
export function CreatePageForm({ action }: CreatePageFormProps) {
return (
<form action={action} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Summary</span>
<input
name="summary"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<PageBlockEditor
name="content"
initialContent={serializePageBlocks([
{
id: "initial-rich-text",
type: "rich_text",
body: "",
},
])}
/>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO title</span>
<input
name="seoTitle"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO description</span>
<input
name="seoDescription"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Create page</Button>
</form>
)
}

View File

@@ -0,0 +1,292 @@
"use client"
import { type PageBlock, type PageBlocks, parsePageBlocks, serializePageBlocks } from "@cms/content"
import { useMemo, useState } from "react"
type PageBlockEditorProps = {
name: string
initialContent: string
label?: string
}
function randomId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`
}
function normalizeInitialBlocks(initialContent: string): PageBlocks {
if (!initialContent.trim()) {
return [
{
id: randomId("rich"),
type: "rich_text",
body: "",
},
]
}
try {
return parsePageBlocks(initialContent)
} catch {
return [
{
id: randomId("rich"),
type: "rich_text",
body: initialContent,
},
]
}
}
function updateBlock(blocks: PageBlocks, blockId: string, next: Partial<PageBlock>): PageBlocks {
return blocks.map((block) =>
block.id === blockId ? ({ ...block, ...next } as PageBlock) : block,
)
}
export function PageBlockEditor({
name,
initialContent,
label = "Page Blocks",
}: PageBlockEditorProps) {
const [blocks, setBlocks] = useState<PageBlocks>(() => normalizeInitialBlocks(initialContent))
const serialized = useMemo(() => serializePageBlocks(blocks), [blocks])
function addBlock(type: PageBlock["type"]) {
if (type === "hero") {
setBlocks((prev) => [
...prev,
{
id: randomId("hero"),
type,
heading: "Hero heading",
subheading: null,
ctaHref: null,
ctaLabel: null,
},
])
return
}
if (type === "rich_text") {
setBlocks((prev) => [...prev, { id: randomId("rich"), type, body: "" }])
return
}
if (type === "gallery") {
setBlocks((prev) => [...prev, { id: randomId("gallery"), type, title: null, imageIds: [] }])
return
}
if (type === "cta") {
setBlocks((prev) => [
...prev,
{ id: randomId("cta"), type, label: "Open", href: "/", variant: "primary" },
])
return
}
if (type === "form") {
setBlocks((prev) => [
...prev,
{ id: randomId("form"), type, formKey: "contact", title: null, description: null },
])
return
}
setBlocks((prev) => [
...prev,
{ id: randomId("price"), type: "price_cards", title: null, cards: [] },
])
}
return (
<div className="space-y-3 rounded border border-neutral-200 p-3">
<input type="hidden" name={name} value={serialized} />
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-neutral-600">{label}</span>
<button
type="button"
className="rounded border px-2 py-1 text-xs"
onClick={() => addBlock("hero")}
>
+ Hero
</button>
<button
type="button"
className="rounded border px-2 py-1 text-xs"
onClick={() => addBlock("rich_text")}
>
+ Rich text
</button>
<button
type="button"
className="rounded border px-2 py-1 text-xs"
onClick={() => addBlock("gallery")}
>
+ Gallery
</button>
<button
type="button"
className="rounded border px-2 py-1 text-xs"
onClick={() => addBlock("cta")}
>
+ CTA
</button>
<button
type="button"
className="rounded border px-2 py-1 text-xs"
onClick={() => addBlock("form")}
>
+ Form
</button>
<button
type="button"
className="rounded border px-2 py-1 text-xs"
onClick={() => addBlock("price_cards")}
>
+ Price cards
</button>
</div>
<div className="space-y-3">
{blocks.map((block, index) => (
<article key={block.id} className="space-y-2 rounded border border-neutral-200 p-3">
<div className="flex items-center justify-between text-xs text-neutral-600">
<span>
#{index + 1} {block.type}
</span>
<button
type="button"
className="rounded border px-2 py-1"
onClick={() => setBlocks((prev) => prev.filter((entry) => entry.id !== block.id))}
>
Remove
</button>
</div>
{block.type === "hero" ? (
<div className="grid gap-2 md:grid-cols-2">
<input
value={block.heading}
onChange={(event) =>
setBlocks((prev) =>
updateBlock(prev, block.id, { heading: event.target.value }),
)
}
placeholder="Heading"
className="rounded border border-neutral-300 px-2 py-1 text-sm"
/>
<input
value={block.subheading ?? ""}
onChange={(event) =>
setBlocks((prev) =>
updateBlock(prev, block.id, { subheading: event.target.value || null }),
)
}
placeholder="Subheading"
className="rounded border border-neutral-300 px-2 py-1 text-sm"
/>
</div>
) : null}
{block.type === "rich_text" ? (
<textarea
rows={5}
value={block.body}
onChange={(event) =>
setBlocks((prev) => updateBlock(prev, block.id, { body: event.target.value }))
}
placeholder="Text"
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
/>
) : null}
{block.type === "gallery" ? (
<textarea
rows={3}
value={block.imageIds.join(",")}
onChange={(event) =>
setBlocks((prev) =>
updateBlock(prev, block.id, {
imageIds: event.target.value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
}),
)
}
placeholder="Media asset IDs (comma separated UUIDs)"
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
/>
) : null}
{block.type === "cta" ? (
<div className="grid gap-2 md:grid-cols-2">
<input
value={block.label}
onChange={(event) =>
setBlocks((prev) => updateBlock(prev, block.id, { label: event.target.value }))
}
placeholder="Button label"
className="rounded border border-neutral-300 px-2 py-1 text-sm"
/>
<input
value={block.href}
onChange={(event) =>
setBlocks((prev) => updateBlock(prev, block.id, { href: event.target.value }))
}
placeholder="Link href"
className="rounded border border-neutral-300 px-2 py-1 text-sm"
/>
</div>
) : null}
{block.type === "form" ? (
<input
value={block.formKey}
onChange={(event) =>
setBlocks((prev) => updateBlock(prev, block.id, { formKey: event.target.value }))
}
placeholder="Form key (e.g. contact, commission)"
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
/>
) : null}
{block.type === "price_cards" ? (
<textarea
rows={4}
value={block.cards
.map((card) => [card.name, card.price ?? "", card.description ?? ""].join("|"))
.join("\n")}
onChange={(event) =>
setBlocks((prev) =>
updateBlock(prev, block.id, {
cards: event.target.value
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line, lineIndex) => {
const [name, price, description] = line
.split("|")
.map((entry) => entry.trim())
return {
id: `card-${lineIndex}`,
name: name || `Card ${lineIndex + 1}`,
price: price || null,
description: description || null,
}
}),
}),
)
}
placeholder="One card per line: Name|Price|Description"
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
/>
) : null}
</article>
))}
</div>
</div>
)
}

View File

@@ -266,11 +266,15 @@ type BootstrapUserConfig = {
password: string
role: Role
isHidden: boolean
isSystem?: boolean
isProtected?: boolean
}
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
const ctx = await auth.$context
const normalizedEmail = config.email.toLowerCase()
const isSystem = config.isSystem ?? true
const isProtected = config.isProtected ?? true
const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, {
includeAccounts: true,
})
@@ -282,9 +286,9 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
name: config.name,
role: config.role,
isBanned: false,
isSystem: true,
isSystem,
isHidden: config.isHidden,
isProtected: true,
isProtected,
},
})
@@ -321,9 +325,9 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
emailVerified: true,
role: config.role,
isBanned: false,
isSystem: true,
isSystem,
isHidden: config.isHidden,
isProtected: true,
isProtected,
})
await ctx.internalAdapter.linkAccount({
@@ -371,6 +375,29 @@ export async function ensureSupportUserBootstrap(): Promise<void> {
}
}
const DEFAULT_E2E_ADMIN_EMAIL = "e2e-admin@cms.local"
const DEFAULT_E2E_ADMIN_USERNAME = "e2e-admin"
const DEFAULT_E2E_ADMIN_PASSWORD = "e2e-admin-password"
const DEFAULT_E2E_ADMIN_NAME = "E2E Admin"
export async function ensureE2EAdminBootstrap(): Promise<void> {
const email = resolveBootstrapValue("CMS_E2E_ADMIN_EMAIL", DEFAULT_E2E_ADMIN_EMAIL)
const username = resolveBootstrapValue("CMS_E2E_ADMIN_USERNAME", DEFAULT_E2E_ADMIN_USERNAME)
const password = resolveBootstrapValue("CMS_E2E_ADMIN_PASSWORD", DEFAULT_E2E_ADMIN_PASSWORD)
const name = resolveBootstrapValue("CMS_E2E_ADMIN_NAME", DEFAULT_E2E_ADMIN_NAME)
await ensureCredentialUser({
email,
username,
password,
name,
role: "admin",
isHidden: false,
isSystem: true,
isProtected: false,
})
}
type OwnerInvariantState = {
ownerId: string | null
ownerCount: number

View File

@@ -11,6 +11,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "3.988.0",
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",

View File

@@ -1,4 +1,4 @@
import { getPublishedPageBySlug } from "@cms/db"
import { getPublishedPageBySlugForLocale } from "@cms/db"
import { notFound } from "next/navigation"
import { PublicPageView } from "@/components/public-page-view"
@@ -6,12 +6,12 @@ import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
params: Promise<{ locale: string; slug: string }>
}
export default async function CmsPageRoute({ params }: PageProps) {
const { slug } = await params
const page = await getPublishedPageBySlug(slug)
const { locale, slug } = await params
const page = await getPublishedPageBySlugForLocale(slug, locale)
if (!page) {
notFound()

View File

@@ -0,0 +1,217 @@
import { createPublicCommissionRequest } from "@cms/db"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
type PublicCommissionRequestPageProps = {
params: Promise<{ locale: string }>
searchParams: Promise<SearchParamsInput>
}
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | null {
const value = readInputString(formData, field)
return value.length > 0 ? value : null
}
function readNullableNumber(formData: FormData, field: string): number | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : null
}
function buildRedirect(locale: string, params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const serialized = query.toString()
return serialized ? `/${locale}/commissions?${serialized}` : `/${locale}/commissions`
}
export default async function PublicCommissionRequestPage({
params,
searchParams,
}: PublicCommissionRequestPageProps) {
const { locale } = await params
const [resolvedSearchParams, t] = await Promise.all([
searchParams,
getTranslations("CommissionRequest"),
])
async function submitCommissionRequestAction(formData: FormData) {
"use server"
const budgetMin = readNullableNumber(formData, "budgetMin")
const budgetMax = readNullableNumber(formData, "budgetMax")
if (budgetMin != null && budgetMax != null && budgetMax < budgetMin) {
redirect(buildRedirect(locale, { error: "budget_range_invalid" }))
}
try {
await createPublicCommissionRequest({
customerName: readInputString(formData, "customerName"),
customerEmail: readInputString(formData, "customerEmail"),
customerPhone: readNullableString(formData, "customerPhone"),
customerInstagram: readNullableString(formData, "customerInstagram"),
title: readInputString(formData, "title"),
description: readNullableString(formData, "description"),
budgetMin,
budgetMax,
})
} catch {
redirect(buildRedirect(locale, { error: "submission_failed" }))
}
revalidatePath(`/${locale}/commissions`)
redirect(buildRedirect(locale, { notice: "submitted" }))
}
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<section className="mx-auto w-full max-w-3xl space-y-6 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
{notice === "submitted" ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{t("success")}
</section>
) : null}
{error === "submission_failed" ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{t("error")}
</section>
) : null}
{error === "budget_range_invalid" ? (
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{t("budgetRangeError")}
</section>
) : null}
<form action={submitCommissionRequestAction} className="space-y-4 rounded-xl border p-6">
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerName")}</span>
<input
name="customerName"
autoComplete="name"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerEmail")}</span>
<input
name="customerEmail"
type="email"
autoComplete="email"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerPhone")}</span>
<input
name="customerPhone"
autoComplete="tel"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerInstagram")}</span>
<input
name="customerInstagram"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.title")}</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.description")}</span>
<textarea
name="description"
rows={6}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.budgetMin")}</span>
<input
name="budgetMin"
type="number"
min="0"
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.budgetMax")}</span>
<input
name="budgetMax"
type="number"
min="0"
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<button
type="submit"
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700"
>
{t("submit")}
</button>
</form>
</section>
)
}

View File

@@ -1,15 +1,15 @@
import { getPostBySlug } from "@cms/db"
import { getPostBySlugForLocale } from "@cms/db"
import { notFound } from "next/navigation"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
params: Promise<{ locale: string; slug: string }>
}
export default async function PublicNewsDetailPage({ params }: PageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
const { locale, slug } = await params
const post = await getPostBySlugForLocale(slug, locale)
if (!post || post.status !== "published") {
notFound()

View File

@@ -1,10 +1,15 @@
import { listPosts } from "@cms/db"
import { listPostsForLocale } from "@cms/db"
import Link from "next/link"
export const dynamic = "force-dynamic"
export default async function PublicNewsIndexPage() {
const posts = await listPosts()
type PublicNewsIndexPageProps = {
params: Promise<{ locale: string }>
}
export default async function PublicNewsIndexPage({ params }: PublicNewsIndexPageProps) {
const { locale } = await params
const posts = await listPostsForLocale(locale)
return (
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">

View File

@@ -1,14 +1,20 @@
import { getPublishedPageBySlug, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getPublishedPageBySlugForLocale, listPosts } from "@cms/db"
import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements"
import { PublicPageView } from "@/components/public-page-view"
import { Link } from "@/i18n/navigation"
export const dynamic = "force-dynamic"
export default async function HomePage() {
type HomePageProps = {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params
const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlug("home"),
getPublishedPageBySlugForLocale("home", locale),
listPosts(),
getTranslations("Home"),
])
@@ -28,7 +34,20 @@ export default async function HomePage() {
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between">
<h3 className="text-xl font-medium">{t("latestPosts")}</h3>
<Button variant="secondary">{t("explore")}</Button>
<div className="flex items-center gap-2">
<Link
href="/news"
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-900 transition-colors hover:bg-neutral-200"
>
{t("explore")}
</Link>
<Link
href="/commissions"
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
{t("requestCommission")}
</Link>
</div>
</div>
<ul className="space-y-3">

View File

@@ -0,0 +1,101 @@
import { getPublishedArtworkBySlug } from "@cms/db"
import Image from "next/image"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
type PublicArtworkPageProps = {
params: Promise<{ slug: string }>
}
function formatLabelList(values: string[]) {
if (values.length === 0) {
return "-"
}
return values.join(", ")
}
export default async function PublicArtworkPage({ params }: PublicArtworkPageProps) {
const [{ slug }, t] = await Promise.all([params, getTranslations("Portfolio")])
const artwork = await getPublishedArtworkBySlug(slug)
if (!artwork) {
notFound()
}
return (
<section className="mx-auto w-full max-w-5xl space-y-6 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{artwork.title}</h1>
<p className="text-neutral-600">{artwork.description || t("noDescription")}</p>
</header>
<section className="grid gap-4 md:grid-cols-2">
{artwork.renditions.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
{t("noPreview")}
</article>
) : (
artwork.renditions.map((rendition) => (
<article
key={rendition.id}
className="overflow-hidden rounded-xl border border-neutral-200"
>
<Image
src={`/api/media/file/${rendition.mediaAssetId}`}
alt={rendition.mediaAsset.altText || artwork.title}
width={1400}
height={1000}
className="h-72 w-full object-cover"
/>
<div className="flex items-center justify-between px-4 py-2 text-xs text-neutral-600">
<span>{rendition.slot}</span>
<span>
{rendition.mediaAsset.width ?? "-"} x {rendition.mediaAsset.height ?? "-"}
</span>
</div>
</article>
))
)}
</section>
<section className="grid gap-4 rounded-xl border border-neutral-200 p-6 md:grid-cols-2">
<div className="space-y-2 text-sm">
<p>
<strong>{t("fields.medium")}:</strong> {artwork.medium || "-"}
</p>
<p>
<strong>{t("fields.dimensions")}:</strong> {artwork.dimensions || "-"}
</p>
<p>
<strong>{t("fields.year")}:</strong> {artwork.year || "-"}
</p>
<p>
<strong>{t("fields.availability")}:</strong> {artwork.availability || "-"}
</p>
</div>
<div className="space-y-2 text-sm">
<p>
<strong>{t("fields.galleries")}:</strong>{" "}
{formatLabelList(artwork.galleryLinks.map((entry) => entry.gallery.name))}
</p>
<p>
<strong>{t("fields.albums")}:</strong>{" "}
{formatLabelList(artwork.albumLinks.map((entry) => entry.album.name))}
</p>
<p>
<strong>{t("fields.categories")}:</strong>{" "}
{formatLabelList(artwork.categoryLinks.map((entry) => entry.category.name))}
</p>
<p>
<strong>{t("fields.tags")}:</strong>{" "}
{formatLabelList(artwork.tagLinks.map((entry) => entry.tag.name))}
</p>
</div>
</section>
</section>
)
}

View File

@@ -0,0 +1,178 @@
import { listPublishedArtworks, listPublishedPortfolioGroups } from "@cms/db"
import Image from "next/image"
import { getTranslations } from "next-intl/server"
import { Link } from "@/i18n/navigation"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
type PortfolioPageProps = {
searchParams: Promise<SearchParamsInput>
}
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function resolveGroupFilter(searchParams: SearchParamsInput) {
const gallery = readFirstValue(searchParams.gallery)
if (gallery) {
return { groupType: "gallery" as const, groupSlug: gallery }
}
const album = readFirstValue(searchParams.album)
if (album) {
return { groupType: "album" as const, groupSlug: album }
}
const category = readFirstValue(searchParams.category)
if (category) {
return { groupType: "category" as const, groupSlug: category }
}
const tag = readFirstValue(searchParams.tag)
if (tag) {
return { groupType: "tag" as const, groupSlug: tag }
}
return null
}
function findPreviewAsset(
renditions: Array<{
slot: string
mediaAssetId: string
mediaAsset: {
id: string
altText: string | null
title: string
}
}>,
) {
const byPreference =
renditions.find((item) => item.slot === "card") ??
renditions.find((item) => item.slot === "thumbnail") ??
renditions.find((item) => item.slot === "full") ??
renditions[0]
return byPreference ?? null
}
export default async function PortfolioPage({ searchParams }: PortfolioPageProps) {
const [resolvedSearchParams, t] = await Promise.all([searchParams, getTranslations("Portfolio")])
const activeFilter = resolveGroupFilter(resolvedSearchParams)
const [groups, artworks] = await Promise.all([
listPublishedPortfolioGroups(),
listPublishedArtworks(
activeFilter
? {
groupType: activeFilter.groupType,
groupSlug: activeFilter.groupSlug,
}
: undefined,
),
])
return (
<section className="mx-auto w-full max-w-6xl space-y-6 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
<section className="rounded-xl border border-neutral-200 p-4">
<div className="flex flex-wrap items-center gap-2">
<Link
href="/portfolio"
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.clear")}
</Link>
{groups.galleries.map((group) => (
<Link
key={`gallery-${group.id}`}
href={{ pathname: "/portfolio", query: { gallery: group.slug } }}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.gallery")}: {group.name}
</Link>
))}
{groups.albums.map((group) => (
<Link
key={`album-${group.id}`}
href={{ pathname: "/portfolio", query: { album: group.slug } }}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.album")}: {group.name}
</Link>
))}
{groups.categories.map((group) => (
<Link
key={`category-${group.id}`}
href={{ pathname: "/portfolio", query: { category: group.slug } }}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t("filters.category")}: {group.name}
</Link>
))}
</div>
</section>
{artworks.length === 0 ? (
<section className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
{t("empty")}
</section>
) : (
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{artworks.map((artwork) => {
const preview = findPreviewAsset(artwork.renditions)
return (
<article
key={artwork.id}
className="overflow-hidden rounded-xl border border-neutral-200"
>
{preview ? (
<Image
src={`/api/media/file/${preview.mediaAssetId}`}
alt={preview.mediaAsset.altText || artwork.title}
width={1200}
height={800}
className="h-56 w-full object-cover"
/>
) : (
<div className="flex h-56 items-center justify-center bg-neutral-100 text-sm text-neutral-500">
{t("noPreview")}
</div>
)}
<div className="space-y-2 p-4">
<h2 className="text-lg font-medium">{artwork.title}</h2>
<p className="line-clamp-3 text-sm text-neutral-600">
{artwork.description || t("noDescription")}
</p>
<Link
href={`/portfolio/${artwork.slug}`}
className="text-sm underline underline-offset-2"
>
{t("viewArtwork")}
</Link>
</div>
</article>
)
})}
</section>
)}
</section>
)
}

View File

@@ -0,0 +1,47 @@
import { getMediaAssetById } from "@cms/db"
import { readMediaStorageObject } from "@/lib/media/storage-read"
export const runtime = "nodejs"
type RouteContext = {
params: Promise<{ id: string }>
}
function toBody(data: Uint8Array): BodyInit {
const bytes = new Uint8Array(data.byteLength)
bytes.set(data)
return bytes
}
export async function GET(_request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params
const asset = await getMediaAssetById(id)
if (!asset || !asset.storageKey || !asset.isPublished) {
return Response.json(
{
message: "Media file not found",
},
{ status: 404 },
)
}
try {
const data = await readMediaStorageObject(asset.storageKey)
return new Response(toBody(data), {
status: 200,
headers: {
"content-type": asset.mimeType || "application/octet-stream",
"cache-control": "public, max-age=3600",
},
})
} catch {
return Response.json(
{
message: "Unable to read media file from configured storage backends",
},
{ status: 404 },
)
}
}

View File

@@ -1,3 +1,6 @@
import { parsePageBlocks } from "@cms/content"
import Image from "next/image"
type PageEntity = {
title: string
status: string
@@ -10,6 +13,20 @@ type PublicPageViewProps = {
}
export function PublicPageView({ page }: PublicPageViewProps) {
const blocks = (() => {
try {
return parsePageBlocks(page.content)
} catch {
return [
{
id: "fallback-rich-text",
type: "rich_text" as const,
body: page.content,
},
]
}
})()
return (
<article className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-16">
<header className="space-y-3">
@@ -18,8 +35,105 @@ export function PublicPageView({ page }: PublicPageViewProps) {
{page.summary ? <p className="text-neutral-600">{page.summary}</p> : null}
</header>
<section className="prose prose-neutral max-w-none whitespace-pre-wrap rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
{page.content}
<section className="space-y-4 rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
{blocks.map((block) => {
if (block.type === "hero") {
return (
<section key={block.id} className="space-y-2 rounded border border-neutral-200 p-4">
<h2 className="text-2xl font-semibold">{block.heading}</h2>
{block.subheading ? <p className="text-neutral-600">{block.subheading}</p> : null}
{block.ctaLabel && block.ctaHref ? (
<a
href={block.ctaHref}
className="inline-flex rounded bg-neutral-900 px-3 py-1.5 text-sm text-white"
>
{block.ctaLabel}
</a>
) : null}
</section>
)
}
if (block.type === "rich_text") {
return (
<section
key={block.id}
className="prose prose-neutral max-w-none whitespace-pre-wrap"
>
{block.body}
</section>
)
}
if (block.type === "gallery") {
return (
<section key={block.id} className="space-y-3">
{block.title ? <h3 className="text-lg font-medium">{block.title}</h3> : null}
<div className="grid gap-3 sm:grid-cols-2">
{block.imageIds.length === 0 ? (
<p className="text-sm text-neutral-500">No media linked yet.</p>
) : (
block.imageIds.map((imageId) => (
<Image
key={imageId}
src={`/api/media/file/${imageId}`}
alt=""
width={1200}
height={800}
className="h-48 w-full rounded border border-neutral-200 object-cover"
/>
))
)}
</div>
</section>
)
}
if (block.type === "cta") {
return (
<a
key={block.id}
href={block.href}
className={`inline-flex rounded px-3 py-2 text-sm ${
block.variant === "secondary"
? "border border-neutral-300 text-neutral-800"
: "bg-neutral-900 text-white"
}`}
>
{block.label}
</a>
)
}
if (block.type === "form") {
return (
<section key={block.id} className="space-y-2 rounded border border-neutral-200 p-4">
<h3 className="text-lg font-medium">{block.title || "Form block"}</h3>
<p className="text-sm text-neutral-600">
{block.description || "Form integration pending."}
</p>
<p className="text-xs text-neutral-500">formKey: {block.formKey}</p>
</section>
)
}
return (
<section key={block.id} className="space-y-2 rounded border border-neutral-200 p-4">
{block.title ? <h3 className="text-lg font-medium">{block.title}</h3> : null}
<div className="grid gap-3 md:grid-cols-2">
{block.cards.map((card) => (
<article key={card.id} className="rounded border border-neutral-200 p-3">
<h4 className="font-medium">{card.name}</h4>
{card.price ? <p className="text-sm text-neutral-700">{card.price}</p> : null}
{card.description ? (
<p className="text-sm text-neutral-600">{card.description}</p>
) : null}
</article>
))}
</div>
</section>
)
})}
</section>
</article>
)

View File

@@ -1,11 +1,39 @@
import { listPublicNavigation } from "@cms/db"
import { getLocale, getTranslations } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { LanguageSwitcher } from "./language-switcher"
export async function PublicSiteHeader() {
const navItems = await listPublicNavigation("header")
const locale = await getLocale()
const [navItems, t] = await Promise.all([
listPublicNavigation("header", locale),
getTranslations("Layout.nav"),
])
const fallbackNavItems = [
{
id: "fallback-home",
href: "/",
label: t("home"),
},
{
id: "fallback-portfolio",
href: "/portfolio",
label: t("portfolio"),
},
{
id: "fallback-news",
href: "/news",
label: t("news"),
},
{
id: "fallback-commissions",
href: "/commissions",
label: t("commissions"),
},
]
const resolvedNavItems = navItems.length > 0 ? navItems : fallbackNavItems
return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
@@ -18,24 +46,15 @@ export async function PublicSiteHeader() {
</Link>
<nav className="flex flex-wrap items-center gap-2">
{navItems.length === 0 ? (
{resolvedNavItems.map((item) => (
<Link
href="/"
key={item.id}
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
Home
{item.label}
</Link>
) : (
navItems.map((item) => (
<Link
key={item.id}
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
{item.label}
</Link>
))
)}
))}
</nav>
<LanguageSwitcher />

View File

@@ -0,0 +1,114 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"
export type MediaStorageProvider = "local" | "s3"
type S3Config = {
bucket: string
region: string
endpoint?: string
accessKeyId: string
secretAccessKey: string
forcePathStyle?: boolean
}
function parseBoolean(value: string | undefined): boolean {
return value?.toLowerCase() === "true"
}
export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider {
if (raw?.toLowerCase() === "local") {
return "local"
}
return "s3"
}
function resolveS3Config(): S3Config {
const bucket = process.env.CMS_MEDIA_S3_BUCKET?.trim()
const region = process.env.CMS_MEDIA_S3_REGION?.trim()
const accessKeyId = process.env.CMS_MEDIA_S3_ACCESS_KEY_ID?.trim()
const secretAccessKey = process.env.CMS_MEDIA_S3_SECRET_ACCESS_KEY?.trim()
const endpoint = process.env.CMS_MEDIA_S3_ENDPOINT?.trim() || undefined
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
throw new Error(
"S3 storage selected but required env vars are missing: CMS_MEDIA_S3_BUCKET, CMS_MEDIA_S3_REGION, CMS_MEDIA_S3_ACCESS_KEY_ID, CMS_MEDIA_S3_SECRET_ACCESS_KEY",
)
}
return {
bucket,
region,
endpoint,
accessKeyId,
secretAccessKey,
forcePathStyle: parseBoolean(process.env.CMS_MEDIA_S3_FORCE_PATH_STYLE),
}
}
function createS3Client(config: S3Config): S3Client {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
})
}
function resolveLocalMediaBaseDirectory(): string {
const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
if (configured) {
return path.resolve(configured)
}
return path.resolve(process.cwd(), ".data", "media")
}
async function readFromLocalStorage(storageKey: string): Promise<Uint8Array> {
const baseDirectory = resolveLocalMediaBaseDirectory()
const outputPath = path.join(baseDirectory, storageKey)
return readFile(outputPath)
}
async function readFromS3Storage(storageKey: string): Promise<Uint8Array> {
const config = resolveS3Config()
const client = createS3Client(config)
const response = await client.send(
new GetObjectCommand({
Bucket: config.bucket,
Key: storageKey,
}),
)
if (!response.Body) {
throw new Error("S3 object body is empty")
}
return response.Body.transformToByteArray()
}
export async function readMediaStorageObject(storageKey: string): Promise<Uint8Array> {
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
const reads =
preferred === "s3"
? [() => readFromS3Storage(storageKey), () => readFromLocalStorage(storageKey)]
: [() => readFromLocalStorage(storageKey), () => readFromS3Storage(storageKey)]
for (const read of reads) {
try {
return await read()
} catch {
// Try next backend.
}
}
throw new Error("Unable to read media file from configured storage backends")
}

View File

@@ -5,7 +5,8 @@
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
"latestPosts": "Neueste Beiträge",
"explore": "Entdecken",
"noExcerpt": "Kein Auszug"
"noExcerpt": "Kein Auszug",
"requestCommission": "Auftrag anfragen"
},
"LanguageSwitcher": {
"label": "Sprache",
@@ -20,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Start",
"portfolio": "Portfolio",
"news": "News",
"commissions": "Aufträge",
"about": "Über uns",
"contact": "Kontakt"
},
@@ -41,5 +45,49 @@
"badge": "Kontakt",
"title": "Kontakt",
"description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt."
},
"CommissionRequest": {
"badge": "Aufträge",
"title": "Auftragsanfrage",
"description": "Teile deine Idee und Projektdetails. Wir prüfen die Anfrage und melden uns zeitnah.",
"success": "Deine Auftragsanfrage wurde übermittelt.",
"error": "Übermittlung fehlgeschlagen. Bitte prüfe die Eingaben und versuche es erneut.",
"budgetRangeError": "Das maximale Budget muss größer oder gleich dem minimalen Budget sein.",
"submit": "Anfrage senden",
"fields": {
"customerName": "Name",
"customerEmail": "E-Mail",
"customerPhone": "Telefon",
"customerInstagram": "Instagram",
"title": "Projekttitel",
"description": "Projektdetails",
"budgetMin": "Budget min.",
"budgetMax": "Budget max."
}
},
"Portfolio": {
"badge": "Portfolio",
"title": "Kunstwerk-Portfolio",
"description": "Durchsuche veröffentlichte Kunstwerke aus Galerien, Alben und Kategorien.",
"empty": "Keine Kunstwerke für diesen Filter gefunden.",
"noPreview": "Keine Vorschau verfügbar",
"noDescription": "Keine Beschreibung",
"viewArtwork": "Kunstwerk ansehen",
"filters": {
"clear": "Filter zurücksetzen",
"gallery": "Galerie",
"album": "Album",
"category": "Kategorie"
},
"fields": {
"medium": "Medium",
"dimensions": "Abmessungen",
"year": "Jahr",
"availability": "Verfügbarkeit",
"galleries": "Galerien",
"albums": "Alben",
"categories": "Kategorien",
"tags": "Tags"
}
}
}

View File

@@ -5,7 +5,8 @@
"description": "This page reads posts through the shared database package.",
"latestPosts": "Latest posts",
"explore": "Explore",
"noExcerpt": "No excerpt"
"noExcerpt": "No excerpt",
"requestCommission": "Request commission"
},
"LanguageSwitcher": {
"label": "Language",
@@ -20,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Home",
"portfolio": "Portfolio",
"news": "News",
"commissions": "Commissions",
"about": "About",
"contact": "Contact"
},
@@ -41,5 +45,49 @@
"badge": "Contact",
"title": "Contact",
"description": "Contact and commission flows will be introduced in upcoming MVP steps."
},
"CommissionRequest": {
"badge": "Commissions",
"title": "Commission request",
"description": "Share your idea and project details. We will review and reply as soon as possible.",
"success": "Your commission request was submitted.",
"error": "Submission failed. Please review your data and try again.",
"budgetRangeError": "Budget max must be greater than or equal to budget min.",
"submit": "Submit request",
"fields": {
"customerName": "Name",
"customerEmail": "Email",
"customerPhone": "Phone",
"customerInstagram": "Instagram",
"title": "Project title",
"description": "Project details",
"budgetMin": "Budget min",
"budgetMax": "Budget max"
}
},
"Portfolio": {
"badge": "Portfolio",
"title": "Artwork portfolio",
"description": "Browse published artworks from galleries, albums, and categories.",
"empty": "No artworks found for this filter.",
"noPreview": "No preview available",
"noDescription": "No description",
"viewArtwork": "View artwork",
"filters": {
"clear": "Clear filters",
"gallery": "Gallery",
"album": "Album",
"category": "Category"
},
"fields": {
"medium": "Medium",
"dimensions": "Dimensions",
"year": "Year",
"availability": "Availability",
"galleries": "Galleries",
"albums": "Albums",
"categories": "Categories",
"tags": "Tags"
}
}
}

View File

@@ -5,7 +5,8 @@
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
"latestPosts": "Últimas publicaciones",
"explore": "Explorar",
"noExcerpt": "Sin extracto"
"noExcerpt": "Sin extracto",
"requestCommission": "Solicitar comisión"
},
"LanguageSwitcher": {
"label": "Idioma",
@@ -20,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Inicio",
"portfolio": "Portafolio",
"news": "Noticias",
"commissions": "Comisiones",
"about": "Acerca de",
"contact": "Contacto"
},
@@ -41,5 +45,49 @@
"badge": "Contacto",
"title": "Contacto",
"description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP."
},
"CommissionRequest": {
"badge": "Comisiones",
"title": "Solicitud de comisión",
"description": "Comparte tu idea y detalles del proyecto. Revisaremos la solicitud y responderemos pronto.",
"success": "Tu solicitud de comisión fue enviada.",
"error": "No se pudo enviar la solicitud. Revisa los datos e inténtalo de nuevo.",
"budgetRangeError": "El presupuesto máximo debe ser mayor o igual al mínimo.",
"submit": "Enviar solicitud",
"fields": {
"customerName": "Nombre",
"customerEmail": "Correo electrónico",
"customerPhone": "Teléfono",
"customerInstagram": "Instagram",
"title": "Título del proyecto",
"description": "Detalles del proyecto",
"budgetMin": "Presupuesto mínimo",
"budgetMax": "Presupuesto máximo"
}
},
"Portfolio": {
"badge": "Portafolio",
"title": "Portafolio de obras",
"description": "Explora obras publicadas de galerías, álbumes y categorías.",
"empty": "No se encontraron obras para este filtro.",
"noPreview": "Sin vista previa",
"noDescription": "Sin descripción",
"viewArtwork": "Ver obra",
"filters": {
"clear": "Limpiar filtros",
"gallery": "Galería",
"album": "Álbum",
"category": "Categoría"
},
"fields": {
"medium": "Técnica",
"dimensions": "Dimensiones",
"year": "Año",
"availability": "Disponibilidad",
"galleries": "Galerías",
"albums": "Álbumes",
"categories": "Categorías",
"tags": "Etiquetas"
}
}
}

View File

@@ -5,7 +5,8 @@
"description": "Cette page lit les publications via le package base de données partagé.",
"latestPosts": "Dernières publications",
"explore": "Explorer",
"noExcerpt": "Aucun extrait"
"noExcerpt": "Aucun extrait",
"requestCommission": "Demander une commission"
},
"LanguageSwitcher": {
"label": "Langue",
@@ -20,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Accueil",
"portfolio": "Portfolio",
"news": "Actualités",
"commissions": "Commissions",
"about": "À propos",
"contact": "Contact"
},
@@ -41,5 +45,49 @@
"badge": "Contact",
"title": "Contact",
"description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP."
},
"CommissionRequest": {
"badge": "Commissions",
"title": "Demande de commission",
"description": "Partagez votre idée et les détails du projet. Nous examinerons la demande et répondrons rapidement.",
"success": "Votre demande de commission a été envoyée.",
"error": "Échec de l'envoi. Vérifiez les données et réessayez.",
"budgetRangeError": "Le budget max doit être supérieur ou égal au budget min.",
"submit": "Envoyer la demande",
"fields": {
"customerName": "Nom",
"customerEmail": "E-mail",
"customerPhone": "Téléphone",
"customerInstagram": "Instagram",
"title": "Titre du projet",
"description": "Détails du projet",
"budgetMin": "Budget min",
"budgetMax": "Budget max"
}
},
"Portfolio": {
"badge": "Portfolio",
"title": "Portfolio d'oeuvres",
"description": "Parcourez les oeuvres publiées par galeries, albums et catégories.",
"empty": "Aucune oeuvre trouvée pour ce filtre.",
"noPreview": "Aperçu indisponible",
"noDescription": "Aucune description",
"viewArtwork": "Voir l'oeuvre",
"filters": {
"clear": "Réinitialiser les filtres",
"gallery": "Galerie",
"album": "Album",
"category": "Catégorie"
},
"fields": {
"medium": "Médium",
"dimensions": "Dimensions",
"year": "Année",
"availability": "Disponibilité",
"galleries": "Galeries",
"albums": "Albums",
"categories": "Catégories",
"tags": "Tags"
}
}
}

View File

@@ -28,7 +28,7 @@
"name": "@cms/admin",
"version": "0.0.1",
"dependencies": {
"@aws-sdk/client-s3": "^3.988.0",
"@aws-sdk/client-s3": "3.988.0",
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",
@@ -58,6 +58,7 @@
"name": "@cms/web",
"version": "0.0.1",
"dependencies": {
"@aws-sdk/client-s3": "3.988.0",
"@cms/content": "workspace:*",
"@cms/db": "workspace:*",
"@cms/i18n": "workspace:*",

View File

@@ -59,3 +59,29 @@ Key files:
Key files:
- `apps/admin/src/app/commissions/page.tsx`
- `packages/db/src/commissions.ts`
## 6. Public Commission Request Submission
1. Visitor opens `/{locale}/commissions` and submits request form.
2. Server action validates input through shared schema.
3. Existing customer is reused by email (and marked recurring) or a new customer is created.
4. A new commission is created in `new` status and linked to the resolved customer.
Key files:
- `apps/web/src/app/[locale]/commissions/page.tsx`
- `packages/content/src/commissions.ts`
- `packages/db/src/commissions.ts`
## 7. Public Portfolio Rendering
1. Visitor opens `/{locale}/portfolio` with optional group filter query.
2. Public app loads published portfolio groups and filtered published artworks.
3. Artwork cards render preferred rendition preview (`card` > `thumbnail` > `full`).
4. Image bytes are streamed through web media endpoint using configured storage provider fallback.
Key files:
- `apps/web/src/app/[locale]/portfolio/page.tsx`
- `apps/web/src/app/[locale]/portfolio/[slug]/page.tsx`
- `apps/web/src/app/api/media/file/[id]/route.ts`
- `apps/web/src/lib/media/storage-read.ts`
- `packages/db/src/media-foundation.ts`

View File

@@ -0,0 +1,84 @@
import { expect, test } from "@playwright/test"
const E2E_ADMIN_EMAIL = process.env.CMS_E2E_ADMIN_EMAIL ?? "e2e-admin@cms.local"
const E2E_ADMIN_PASSWORD = process.env.CMS_E2E_ADMIN_PASSWORD ?? "e2e-admin-password"
async function ensureE2EAdminSession(page: import("@playwright/test").Page) {
const commissionsHeading = page.getByRole("heading", { name: /commissions/i })
await page.goto("http://127.0.0.1:3001/commissions")
if (await commissionsHeading.isVisible({ timeout: 2000 }).catch(() => false)) {
return
}
await page.goto("http://127.0.0.1:3001/login")
await page.locator("#email").fill(E2E_ADMIN_EMAIL)
await page.locator("#password").fill(E2E_ADMIN_PASSWORD)
await page.getByRole("button", { name: /sign in/i }).click()
await page.goto("http://127.0.0.1:3001/commissions")
await expect(commissionsHeading).toBeVisible()
}
test.describe("public rendering integration", () => {
test("header exposes portfolio/news/commissions navigation", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "web-chromium")
await page.goto("/")
const header = page.locator("header").first()
await expect(header.getByRole("link", { name: /portfolio/i })).toBeVisible()
await expect(header.getByRole("link", { name: /news|actualités|noticias/i })).toBeVisible()
await expect(header.getByRole("link", { name: /commissions|auftrag/i })).toBeVisible()
})
test("portfolio routes render list and seeded artwork detail", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "web-chromium")
await page.goto("/portfolio")
await expect(page.getByRole("heading", { name: /portfolio|portafolio/i })).toBeVisible()
await page.goto("/portfolio/seed-artwork-welcome")
await expect(page.getByRole("heading", { name: /seed artwork/i })).toBeVisible()
})
test("commission form rejects invalid budget ranges", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "web-chromium")
await page.goto("http://127.0.0.1:3000/commissions")
await page.locator('input[name="customerName"]').fill("E2E Client")
await page.locator('input[name="customerEmail"]').fill(`e2e-${Date.now()}@example.com`)
await page.locator('input[name="title"]').fill("E2E Budget Validation")
await page.locator('input[name="budgetMin"]').fill("1000")
await page.locator('input[name="budgetMax"]').fill("500")
await page.getByRole("button", { name: /submit|senden|envoyer/i }).click()
await expect(page).toHaveURL(/\/commissions\?error=budget_range_invalid/)
})
test("public commission submission is visible in admin board", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "admin-chromium")
const customerName = `E2E Public Customer ${Date.now()}`
const commissionTitle = `E2E Public Commission ${Date.now()}`
const customerEmail = `public-commission-${Date.now()}@example.com`
await page.goto("http://127.0.0.1:3000/commissions")
await page.locator('input[name="customerName"]').fill(customerName)
await page.locator('input[name="customerEmail"]').fill(customerEmail)
await page.locator('input[name="title"]').fill(commissionTitle)
await page
.locator('textarea[name="description"]')
.fill("E2E public request -> admin visibility")
await page.locator('input[name="budgetMin"]').fill("250")
await page.locator('input[name="budgetMax"]').fill("500")
await page.getByRole("button", { name: /submit|senden|envoyer/i }).click()
await expect(page).toHaveURL(/\/commissions\?notice=submitted/)
await expect(page.getByText(/submitted|übermittelt|enviada|envoyée/i)).toBeVisible()
await ensureE2EAdminSession(page)
await page.goto("http://127.0.0.1:3001/commissions")
await expect(page.getByText(new RegExp(commissionTitle, "i"))).toBeVisible()
await expect(page.getByText(new RegExp(customerName, "i"))).toBeVisible()
})
})

View File

@@ -15,13 +15,13 @@
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --port 4173",
"build": "turbo build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e:prepare": "bun run db:generate && bun run db:migrate:deploy && bun run db:seed",
"test:e2e": "bun run test:e2e:prepare && playwright test",
"test:e2e:headed": "bun run test:e2e:prepare && playwright test --headed",
"test:e2e:ui": "bun run test:e2e:prepare && playwright test --ui",
"test": "echo \"Testing is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"test:watch": "echo \"Testing is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"test:coverage": "echo \"Testing is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"test:e2e:prepare": "echo \"E2E preparation is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"test:e2e": "echo \"E2E testing is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"test:e2e:headed": "echo \"E2E testing is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"test:e2e:ui": "echo \"E2E testing is temporarily disabled. See TODO.md MVP 3: Testing and Quality.\"",
"commitlint": "commitlint --last",
"changelog:preview": "conventional-changelog -p conventionalcommits -r 0 -u",
"changelog:release": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -u",

View File

@@ -29,6 +29,26 @@ export const createCommissionInputSchema = z.object({
dueAt: z.date().nullable().optional(),
})
export const createPublicCommissionRequestInputSchema = z
.object({
customerName: z.string().min(1).max(180),
customerEmail: z.string().email().max(320),
customerPhone: z.string().max(80).nullable().optional(),
customerInstagram: z.string().max(120).nullable().optional(),
title: z.string().min(1).max(180),
description: z.string().max(4000).nullable().optional(),
budgetMin: z.number().nonnegative().nullable().optional(),
budgetMax: z.number().nonnegative().nullable().optional(),
})
.refine(
(value) =>
value.budgetMin == null || value.budgetMax == null || value.budgetMax >= value.budgetMin,
{
message: "budgetMax must be greater than or equal to budgetMin.",
path: ["budgetMax"],
},
)
export const updateCommissionStatusInputSchema = z.object({
id: z.string().uuid(),
status: commissionStatusSchema,
@@ -37,4 +57,7 @@ export const updateCommissionStatusInputSchema = z.object({
export type CommissionStatus = z.infer<typeof commissionStatusSchema>
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>
export type CreateCommissionInput = z.infer<typeof createCommissionInputSchema>
export type CreatePublicCommissionRequestInput = z.infer<
typeof createPublicCommissionRequestInputSchema
>
export type UpdateCommissionStatusInput = z.infer<typeof updateCommissionStatusInputSchema>

View File

@@ -6,6 +6,8 @@ import {
createCustomerInputSchema,
createNavigationMenuInputSchema,
createPageInputSchema,
parsePageBlocks,
serializePageBlocks,
updateCommissionStatusInputSchema,
updateNavigationItemInputSchema,
} from "./index"
@@ -64,4 +66,23 @@ describe("domain schemas", () => {
expect(menu.success).toBe(true)
expect(navUpdate.success).toBe(false)
})
it("parses and serializes page blocks with legacy fallback", () => {
const legacy = parsePageBlocks("Legacy body")
expect(legacy[0]?.type).toBe("rich_text")
const serialized = serializePageBlocks([
{
id: "hero-1",
type: "hero",
heading: "Hello",
subheading: null,
ctaLabel: null,
ctaHref: null,
},
])
const parsed = parsePageBlocks(serialized)
expect(parsed[0]?.type).toBe("hero")
})
})

View File

@@ -1,6 +1,99 @@
import { z } from "zod"
export const pageStatusSchema = z.enum(["draft", "published"])
export const pageLocaleSchema = z.enum(["de", "en", "es", "fr"])
const pageBlockBaseSchema = z.object({
id: z.string().min(1),
})
export const heroPageBlockSchema = pageBlockBaseSchema.extend({
type: z.literal("hero"),
heading: z.string().min(1).max(180),
subheading: z.string().max(500).nullable().optional(),
ctaLabel: z.string().max(80).nullable().optional(),
ctaHref: z.string().max(500).nullable().optional(),
})
export const richTextPageBlockSchema = pageBlockBaseSchema.extend({
type: z.literal("rich_text"),
body: z.string().min(1),
})
export const galleryPageBlockSchema = pageBlockBaseSchema.extend({
type: z.literal("gallery"),
title: z.string().max(180).nullable().optional(),
imageIds: z.array(z.string().uuid()).default([]),
})
export const ctaPageBlockSchema = pageBlockBaseSchema.extend({
type: z.literal("cta"),
label: z.string().min(1).max(80),
href: z.string().max(500),
variant: z.enum(["primary", "secondary"]).default("primary"),
})
export const formPageBlockSchema = pageBlockBaseSchema.extend({
type: z.literal("form"),
formKey: z.string().min(1).max(120),
title: z.string().max(180).nullable().optional(),
description: z.string().max(500).nullable().optional(),
})
export const priceCardsPageBlockSchema = pageBlockBaseSchema.extend({
type: z.literal("price_cards"),
title: z.string().max(180).nullable().optional(),
cards: z
.array(
z.object({
id: z.string().min(1),
name: z.string().min(1).max(180),
price: z.string().max(80).nullable().optional(),
description: z.string().max(500).nullable().optional(),
}),
)
.default([]),
})
export const pageBlockSchema = z.discriminatedUnion("type", [
heroPageBlockSchema,
richTextPageBlockSchema,
galleryPageBlockSchema,
ctaPageBlockSchema,
formPageBlockSchema,
priceCardsPageBlockSchema,
])
export const pageBlocksSchema = z.array(pageBlockSchema)
function isJsonLike(value: string): boolean {
const trimmed = value.trim()
return trimmed.startsWith("{") || trimmed.startsWith("[")
}
export function parsePageBlocks(content: string): z.infer<typeof pageBlocksSchema> {
if (!isJsonLike(content)) {
return [
{
id: "legacy-rich-text",
type: "rich_text",
body: content,
},
]
}
const parsed = JSON.parse(content)
if (!Array.isArray(parsed)) {
throw new Error("Page block payload must be an array.")
}
return pageBlocksSchema.parse(parsed)
}
export function serializePageBlocks(blocks: z.infer<typeof pageBlocksSchema>): string {
return JSON.stringify(pageBlocksSchema.parse(blocks))
}
export const createPageInputSchema = z.object({
title: z.string().min(1).max(180),
@@ -23,6 +116,16 @@ export const updatePageInputSchema = z.object({
seoDescription: z.string().max(320).nullable().optional(),
})
export const upsertPageTranslationInputSchema = z.object({
pageId: z.string().uuid(),
locale: pageLocaleSchema,
title: z.string().min(1).max(180),
summary: z.string().max(500).nullable().optional(),
content: z.string().min(1),
seoTitle: z.string().max(180).nullable().optional(),
seoDescription: z.string().max(320).nullable().optional(),
})
export const createNavigationMenuInputSchema = z.object({
name: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
@@ -52,6 +155,9 @@ export const updateNavigationItemInputSchema = z.object({
export type CreatePageInput = z.infer<typeof createPageInputSchema>
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
export type UpsertPageTranslationInput = z.infer<typeof upsertPageTranslationInputSchema>
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema>
export type PageBlock = z.infer<typeof pageBlockSchema>
export type PageBlocks = z.infer<typeof pageBlocksSchema>

View File

@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "PageTranslation" (
"id" TEXT NOT NULL,
"pageId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"seoTitle" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PageTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PageTranslation_locale_idx" ON "PageTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PageTranslation_pageId_locale_key" ON "PageTranslation"("pageId", "locale");
-- AddForeignKey
ALTER TABLE "PageTranslation" ADD CONSTRAINT "PageTranslation_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "PostTranslation" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PostTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NavigationItemTranslation" (
"id" TEXT NOT NULL,
"navigationItemId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"label" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NavigationItemTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PostTranslation_locale_idx" ON "PostTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PostTranslation_postId_locale_key" ON "PostTranslation"("postId", "locale");
-- CreateIndex
CREATE INDEX "NavigationItemTranslation_locale_idx" ON "NavigationItemTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "NavigationItemTranslation_navigationItemId_locale_key" ON "NavigationItemTranslation"("navigationItemId", "locale");
-- AddForeignKey
ALTER TABLE "PostTranslation" ADD CONSTRAINT "PostTranslation_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NavigationItemTranslation" ADD CONSTRAINT "NavigationItemTranslation_navigationItemId_fkey" FOREIGN KEY ("navigationItemId") REFERENCES "NavigationItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -16,6 +16,22 @@ model Post {
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
translations PostTranslation[]
}
model PostTranslation {
id String @id @default(uuid())
postId String
locale String
title String
excerpt String?
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, locale])
@@index([locale])
}
model User {
@@ -267,10 +283,28 @@ model Page {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navItems NavigationItem[]
translations PageTranslation[]
@@index([status])
}
model PageTranslation {
id String @id @default(uuid())
pageId String
locale String
title String
summary String?
content String
seoTitle String?
seoDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@unique([pageId, locale])
@@index([locale])
}
model NavigationMenu {
id String @id @default(uuid())
name String
@@ -297,6 +331,7 @@ model NavigationItem {
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
children NavigationItem[] @relation("NavigationItemParent")
translations NavigationItemTranslation[]
@@index([menuId])
@@index([pageId])
@@ -304,6 +339,19 @@ model NavigationItem {
@@unique([menuId, parentId, sortOrder, label])
}
model NavigationItemTranslation {
id String @id @default(uuid())
navigationItemId String
locale String
label String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navigationItem NavigationItem @relation(fields: [navigationItemId], references: [id], onDelete: Cascade)
@@unique([navigationItemId, locale])
@@index([locale])
}
model Customer {
id String @id @default(uuid())
name String

View File

@@ -159,6 +159,67 @@ async function main() {
})
}
const defaultHeaderItems = [
{
label: "Portfolio",
href: "/portfolio",
sortOrder: 1,
pageId: null,
},
{
label: "News",
href: "/news",
sortOrder: 2,
pageId: null,
},
{
label: "Commissions",
href: "/commissions",
sortOrder: 3,
pageId: null,
},
] as const
for (const item of defaultHeaderItems) {
const existingItem = await db.navigationItem.findFirst({
where: {
menuId: primaryMenu.id,
parentId: null,
href: item.href,
},
select: {
id: true,
},
})
if (existingItem) {
await db.navigationItem.update({
where: {
id: existingItem.id,
},
data: {
label: item.label,
sortOrder: item.sortOrder,
isVisible: true,
pageId: item.pageId,
},
})
continue
}
await db.navigationItem.create({
data: {
menuId: primaryMenu.id,
label: item.label,
href: item.href,
pageId: item.pageId,
parentId: null,
sortOrder: item.sortOrder,
isVisible: true,
},
})
}
const existingCustomer = await db.customer.findFirst({
where: {
email: "collector@example.com",

View File

@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
$transaction: vi.fn(),
customer: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
commission: {
create: vi.fn(),
@@ -18,17 +21,29 @@ vi.mock("./client", () => ({
db: mockDb,
}))
import { createCommission, createCustomer, updateCommissionStatus } from "./commissions"
import {
createCommission,
createCustomer,
createPublicCommissionRequest,
updateCommissionStatus,
} from "./commissions"
describe("commissions service", () => {
beforeEach(() => {
for (const value of Object.values(mockDb)) {
if (typeof value === "function") {
value.mockReset()
continue
}
for (const fn of Object.values(value)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
}
mockDb.$transaction.mockImplementation(async (callback) => callback(mockDb))
})
it("creates customer and commission payloads", async () => {
@@ -51,6 +66,37 @@ describe("commissions service", () => {
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
})
it("creates a public commission request with customer upsert behavior", async () => {
mockDb.customer.findFirst.mockResolvedValue({
id: "customer-existing",
phone: null,
instagram: null,
})
mockDb.customer.update.mockResolvedValue({
id: "customer-existing",
})
mockDb.commission.create.mockResolvedValue({
id: "commission-2",
})
await createPublicCommissionRequest({
customerName: "Grace Hopper",
customerEmail: "GRACE@EXAMPLE.COM",
customerPhone: "12345",
title: "Landscape commission",
description: "Oil painting request",
budgetMin: 500,
budgetMax: 900,
})
expect(mockDb.customer.findFirst).toHaveBeenCalledWith({
where: { email: "grace@example.com" },
orderBy: { updatedAt: "desc" },
})
expect(mockDb.customer.update).toHaveBeenCalledTimes(1)
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
})
it("updates commission status", async () => {
mockDb.commission.update.mockResolvedValue({ id: "commission-1", status: "done" })

View File

@@ -2,6 +2,7 @@ import {
commissionStatusSchema,
createCommissionInputSchema,
createCustomerInputSchema,
createPublicCommissionRequestInputSchema,
updateCommissionStatusInputSchema,
} from "@cms/content"
@@ -56,6 +57,63 @@ export async function createCommission(input: unknown) {
})
}
export async function createPublicCommissionRequest(input: unknown) {
const payload = createPublicCommissionRequestInputSchema.parse(input)
const normalizedEmail = payload.customerEmail.trim().toLowerCase()
return db.$transaction(async (tx) => {
const existingCustomer = await tx.customer.findFirst({
where: {
email: normalizedEmail,
},
orderBy: {
updatedAt: "desc",
},
})
const customer = existingCustomer
? await tx.customer.update({
where: { id: existingCustomer.id },
data: {
name: payload.customerName,
phone: payload.customerPhone ?? existingCustomer.phone,
instagram: payload.customerInstagram ?? existingCustomer.instagram,
isRecurring: true,
},
})
: await tx.customer.create({
data: {
name: payload.customerName,
email: normalizedEmail,
phone: payload.customerPhone,
instagram: payload.customerInstagram,
isRecurring: false,
},
})
return tx.commission.create({
data: {
title: payload.title,
description: payload.description,
status: "new",
customerId: customer.id,
budgetMin: payload.budgetMin,
budgetMax: payload.budgetMax,
},
include: {
customer: {
select: {
id: true,
name: true,
email: true,
isRecurring: true,
},
},
},
})
})
}
export async function updateCommissionStatus(input: unknown) {
const payload = updateCommissionStatusInputSchema.parse(input)

View File

@@ -11,6 +11,7 @@ export {
commissionKanbanOrder,
createCommission,
createCustomer,
createPublicCommissionRequest,
listCommissions,
listCustomers,
updateCommissionStatus,
@@ -26,10 +27,13 @@ export {
deleteMediaAsset,
getMediaAssetById,
getMediaFoundationSummary,
getPublishedArtworkBySlug,
linkArtworkToGrouping,
listArtworks,
listMediaAssets,
listMediaFoundationGroups,
listPublishedArtworks,
listPublishedPortfolioGroups,
updateMediaAsset,
} from "./media-foundation"
export type { PublicNavigationItem } from "./pages-navigation"
@@ -41,21 +45,29 @@ export {
deletePage,
getPageById,
getPublishedPageBySlug,
getPublishedPageBySlugForLocale,
listNavigationMenus,
listPages,
listPageTranslations,
listPublicNavigation,
listPublishedPageSlugs,
updateNavigationItem,
updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation"
export {
createPost,
deletePost,
getPostById,
getPostBySlug,
getPostBySlugForLocale,
listPosts,
listPostsForLocale,
listPostsWithTranslations,
registerPostCrudAuditHook,
updatePost,
upsertPostTranslation,
} from "./posts"
export type { PublicHeaderBanner, PublicHeaderBannerConfig } from "./settings"
export {

View File

@@ -8,11 +8,11 @@ const { mockDb } = vi.hoisted(() => ({
artworkTag: { upsert: vi.fn() },
artworkRendition: { upsert: vi.fn() },
mediaAsset: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), delete: vi.fn() },
artwork: { create: vi.fn() },
gallery: { create: vi.fn() },
album: { create: vi.fn() },
category: { create: vi.fn() },
tag: { create: vi.fn() },
artwork: { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn() },
gallery: { create: vi.fn(), findMany: vi.fn() },
album: { create: vi.fn(), findMany: vi.fn() },
category: { create: vi.fn(), findMany: vi.fn() },
tag: { create: vi.fn(), findMany: vi.fn() },
},
}))
@@ -26,7 +26,10 @@ import {
createMediaAsset,
deleteMediaAsset,
getMediaAssetById,
getPublishedArtworkBySlug,
linkArtworkToGrouping,
listPublishedArtworks,
listPublishedPortfolioGroups,
updateMediaAsset,
} from "./media-foundation"
@@ -42,6 +45,12 @@ describe("media foundation service", () => {
if ("findUnique" in value) {
value.findUnique.mockReset()
}
if ("findMany" in value) {
value.findMany.mockReset()
}
if ("findFirst" in value) {
value.findFirst.mockReset()
}
if ("update" in value) {
value.update.mockReset()
}
@@ -120,4 +129,58 @@ describe("media foundation service", () => {
expect(mockDb.mediaAsset.update).toHaveBeenCalledTimes(1)
expect(mockDb.mediaAsset.delete).toHaveBeenCalledTimes(1)
})
it("lists published artworks with group filters", async () => {
mockDb.artwork.findMany.mockResolvedValue([])
await listPublishedArtworks({
groupType: "gallery",
groupSlug: "showcase",
})
expect(mockDb.artwork.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isPublished: true,
galleryLinks: {
some: {
gallery: {
slug: "showcase",
isVisible: true,
},
},
},
}),
}),
)
})
it("lists published portfolio groups", async () => {
mockDb.gallery.findMany.mockResolvedValue([])
mockDb.album.findMany.mockResolvedValue([])
mockDb.category.findMany.mockResolvedValue([])
mockDb.tag.findMany.mockResolvedValue([])
await listPublishedPortfolioGroups()
expect(mockDb.gallery.findMany).toHaveBeenCalledTimes(1)
expect(mockDb.album.findMany).toHaveBeenCalledTimes(1)
expect(mockDb.category.findMany).toHaveBeenCalledTimes(1)
expect(mockDb.tag.findMany).toHaveBeenCalledTimes(1)
})
it("loads a published artwork by slug", async () => {
mockDb.artwork.findFirst.mockResolvedValue(null)
await getPublishedArtworkBySlug("artwork-slug")
expect(mockDb.artwork.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
slug: "artwork-slug",
isPublished: true,
},
}),
)
})
})

View File

@@ -9,6 +9,14 @@ import {
import { db } from "./client"
type PublicArtworkGroupType = "gallery" | "album" | "category" | "tag"
type ListPublishedArtworksInput = {
groupType?: PublicArtworkGroupType
groupSlug?: string
limit?: number
}
export async function listMediaAssets(limit = 24) {
return db.mediaAsset.findMany({
orderBy: { updatedAt: "desc" },
@@ -280,3 +288,251 @@ export async function getMediaFoundationSummary() {
tags,
}
}
export async function listPublishedPortfolioGroups() {
const [galleries, albums, categories, tags] = await Promise.all([
db.gallery.findMany({
where: {
isVisible: true,
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
slug: true,
},
}),
db.album.findMany({
where: {
isVisible: true,
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
slug: true,
},
}),
db.category.findMany({
where: {
isVisible: true,
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
slug: true,
},
}),
db.tag.findMany({
orderBy: [{ name: "asc" }],
select: {
id: true,
name: true,
slug: true,
},
}),
])
return {
galleries,
albums,
categories,
tags,
}
}
export async function listPublishedArtworks(input: ListPublishedArtworksInput = {}) {
const take = input.limit ?? 36
const where: Record<string, unknown> = {
isPublished: true,
}
if (input.groupType && input.groupSlug) {
if (input.groupType === "gallery") {
where.galleryLinks = {
some: {
gallery: {
slug: input.groupSlug,
isVisible: true,
},
},
}
} else if (input.groupType === "album") {
where.albumLinks = {
some: {
album: {
slug: input.groupSlug,
isVisible: true,
},
},
}
} else if (input.groupType === "category") {
where.categoryLinks = {
some: {
category: {
slug: input.groupSlug,
isVisible: true,
},
},
}
} else if (input.groupType === "tag") {
where.tagLinks = {
some: {
tag: {
slug: input.groupSlug,
},
},
}
}
}
return db.artwork.findMany({
where,
orderBy: [{ updatedAt: "desc" }],
take,
include: {
renditions: {
where: {
mediaAsset: {
isPublished: true,
},
},
include: {
mediaAsset: {
select: {
id: true,
title: true,
altText: true,
mimeType: true,
width: true,
height: true,
},
},
},
},
galleryLinks: {
include: {
gallery: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
albumLinks: {
include: {
album: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
categoryLinks: {
include: {
category: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
tagLinks: {
include: {
tag: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
})
}
export async function getPublishedArtworkBySlug(slug: string) {
return db.artwork.findFirst({
where: {
slug,
isPublished: true,
},
include: {
renditions: {
where: {
mediaAsset: {
isPublished: true,
},
},
include: {
mediaAsset: {
select: {
id: true,
title: true,
altText: true,
mimeType: true,
width: true,
height: true,
source: true,
author: true,
copyright: true,
tags: true,
},
},
},
},
galleryLinks: {
include: {
gallery: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
albumLinks: {
include: {
album: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
categoryLinks: {
include: {
category: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
tagLinks: {
include: {
tag: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
})
}

View File

@@ -7,6 +7,11 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
pageTranslation: {
upsert: vi.fn(),
findMany: vi.fn(),
},
navigationMenu: {
@@ -19,6 +24,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
navigationItemTranslation: {
upsert: vi.fn(),
},
},
}))
@@ -30,8 +38,11 @@ import {
createNavigationItem,
createNavigationMenu,
createPage,
getPublishedPageBySlugForLocale,
listPublicNavigation,
updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation"
describe("pages-navigation service", () => {
@@ -105,19 +116,89 @@ describe("pages-navigation service", () => {
slug: "home",
status: "published",
},
translations: [{ locale: "de", label: "Startseite" }],
},
],
})
const navigation = await listPublicNavigation("header")
const navigation = await listPublicNavigation("header", "de")
expect(navigation).toEqual([
{
id: "item-1",
label: "Home",
label: "Startseite",
href: "/",
children: [],
},
])
})
it("validates locale when upserting navigation item translation", async () => {
await expect(() =>
upsertNavigationItemTranslation({
navigationItemId: "550e8400-e29b-41d4-a716-446655440001",
locale: "it",
label: "Home",
}),
).rejects.toThrow()
})
it("validates locale when upserting page translation", async () => {
await expect(() =>
upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
content: "Contenuto",
}),
).rejects.toThrow()
})
it("upserts page translation and reads localized page with fallback", async () => {
mockDb.pageTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.page.findFirst
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [
{
locale: "de",
title: "Uber Uns",
summary: "Zusammenfassung",
content: "Inhalt",
seoTitle: "SEO DE",
seoDescription: "Beschreibung",
},
],
})
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [],
})
await upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Uber Uns",
content: "Inhalt",
})
const translated = await getPublishedPageBySlugForLocale("about", "de")
const fallback = await getPublishedPageBySlugForLocale("about", "fr")
expect(mockDb.pageTranslation.upsert).toHaveBeenCalledTimes(1)
expect(translated?.title).toBe("Uber Uns")
expect(translated?.content).toBe("Inhalt")
expect(fallback?.title).toBe("About")
expect(fallback?.content).toBe("Base content")
})
})

View File

@@ -4,7 +4,9 @@ import {
createPageInputSchema,
updateNavigationItemInputSchema,
updatePageInputSchema,
upsertPageTranslationInputSchema,
} from "@cms/content"
import { z } from "zod"
import { db } from "./client"
@@ -15,6 +17,13 @@ export type PublicNavigationItem = {
children: PublicNavigationItem[]
}
const supportedLocaleSchema = z.enum(["de", "en", "es", "fr"])
const upsertNavigationItemTranslationInputSchema = z.object({
navigationItemId: z.string().uuid(),
locale: supportedLocaleSchema,
label: z.string().min(1).max(180),
})
function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null
}
@@ -54,6 +63,38 @@ export async function getPublishedPageBySlug(slug: string) {
})
}
export async function getPublishedPageBySlugForLocale(slug: string, locale: string) {
const page = await db.page.findFirst({
where: {
slug,
status: "published",
},
include: {
translations: {
where: {
locale,
},
take: 1,
},
},
})
if (!page) {
return null
}
const translation = page.translations[0]
return {
...page,
title: translation?.title ?? page.title,
summary: translation?.summary ?? page.summary,
content: translation?.content ?? page.content,
seoTitle: translation?.seoTitle ?? page.seoTitle,
seoDescription: translation?.seoDescription ?? page.seoDescription,
}
}
export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input)
@@ -85,6 +126,33 @@ export async function deletePage(id: string) {
})
}
export async function upsertPageTranslation(input: unknown) {
const payload = upsertPageTranslationInputSchema.parse(input)
const { pageId, locale, ...data } = payload
return db.pageTranslation.upsert({
where: {
pageId_locale: {
pageId,
locale,
},
},
create: {
pageId,
locale,
...data,
},
update: data,
})
}
export async function listPageTranslations(pageId: string) {
return db.pageTranslation.findMany({
where: { pageId },
orderBy: [{ locale: "asc" }],
})
}
export async function listNavigationMenus() {
return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }],
@@ -99,6 +167,9 @@ export async function listNavigationMenus() {
slug: true,
},
},
translations: {
orderBy: [{ locale: "asc" }],
},
},
},
},
@@ -123,7 +194,12 @@ function resolveNavigationHref(item: {
return null
}
export async function listPublicNavigation(location = "header"): Promise<PublicNavigationItem[]> {
export async function listPublicNavigation(
location = "header",
locale?: string,
): Promise<PublicNavigationItem[]> {
const normalizedLocale = locale ? supportedLocaleSchema.safeParse(locale).data : undefined
const menu = await db.navigationMenu.findFirst({
where: {
location,
@@ -143,6 +219,12 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
status: true,
},
},
translations: normalizedLocale
? {
where: { locale: normalizedLocale },
take: 1,
}
: false,
},
},
},
@@ -172,7 +254,7 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
itemMap.set(item.id, {
id: item.id,
label: item.label,
label: item.translations?.[0]?.label ?? item.label,
href,
parentId: item.parentId,
children: [],
@@ -238,3 +320,20 @@ export async function deleteNavigationItem(id: string) {
where: { id },
})
}
export async function upsertNavigationItemTranslation(input: unknown) {
const payload = upsertNavigationItemTranslationInputSchema.parse(input)
return db.navigationItemTranslation.upsert({
where: {
navigationItemId_locale: {
navigationItemId: payload.navigationItemId,
locale: payload.locale,
},
},
create: payload,
update: {
label: payload.label,
},
})
}

View File

@@ -9,6 +9,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
postTranslation: {
upsert: vi.fn(),
},
},
}))
@@ -16,7 +19,15 @@ vi.mock("./client", () => ({
db: mockDb,
}))
import { createPost, getPostBySlug, listPosts, updatePost } from "./posts"
import {
createPost,
getPostBySlug,
getPostBySlugForLocale,
listPosts,
listPostsForLocale,
updatePost,
upsertPostTranslation,
} from "./posts"
describe("posts service", () => {
beforeEach(() => {
@@ -25,6 +36,7 @@ describe("posts service", () => {
fn.mockReset()
}
}
mockDb.postTranslation.upsert.mockReset()
})
it("lists posts ordered by update date desc", async () => {
@@ -72,4 +84,63 @@ describe("posts service", () => {
},
})
})
it("upserts post translation and reads localized/fallback post views", async () => {
mockDb.postTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.post.findUnique
.mockResolvedValueOnce({
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
translations: [{ locale: "de", title: "Titel", excerpt: "Auszug", body: "Text" }],
})
.mockResolvedValueOnce({
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
translations: [],
})
mockDb.post.findMany.mockResolvedValue([
{
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
status: "published",
translations: [{ locale: "de", title: "Titel", excerpt: "Auszug", body: "Text" }],
},
])
await upsertPostTranslation({
postId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Titel",
body: "Text",
})
const localized = await getPostBySlugForLocale("hello", "de")
const fallback = await getPostBySlugForLocale("hello", "fr")
const localizedList = await listPostsForLocale("de")
expect(mockDb.postTranslation.upsert).toHaveBeenCalledTimes(1)
expect(localized?.title).toBe("Titel")
expect(fallback?.title).toBe("Base title")
expect(localizedList[0]?.title).toBe("Titel")
})
it("validates locale for post translations", async () => {
await expect(() =>
upsertPostTranslation({
postId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
body: "Testo",
}),
).rejects.toThrow()
})
})

View File

@@ -5,6 +5,7 @@ import {
updatePostInputSchema,
} from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import { z } from "zod"
import type { Post } from "../prisma/generated/client/client"
import { db } from "./client"
@@ -35,6 +36,15 @@ const postRepository = {
}),
}
const supportedLocaleSchema = z.enum(["de", "en", "es", "fr"])
const upsertPostTranslationInputSchema = z.object({
postId: z.string().uuid(),
locale: supportedLocaleSchema,
title: z.string().min(3).max(180),
excerpt: z.string().max(320).nullable().optional(),
body: z.string().min(1),
})
const postAuditHooks: Array<CrudAuditHook<Post>> = []
const postCrudService = createCrudService({
@@ -73,6 +83,100 @@ export async function getPostBySlug(slug: string) {
})
}
export async function getPostBySlugForLocale(slug: string, locale: string) {
const normalizedLocale = supportedLocaleSchema.safeParse(locale).data
const post = await db.post.findUnique({
where: { slug },
include: {
translations: normalizedLocale
? {
where: {
locale: normalizedLocale,
},
take: 1,
}
: false,
},
})
if (!post) {
return null
}
const translation = post.translations?.[0]
return {
...post,
title: translation?.title ?? post.title,
excerpt: translation?.excerpt ?? post.excerpt,
body: translation?.body ?? post.body,
}
}
export async function listPostsForLocale(locale: string) {
const normalizedLocale = supportedLocaleSchema.safeParse(locale).data
const posts = await db.post.findMany({
where: {
status: "published",
},
orderBy: {
updatedAt: "desc",
},
include: {
translations: normalizedLocale
? {
where: { locale: normalizedLocale },
take: 1,
}
: false,
},
})
return posts.map((post) => {
const translation = post.translations?.[0]
return {
...post,
title: translation?.title ?? post.title,
excerpt: translation?.excerpt ?? post.excerpt,
body: translation?.body ?? post.body,
}
})
}
export async function listPostsWithTranslations() {
return db.post.findMany({
orderBy: {
updatedAt: "desc",
},
include: {
translations: {
orderBy: [{ locale: "asc" }],
},
},
})
}
export async function upsertPostTranslation(input: unknown) {
const payload = upsertPostTranslationInputSchema.parse(input)
const { postId, locale, ...data } = payload
return db.postTranslation.upsert({
where: {
postId_locale: {
postId,
locale,
},
},
create: {
postId,
locale,
...data,
},
update: data,
})
}
export async function createPost(input: unknown, context?: CrudMutationContext) {
return postCrudService.create(input, context)
}