From b2e8d25828895feded5d957419e0eb2d3103d869 Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 19 Oct 2026 22:33:57 +0200 Subject: [PATCH 1/5] feat: introduce Freshman page and update navigation --- README.md | 15 + src/components/shell/nav.ts | 19 +- src/features/freshman/freshman-page.tsx | 428 ++++++++++++++++++++++++ src/features/freshman/freshman.api.ts | 127 +++++++ src/features/freshman/freshman.data.ts | 186 ++++++++++ src/features/guides/guides-page.tsx | 7 +- src/routeTree.gen.ts | 21 ++ src/routes/dashboard/web/freshman.tsx | 35 ++ src/routes/dashboard/web/guides.tsx | 28 +- tests/freshman.test.mjs | 105 ++++++ tests/server-security.test.mjs | 1 + 11 files changed, 933 insertions(+), 39 deletions(-) create mode 100644 src/features/freshman/freshman-page.tsx create mode 100644 src/features/freshman/freshman.api.ts create mode 100644 src/features/freshman/freshman.data.ts create mode 100644 src/routes/dashboard/web/freshman.tsx create mode 100644 tests/freshman.test.mjs diff --git a/README.md b/README.md index efd41fdc..9c5e9eb5 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,21 @@ signed ID tokens, checks private-key assertions, PKCE, nonce, signature failure, refresh and revocation. It requires no production credentials. The optional compiled HTTP tests are documented in [`docs/idp-phase4c-handoff.md`](docs/idp-phase4c-handoff.md). +## Freshman content preview + +Web › Freshman contains Resources, Telegram Hub, FAQs and Guide tabs. The Guide tab reuses the existing backend-backed +PDF edition management; it loads editions only when selected. The former `/dashboard/web/guides` URL redirects to +`/dashboard/web/freshman?tab=guide`. The selected tab is kept in the URL, including across reloads and browser history. + +Web › Freshman manages the fixed slots on `/matricole`: 10 resources, 8 Telegram links and 5 featured FAQs. +The Resources, Telegram Hub and FAQs tabs use a browser-only mock API and save demo edits in local storage; they +do not update the public site or call backend mutation endpoints. Edit controls require `web:content:write`. Page titles and introductory text stay +fixed. Cards can be reordered within their section without changing slot counts. FAQs are selected from a demo catalog; choosing an already featured question swaps its position. + +The adapter is in `src/features/freshman/freshman.api.ts`, with initial site content in `freshman.data.ts`. Replace +it with authenticated server functions and the existing FAQ catalog when backend support is available. Real +mutations must enforce `web:content:write` on the server and follow the loader invalidation/security test conventions. + ## Production and cutover `pnpm build` writes `.output/`; `pnpm start` runs it. Main-branch CI publishes `ghcr.io/polinetworkorg/admin:latest`. diff --git a/src/components/shell/nav.ts b/src/components/shell/nav.ts index 3403b81f..0621dd02 100644 --- a/src/components/shell/nav.ts +++ b/src/components/shell/nav.ts @@ -71,7 +71,7 @@ export type PageMatch = | { kind: "m365-members" } | { kind: "web-projects" } | { kind: "web-associations" } - | { kind: "web-guides" } + | { kind: "web-freshman" } | { kind: "web-faqs" } | { kind: "web-labels" } | { kind: "web-categories" } @@ -197,13 +197,7 @@ const web: Service = { path: "/dashboard/web/associations", searchPlaceholder: "Search associations…", }, - { - id: "guides", - title: "Freshman guide", - icon: BookOpen, - path: "/dashboard/web/guides", - searchPlaceholder: "Search by version…", - }, + { id: "freshman", title: "Freshman", icon: BookOpen, path: "/dashboard/web/freshman" }, { id: "faqs", title: "FAQs", @@ -306,7 +300,8 @@ export function matchPath(pathname: string): PageMatch { if (third === undefined) { if (second === "projects") return { kind: "web-projects" } if (second === "associations") return { kind: "web-associations" } - if (second === "guides") return { kind: "web-guides" } + if (second === "freshman") return { kind: "web-freshman" } + if (second === "guides") return { kind: "web-freshman" } if (second === "faqs") return { kind: "web-faqs" } if (second === "group-labels") return { kind: "web-labels" } if (second === "groups-by-label") return { kind: "web-categories" } @@ -341,7 +336,7 @@ export function serviceFor(match: PageMatch): Service { return m365 case "web-projects": case "web-associations": - case "web-guides": + case "web-freshman": case "web-faqs": case "web-labels": case "web-categories": @@ -378,8 +373,8 @@ export function sectionFor(match: PageMatch): Section | null { return section(web, "projects") case "web-associations": return section(web, "associations") - case "web-guides": - return section(web, "guides") + case "web-freshman": + return section(web, "freshman") case "web-faqs": return section(web, "faqs") case "web-labels": diff --git a/src/features/freshman/freshman-page.tsx b/src/features/freshman/freshman-page.tsx new file mode 100644 index 00000000..c16c6f89 --- /dev/null +++ b/src/features/freshman/freshman-page.tsx @@ -0,0 +1,428 @@ +import { + ArrowDown, + ArrowUp, + Bell, + BookOpen, + ChartNoAxesColumn, + Folder, + Home, + Map, + Monitor, + Pencil, + Radio, + RefreshCw, + Repeat, + ShoppingCart, + DollarSign, + UserCheck, + Users, +} from "lucide-react" +import { useEffect, useState } from "react" + +import { + fieldControl, + fieldHintId, + FormDialog, + FormField, + IconButton, + InlineAlert, + SectionCard, + SegmentedControl, + TableSkeleton, +} from "@/components/primitives" +import { appToast, PageBar, PageContent, Toolbar, useCan } from "@/components/shell" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { GuidesPage } from "@/features/guides/guides-page" +import type { Guide } from "@/features/guides/types" + +import { + freshmanApi, + freshmanCardInput, + freshmanIconInput, + type FreshmanCard, + type FreshmanCardInput, + type FreshmanContent, +} from "./freshman.api" +import { freshmanFaqCatalog } from "./freshman.data" + +const icons = { + chart: ChartNoAxesColumn, + monitor: Monitor, + book: BookOpen, + map: Map, + news: Radio, + money: DollarSign, + folder: Folder, + repeat: Repeat, + refresh: RefreshCw, + bell: Bell, + user: UserCheck, + users: Users, + cart: ShoppingCart, + home: Home, +} +const groups = [ + { id: "featured", title: "In evidenza" }, + { id: "guides", title: "Guide e Utility" }, + { id: "materials", title: "Materiali" }, + { id: "tools", title: "Tech Tools" }, +] +const tabs = [ + { value: "resources", label: "Resources" }, + { value: "telegram", label: "Telegram Hub" }, + { value: "faqs", label: "FAQs" }, + { value: "guide", label: "Guide" }, +] as const + +export type FreshmanTab = (typeof tabs)[number]["value"] +type Editing = + | { section: "resources" | "telegram"; card: FreshmanCard } + | { section: "faqs"; index: number; faqId: string } + +export function FreshmanPage({ + tab, + onTabChange, + guides, +}: { + tab: FreshmanTab + onTabChange: (tab: FreshmanTab) => void + guides: Guide[] | null +}) { + const canWrite = useCan("web:content:write") + const [content, setContent] = useState(null) + const [loadError, setLoadError] = useState(false) + const [attempt, setAttempt] = useState(0) + const [moving, setMoving] = useState(false) + const [editing, setEditing] = useState(null) + + useEffect(() => { + let ignore = false + freshmanApi + .getContent() + .then((result) => { + if (!ignore) { + setContent(result) + setLoadError(false) + } + }) + .catch((error) => { + console.error(error) + if (!ignore) setLoadError(true) + }) + return () => { + ignore = true + } + }, [attempt]) + + function saved(result: FreshmanContent) { + setContent(result) + setEditing(null) + appToast.success("Demo changes saved in this browser.") + } + + async function move(section: "resources" | "telegram", id: string, direction: -1 | 1) { + setMoving(true) + try { + setContent(await freshmanApi.moveCard(section, id, direction)) + appToast.success("Demo order saved in this browser.") + } catch (error) { + console.error(error) + appToast.error("The demo order could not be saved.") + } finally { + setMoving(false) + } + } + + function cardRows(section: "resources" | "telegram", cards: FreshmanCard[]) { + return cards.map((card, index) => { + const Icon = icons[card.icon] + return ( +
+ +
+

