feat(changelog): What's New page with sidebar badge
Adds a hardcoded changelog (internal/changelog) served via GET /api/changelog and /api/changelog/unseen-count?since=<iso>, a /changelog page that renders entries newest-first, and a sidebar "Neuigkeiten" link with a lime badge showing the count of unseen entries since the caller's last visit (localStorage stamp). - internal/changelog: Entry struct, 11 pre-populated entries covering everything shipped so far (Dashboard, Projects/Deadlines/Appointments, CalDAV, Checklists v2, Glossary, Courts, Invitations, Settings, Paliad rename, and the changelog itself). - Handler: public via auth-gated protected mux. Lexicographic string compare treats YYYY-MM-DD entries and ISO 8601 cutoffs symmetrically. - Sidebar: new sidebar-changelog link before the Einladen button; the badge is populated by a fetch on every page load, suppressed on /changelog itself to avoid flash, and cleared on visit by stamping localStorage in changelog.ts's DOMContentLoaded handler. - i18n: DE + EN keys for nav, page chrome, and tag labels. - Unit tests for sort order, copy semantics, and same-day cutoff. Task: t-paliad-027
This commit is contained in:
41
frontend/src/changelog.tsx
Normal file
41
frontend/src/changelog.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderChangelog(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="changelog.title">Neuigkeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/changelog" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="changelog.heading">Neuigkeiten</h1>
|
||||
<p className="tool-subtitle" data-i18n="changelog.subtitle">
|
||||
Was sich in Paliad in letzter Zeit getan hat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="changelog-list" id="changelog-list" />
|
||||
|
||||
<p className="changelog-empty" id="changelog-empty" style="display:none" data-i18n="changelog.empty">
|
||||
Noch keine Einträge.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/changelog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
15
frontend/src/client/changelog-seen.ts
Normal file
15
frontend/src/client/changelog-seen.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Shared localStorage tracker for the "What's New" badge.
|
||||
//
|
||||
// sidebar.ts reads the stamp on every page to ask the backend how many
|
||||
// entries are newer; changelog.ts writes the stamp when the user visits
|
||||
// /changelog so the badge clears on their next page load.
|
||||
|
||||
export const SEEN_KEY = "paliad-changelog-seen";
|
||||
|
||||
export function getChangelogSeen(): string {
|
||||
return localStorage.getItem(SEEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function markChangelogSeen(): void {
|
||||
localStorage.setItem(SEEN_KEY, new Date().toISOString());
|
||||
}
|
||||
89
frontend/src/client/changelog.ts
Normal file
89
frontend/src/client/changelog.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getLang, initI18n, onLangChange, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { markChangelogSeen } from "./changelog-seen";
|
||||
|
||||
interface Entry {
|
||||
date: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
body_de: string;
|
||||
body_en: string;
|
||||
tag: "feature" | "content" | "fix";
|
||||
}
|
||||
|
||||
let entries: Entry[] = [];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const resp = await fetch("/api/changelog");
|
||||
if (!resp.ok) return;
|
||||
entries = await resp.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
// iso = YYYY-MM-DD. Render locale-aware without Intl allocations per row:
|
||||
// "20. April 2026" (DE) or "20 April 2026" (EN). Cheap and deterministic.
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
const monthsDE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
||||
const monthsEN = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const monthIdx = parseInt(m, 10) - 1;
|
||||
const day = parseInt(d, 10);
|
||||
if (getLang() === "en") {
|
||||
return `${day} ${monthsEN[monthIdx] ?? m} ${y}`;
|
||||
}
|
||||
return `${day}. ${monthsDE[monthIdx] ?? m} ${y}`;
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const list = document.getElementById("changelog-list") as HTMLOListElement | null;
|
||||
const empty = document.getElementById("changelog-empty") as HTMLElement | null;
|
||||
if (!list || !empty) return;
|
||||
|
||||
if (entries.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
const lang = getLang();
|
||||
list.innerHTML = entries.map((e) => {
|
||||
const title = lang === "en" ? e.title_en : e.title_de;
|
||||
const body = lang === "en" ? e.body_en : e.body_de;
|
||||
const tagLabel = t(`changelog.tag.${e.tag}`);
|
||||
return (
|
||||
`<li class="changelog-entry">` +
|
||||
`<div class="changelog-meta">` +
|
||||
`<time class="changelog-date" datetime="${escapeHTML(e.date)}">${escapeHTML(formatDate(e.date))}</time>` +
|
||||
`<span class="changelog-tag changelog-tag-${escapeHTML(e.tag)}">${escapeHTML(tagLabel)}</span>` +
|
||||
`</div>` +
|
||||
`<h2 class="changelog-title">${escapeHTML(title)}</h2>` +
|
||||
`<p class="changelog-body">${escapeHTML(body)}</p>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
// Stamp the visit immediately so the sidebar badge clears even if the
|
||||
// user navigates away before /api/changelog returns.
|
||||
markChangelogSeen();
|
||||
// Also clear any locally-rendered badge in the current DOM so it
|
||||
// disappears without waiting for a reload.
|
||||
document.querySelectorAll<HTMLElement>(".sidebar-badge").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
onLangChange(render);
|
||||
load();
|
||||
});
|
||||
@@ -32,8 +32,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.group.werkzeuge": "Werkzeuge",
|
||||
"nav.group.wissen": "Wissen",
|
||||
"nav.group.ressourcen": "Ressourcen",
|
||||
"nav.neuigkeiten": "Neuigkeiten",
|
||||
"nav.soon.tooltip": "Bald verf\u00fcgbar",
|
||||
|
||||
// Changelog (What's New) — t-paliad-027
|
||||
"changelog.title": "Neuigkeiten — Paliad",
|
||||
"changelog.heading": "Neuigkeiten",
|
||||
"changelog.subtitle": "Was sich in Paliad in letzter Zeit getan hat.",
|
||||
"changelog.empty": "Noch keine Eintr\u00e4ge.",
|
||||
"changelog.tag.feature": "Neu",
|
||||
"changelog.tag.content": "Inhalt",
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 Nur f\u00fcr internen Gebrauch.",
|
||||
|
||||
@@ -1035,8 +1045,18 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.group.werkzeuge": "Tools",
|
||||
"nav.group.wissen": "Knowledge",
|
||||
"nav.group.ressourcen": "Resources",
|
||||
"nav.neuigkeiten": "What's New",
|
||||
"nav.soon.tooltip": "Coming soon",
|
||||
|
||||
// Changelog (What's New) — t-paliad-027
|
||||
"changelog.title": "What's New — Paliad",
|
||||
"changelog.heading": "What's New",
|
||||
"changelog.subtitle": "Recent changes and additions in Paliad.",
|
||||
"changelog.empty": "Nothing here yet.",
|
||||
"changelog.tag.feature": "New",
|
||||
"changelog.tag.content": "Content",
|
||||
"changelog.tag.fix": "Fix",
|
||||
|
||||
// Footer
|
||||
"footer.text": "\u00a9 2026 Paliad \u2014 Internal use only.",
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Sidebar client-side behavior
|
||||
// Hover expand with delay, pin toggle, mobile hamburger
|
||||
|
||||
import { getChangelogSeen } from "./changelog-seen";
|
||||
|
||||
const PIN_KEY = "paliad-sidebar-pinned";
|
||||
const LEGACY_PIN_KEY = "patholo-sidebar-pinned";
|
||||
|
||||
@@ -19,6 +21,7 @@ function migrateLegacyPinKey(): void {
|
||||
export function initSidebar() {
|
||||
migrateLegacyPinKey();
|
||||
initInviteModal();
|
||||
initChangelogBadge();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
|
||||
@@ -123,6 +126,31 @@ export function initSidebar() {
|
||||
});
|
||||
}
|
||||
|
||||
// Changelog badge — fetches the count of entries newer than the locally
|
||||
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
|
||||
// link. Skipped on the changelog page itself because changelog.ts stamps
|
||||
// the visit on load and we don't want a flash of badge before that runs.
|
||||
function initChangelogBadge(): void {
|
||||
const badge = document.getElementById("sidebar-changelog-badge") as HTMLElement | null;
|
||||
if (!badge) return;
|
||||
if (window.location.pathname === "/changelog") return;
|
||||
|
||||
const since = getChangelogSeen();
|
||||
const url = since
|
||||
? `/api/changelog/unseen-count?since=${encodeURIComponent(since)}`
|
||||
: "/api/changelog/unseen-count";
|
||||
fetch(url, { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { count?: number } | null) => {
|
||||
if (!data || typeof data.count !== "number" || data.count <= 0) return;
|
||||
badge.textContent = data.count > 9 ? "9+" : String(data.count);
|
||||
badge.style.display = "";
|
||||
})
|
||||
.catch(() => {
|
||||
// silent: the badge is optional and must never break the page.
|
||||
});
|
||||
}
|
||||
|
||||
// Invitation modal — opened from the sidebar "Kolleg:in einladen" button.
|
||||
// Keeps the whole flow client-side: validates, POSTs to /api/invite, shows
|
||||
// success or the server's error message in the same modal. Kept inside
|
||||
|
||||
@@ -18,6 +18,7 @@ const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor
|
||||
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
||||
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
||||
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
||||
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
|
||||
|
||||
interface SidebarProps {
|
||||
currentPath: string;
|
||||
@@ -108,6 +109,11 @@ export function Sidebar({ currentPath }: SidebarProps): string {
|
||||
<div className="sidebar-spacer" />
|
||||
|
||||
<div className="sidebar-bottom">
|
||||
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
|
||||
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
|
||||
</a>
|
||||
<button type="button" className="sidebar-item sidebar-invite-btn" id="sidebar-invite-btn">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
|
||||
<span className="sidebar-label" data-i18n="invite.button">Kolleg:in einladen</span>
|
||||
|
||||
@@ -5381,3 +5381,113 @@ input[type="range"]::-moz-range-thumb {
|
||||
resize: vertical;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
/* --- Changelog / What's New (t-paliad-027) --- */
|
||||
|
||||
.sidebar-changelog {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-badge {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
left: calc(var(--sidebar-collapsed) - 1rem);
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--color-bg, #fff);
|
||||
pointer-events: none;
|
||||
transition: left 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar.expanded .sidebar-badge,
|
||||
.sidebar.pinned .sidebar-badge {
|
||||
left: auto;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.changelog-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.changelog-entry {
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
padding-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.changelog-entry:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.changelog-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.changelog-date {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.changelog-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.changelog-tag-feature {
|
||||
background: rgba(101, 163, 13, 0.14);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.changelog-tag-content {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.changelog-tag-fix {
|
||||
background: rgba(180, 83, 9, 0.14);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.changelog-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.35rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.changelog-body {
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.changelog-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user