Add public changelog with Atom feed and What's New indicator

- Changelog entries authored as markdown files in apps/journal/changelog/
  with YAML frontmatter, loaded via Vite import.meta.glob at build time
- /changelog list page with date, title, and preview
- /changelog/:slug detail page with full markdown rendering (react-markdown)
  and Open Graph meta tags for social sharing
- /changelog/feed.xml Atom feed with auto-discovery <link> tags
- "What's New" dot on nav bar using localStorage timestamp tracking
- Notification toggle: users can permanently disable/re-enable the indicator
- i18n strings for en and de
- Initial changelog entry covering the Phase 1 launch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-07 13:49:52 +02:00
parent 8a076f8c4b
commit 18e68bb176
16 changed files with 1109 additions and 25 deletions

View file

@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
const LAST_SEEN_KEY = "changelog:lastSeen";
const DISABLED_KEY = "changelog:disabled";
export function ChangelogLink({
newestDate,
className,
}: {
newestDate: string | null;
className: (active: boolean) => string;
}) {
const { t } = useTranslation("journal");
const location = useLocation();
const [showDot, setShowDot] = useState(false);
const isActive =
location.pathname === "/changelog" ||
location.pathname.startsWith("/changelog/");
useEffect(() => {
if (!newestDate) return;
const disabled = localStorage.getItem(DISABLED_KEY);
if (disabled === "true") return;
const lastSeen = localStorage.getItem(LAST_SEEN_KEY);
if (!lastSeen || newestDate > lastSeen) {
setShowDot(true);
}
}, [newestDate]);
useEffect(() => {
if (isActive && newestDate) {
localStorage.setItem(LAST_SEEN_KEY, newestDate);
setShowDot(false);
}
}, [isActive, newestDate]);
return (
<Link to="/changelog" className={className(isActive)}>
{t("nav.changelog")}
{showDot && (
<span className="ml-1 inline-block h-2 w-2 rounded-full bg-blue-500" />
)}
</Link>
);
}
export function ChangelogNotificationToggle() {
const { t } = useTranslation("journal");
const [disabled, setDisabled] = useState(false);
useEffect(() => {
setDisabled(localStorage.getItem(DISABLED_KEY) === "true");
}, []);
function toggle() {
if (disabled) {
localStorage.removeItem(DISABLED_KEY);
setDisabled(false);
} else {
localStorage.setItem(DISABLED_KEY, "true");
setDisabled(true);
}
}
return (
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-500">
<input
type="checkbox"
checked={!disabled}
onChange={toggle}
className="rounded border-gray-300"
/>
{t("changelog.showNotifications")}
</label>
);
}