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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user