Phase 6: polish — error boundaries, a11y, smoke test doc
Close out the read-only surface before cutover with three families of small fixes that the previous phases left unfinished: - Error boundaries: add src/app/error.tsx (route-segment), global-error.tsx (root crash fallback with its own minimal html/body — no Providers dependency since those may be the thing that crashed), and not-found.tsx for a Hebrew 404 instead of the default Next page. - Accessibility: wire usePathname() into AppShell so the current nav item gets aria-current="page" and a gold underline. Add aria-label + aria-hidden on the icon-only buttons that Phase 5 left text-less (corpus trash, parties-field Plus). Nav gets an aria-label of its own. - Metadata template: title on each route now reads "X · עוזר משפטי" via the layout.tsx title.template. Description localized to Jerusalem. - README: full E2E smoke test checklist covering all 9 screens, plus a backend contract table so future phases know which hook wraps which endpoint. Documents the known Gitea→Coolify webhook issue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
57
web-ui/src/app/error.tsx
Normal file
57
web-ui/src/app/error.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/*
|
||||
* Route-segment error boundary. Next 16 App Router convention: this file
|
||||
* catches render-time errors thrown below the root layout. `reset` clears
|
||||
* the error and re-renders the segment; we keep the user's nav in place
|
||||
* (no AppShell here — the shell re-renders from the layout above us).
|
||||
*/
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||
<div className="max-w-xl mx-auto text-center space-y-5 py-16">
|
||||
<AlertTriangle className="w-12 h-12 text-danger mx-auto" aria-hidden="true" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-navy">משהו השתבש</h1>
|
||||
<p className="text-ink-muted leading-relaxed">
|
||||
נכשלה טעינת המסך. זה עשוי להיות כשל זמני ברשת או באחד מה-endpoints
|
||||
של FastAPI.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-[0.72rem] text-ink-light tabular-nums">
|
||||
error id: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
{error.message && (
|
||||
<code className="text-[0.78rem] text-ink-soft bg-rule-soft/60 rounded px-3 py-1 inline-block mt-2">
|
||||
{error.message}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button onClick={reset} className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
נסה שוב
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">חזרה לבית</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
60
web-ui/src/app/global-error.tsx
Normal file
60
web-ui/src/app/global-error.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
/*
|
||||
* Root-level error boundary. Renders only when the root layout itself
|
||||
* crashes, so it must include its own <html> / <body>. No AppShell here —
|
||||
* the Providers that wrap AppShell are also above the crash boundary and
|
||||
* may themselves be the thing that failed.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="he" dir="rtl">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f5f1e8",
|
||||
color: "#1a1a2e",
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 520, textAlign: "center" }}>
|
||||
<h1 style={{ color: "#0f172a", fontSize: "2rem", marginBottom: 8 }}>
|
||||
שגיאה חמורה
|
||||
</h1>
|
||||
<p style={{ color: "#6b7280", lineHeight: 1.6, marginBottom: 16 }}>
|
||||
האפליקציה נכשלה לטעון. נסה לרענן את הדף.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p style={{ fontSize: 12, color: "#9ca3af", marginBottom: 16 }}>
|
||||
error id: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#0f172a",
|
||||
color: "#fbf8f0",
|
||||
border: 0,
|
||||
padding: "0.6rem 1.25rem",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.95rem",
|
||||
}}
|
||||
>
|
||||
נסה שוב
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,11 @@ const heebo = Heebo({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "עוזר משפטי — ניהול תיקים",
|
||||
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
|
||||
title: {
|
||||
default: "עוזר משפטי — ניהול תיקים",
|
||||
template: "%s · עוזר משפטי",
|
||||
},
|
||||
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה, ירושלים",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
24
web-ui/src/app/not-found.tsx
Normal file
24
web-ui/src/app/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="max-w-xl mx-auto text-center py-16 space-y-5">
|
||||
<div className="font-display text-gold text-6xl leading-none" aria-hidden="true">
|
||||
404
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-navy">הדף לא נמצא</h1>
|
||||
<p className="text-ink-muted leading-relaxed">
|
||||
הכתובת שביקשת אינה קיימת או שהוזזה לדף אחר.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
<Link href="/">חזרה לבית</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Ezer Mishpati navigation shell.
|
||||
@@ -9,8 +12,9 @@ import Link from "next/link";
|
||||
* - Parchment/cream body background (set on <body> via globals.css)
|
||||
* - Hebrew RTL throughout (set on <html> in layout.tsx)
|
||||
*
|
||||
* Structure mirrors the current vanilla index.html header so that visual
|
||||
* continuity is preserved while we migrate screen-by-screen.
|
||||
* Nav items pick up an `aria-current="page"` and a gold underline when
|
||||
* the current route matches, so screen readers announce the active
|
||||
* section and sighted users can see where they are.
|
||||
*/
|
||||
|
||||
type NavItem = {
|
||||
@@ -19,14 +23,21 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/upload", label: "העלאת מסמכים" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/cases/new", label: "תיק חדש" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
];
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -45,25 +56,43 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
||||
</Link>
|
||||
|
||||
<nav className="me-auto flex items-center gap-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="
|
||||
px-3 py-1.5 rounded
|
||||
text-sm text-parchment/80
|
||||
transition-colors
|
||||
hover:text-parchment hover:bg-navy-soft/60
|
||||
"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<nav
|
||||
className="me-auto flex items-center gap-1"
|
||||
aria-label="ניווט ראשי"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isActive(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`
|
||||
relative px-3 py-1.5 rounded text-sm transition-colors
|
||||
${
|
||||
active
|
||||
? "text-parchment font-semibold bg-navy-soft/80"
|
||||
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
{active && (
|
||||
<span
|
||||
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||
<main
|
||||
id="main"
|
||||
className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -79,9 +79,10 @@ function Row({ item }: { item: CorpusDecision }) {
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={del.isPending}
|
||||
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -79,8 +79,14 @@ export function PartiesField({
|
||||
placeholder="שם מלא של הצד"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={add}>
|
||||
<Plus className="w-4 h-4" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
aria-label={`הוסף ${label}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
|
||||
|
||||
Reference in New Issue
Block a user