Compare commits
2 Commits
todo/mvp0-
...
todo/mvp0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
07e5f53793
|
|||
|
de26cb7647
|
19
TODO.md
19
TODO.md
@@ -21,9 +21,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
|
||||
- [x] [P1] RBAC enforcement at route and action level in admin
|
||||
- [x] [P1] Permission matrix documented and tested
|
||||
- [ ] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
|
||||
- [ ] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
|
||||
- [ ] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
||||
- [~] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
|
||||
- [~] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
|
||||
- [~] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
||||
- [x] [P1] Integrate Better Auth core configuration and session wiring
|
||||
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
|
||||
- [x] [P1] Enforce invariant: exactly one owner user must always exist
|
||||
@@ -32,9 +32,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
||||
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
||||
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
||||
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||
- [ ] [P1] Shared error and audit hooks for CRUD mutations
|
||||
- [~] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||
- [~] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||
- [~] [P1] Shared error and audit hooks for CRUD mutations
|
||||
|
||||
### Admin App
|
||||
|
||||
@@ -44,6 +44,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
||||
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
||||
- [x] [P1] Protected admin routes and session handling
|
||||
- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
|
||||
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
|
||||
|
||||
### Public App
|
||||
@@ -73,7 +74,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [x] [P1] Docs tool baseline added (`docs/` via VitePress)
|
||||
- [x] [P1] RBAC and permission model documentation in docs site
|
||||
- [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
|
||||
- [ ] [P1] CRUD base patterns documentation and examples
|
||||
- [~] [P1] CRUD base patterns documentation and examples
|
||||
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
|
||||
- [ ] [P2] API and domain glossary pages
|
||||
- [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs
|
||||
@@ -192,6 +193,10 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
|
||||
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
|
||||
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
|
||||
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending.
|
||||
- [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later.
|
||||
- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far.
|
||||
- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI.
|
||||
|
||||
## How We Use This File
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import { listPosts } from "@cms/db"
|
||||
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
@@ -9,7 +10,131 @@ import { LogoutButton } from "./logout-button"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminHomePage() {
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readRequiredField(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function readOptionalField(formData: FormData, field: string): string | undefined {
|
||||
const value = readRequiredField(formData, field)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function requireNewsWritePermission() {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (!role || !hasPermission(role, "news:write", "team")) {
|
||||
redirect("/unauthorized?required=news:write&scope=team")
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithState(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 value = query.toString()
|
||||
redirect(value ? `/?${value}` : "/")
|
||||
}
|
||||
|
||||
async function createPostAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireNewsWritePermission()
|
||||
|
||||
const status = readRequiredField(formData, "status")
|
||||
|
||||
try {
|
||||
await createPost({
|
||||
title: readRequiredField(formData, "title"),
|
||||
slug: readRequiredField(formData, "slug"),
|
||||
excerpt: readOptionalField(formData, "excerpt"),
|
||||
body: readRequiredField(formData, "body"),
|
||||
status: status === "published" ? "published" : "draft",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Create failed. Please check your input." })
|
||||
}
|
||||
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post created." })
|
||||
}
|
||||
|
||||
async function updatePostAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireNewsWritePermission()
|
||||
|
||||
const id = readRequiredField(formData, "id")
|
||||
const status = readRequiredField(formData, "status")
|
||||
|
||||
if (!id) {
|
||||
redirectWithState({ error: "Update failed. Missing post id." })
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePost(id, {
|
||||
title: readRequiredField(formData, "title"),
|
||||
slug: readRequiredField(formData, "slug"),
|
||||
excerpt: readOptionalField(formData, "excerpt"),
|
||||
body: readRequiredField(formData, "body"),
|
||||
status: status === "published" ? "published" : "draft",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Update failed. Please check your input." })
|
||||
}
|
||||
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post updated." })
|
||||
}
|
||||
|
||||
async function deletePostAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireNewsWritePermission()
|
||||
|
||||
const id = readRequiredField(formData, "id")
|
||||
|
||||
if (!id) {
|
||||
redirectWithState({ error: "Delete failed. Missing post id." })
|
||||
}
|
||||
|
||||
try {
|
||||
await deletePost(id)
|
||||
} catch {
|
||||
redirectWithState({ error: "Delete failed." })
|
||||
}
|
||||
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post deleted." })
|
||||
}
|
||||
|
||||
export default async function AdminHomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (!role) {
|
||||
@@ -20,6 +145,9 @@ export default async function AdminHomePage() {
|
||||
redirect("/unauthorized?required=news:read&scope=team")
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const canCreatePost = hasPermission(role, "news:write", "team")
|
||||
const posts = await listPosts()
|
||||
|
||||
@@ -40,15 +168,158 @@ export default async function AdminHomePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Posts</h2>
|
||||
<Button disabled={!canCreatePost}>Create post</Button>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Posts CRUD Sandbox</h2>
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">MVP0 functional test</p>
|
||||
</div>
|
||||
|
||||
{canCreatePost ? (
|
||||
<form
|
||||
action={createPostAction}
|
||||
className="space-y-3 rounded-lg border border-neutral-200 p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Create post</h3>
|
||||
<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"
|
||||
required
|
||||
minLength={3}
|
||||
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
|
||||
minLength={3}
|
||||
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">Excerpt</span>
|
||||
<input
|
||||
name="excerpt"
|
||||
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">Body</span>
|
||||
<textarea
|
||||
name="body"
|
||||
required
|
||||
minLength={1}
|
||||
rows={4}
|
||||
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>
|
||||
<Button type="submit">Create post</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
You can read posts, but your role cannot create/update/delete posts.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{posts.map((post) => (
|
||||
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
||||
{canCreatePost ? (
|
||||
<>
|
||||
<form action={updatePostAction} className="space-y-3">
|
||||
<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"
|
||||
required
|
||||
minLength={3}
|
||||
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"
|
||||
required
|
||||
minLength={3}
|
||||
defaultValue={post.slug}
|
||||
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">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="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Body</span>
|
||||
<textarea
|
||||
name="body"
|
||||
required
|
||||
minLength={1}
|
||||
rows={4}
|
||||
defaultValue={post.body}
|
||||
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={post.status}
|
||||
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>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</form>
|
||||
<form action={deletePostAction} className="mt-3">
|
||||
<input type="hidden" name="id" value={post.id} />
|
||||
<Button type="submit" variant="secondary">
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-medium">{post.title}</h3>
|
||||
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide">
|
||||
@@ -56,6 +327,9 @@ export default async function AdminHomePage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from "next"
|
||||
import createNextIntlPlugin from "next-intl/plugin"
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts")
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ["@cms/ui", "@cms/content", "@cms/db"],
|
||||
transpilePackages: ["@cms/ui", "@cms/content", "@cms/db", "@cms/i18n"],
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
export default withNextIntl(nextConfig)
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
"dependencies": {
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/i18n": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "4.4.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "5.0.11"
|
||||
|
||||
27
apps/web/src/app/[locale]/layout.tsx
Normal file
27
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { routing } from "@/i18n/routing"
|
||||
import { Providers } from "../providers"
|
||||
|
||||
type LocaleLayoutProps = {
|
||||
children: ReactNode
|
||||
params: Promise<{
|
||||
locale: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale } = await params
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
import { listPosts } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomePage() {
|
||||
const posts = await listPosts()
|
||||
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Web App</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Your Next.js CMS Frontend</h1>
|
||||
<p className="text-neutral-600">
|
||||
This page reads posts through the shared database package.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-neutral-600">{t("description")}</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Latest posts</h2>
|
||||
<Button variant="secondary">Explore</Button>
|
||||
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
|
||||
<Button variant="secondary">{t("explore")}</Button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
@@ -27,7 +31,7 @@ export default async function HomePage() {
|
||||
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
|
||||
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -2,7 +2,6 @@ import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import "./globals.css"
|
||||
import { Providers } from "./providers"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CMS Web",
|
||||
@@ -12,9 +11,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
50
apps/web/src/components/language-switcher.tsx
Normal file
50
apps/web/src/components/language-switcher.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { type AppLocale, localeLabels, locales } from "@cms/i18n"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { useEffect, useTransition } from "react"
|
||||
|
||||
import { usePathname, useRouter } from "@/i18n/navigation"
|
||||
import { useLocaleStore } from "@/store/locale"
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const t = useTranslations("LanguageSwitcher")
|
||||
const currentLocale = useLocale() as AppLocale
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const locale = useLocaleStore((state) => state.locale)
|
||||
const setLocale = useLocaleStore((state) => state.setLocale)
|
||||
|
||||
useEffect(() => {
|
||||
if (locale !== currentLocale) {
|
||||
setLocale(currentLocale)
|
||||
}
|
||||
}, [currentLocale, locale, setLocale])
|
||||
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<span>{t("label")}</span>
|
||||
<select
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm"
|
||||
value={locale}
|
||||
disabled={isPending}
|
||||
onChange={(event) => {
|
||||
const nextLocale = event.target.value as AppLocale
|
||||
setLocale(nextLocale)
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(pathname, { locale: nextLocale })
|
||||
})
|
||||
}}
|
||||
>
|
||||
{locales.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`localeNames.${value}`)} ({localeLabels[value]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
5
apps/web/src/i18n/navigation.ts
Normal file
5
apps/web/src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createNavigation } from "next-intl/navigation"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
|
||||
14
apps/web/src/i18n/request.ts
Normal file
14
apps/web/src/i18n/request.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { hasLocale } from "next-intl"
|
||||
import { getRequestConfig } from "next-intl/server"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale
|
||||
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
}
|
||||
})
|
||||
8
apps/web/src/i18n/routing.ts
Normal file
8
apps/web/src/i18n/routing.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defaultLocale, locales } from "@cms/i18n"
|
||||
import { defineRouting } from "next-intl/routing"
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: [...locales],
|
||||
defaultLocale,
|
||||
localePrefix: "never",
|
||||
})
|
||||
19
apps/web/src/messages/de.json
Normal file
19
apps/web/src/messages/de.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Web-App",
|
||||
"title": "Dein Next.js CMS Frontend",
|
||||
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
|
||||
"latestPosts": "Neueste Beiträge",
|
||||
"explore": "Entdecken",
|
||||
"noExcerpt": "Kein Auszug"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Sprache",
|
||||
"localeNames": {
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"fr": "Französisch"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/web/src/messages/en.json
Normal file
19
apps/web/src/messages/en.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Web App",
|
||||
"title": "Your Next.js CMS Frontend",
|
||||
"description": "This page reads posts through the shared database package.",
|
||||
"latestPosts": "Latest posts",
|
||||
"explore": "Explore",
|
||||
"noExcerpt": "No excerpt"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Language",
|
||||
"localeNames": {
|
||||
"de": "German",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fr": "French"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/web/src/messages/es.json
Normal file
19
apps/web/src/messages/es.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Aplicación Web",
|
||||
"title": "Tu Frontend CMS con Next.js",
|
||||
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
|
||||
"latestPosts": "Últimas publicaciones",
|
||||
"explore": "Explorar",
|
||||
"noExcerpt": "Sin extracto"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Idioma",
|
||||
"localeNames": {
|
||||
"de": "Alemán",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"fr": "Francés"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/web/src/messages/fr.json
Normal file
19
apps/web/src/messages/fr.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Home": {
|
||||
"badge": "Application Web",
|
||||
"title": "Votre Frontend CMS Next.js",
|
||||
"description": "Cette page lit les publications via le package base de données partagé.",
|
||||
"latestPosts": "Dernières publications",
|
||||
"explore": "Explorer",
|
||||
"noExcerpt": "Aucun extrait"
|
||||
},
|
||||
"LanguageSwitcher": {
|
||||
"label": "Langue",
|
||||
"localeNames": {
|
||||
"de": "Allemand",
|
||||
"en": "Anglais",
|
||||
"es": "Espagnol",
|
||||
"fr": "Français"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/web/src/proxy.ts
Normal file
14
apps/web/src/proxy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NextRequest } from "next/server"
|
||||
import createMiddleware from "next-intl/middleware"
|
||||
|
||||
import { routing } from "@/i18n/routing"
|
||||
|
||||
const handleI18nRouting = createMiddleware(routing)
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
return handleI18nRouting(request)
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|trpc|_next|_vercel|.*\\..*).*)"],
|
||||
}
|
||||
12
apps/web/src/store/locale.test.ts
Normal file
12
apps/web/src/store/locale.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { useLocaleStore } from "./locale"
|
||||
|
||||
describe("web locale store", () => {
|
||||
it("sets locale", () => {
|
||||
useLocaleStore.setState({ locale: "en" })
|
||||
useLocaleStore.getState().setLocale("de")
|
||||
|
||||
expect(useLocaleStore.getState().locale).toBe("de")
|
||||
})
|
||||
})
|
||||
12
apps/web/src/store/locale.ts
Normal file
12
apps/web/src/store/locale.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type AppLocale, defaultLocale } from "@cms/i18n"
|
||||
import { create } from "zustand"
|
||||
|
||||
type LocaleStore = {
|
||||
locale: AppLocale
|
||||
setLocale: (value: AppLocale) => void
|
||||
}
|
||||
|
||||
export const useLocaleStore = create<LocaleStore>((set) => ({
|
||||
locale: defaultLocale,
|
||||
setLocale: (value) => set({ locale: value }),
|
||||
}))
|
||||
185
bun.lock
185
bun.lock
@@ -5,23 +5,23 @@
|
||||
"": {
|
||||
"name": "cms-monorepo",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@commitlint/cli": "latest",
|
||||
"@commitlint/config-conventional": "latest",
|
||||
"@playwright/test": "latest",
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"@testing-library/react": "latest",
|
||||
"@testing-library/user-event": "latest",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"@vitest/coverage-istanbul": "latest",
|
||||
"conventional-changelog-cli": "latest",
|
||||
"jsdom": "latest",
|
||||
"msw": "latest",
|
||||
"turbo": "latest",
|
||||
"typescript": "latest",
|
||||
"vite-tsconfig-paths": "latest",
|
||||
"vitepress": "latest",
|
||||
"vitest": "latest",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@commitlint/cli": "20.4.1",
|
||||
"@commitlint/config-conventional": "20.4.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@vitejs/plugin-react": "5.1.3",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"conventional-changelog-cli": "5.0.0",
|
||||
"jsdom": "28.0.0",
|
||||
"msw": "2.12.9",
|
||||
"turbo": "2.8.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite-tsconfig-paths": "6.1.0",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "4.0.18",
|
||||
},
|
||||
},
|
||||
"apps/admin": {
|
||||
@@ -31,25 +31,25 @@
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-form": "latest",
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-query-devtools": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
"@tanstack/react-form": "1.28.0",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"better-auth": "1.4.18",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"zustand": "latest",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "5.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"typescript": "latest",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@types/node": "25.2.2",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/web": {
|
||||
@@ -58,23 +58,25 @@
|
||||
"dependencies": {
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/i18n": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-query-devtools": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"zustand": "latest",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "4.4.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "5.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"typescript": "latest",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@types/node": "25.2.2",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"packages/config": {
|
||||
@@ -85,12 +87,24 @@
|
||||
"name": "@cms/content",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"zod": "latest",
|
||||
"zod": "4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"typescript": "latest",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"packages/crud": {
|
||||
"name": "@cms/crud",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"zod": "4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"packages/db": {
|
||||
@@ -98,39 +112,48 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@cms/content": "workspace:*",
|
||||
"@prisma/adapter-pg": "latest",
|
||||
"@prisma/client": "latest",
|
||||
"pg": "latest",
|
||||
"zod": "latest",
|
||||
"@cms/crud": "workspace:*",
|
||||
"@prisma/adapter-pg": "7.3.0",
|
||||
"@prisma/client": "7.3.0",
|
||||
"pg": "8.18.0",
|
||||
"zod": "4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"@types/node": "latest",
|
||||
"@types/pg": "latest",
|
||||
"better-auth": "1.4.18",
|
||||
"prisma": "latest",
|
||||
"typescript": "latest",
|
||||
"@types/node": "25.2.2",
|
||||
"@types/pg": "8.16.0",
|
||||
"prisma": "7.3.0",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"packages/i18n": {
|
||||
"name": "@cms/i18n",
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@cms/ui",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"class-variance-authority": "latest",
|
||||
"clsx": "latest",
|
||||
"tailwind-merge": "latest",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@cms/config": "workspace:*",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -263,8 +286,12 @@
|
||||
|
||||
"@cms/content": ["@cms/content@workspace:packages/content"],
|
||||
|
||||
"@cms/crud": ["@cms/crud@workspace:packages/crud"],
|
||||
|
||||
"@cms/db": ["@cms/db@workspace:packages/db"],
|
||||
|
||||
"@cms/i18n": ["@cms/i18n@workspace:packages/i18n"],
|
||||
|
||||
"@cms/ui": ["@cms/ui@workspace:packages/ui"],
|
||||
|
||||
"@cms/web": ["@cms/web@workspace:apps/web"],
|
||||
@@ -385,6 +412,16 @@
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.12.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw=="],
|
||||
|
||||
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.1.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "@formatjs/intl-localematcher": "0.8.1", "decimal.js": "^10.6.0", "tslib": "^2.8.1" } }, "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q=="],
|
||||
|
||||
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.0", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg=="],
|
||||
|
||||
"@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/icu-skeleton-parser": "2.1.1", "tslib": "^2.8.1" } }, "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA=="],
|
||||
|
||||
"@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.1", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "tslib": "^2.8.1" } }, "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q=="],
|
||||
|
||||
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.5.10", "", { "dependencies": { "tslib": "2" } }, "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
"@hutson/parse-repository-url": ["@hutson/parse-repository-url@5.0.0", "", {}, "sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg=="],
|
||||
@@ -577,6 +614,8 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
|
||||
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="],
|
||||
@@ -1015,6 +1054,8 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"icu-minify": ["icu-minify@4.8.2", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-LHBQV+skKkjZSPd590pZ7ZAHftUgda3eFjeuNwA8/15L8T8loCNBktKQyTlkodAU86KovFXeg/9WntlAo5wA5A=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -1025,6 +1066,8 @@
|
||||
|
||||
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@11.1.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.1.1", "@formatjs/fast-memoize": "3.1.0", "@formatjs/icu-messageformat-parser": "3.5.1", "tslib": "^2.8.1" } }, "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
@@ -1167,10 +1210,14 @@
|
||||
|
||||
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="],
|
||||
|
||||
"next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
|
||||
|
||||
"next-intl": ["next-intl@4.4.0", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "negotiator": "^1.0.0", "use-intl": "^4.4.0" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", "typescript": "^5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
@@ -1443,6 +1490,8 @@
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"use-intl": ["use-intl@4.8.2", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.8.2", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-3VNXZgDnPFqhIYosQ9W1Hc6K5q+ZelMfawNbexdwL/dY7BTHbceLUBX5Eeex9lgogxTp0pf1SjHuhYNAjr9H3g=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
||||
@@ -1509,6 +1558,8 @@
|
||||
|
||||
"@conventional-changelog/git-client/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@formatjs/ecma402-abstract/@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.1", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.0", "tslib": "^2.8.1" } }, "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA=="],
|
||||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="],
|
||||
|
||||
@@ -20,6 +20,8 @@ export default defineConfig({
|
||||
{ text: "Getting Started", link: "/getting-started" },
|
||||
{ text: "Architecture", link: "/architecture" },
|
||||
{ text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" },
|
||||
{ text: "CRUD Baseline", link: "/product-engineering/crud-baseline" },
|
||||
{ text: "i18n Baseline", link: "/product-engineering/i18n-baseline" },
|
||||
{ text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" },
|
||||
{ text: "Workflow", link: "/workflow" },
|
||||
],
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
- `apps/admin`: admin app
|
||||
- `packages/db`: prisma + data access
|
||||
- `packages/content`: shared schemas and domain contracts
|
||||
- `packages/crud`: shared CRUD service patterns (validation, errors, audit hooks)
|
||||
- `packages/ui`: shared UI layer
|
||||
- `packages/i18n`: shared locale definitions and i18n helpers
|
||||
- `packages/config`: shared TS config
|
||||
|
||||
## Design Principles
|
||||
@@ -14,6 +16,7 @@
|
||||
- Shared contracts before feature implementation
|
||||
- RBAC and CRUD base as prerequisites for MVP1 feature work
|
||||
- Keep admin and public responsibilities clearly separated
|
||||
- Public routing is path-stable; locale is resolved via `next-intl` middleware + cookie
|
||||
|
||||
## Pending Documentation
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ bun run dev
|
||||
```
|
||||
|
||||
- Web: `http://localhost:3000`
|
||||
- Web locale switching: use the language switcher in the page header
|
||||
- Admin: `http://localhost:3001`
|
||||
- Admin welcome (first start): `http://localhost:3001/welcome`
|
||||
- Admin login: `http://localhost:3001/login`
|
||||
|
||||
33
docs/product-engineering/crud-baseline.md
Normal file
33
docs/product-engineering/crud-baseline.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# CRUD Baseline
|
||||
|
||||
## Scope
|
||||
|
||||
MVP0 now includes a shared CRUD foundation package: `@cms/crud`.
|
||||
|
||||
Current baseline:
|
||||
|
||||
- Shared service factory: `createCrudService`
|
||||
- Shared validation error type: `CrudValidationError`
|
||||
- Shared not-found error type: `CrudNotFoundError`
|
||||
- Shared mutation audit hook contract: `CrudAuditHook`
|
||||
- Shared mutation context contract (`actor`, `metadata`)
|
||||
|
||||
## First Integration
|
||||
|
||||
`@cms/db` `posts` now uses the shared CRUD foundation:
|
||||
|
||||
- `listPosts`
|
||||
- `getPostById`
|
||||
- `createPost`
|
||||
- `updatePost`
|
||||
- `deletePost`
|
||||
- `registerPostCrudAuditHook`
|
||||
|
||||
Validation for create/update is enforced by `@cms/content` schemas.
|
||||
|
||||
The admin dashboard currently includes a temporary posts CRUD sandbox to validate this flow through a real app UI.
|
||||
|
||||
## Notes
|
||||
|
||||
- This is the base layer for future entities (pages, navigation, media, users, commissions).
|
||||
- Audit hook persistence/transport is intentionally left for later implementation work.
|
||||
20
docs/product-engineering/i18n-baseline.md
Normal file
20
docs/product-engineering/i18n-baseline.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# i18n Baseline
|
||||
|
||||
## Scope
|
||||
|
||||
MVP0 introduces i18n runtime only for the public app (`@cms/web`) using `next-intl`.
|
||||
|
||||
Current baseline:
|
||||
|
||||
- Shared locale contract in `@cms/i18n` (`de`, `en`, `es`, `fr`; default `en`)
|
||||
- Path-stable routing (no locale in URL) via `apps/web/src/proxy.ts`
|
||||
- Message loading through `apps/web/src/i18n/request.ts`
|
||||
- Locale-aware navigation helpers in `apps/web/src/i18n/navigation.ts`
|
||||
- Public language switcher component backed by Zustand store
|
||||
|
||||
## Notes
|
||||
|
||||
- Public app locale is resolved through `next-intl` middleware + cookie.
|
||||
- Enabled locales are currently static in code and will later be managed from admin settings.
|
||||
- Admin app i18n provider/message loading is still pending.
|
||||
- Translation key conventions and workflow docs are tracked in `TODO.md`.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { postSchema, upsertPostSchema } from "./index"
|
||||
import { createPostInputSchema, postSchema, updatePostInputSchema, upsertPostSchema } from "./index"
|
||||
|
||||
describe("content schemas", () => {
|
||||
it("accepts a valid post", () => {
|
||||
@@ -17,7 +17,24 @@ describe("content schemas", () => {
|
||||
expect(post.slug).toBe("hello-world")
|
||||
})
|
||||
|
||||
it("rejects invalid upsert payload", () => {
|
||||
it("rejects invalid create payload", () => {
|
||||
const result = createPostInputSchema.safeParse({
|
||||
title: "Hi",
|
||||
slug: "x",
|
||||
body: "",
|
||||
status: "unknown",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects empty update payload", () => {
|
||||
const result = updatePostInputSchema.safeParse({})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps upsert alias for backward compatibility", () => {
|
||||
const result = upsertPostSchema.safeParse({
|
||||
title: "Hi",
|
||||
slug: "x",
|
||||
|
||||
@@ -4,22 +4,32 @@ export * from "./rbac"
|
||||
|
||||
export const postStatusSchema = z.enum(["draft", "published"])
|
||||
|
||||
export const postSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
const postMutableFieldsSchema = z.object({
|
||||
title: z.string().min(3).max(180),
|
||||
slug: z.string().min(3).max(180),
|
||||
excerpt: z.string().max(320).optional(),
|
||||
body: z.string().min(1),
|
||||
status: postStatusSchema,
|
||||
})
|
||||
|
||||
export const postSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
...postMutableFieldsSchema.shape,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
})
|
||||
|
||||
export const upsertPostSchema = postSchema.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
})
|
||||
export const createPostInputSchema = postMutableFieldsSchema
|
||||
export const updatePostInputSchema = postMutableFieldsSchema
|
||||
.partial()
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one field is required for an update.",
|
||||
})
|
||||
|
||||
// Backward-compatible alias while migrating callers to create/update-specific schemas.
|
||||
export const upsertPostSchema = createPostInputSchema
|
||||
|
||||
export type Post = z.infer<typeof postSchema>
|
||||
export type CreatePostInput = z.infer<typeof createPostInputSchema>
|
||||
export type UpdatePostInput = z.infer<typeof updatePostInputSchema>
|
||||
export type UpsertPostInput = z.infer<typeof upsertPostSchema>
|
||||
|
||||
22
packages/crud/package.json
Normal file
22
packages/crud/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@cms/crud",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "biome check src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
41
packages/crud/src/errors.ts
Normal file
41
packages/crud/src/errors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ZodIssue } from "zod"
|
||||
|
||||
export class CrudError extends Error {
|
||||
public readonly code: string
|
||||
|
||||
constructor(message: string, code: string) {
|
||||
super(message)
|
||||
this.name = "CrudError"
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export class CrudValidationError extends CrudError {
|
||||
public readonly resource: string
|
||||
public readonly operation: "create" | "update"
|
||||
public readonly issues: ZodIssue[]
|
||||
|
||||
constructor(params: {
|
||||
resource: string
|
||||
operation: "create" | "update"
|
||||
issues: ZodIssue[]
|
||||
}) {
|
||||
super(`Validation failed for ${params.resource} ${params.operation}`, "CRUD_VALIDATION")
|
||||
this.name = "CrudValidationError"
|
||||
this.resource = params.resource
|
||||
this.operation = params.operation
|
||||
this.issues = params.issues
|
||||
}
|
||||
}
|
||||
|
||||
export class CrudNotFoundError extends CrudError {
|
||||
public readonly resource: string
|
||||
public readonly id: string
|
||||
|
||||
constructor(params: { resource: string; id: string }) {
|
||||
super(`${params.resource} ${params.id} was not found`, "CRUD_NOT_FOUND")
|
||||
this.name = "CrudNotFoundError"
|
||||
this.resource = params.resource
|
||||
this.id = params.id
|
||||
}
|
||||
}
|
||||
3
packages/crud/src/index.ts
Normal file
3
packages/crud/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./errors"
|
||||
export * from "./service"
|
||||
export * from "./types"
|
||||
161
packages/crud/src/service.test.ts
Normal file
161
packages/crud/src/service.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { z } from "zod"
|
||||
|
||||
import { CrudNotFoundError, CrudValidationError } from "./errors"
|
||||
import { createCrudService } from "./service"
|
||||
|
||||
type FakeEntity = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
type CreateFakeEntityInput = {
|
||||
title: string
|
||||
}
|
||||
|
||||
type UpdateFakeEntityInput = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
function createMemoryRepository() {
|
||||
const state = new Map<string, FakeEntity>()
|
||||
let sequence = 0
|
||||
|
||||
return {
|
||||
list: async () => Array.from(state.values()),
|
||||
findById: async (id: string) => state.get(id) ?? null,
|
||||
create: async (input: CreateFakeEntityInput) => {
|
||||
sequence += 1
|
||||
const created = {
|
||||
id: `${sequence}`,
|
||||
title: input.title,
|
||||
}
|
||||
|
||||
state.set(created.id, created)
|
||||
return created
|
||||
},
|
||||
update: async (id: string, input: UpdateFakeEntityInput) => {
|
||||
const current = state.get(id)
|
||||
|
||||
if (!current) {
|
||||
throw new Error("unexpected missing entity in test repository")
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...current,
|
||||
...input,
|
||||
}
|
||||
|
||||
state.set(id, updated)
|
||||
return updated
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const current = state.get(id)
|
||||
|
||||
if (!current) {
|
||||
throw new Error("unexpected missing entity in test repository")
|
||||
}
|
||||
|
||||
state.delete(id)
|
||||
return current
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createCrudService", () => {
|
||||
it("validates create and update payloads", async () => {
|
||||
const service = createCrudService({
|
||||
resource: "fake-entity",
|
||||
repository: createMemoryRepository(),
|
||||
schemas: {
|
||||
create: z.object({
|
||||
title: z.string().min(3),
|
||||
}),
|
||||
update: z
|
||||
.object({
|
||||
title: z.string().min(3).optional(),
|
||||
})
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: "at least one field must be updated",
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(service.create({ title: "ok" })).rejects.toBeInstanceOf(CrudValidationError)
|
||||
await expect(service.update("1", {})).rejects.toBeInstanceOf(CrudValidationError)
|
||||
})
|
||||
|
||||
it("throws not found for unknown update and delete", async () => {
|
||||
const service = createCrudService({
|
||||
resource: "fake-entity",
|
||||
repository: createMemoryRepository(),
|
||||
schemas: {
|
||||
create: z.object({
|
||||
title: z.string().min(3),
|
||||
}),
|
||||
update: z.object({
|
||||
title: z.string().min(3).optional(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(service.update("missing", { title: "Updated" })).rejects.toBeInstanceOf(
|
||||
CrudNotFoundError,
|
||||
)
|
||||
await expect(service.delete("missing")).rejects.toBeInstanceOf(CrudNotFoundError)
|
||||
})
|
||||
|
||||
it("emits audit events for create, update and delete", async () => {
|
||||
const events: Array<{ action: string; beforeTitle: string | null; afterTitle: string | null }> =
|
||||
[]
|
||||
const service = createCrudService({
|
||||
resource: "fake-entity",
|
||||
repository: createMemoryRepository(),
|
||||
schemas: {
|
||||
create: z.object({
|
||||
title: z.string().min(3),
|
||||
}),
|
||||
update: z.object({
|
||||
title: z.string().min(3).optional(),
|
||||
}),
|
||||
},
|
||||
auditHooks: [
|
||||
(event) => {
|
||||
events.push({
|
||||
action: event.action,
|
||||
beforeTitle: event.before?.title ?? null,
|
||||
afterTitle: event.after?.title ?? null,
|
||||
})
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const created = await service.create(
|
||||
{ title: "Created" },
|
||||
{
|
||||
actor: { id: "u-1", role: "owner" },
|
||||
},
|
||||
)
|
||||
|
||||
await service.update(created.id, { title: "Updated" })
|
||||
await service.delete(created.id)
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
action: "create",
|
||||
beforeTitle: null,
|
||||
afterTitle: "Created",
|
||||
},
|
||||
{
|
||||
action: "update",
|
||||
beforeTitle: "Created",
|
||||
afterTitle: "Updated",
|
||||
},
|
||||
{
|
||||
action: "delete",
|
||||
beforeTitle: "Updated",
|
||||
afterTitle: null,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
159
packages/crud/src/service.ts
Normal file
159
packages/crud/src/service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { ZodIssue } from "zod"
|
||||
|
||||
import { CrudNotFoundError, CrudValidationError } from "./errors"
|
||||
import type { CrudAction, CrudAuditHook, CrudMutationContext, CrudRepository } from "./types"
|
||||
|
||||
type SchemaSafeParseResult<TInput> =
|
||||
| {
|
||||
success: true
|
||||
data: TInput
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: {
|
||||
issues: ZodIssue[]
|
||||
}
|
||||
}
|
||||
|
||||
type CrudSchema<TInput> = {
|
||||
safeParse: (input: unknown) => SchemaSafeParseResult<TInput>
|
||||
}
|
||||
|
||||
type CrudSchemas<TCreateInput, TUpdateInput> = {
|
||||
create: CrudSchema<TCreateInput>
|
||||
update: CrudSchema<TUpdateInput>
|
||||
}
|
||||
|
||||
type CreateCrudServiceOptions<TRecord, TCreateInput, TUpdateInput, TId extends string = string> = {
|
||||
resource: string
|
||||
repository: CrudRepository<TRecord, TCreateInput, TUpdateInput, TId>
|
||||
schemas: CrudSchemas<TCreateInput, TUpdateInput>
|
||||
auditHooks?: Array<CrudAuditHook<TRecord>>
|
||||
}
|
||||
|
||||
async function emitAuditHooks<TRecord>(
|
||||
hooks: Array<CrudAuditHook<TRecord>>,
|
||||
event: {
|
||||
resource: string
|
||||
action: CrudAction
|
||||
actor: CrudMutationContext["actor"]
|
||||
metadata: CrudMutationContext["metadata"]
|
||||
before: TRecord | null
|
||||
after: TRecord | null
|
||||
},
|
||||
): Promise<void> {
|
||||
if (hooks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...event,
|
||||
actor: event.actor ?? null,
|
||||
at: new Date(),
|
||||
}
|
||||
|
||||
for (const hook of hooks) {
|
||||
await hook(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function parseOrThrow<TInput>(params: {
|
||||
schema: CrudSchema<TInput>
|
||||
input: unknown
|
||||
resource: string
|
||||
operation: "create" | "update"
|
||||
}): TInput {
|
||||
const parsed = params.schema.safeParse(params.input)
|
||||
|
||||
if (parsed.success) {
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
throw new CrudValidationError({
|
||||
resource: params.resource,
|
||||
operation: params.operation,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
export function createCrudService<TRecord, TCreateInput, TUpdateInput, TId extends string = string>(
|
||||
options: CreateCrudServiceOptions<TRecord, TCreateInput, TUpdateInput, TId>,
|
||||
) {
|
||||
const auditHooks = options.auditHooks ?? []
|
||||
|
||||
return {
|
||||
list: () => options.repository.list(),
|
||||
getById: (id: TId) => options.repository.findById(id),
|
||||
create: async (input: unknown, context: CrudMutationContext = {}) => {
|
||||
const payload = parseOrThrow({
|
||||
schema: options.schemas.create,
|
||||
input,
|
||||
resource: options.resource,
|
||||
operation: "create",
|
||||
})
|
||||
|
||||
const created = await options.repository.create(payload)
|
||||
await emitAuditHooks(auditHooks, {
|
||||
resource: options.resource,
|
||||
action: "create",
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
before: null,
|
||||
after: created,
|
||||
})
|
||||
|
||||
return created
|
||||
},
|
||||
update: async (id: TId, input: unknown, context: CrudMutationContext = {}) => {
|
||||
const payload = parseOrThrow({
|
||||
schema: options.schemas.update,
|
||||
input,
|
||||
resource: options.resource,
|
||||
operation: "update",
|
||||
})
|
||||
|
||||
const existing = await options.repository.findById(id)
|
||||
|
||||
if (!existing) {
|
||||
throw new CrudNotFoundError({
|
||||
resource: options.resource,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await options.repository.update(id, payload)
|
||||
await emitAuditHooks(auditHooks, {
|
||||
resource: options.resource,
|
||||
action: "update",
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
before: existing,
|
||||
after: updated,
|
||||
})
|
||||
|
||||
return updated
|
||||
},
|
||||
delete: async (id: TId, context: CrudMutationContext = {}) => {
|
||||
const existing = await options.repository.findById(id)
|
||||
|
||||
if (!existing) {
|
||||
throw new CrudNotFoundError({
|
||||
resource: options.resource,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
const deleted = await options.repository.delete(id)
|
||||
await emitAuditHooks(auditHooks, {
|
||||
resource: options.resource,
|
||||
action: "delete",
|
||||
actor: context.actor,
|
||||
metadata: context.metadata,
|
||||
before: existing,
|
||||
after: null,
|
||||
})
|
||||
|
||||
return deleted
|
||||
},
|
||||
}
|
||||
}
|
||||
31
packages/crud/src/types.ts
Normal file
31
packages/crud/src/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type CrudAction = "create" | "update" | "delete"
|
||||
|
||||
export type CrudActor = {
|
||||
id?: string | null
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export type CrudMutationContext = {
|
||||
actor?: CrudActor | null
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CrudAuditEvent<TRecord> = {
|
||||
resource: string
|
||||
action: CrudAction
|
||||
at: Date
|
||||
actor: CrudActor | null
|
||||
metadata?: Record<string, unknown>
|
||||
before: TRecord | null
|
||||
after: TRecord | null
|
||||
}
|
||||
|
||||
export type CrudAuditHook<TRecord> = (event: CrudAuditEvent<TRecord>) => Promise<void> | void
|
||||
|
||||
export type CrudRepository<TRecord, TCreateInput, TUpdateInput, TId extends string = string> = {
|
||||
list: () => Promise<TRecord[]>
|
||||
findById: (id: TId) => Promise<TRecord | null>
|
||||
create: (input: TCreateInput) => Promise<TRecord>
|
||||
update: (id: TId, input: TUpdateInput) => Promise<TRecord>
|
||||
delete: (id: TId) => Promise<TRecord>
|
||||
}
|
||||
9
packages/crud/tsconfig.json
Normal file
9
packages/crud/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@cms/config/tsconfig/base",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cms/crud": "workspace:*",
|
||||
"@cms/content": "workspace:*",
|
||||
"@prisma/adapter-pg": "7.3.0",
|
||||
"@prisma/client": "7.3.0",
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
export { db } from "./client"
|
||||
export { createPost, listPosts } from "./posts"
|
||||
export {
|
||||
createPost,
|
||||
deletePost,
|
||||
getPostById,
|
||||
listPosts,
|
||||
registerPostCrudAuditHook,
|
||||
updatePost,
|
||||
} from "./posts"
|
||||
|
||||
@@ -1,19 +1,80 @@
|
||||
import { upsertPostSchema } from "@cms/content"
|
||||
import {
|
||||
type CreatePostInput,
|
||||
createPostInputSchema,
|
||||
type UpdatePostInput,
|
||||
updatePostInputSchema,
|
||||
} from "@cms/content"
|
||||
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
|
||||
import type { Post } from "../prisma/generated/client/client"
|
||||
|
||||
import { db } from "./client"
|
||||
|
||||
export async function listPosts() {
|
||||
return db.post.findMany({
|
||||
const postRepository = {
|
||||
list: () =>
|
||||
db.post.findMany({
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
})
|
||||
}),
|
||||
findById: (id: string) =>
|
||||
db.post.findUnique({
|
||||
where: { id },
|
||||
}),
|
||||
create: (input: CreatePostInput) =>
|
||||
db.post.create({
|
||||
data: input,
|
||||
}),
|
||||
update: (id: string, input: UpdatePostInput) =>
|
||||
db.post.update({
|
||||
where: { id },
|
||||
data: input,
|
||||
}),
|
||||
delete: (id: string) =>
|
||||
db.post.delete({
|
||||
where: { id },
|
||||
}),
|
||||
}
|
||||
|
||||
export async function createPost(input: unknown) {
|
||||
const payload = upsertPostSchema.parse(input)
|
||||
const postAuditHooks: Array<CrudAuditHook<Post>> = []
|
||||
|
||||
return db.post.create({
|
||||
data: payload,
|
||||
})
|
||||
const postCrudService = createCrudService({
|
||||
resource: "post",
|
||||
repository: postRepository,
|
||||
schemas: {
|
||||
create: createPostInputSchema,
|
||||
update: updatePostInputSchema,
|
||||
},
|
||||
auditHooks: postAuditHooks,
|
||||
})
|
||||
|
||||
export function registerPostCrudAuditHook(hook: CrudAuditHook<Post>): () => void {
|
||||
postAuditHooks.push(hook)
|
||||
|
||||
return () => {
|
||||
const index = postAuditHooks.indexOf(hook)
|
||||
|
||||
if (index >= 0) {
|
||||
postAuditHooks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPosts() {
|
||||
return postCrudService.list()
|
||||
}
|
||||
|
||||
export async function getPostById(id: string) {
|
||||
return postCrudService.getById(id)
|
||||
}
|
||||
|
||||
export async function createPost(input: unknown, context?: CrudMutationContext) {
|
||||
return postCrudService.create(input, context)
|
||||
}
|
||||
|
||||
export async function updatePost(id: string, input: unknown, context?: CrudMutationContext) {
|
||||
return postCrudService.update(id, input, context)
|
||||
}
|
||||
|
||||
export async function deletePost(id: string, context?: CrudMutationContext) {
|
||||
return postCrudService.delete(id, context)
|
||||
}
|
||||
|
||||
19
packages/i18n/package.json
Normal file
19
packages/i18n/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@cms/i18n",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "biome check src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
16
packages/i18n/src/index.ts
Normal file
16
packages/i18n/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const locales = ["de", "en", "es", "fr"] as const
|
||||
|
||||
export type AppLocale = (typeof locales)[number]
|
||||
|
||||
export const defaultLocale: AppLocale = "en"
|
||||
|
||||
export const localeLabels: Record<AppLocale, string> = {
|
||||
de: "Deutsch",
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
}
|
||||
|
||||
export function isAppLocale(value: string): value is AppLocale {
|
||||
return locales.includes(value as AppLocale)
|
||||
}
|
||||
8
packages/i18n/tsconfig.json
Normal file
8
packages/i18n/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@cms/config/tsconfig/base",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user