81d3587a91
Two small UX polishes:
1. Switching between chats no longer slams you to the bottom. Each
conversation's scroll position (pixel offset + stickToBottom flag)
is remembered in a module-scoped Map for the lifetime of the
renderer process. Discord-style: leave Chat A scrolled up, peek at
another conversation, come back — same spot you were reading.
Chats left at the bottom keep auto-following new messages on return.
Reload resets everything (session-only, no localStorage).
The restore runs once messages.length > 0 to avoid the browser
clamping scrollTop to a near-zero scrollHeight before the message
list has rendered. A small isRestoringRef guard prevents the
programmatic scroll event from immediately overwriting the saved
position with a clamped value.
2. Changelog page now shows a version badge in the header that compares
the installed app version against entries[0].version from the
server-side changelog feed. Three states:
* `vX.Y.Z · aktuell` (emerald) — installed matches latest
* `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
* `vX.Y.Z` neutral — installed is ahead of the published feed
(dev/test builds)
Semver compare is integer-major.minor.patch with a graceful
garbage-fallback so a malformed version string doesn't false-flag
a current install as outdated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
8.0 KiB
TypeScript
216 lines
8.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
|
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
// Installed app version comes from the preload bridge (process.env.npm_-
|
|
// package_version at preload build time). Falls back to '0.0.0' outside
|
|
// Electron so the page still renders in a browser preview.
|
|
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
|
|
|
|
export function ChangelogPage() {
|
|
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [visible, setVisible] = useState(PAGE_SIZE);
|
|
|
|
// Compare the installed version against the top changelog entry. The
|
|
// server-side changelog is sorted newest-first by the release script, so
|
|
// entries[0] is always the published latest.
|
|
const latestVersion = entries?.[0]?.version ?? null;
|
|
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
|
|
if (entries === null) return 'loading';
|
|
if (!latestVersion) return 'current';
|
|
const cmp = compareSemver(installedVersion, latestVersion);
|
|
if (cmp === 0) return 'current';
|
|
if (cmp < 0) return 'outdated';
|
|
return 'ahead';
|
|
}, [entries, latestVersion]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const list = await fetchChangelog();
|
|
if (!cancelled) setEntries(list);
|
|
} catch (err: unknown) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : 'Konnte Changelog nicht laden.');
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="min-h-full bg-surface-3 text-fg">
|
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
|
<header className="mb-2 flex items-start gap-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
|
<SparklesIcon className="h-5 w-5" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
|
Was ist neu
|
|
</h1>
|
|
<p className="mt-0.5 text-sm text-fg-muted">
|
|
Alle Änderungen in dieser App, neueste zuerst.
|
|
</p>
|
|
</div>
|
|
<VersionBadge
|
|
status={versionStatus}
|
|
installed={installedVersion}
|
|
latest={latestVersion}
|
|
/>
|
|
</header>
|
|
|
|
{entries === null && !error && (
|
|
<div className="flex items-center gap-2 rounded-xl border border-line bg-surface-2 p-6 text-sm text-fg-muted">
|
|
<SpinnerIcon className="h-4 w-4 text-accent" />
|
|
<span>Lade Changelog…</span>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-sm text-rose-600 dark:text-rose-200">
|
|
<p className="font-semibold">Fehler beim Laden</p>
|
|
<p className="mt-1 text-xs opacity-90">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{entries !== null && entries.length === 0 && !error && (
|
|
<div className="rounded-xl border border-line bg-surface-2 p-8 text-center text-sm text-fg-muted">
|
|
Noch keine Einträge vorhanden.
|
|
</div>
|
|
)}
|
|
|
|
{entries && entries.length > 0 && (
|
|
<ol className="flex flex-col gap-4">
|
|
{entries.slice(0, visible).map((entry) => (
|
|
<li
|
|
key={entry.version}
|
|
className="rounded-2xl border border-line bg-surface-2 p-5 shadow-sm"
|
|
>
|
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
|
<h2 className="font-display text-lg font-semibold tracking-tight text-fg">
|
|
v{entry.version}
|
|
</h2>
|
|
<time
|
|
dateTime={entry.pub_date}
|
|
className="text-xs tabular-nums text-fg-muted"
|
|
>
|
|
{formatDate(entry.pub_date)}
|
|
</time>
|
|
</div>
|
|
<p className="mt-3 whitespace-pre-line break-words text-sm leading-relaxed text-fg">
|
|
{entry.notes}
|
|
</p>
|
|
</li>
|
|
))}
|
|
{visible < entries.length && (
|
|
<li>
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisible((n) => n + PAGE_SIZE)}
|
|
className="inline-flex w-full cursor-pointer items-center justify-center rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm font-semibold text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
Mehr laden ({entries.length - visible} verbleibend)
|
|
</button>
|
|
</li>
|
|
)}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return d.toLocaleDateString('de-DE', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
// Compact status chip in the header that tells the user whether their
|
|
// installed build matches the latest published version. Three visual
|
|
// tones: emerald (current), amber (outdated → update available), neutral
|
|
// (loading / unknown). The "ahead" case (dev build > released) shares the
|
|
// neutral tone since users running it always know what they're doing.
|
|
function VersionBadge({
|
|
status,
|
|
installed,
|
|
latest,
|
|
}: {
|
|
status: 'loading' | 'current' | 'outdated' | 'ahead';
|
|
installed: string;
|
|
latest: string | null;
|
|
}) {
|
|
if (status === 'loading') {
|
|
return (
|
|
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
|
|
v{installed}
|
|
</span>
|
|
);
|
|
}
|
|
if (status === 'current') {
|
|
return (
|
|
<span
|
|
title="Du läufst auf der neuesten Version."
|
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
|
|
>
|
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
|
v{installed} · aktuell
|
|
</span>
|
|
);
|
|
}
|
|
if (status === 'outdated' && latest) {
|
|
return (
|
|
<span
|
|
title={`Update verfügbar — neueste Version: v${latest}.`}
|
|
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
|
|
>
|
|
<span>v{installed} · Update verfügbar</span>
|
|
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
|
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
|
|
>
|
|
v{installed}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Lightweight semver comparator: parses major.minor.patch as ints and
|
|
// compares numerically. Returns negative if a < b, zero if equal, positive
|
|
// if a > b. Handles malformed inputs by treating non-numeric segments as
|
|
// 0 so a typo doesn't flag a perfectly current install as outdated.
|
|
function compareSemver(a: string, b: string): number {
|
|
const parse = (s: string): [number, number, number] => {
|
|
const parts = s.split('.').map((p) => {
|
|
const n = parseInt(p, 10);
|
|
return Number.isFinite(n) ? n : 0;
|
|
});
|
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
};
|
|
const [aMaj, aMin, aPat] = parse(a);
|
|
const [bMaj, bMin, bPat] = parse(b);
|
|
if (aMaj !== bMaj) return aMaj - bMaj;
|
|
if (aMin !== bMin) return aMin - bMin;
|
|
return aPat - bPat;
|
|
}
|