diff --git a/apps/web/src/components/changelog/changelog-view.tsx b/apps/web/src/components/changelog/changelog-view.tsx index f749698..e34677f 100644 --- a/apps/web/src/components/changelog/changelog-view.tsx +++ b/apps/web/src/components/changelog/changelog-view.tsx @@ -15,8 +15,19 @@ import { useTheme } from 'next-themes'; * uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt * als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog` * bewusst NICHT, damit der Text nicht in Client-Chunks landet. + * + * quick-260925-bow: `variant="plain"` laesst Rahmen, Hintergrund und + * Innenabstand weg — so rendert das "Was ist neu"-Fenster seine Abschnitte mit + * demselben Renderer wie die Seite /changelog (Vorgabe `card`, Seite + * unveraendert). */ -export function ChangelogView({ markdown }: { markdown: string }) { +export function ChangelogView({ + markdown, + variant = 'card', +}: { + markdown: string; + variant?: 'card' | 'plain'; +}) { const { resolvedTheme } = useTheme(); const [mounted, setMounted] = useState(false); @@ -27,7 +38,10 @@ export function ChangelogView({ markdown }: { markdown: string }) { const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light'; return ( -
+
s.isCollapsed); @@ -37,6 +38,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { > {children} + {/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow): + nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */} +
); } diff --git a/apps/web/src/components/release-notice/release-notice-dialog.tsx b/apps/web/src/components/release-notice/release-notice-dialog.tsx new file mode 100644 index 0000000..c9b5280 --- /dev/null +++ b/apps/web/src/components/release-notice/release-notice-dialog.tsx @@ -0,0 +1,227 @@ +'use client'; + +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { useEffect, useId, useRef } from 'react'; +import { ChangelogView } from '@/components/changelog/changelog-view'; +import type { ReleaseNotice } from '@/lib/release-notes'; + +/** + * "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow, D-07). + * + * Aufbau nach `widget-catalog-modal.tsx`: Hintergrund als echte, benannte + * Schaltflaeche, Dialog mit `role="dialog"`, `aria-modal` und + * `aria-labelledby` auf die Ueberschrift. Anfangsfokus auf der Ueberschrift, + * Escape schliesst, Tab/Umschalt+Tab bleiben im Fenster (die fokussierbaren + * Elemente werden bei jedem Tastendruck neu ermittelt, weil die + * Markdown-Ausgabe Links enthalten kann). Beim Schliessen geht der Fokus an + * das zuvor aktive Element zurueck. Keine Einblendanimation. + * + * Eintraege rendert `ChangelogView` (derselbe Renderer wie /changelog, mit + * `rehype-sanitize`). Dieses Modul importiert `@/lib/changelog` NICHT — den + * Inhalt bekommt es fertig ausgewaehlt als Eigenschaft von der Server-Aktion. + * + * Jeder Schliessweg (Verstanden, Kreuz, Escape, Hintergrund, Link zu + * /changelog) ruft `onClose` genau einmal; gemerkt wird im Host. + */ + +const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +interface ReleaseNoticeDialogProps { + notice: ReleaseNotice; + onClose: () => void; +} + +export function ReleaseNoticeDialog({ notice, onClose }: ReleaseNoticeDialogProps) { + const t = useTranslations('releaseNotice'); + const tCommon = useTranslations('common'); + const titleId = useId(); + const dialogRef = useRef(null); + const headingRef = useRef(null); + const onCloseRef = useRef(onClose); + const closedRef = useRef(false); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + // Ein Schliessweg zaehlt genau einmal, auch wenn z. B. Escape und ein Klick + // kurz nacheinander kommen. + const close = () => { + if (closedRef.current) return; + closedRef.current = true; + onCloseRef.current(); + }; + + // Anfangsfokus und Rueckgabe des Fokus beim Unmount. + useEffect(() => { + const previouslyFocused = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + headingRef.current?.focus(); + return () => { + previouslyFocused?.focus(); + }; + }, []); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const active = document.activeElement; + // Liegt der Fokus in einem ANDEREN Dialog, gehoert die Taste ihm. + const otherDialog = active instanceof Element ? active.closest('[role="dialog"]') : null; + if (otherDialog && otherDialog !== dialogRef.current) return; + + if (event.key === 'Escape') { + event.preventDefault(); + if (closedRef.current) return; + closedRef.current = true; + onCloseRef.current(); + return; + } + if (event.key !== 'Tab') return; + + const dialog = dialogRef.current; + if (!dialog) return; + const focusables = Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusables.length === 0) { + event.preventDefault(); + headingRef.current?.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const index = active instanceof HTMLElement ? focusables.indexOf(active) : -1; + + if (index === -1) { + // Fokus auf der Ueberschrift oder ausserhalb (Hintergrund): ins Fenster holen. + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + return; + } + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, []); + + const multipleVersions = notice.versions.length > 1; + + return ( +
+ {/* Hintergrund als echte, benannte Schaltflaeche (wie im Kachel-Katalog). */} + +
+ +

{t('intro')}

+ + {/* Benannter, scrollbarer Bereich. Ohne eigenes tabIndex: heutige + Chromium- und WebKit-Versionen (Browser, Desktop-App) machen einen + Scroll-Container ohne fokussierbaren Inhalt selbst per Tastatur + erreichbar; enthaelt er Links, scrollt der Tab-Fokus mit. */} +
+
+ {notice.versions.map((release) => { + // Ueberschriften-Hierarchie: bei einer Version sind die Gruppen + // h3; bei mehreren traegt die Version h3 und die Gruppen h4. + const GroupHeading = multipleVersions ? 'h4' : 'h3'; + return ( +
+ {multipleVersions && ( +

+ {t('versionHeading', { version: release.version })} +

+ )} + {release.sections.map((section) => ( +
+ + {t(`section.${section.kind}`)} + + +
+ ))} +
+ ); + })} +
+
+ +
+ {notice.omittedCount > 0 && ( +

+ {t('moreVersions', { count: notice.omittedCount })} +

+ )} +
+ + {t('showAll')} + + +
+
+
+ + ); +} diff --git a/apps/web/src/components/release-notice/release-notice-host.tsx b/apps/web/src/components/release-notice/release-notice-host.tsx new file mode 100644 index 0000000..058cef2 --- /dev/null +++ b/apps/web/src/components/release-notice/release-notice-host.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import type { ReleaseNotice } from '@/lib/release-notes'; +import { fetchReleaseNotice, markReleaseSeenAction } from '@/lib/release-notice-actions'; + +/** + * Laedt das "Was ist neu"-Fenster einmal je Seitenladung im Portal-Rahmen + * (quick-260925-bow, D-03/D-05/D-08). + * + * Eingebunden nur in `AppShell` — die Anmeldeseite liegt im `(auth)`-Layout + * ohne AppShell, der Erststart-Dialog der Desktop-App ist die lokale + * `apps/desktop/src/setup.html` vor dem Portal. Auf `/change-password` + * (liegt im Portal-Rahmen, Pflicht-Passwortwechsel) wird nicht abgefragt; + * erst die naechste Seite danach fragt. Eine Ref-Sperre verhindert einen + * zweiten Abruf (StrictMode-Doppeleffekt, Seitenwechsel ohne Neuladen). + * + * Gemerkt wird erst beim Schliessen, nie beim Oeffnen: zuerst verschwindet + * das Fenster, dann geht `markReleaseSeenAction` an die API. Schlaegt das + * Merken fehl, erscheint das Fenster beim naechsten Laden erneut — gewollt, + * statt die Nachricht stumm zu verlieren. + * + * Das Fenster (mit dem Markdown-Renderer) wird per `React.lazy` nur geladen, + * wenn wirklich eine Nachricht da ist. + */ + +const ReleaseNoticeDialog = lazy(() => + import('./release-notice-dialog').then((mod) => ({ default: mod.ReleaseNoticeDialog })), +); + +export function ReleaseNoticeHost() { + const pathname = usePathname(); + const [notice, setNotice] = useState(null); + const requestedRef = useRef(false); + + useEffect(() => { + if (requestedRef.current) return; + if (pathname?.startsWith('/change-password')) return; + requestedRef.current = true; + + // Bewusst ohne Abbruch-Flag im Aufraeumen: der StrictMode-Doppeleffekt + // raeumt nach dem ersten Lauf auf, der zweite Lauf fragt wegen der Sperre + // nicht erneut — ein Flag wuerde das einzige Ergebnis verwerfen. + fetchReleaseNotice() + .then((result) => { + if (result) setNotice(result); + }) + .catch(() => { + // Still: ohne Nachricht kein Fenster. + }); + }, [pathname]); + + if (!notice) return null; + + const handleClose = () => { + const version = notice.currentRelease; + setNotice(null); + void markReleaseSeenAction(version).catch(() => { + // Still: das Fenster erscheint beim naechsten Laden erneut. + }); + }; + + return ( + + + + ); +} diff --git a/apps/web/src/lib/release-notes.ts b/apps/web/src/lib/release-notes.ts new file mode 100644 index 0000000..a88593a --- /dev/null +++ b/apps/web/src/lib/release-notes.ts @@ -0,0 +1,159 @@ +import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared'; + +/** + * Auswahl der Versionsabschnitte fuer das "Was ist neu"-Fenster + * (quick-260925-bow, D-03/D-04). + * + * Reine Funktionen ueber dem Markdown-Text von CHANGELOG.md. Diese Datei + * importiert bewusst NICHT `@/lib/changelog` und keine React-/Next-Module: + * ihre Typen werden auch von Client-Komponenten (Fenster, Host) benutzt, und + * der Text der Aenderungsliste darf nicht in oeffentliche Client-Chunks + * gelangen. Den Text reicht die Server-Aktion `release-notice-actions.ts` + * herein. + * + * Regeln: + * - Nur `## `-Abschnitte, deren erstes Wort eine freigegebene Version ist + * (`parseReleaseVersion`); "Unveröffentlicht" und alles Unparsebare fallen weg. + * - Darin nur die Gruppen "Neu" (new), "Geändert" (changed, im Fenster + * "Verbessert") und "Behoben" (fixed), in dieser festen Reihenfolge; andere + * Gruppen ("Entfernt" ...) und Gruppen ohne Listenpunkt fallen weg, eine + * Version ohne verbleibende Gruppe ebenso. + * - Gezeigt wird jede Version mit gemerkt < Version ≤ laufend, neueste zuerst, + * hoechstens `RELEASE_NOTICE_MAX_VERSIONS`; der Rest zaehlt in + * `omittedCount`. Ohne (gueltigen) gemerkten Stand nur die laufende Version. + * - Fehlt der Abschnitt der laufenden Version ganz (Web-Abbild passt nicht + * zur API), gibt es kein Fenster. + */ + +export type ReleaseSectionKind = 'new' | 'changed' | 'fixed'; + +export interface ReleaseNotesSection { + kind: ReleaseSectionKind; + /** Nur die Zeilen unter der Gruppenueberschrift, Leerzeilen am Rand entfernt. */ + markdown: string; +} + +export interface ReleaseNotesVersion { + version: string; + sections: ReleaseNotesSection[]; +} + +export interface ReleaseNotice { + currentRelease: string; + versions: ReleaseNotesVersion[]; + omittedCount: number; +} + +export const RELEASE_NOTICE_MAX_VERSIONS = 3; + +const VERSION_HEADING_RE = /^## /; +const GROUP_HEADING_RE = /^### /; +const LIST_ITEM_RE = /^\s*[-*] /; + +const GROUP_KIND: Record = { + Neu: 'new', + Geändert: 'changed', + Behoben: 'fixed', +}; + +const KIND_ORDER: ReleaseSectionKind[] = ['new', 'changed', 'fixed']; + +function trimBlankLines(lines: string[]): string[] { + let start = 0; + let end = lines.length; + while (start < end && lines[start].trim() === '') start++; + while (end > start && lines[end - 1].trim() === '') end--; + return lines.slice(start, end); +} + +/** Alle freigegebenen Versionen der Liste, auch solche ohne gezeigte Gruppe. */ +function parseAllReleases(markdown: string): ReleaseNotesVersion[] { + const lines = markdown.replace(/\r\n?/g, '\n').split('\n'); + const releases: ReleaseNotesVersion[] = []; + + let version: string | null = null; + let groups = new Map(); + let currentGroup: string[] | null = null; + + const flush = () => { + if (version === null) return; + const sections: ReleaseNotesSection[] = []; + for (const kind of KIND_ORDER) { + const body = groups.get(kind); + if (!body?.some((line) => LIST_ITEM_RE.test(line))) continue; + sections.push({ kind, markdown: trimBlankLines(body).join('\n') }); + } + releases.push({ version, sections }); + }; + + for (const line of lines) { + if (VERSION_HEADING_RE.test(line)) { + flush(); + const firstWord = line.slice(3).trim().split(/\s+/)[0] ?? ''; + version = parseReleaseVersion(firstWord); + groups = new Map(); + currentGroup = null; + continue; + } + if (version === null) continue; + if (GROUP_HEADING_RE.test(line)) { + const kind = GROUP_KIND[line.slice(4).trim()]; + if (kind && !groups.has(kind)) { + currentGroup = []; + groups.set(kind, currentGroup); + } else { + currentGroup = null; + } + continue; + } + currentGroup?.push(line); + } + flush(); + + return releases; +} + +/** + * Freigegebene Versionen mit mindestens einer Gruppe Neu/Geändert/Behoben, + * in Dateireihenfolge. + */ +export function parseChangelogReleases(markdown: string): ReleaseNotesVersion[] { + return parseAllReleases(markdown).filter((release) => release.sections.length > 0); +} + +/** + * Inhalt des Fensters oder `null` (kein Fenster). `currentRelease` kommt aus + * `GET /users/me/release-notice` (API, `getRunningRelease()`), + * `lastSeen` ist der gemerkte Stand des Benutzers. + */ +export function selectReleaseNotice( + markdown: string, + currentRelease: string | null, + lastSeen: string | null, +): ReleaseNotice | null { + const current = currentRelease === null ? null : parseReleaseVersion(currentRelease); + if (current === null) return null; + + const seen = lastSeen === null ? null : parseReleaseVersion(lastSeen); + if (seen !== null && compareReleaseVersions(seen, current) >= 0) return null; + + const all = parseAllReleases(markdown); + if (!all.some((release) => release.version === current)) return null; + + const inRange = all + .filter((release) => release.sections.length > 0) + .filter((release) => { + if (compareReleaseVersions(release.version, current) > 0) return false; + if (seen === null) return release.version === current; + return compareReleaseVersions(release.version, seen) > 0; + }) + .sort((a, b) => compareReleaseVersions(b.version, a.version)); + + if (inRange.length === 0) return null; + + return { + currentRelease: current, + versions: inRange.slice(0, RELEASE_NOTICE_MAX_VERSIONS), + omittedCount: Math.max(0, inRange.length - RELEASE_NOTICE_MAX_VERSIONS), + }; +} diff --git a/apps/web/src/lib/release-notice-actions.ts b/apps/web/src/lib/release-notice-actions.ts new file mode 100644 index 0000000..06faf07 --- /dev/null +++ b/apps/web/src/lib/release-notice-actions.ts @@ -0,0 +1,72 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { changelogMarkdown } from '@/lib/changelog'; +import { type ReleaseNotice, selectReleaseNotice } from '@/lib/release-notes'; + +/** + * Server-Aktionen des "Was ist neu"-Fensters (quick-260925-bow). + * + * Eigene `'use server'`-Datei, damit `auth-actions.ts` die Aenderungsliste + * nicht importiert. Server-Code darf `@/lib/changelog` importieren; Client- + * Komponenten bekommen von hier nur eine Aktions-Referenz, der Text der + * Aenderungsliste bleibt im Server-Bundle (Regel aus quick-260916-dcz). + * + * Die laufende Version kommt aus der API (`currentRelease`, einzige Quelle + * `APP_VERSION` der API), nicht aus `NEXT_PUBLIC_APP_VERSION` des Webs. + * Jeder Fehler endet still: kein Fenster bzw. `{ success: false }`. + */ + +const API_URL = + process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === 'string'; +} + +async function sessionCookie(): Promise { + const cookieStore = await cookies(); + return cookieStore.get('session')?.value ?? null; +} + +export async function fetchReleaseNotice(): Promise { + try { + const session = await sessionCookie(); + if (!session) return null; + + const response = await fetch(`${API_URL}/users/me/release-notice`, { + headers: { Cookie: `session=${session}` }, + cache: 'no-store', + }); + if (!response.ok) return null; + + const body: unknown = await response.json(); + if (!body || typeof body !== 'object') return null; + const { currentRelease, lastSeenReleaseVersion } = body as Record; + if (!isStringOrNull(currentRelease) || !isStringOrNull(lastSeenReleaseVersion)) return null; + + return selectReleaseNotice(changelogMarkdown, currentRelease, lastSeenReleaseVersion); + } catch { + return null; + } +} + +export async function markReleaseSeenAction(version: string): Promise<{ success: boolean }> { + try { + const session = await sessionCookie(); + if (!session) return { success: false }; + + const response = await fetch(`${API_URL}/users/me/release-seen`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `session=${session}`, + }, + body: JSON.stringify({ version }), + cache: 'no-store', + }); + return { success: response.ok }; + } catch { + return { success: false }; + } +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 8bb443b..f64e870 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1291,5 +1291,20 @@ "notificationSectionTitle": "Benachrichtigung", "platformFeedsNote": "Diese Feeds werden von der Administration gepflegt und gelten für alle." } + }, + "releaseNotice": { + "title": "Neu in Version {version}", + "intro": "Tessera wurde aktualisiert. Das hat sich für Sie geändert:", + "versionHeading": "Version {version}", + "section": { + "new": "Neu", + "changed": "Verbessert", + "fixed": "Behoben" + }, + "moreVersions": "Dazu kommen Änderungen aus {count, plural, one {# älteren Version} other {# älteren Versionen}}.", + "showAll": "Alle Änderungen ansehen", + "confirm": "Verstanden", + "close": "Fenster schließen", + "contentLabel": "Änderungen" } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index e870449..4ba9647 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1291,5 +1291,20 @@ "notificationSectionTitle": "Notification", "platformFeedsNote": "These feeds are maintained by the administration and apply to everyone." } + }, + "releaseNotice": { + "title": "New in version {version}", + "intro": "Tessera has been updated. Here is what changed for you:", + "versionHeading": "Version {version}", + "section": { + "new": "New", + "changed": "Improved", + "fixed": "Fixed" + }, + "moreVersions": "There are also changes from {count, plural, one {# earlier version} other {# earlier versions}}.", + "showAll": "View all changes", + "confirm": "Got it", + "close": "Close window", + "contentLabel": "Changes" } }