feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen

- release-notes.ts: reine Auswahl der Versionsabschnitte aus CHANGELOG.md
- release-notice-actions.ts ('use server'): Abruf und Merken ueber die API
- ReleaseNoticeDialog: barrierefreies Fenster, Eintraege ueber ChangelogView (variant plain)
- ReleaseNoticeHost in AppShell: einmal je Seitenladung, merkt erst beim Schliessen
- Texte releaseNotice in de.json und en.json

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:48:25 +02:00
parent cf7784e409
commit 187fb76c91
8 changed files with 577 additions and 2 deletions
@@ -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 (
<div data-testid="changelog-markdown" className="rounded-md border border-border bg-card p-4">
<div
data-testid="changelog-markdown"
className={variant === 'card' ? 'rounded-md border border-border bg-card p-4' : undefined}
>
<MDEditor.Markdown
source={markdown}
rehypePlugins={[[rehypeSanitize]]}
@@ -5,6 +5,7 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { installErrorBuffer } from '@/lib/error-buffer';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
export function AppShell({ children }: { children: React.ReactNode }) {
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
@@ -37,6 +38,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
>
{children}
</main>
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
<ReleaseNoticeHost />
</div>
);
}
@@ -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<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(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<HTMLElement>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Hintergrund als echte, benannte Schaltflaeche (wie im Kachel-Katalog). */}
<button
type="button"
onClick={close}
aria-label={t('close')}
className="fixed inset-0 bg-black/50"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative z-50 mx-4 flex max-h-[85vh] w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-xl"
>
<div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2
id={titleId}
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-foreground focus:outline-none"
>
{t('title', { version: notice.currentRelease })}
</h2>
<button
type="button"
onClick={close}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon('close')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<p className="px-6 pt-4 text-sm text-muted-foreground">{t('intro')}</p>
{/* 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. */}
<section
aria-label={t('contentLabel')}
className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
>
<div className="flex flex-col gap-5">
{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 (
<div key={release.version} className="flex flex-col gap-3">
{multipleVersions && (
<h3 className="text-base font-semibold text-foreground">
{t('versionHeading', { version: release.version })}
</h3>
)}
{release.sections.map((section) => (
<div key={section.kind} className="flex flex-col gap-1">
<GroupHeading className="text-sm font-semibold text-foreground">
{t(`section.${section.kind}`)}
</GroupHeading>
<ChangelogView markdown={section.markdown} variant="plain" />
</div>
))}
</div>
);
})}
</div>
</section>
<div className="flex flex-col gap-3 border-t border-border px-6 py-4">
{notice.omittedCount > 0 && (
<p className="text-sm text-muted-foreground">
{t('moreVersions', { count: notice.omittedCount })}
</p>
)}
<div className="flex items-center justify-between gap-4">
<Link
href="/changelog"
onClick={close}
className="text-sm font-medium text-primary hover:underline"
>
{t('showAll')}
</Link>
<button
type="button"
onClick={close}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('confirm')}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -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<ReleaseNotice | null>(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 (
<Suspense fallback={null}>
<ReleaseNoticeDialog notice={notice} onClose={handleClose} />
</Suspense>
);
}
+159
View File
@@ -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<string, ReleaseSectionKind> = {
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<ReleaseSectionKind, string[]>();
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),
};
}
@@ -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<string | null> {
const cookieStore = await cookies();
return cookieStore.get('session')?.value ?? null;
}
export async function fetchReleaseNotice(): Promise<ReleaseNotice | null> {
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<string, unknown>;
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 };
}
}
+15
View File
@@ -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"
}
}
+15
View File
@@ -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"
}
}