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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
"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",
|
||||
"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