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>
);
}