{card.title}

+ {card.description &&

{card.description}

} +

{card.href || "No destination set"}

+
+ {canWrite && ( +
+ void move(section, card.id, -1)} + /> + void move(section, card.id, 1)} + /> + setEditing({ section, card })} + /> +
+ )} +
+ ) + }) + } + + const navigation = ( + } + /> + ) + if (tab === "guide") return + + return ( + <> + + +
+ + Demo content · Changes are saved only in this browser and do not update the public website. The number of + items is fixed. + + {loadError ? ( + setAttempt((value) => value + 1)}> + Retry + + } + > + Demo content could not be loaded. Browser storage may be unavailable or contain invalid data. + + ) : !content ? ( + + ) : ( + <> + {tab === "resources" && + groups.map((group) => ( + + {cardRows( + "resources", + content.resources.filter((card) => card.group === group.id) + )} + + ))} + {tab === "telegram" && ( + + {cardRows("telegram", content.telegram)} + + )} + {tab === "faqs" && ( + + {content.faqIds.map((id, index) => { + const faq = freshmanFaqCatalog.find((item) => item.id === id)! + return ( +
+ {index + 1} +
+

{faq.question}

+

{faq.answer}

+
+ {canWrite && ( + setEditing({ section: "faqs", index, faqId: id })} + /> + )} +
+ ) + })} +
+ )} + + )} +
+
+ {editing && + canWrite && + (editing.section === "faqs" ? ( + setEditing(null)} onSaved={saved} /> + ) : ( + setEditing(null)} onSaved={saved} /> + ))} + + ) +} + +function CardDialog({ + card, + section, + onClose, + onSaved, +}: { + card: FreshmanCard + section: "resources" | "telegram" + onClose: () => void + onSaved: (content: FreshmanContent) => void +}) { + const initial: FreshmanCardInput = { + title: card.title, + description: card.description, + href: card.href, + icon: card.icon, + } + const [values, setValues] = useState(initial) + const [errors, setErrors] = useState>>({}) + const dirty = JSON.stringify(values) !== JSON.stringify(initial) + + async function submit() { + const parsed = freshmanCardInput.safeParse(values) + const nextErrors: Partial> = {} + if (!parsed.success) + for (const issue of parsed.error.issues) { + const field = issue.path[0] + if (field === "title" || field === "description" || field === "href" || field === "icon") + nextErrors[field] = issue.message + } + if (section === "telegram" && !values.href.trim()) nextErrors.href = "Enter a group link." + setErrors(nextErrors) + if (!parsed.success || Object.keys(nextErrors).length) { + const firstInvalid = Object.keys(nextErrors)[0] + if (firstInvalid) document.getElementById(`freshman-${firstInvalid}`)?.focus() + return + } + onSaved(await freshmanApi.updateCard(section, card.id, parsed.data)) + } + + return ( + { + if (!open) onClose() + }} + title={`Edit ${card.title}`} + description="Update this fixed position in the demo page." + noun="card" + dirty={dirty} + submitLabel="Save demo changes" + canSubmit={dirty} + onSubmit={submit} + > + + setValues({ ...values, title: event.target.value })} + /> + + {section === "resources" && ( + +