feat: add templates page with list and diff comparison views
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m46s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m46s
Server-first: page.tsx renders list and compare views as server components, with <details>/<summary> accordions and URL-param navigation. Only the two template selects require a client component (TemplateCompareSelects). Diff highlighting uses a subtle cyan underline; layout is 2-col on desktop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
283
src/app/templates/page.tsx
Normal file
283
src/app/templates/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getTemplates } from "@/lib/server-data";
|
||||||
|
import { parseRubric, parseQuestions } from "@/lib/export-utils";
|
||||||
|
import { TemplateCompareSelects } from "@/components/TemplateCompareSelects";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ mode?: string; left?: string; right?: string; diffs?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TemplateDimension = Awaited<ReturnType<typeof getTemplates>>[number]["dimensions"][number];
|
||||||
|
type Template = Awaited<ReturnType<typeof getTemplates>>[number];
|
||||||
|
|
||||||
|
// ── Sub-components (server) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DimensionAccordion({ dim, index }: { dim: TemplateDimension; index: number }) {
|
||||||
|
const rubricLabels = parseRubric(dim.rubric);
|
||||||
|
const questions = parseQuestions(dim.suggestedQuestions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="group border-t border-zinc-200 dark:border-zinc-600 first:border-t-0">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between px-4 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors [&::-webkit-details-marker]:hidden">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-mono text-xs text-zinc-400 tabular-nums w-5 shrink-0">{index + 1}.</span>
|
||||||
|
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100 truncate">{dim.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-zinc-500 text-sm ml-2 group-open:hidden">+</span>
|
||||||
|
<span className="shrink-0 text-zinc-500 text-sm ml-2 hidden group-open:inline">−</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="px-4 pb-3 space-y-2 bg-zinc-50/50 dark:bg-zinc-700/20">
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<div className="rounded bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 p-2.5">
|
||||||
|
<p className="mb-1.5 text-xs font-medium text-zinc-500">Questions suggérées</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm text-zinc-700 dark:text-zinc-200">
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<li key={i}>{q}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="rounded bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 p-2.5 font-mono text-xs space-y-0.5">
|
||||||
|
{rubricLabels.map((label, i) => (
|
||||||
|
<div key={i} className="text-zinc-600 dark:text-zinc-300">
|
||||||
|
<span className="text-cyan-600 dark:text-cyan-400">{i + 1}</span> {label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListView({ templates }: { templates: Template[] }) {
|
||||||
|
if (templates.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center font-mono text-sm text-zinc-500">
|
||||||
|
Aucun template disponible.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30">
|
||||||
|
<h2 className="font-medium text-zinc-800 dark:text-zinc-100">{t.name}</h2>
|
||||||
|
<span className="font-mono text-xs text-zinc-500">{t.dimensions.length} dim.</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t.dimensions.map((dim, i) => (
|
||||||
|
<DimensionAccordion key={dim.id} dim={dim} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompareView({
|
||||||
|
templates,
|
||||||
|
leftId,
|
||||||
|
rightId,
|
||||||
|
onlyDiffs,
|
||||||
|
}: {
|
||||||
|
templates: Template[];
|
||||||
|
leftId: string;
|
||||||
|
rightId: string;
|
||||||
|
onlyDiffs: boolean;
|
||||||
|
}) {
|
||||||
|
const leftTemplate = templates.find((t) => t.id === leftId);
|
||||||
|
const rightTemplate = templates.find((t) => t.id === rightId);
|
||||||
|
|
||||||
|
// Collect all unique slugs, preserving order
|
||||||
|
const slugOrder = new Map<string, number>();
|
||||||
|
for (const t of [leftTemplate, rightTemplate]) {
|
||||||
|
if (!t) continue;
|
||||||
|
for (const dim of t.dimensions) {
|
||||||
|
if (!slugOrder.has(dim.slug)) slugOrder.set(dim.slug, dim.orderIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const slugs = Array.from(slugOrder.keys()).sort(
|
||||||
|
(a, b) => (slugOrder.get(a) ?? 0) - (slugOrder.get(b) ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-compute diffs
|
||||||
|
const diffsBySlugs = new Map<string, number[]>();
|
||||||
|
let totalDiffDims = 0;
|
||||||
|
for (const slug of slugs) {
|
||||||
|
const l = leftTemplate?.dimensions.find((d) => d.slug === slug);
|
||||||
|
const r = rightTemplate?.dimensions.find((d) => d.slug === slug);
|
||||||
|
const ll = l ? parseRubric(l.rubric) : [];
|
||||||
|
const rl = r ? parseRubric(r.rubric) : [];
|
||||||
|
const diff = [0, 1, 2, 3, 4].filter((i) => ll[i] !== rl[i]);
|
||||||
|
diffsBySlugs.set(slug, diff);
|
||||||
|
if (diff.length > 0) totalDiffDims++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleSlugs = onlyDiffs ? slugs.filter((s) => (diffsBySlugs.get(s)?.length ?? 0) > 0) : slugs;
|
||||||
|
|
||||||
|
const base = `?mode=compare&left=${leftId}&right=${rightId}`;
|
||||||
|
const diffsHref = onlyDiffs ? base : `${base}&diffs=1`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{templates.length > 2 && (
|
||||||
|
<TemplateCompareSelects
|
||||||
|
templates={templates.map((t) => ({ id: t.id, name: t.name }))}
|
||||||
|
leftId={leftId}
|
||||||
|
rightId={rightId}
|
||||||
|
onlyDiffs={onlyDiffs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary + filter */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="font-mono text-xs text-zinc-500">
|
||||||
|
<span className="font-semibold text-amber-600 dark:text-amber-400">{totalDiffDims}</span>
|
||||||
|
{" / "}
|
||||||
|
{slugs.length} dimensions modifiées
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={diffsHref}
|
||||||
|
className={`rounded border px-2.5 py-1 font-mono text-xs transition-colors ${
|
||||||
|
onlyDiffs
|
||||||
|
? "border-amber-400 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400"
|
||||||
|
: "border-zinc-300 dark:border-zinc-600 text-zinc-500 dark:text-zinc-400 hover:border-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
△ uniquement les différences
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column headers */}
|
||||||
|
<div className="grid grid-cols-2 gap-px mb-1">
|
||||||
|
<div className="rounded-t-lg bg-zinc-100 dark:bg-zinc-700/60 px-4 py-2 font-mono text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||||
|
{leftTemplate?.name ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-t-lg bg-zinc-100 dark:bg-zinc-700/60 px-4 py-2 font-mono text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||||
|
{rightTemplate?.name ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{visibleSlugs.map((slug, idx) => {
|
||||||
|
const leftDim = leftTemplate?.dimensions.find((d) => d.slug === slug);
|
||||||
|
const rightDim = rightTemplate?.dimensions.find((d) => d.slug === slug);
|
||||||
|
const leftLabels = leftDim ? parseRubric(leftDim.rubric) : [];
|
||||||
|
const rightLabels = rightDim ? parseRubric(rightDim.rubric) : [];
|
||||||
|
const title = (leftDim ?? rightDim)?.title ?? slug;
|
||||||
|
const diffLevels = diffsBySlugs.get(slug) ?? [];
|
||||||
|
const hasDiff = diffLevels.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slug}
|
||||||
|
className="rounded-lg border border-zinc-200 dark:border-zinc-600 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/50 flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm text-zinc-800 dark:text-zinc-100">
|
||||||
|
<span className="font-mono text-xs text-zinc-400 mr-1.5 tabular-nums">{idx + 1}.</span>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{hasDiff && (
|
||||||
|
<span className="shrink-0 font-mono text-xs px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-zinc-600 text-zinc-500 dark:text-zinc-400">
|
||||||
|
{diffLevels.length} Δ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 divide-x divide-zinc-200 dark:divide-zinc-600">
|
||||||
|
{[
|
||||||
|
{ dim: leftDim, labels: leftLabels },
|
||||||
|
{ dim: rightDim, labels: rightLabels },
|
||||||
|
].map(({ dim, labels }, col) => (
|
||||||
|
<div key={col} className="p-3 font-mono text-xs space-y-1">
|
||||||
|
{dim ? (
|
||||||
|
labels.map((label, i) => {
|
||||||
|
const differs = diffLevels.includes(i);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-2 px-1.5 py-1">
|
||||||
|
<span className="shrink-0 font-bold tabular-nums text-cyan-600 dark:text-cyan-400">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={`text-zinc-600 dark:text-zinc-300 ${differs ? "bg-cyan-100/70 dark:bg-cyan-900/30 rounded px-0.5" : ""}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-400 italic">absent</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default async function TemplatesPage({ searchParams }: PageProps) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/auth/login");
|
||||||
|
|
||||||
|
const { mode, left, right, diffs } = await searchParams;
|
||||||
|
const templates = await getTemplates();
|
||||||
|
|
||||||
|
const isCompare = mode === "compare";
|
||||||
|
const leftId = left ?? templates[0]?.id ?? "";
|
||||||
|
const rightId = right ?? templates[1]?.id ?? templates[0]?.id ?? "";
|
||||||
|
const onlyDiffs = diffs === "1";
|
||||||
|
|
||||||
|
const compareHref = `?mode=compare&left=${leftId}&right=${rightId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Templates</h1>
|
||||||
|
<div className="inline-flex rounded border border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/50 p-0.5">
|
||||||
|
<Link
|
||||||
|
href="?mode=list"
|
||||||
|
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
|
||||||
|
!isCompare
|
||||||
|
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
|
||||||
|
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
liste
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={compareHref}
|
||||||
|
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
|
||||||
|
isCompare
|
||||||
|
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
|
||||||
|
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
comparer
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCompare ? (
|
||||||
|
<CompareView templates={templates} leftId={leftId} rightId={rightId} onlyDiffs={onlyDiffs} />
|
||||||
|
) : (
|
||||||
|
<ListView templates={templates} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ export function Header({ session }: { session: Session | null }) {
|
|||||||
>
|
>
|
||||||
/new
|
/new
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/templates"
|
||||||
|
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
/templates
|
||||||
|
</Link>
|
||||||
{session.user.role === "admin" && (
|
{session.user.role === "admin" && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
52
src/components/TemplateCompareSelects.tsx
Normal file
52
src/components/TemplateCompareSelects.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface TemplateOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
"rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-2 py-1 font-mono text-xs text-zinc-800 dark:text-zinc-100 focus:border-cyan-500 focus:outline-none";
|
||||||
|
|
||||||
|
export function TemplateCompareSelects({
|
||||||
|
templates,
|
||||||
|
leftId,
|
||||||
|
rightId,
|
||||||
|
onlyDiffs,
|
||||||
|
}: {
|
||||||
|
templates: TemplateOption[];
|
||||||
|
leftId: string;
|
||||||
|
rightId: string;
|
||||||
|
onlyDiffs: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const push = (left: string, right: string) => {
|
||||||
|
const params = new URLSearchParams({ mode: "compare", left, right });
|
||||||
|
if (onlyDiffs) params.set("diffs", "1");
|
||||||
|
router.push(`/templates?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-zinc-500">Gauche :</span>
|
||||||
|
<select value={leftId} onChange={(e) => push(e.target.value, rightId)} className={selectClass}>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-zinc-500">Droite :</span>
|
||||||
|
<select value={rightId} onChange={(e) => push(leftId, e.target.value)} className={selectClass}>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user