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
|
* uebereinstimmen (mounted-Guard wie in AppShell). Der Markdown-Text kommt
|
||||||
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
|
* als Prop von der Server-Seite; dieses Modul importiert `@/lib/changelog`
|
||||||
* bewusst NICHT, damit der Text nicht in Client-Chunks landet.
|
* 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 { resolvedTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -27,7 +38,10 @@ export function ChangelogView({ markdown }: { markdown: string }) {
|
|||||||
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
|
const mode: 'light' | 'dark' = mounted && resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
return (
|
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
|
<MDEditor.Markdown
|
||||||
source={markdown}
|
source={markdown}
|
||||||
rehypePlugins={[[rehypeSanitize]]}
|
rehypePlugins={[[rehypeSanitize]]}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
|||||||
import { installErrorBuffer } from '@/lib/error-buffer';
|
import { installErrorBuffer } from '@/lib/error-buffer';
|
||||||
import { Header } from '@/components/layout/header';
|
import { Header } from '@/components/layout/header';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { Sidebar } from '@/components/layout/sidebar';
|
||||||
|
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
||||||
@@ -37,6 +38,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
|
||||||
|
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
|
||||||
|
<ReleaseNoticeHost />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1291,5 +1291,20 @@
|
|||||||
"notificationSectionTitle": "Benachrichtigung",
|
"notificationSectionTitle": "Benachrichtigung",
|
||||||
"platformFeedsNote": "Diese Feeds werden von der Administration gepflegt und gelten für alle."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1291,5 +1291,20 @@
|
|||||||
"notificationSectionTitle": "Notification",
|
"notificationSectionTitle": "Notification",
|
||||||
"platformFeedsNote": "These feeds are maintained by the administration and apply to everyone."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user