Compare commits

...

3 Commits

Author SHA1 Message Date
Lambda
ae46ae6b3a fix(landing): align changelog nav day/version columns
Reserve a fixed-width right-aligned slot for the day number so
single-digit days (e.g. "1", "9") don't shift the version column.
2026-04-23 15:50:22 +08:00
Lambda
9e7d5bc106 feat(landing): move changelog date nav to left as timeline sidebar
Moves the date navigation from the right to the left and restyles it
as a grouped timeline:

- Releases are grouped under a month-year header ("April 2026").
- A vertical rail connects a dot per release; the active dot is filled
  with a soft halo ring, the row text goes full-opacity + semibold.
- Clicking a date smooth-scrolls to the release and pins the hash; a
  short nav lock suppresses scroll-spy flicker while the page animates.
- Sidebar is sticky up to viewport height, scrollable when there are
  many releases; on <lg the sidebar collapses and content falls back
  to the existing centered layout.
- Entry headers now render the full localized date for clarity.

Label changed from "On this page" / "本页目录" to "All releases" /
"历史版本" to match the new nav-style role.
2026-04-23 15:48:27 +08:00
Lambda
91fbb16749 feat(landing): add sticky date navigation to changelog page
Adds a right-side "On this page" nav that lists every release date and
scroll-spies the active entry as the user reads through the changelog.
Dates are formatted per locale (e.g. "April 22" / "4月22日").
2026-04-23 15:29:35 +08:00
4 changed files with 292 additions and 58 deletions

View File

@@ -1,8 +1,91 @@
"use client";
import {
type MouseEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { LandingHeader } from "./landing-header";
import { LandingFooter } from "./landing-footer";
import { useLocale } from "../i18n";
import type { Locale } from "../i18n/types";
const MONTHS_EN = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
type ParsedDate = { year: number; month: number; day: number };
function parseDate(dateStr: string): ParsedDate {
const parts = dateStr.split("-");
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
};
}
function monthYearLabel(year: number, month: number, locale: Locale) {
if (!year || !month) return "";
if (locale === "zh") return `${year}\u5e74${month}\u6708`;
return `${MONTHS_EN[month - 1]} ${year}`;
}
function fullDateLabel(dateStr: string, locale: Locale) {
const { year, month, day } = parseDate(dateStr);
if (!year || !month || !day) return dateStr;
if (locale === "zh") return `${year}\u5e74${month}\u6708${day}\u65e5`;
return `${MONTHS_EN[month - 1]} ${day}, ${year}`;
}
type Release = {
version: string;
date: string;
title: string;
changes: string[];
features?: string[];
improvements?: string[];
fixes?: string[];
};
type MonthGroup = {
key: string;
year: number;
month: number;
entries: Release[];
};
function groupByMonth(entries: readonly Release[]): MonthGroup[] {
const groups: MonthGroup[] = [];
for (const entry of entries) {
const { year, month } = parseDate(entry.date);
const key = `${year}-${month}`;
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.entries.push(entry);
} else {
groups.push({ key, year, month, entries: [entry] });
}
}
return groups;
}
function anchorId(version: string) {
return `release-${version.replace(/\./g, "-")}`;
}
function ChangeList({ items }: { items: string[] }) {
return (
@@ -21,74 +104,222 @@ function ChangeList({ items }: { items: string[] }) {
}
export function ChangelogPageClient() {
const { t } = useLocale();
const { t, locale } = useLocale();
const categoryLabels = t.changelog.categories;
const entries = t.changelog.entries;
const groups = useMemo(() => groupByMonth(entries), [entries]);
const [activeVersion, setActiveVersion] = useState<string>(
entries[0]?.version ?? ""
);
const navLockRef = useRef<number | null>(null);
useEffect(() => {
if (entries.length === 0) return;
const visible = new Set<string>();
const observer = new IntersectionObserver(
(observed) => {
observed.forEach((e) => {
const v = (e.target as HTMLElement).dataset.version;
if (!v) return;
if (e.isIntersecting) visible.add(v);
else visible.delete(v);
});
// Ignore observer updates while we're programmatically scrolling
// to a clicked target — otherwise the active indicator flickers
// through each passing entry.
if (navLockRef.current !== null) return;
const firstVisible = entries.find((r) => visible.has(r.version));
if (firstVisible) {
setActiveVersion(firstVisible.version);
return;
}
const scrollY = window.scrollY;
let best = entries[0]?.version ?? "";
for (const r of entries) {
const el = document.getElementById(anchorId(r.version));
if (!el) continue;
if (el.getBoundingClientRect().top + scrollY <= scrollY + 160) {
best = r.version;
}
}
setActiveVersion(best);
},
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
);
entries.forEach((r) => {
const el = document.getElementById(anchorId(r.version));
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [entries]);
const jumpTo =
(version: string) => (e: MouseEvent<HTMLAnchorElement>) => {
const el = document.getElementById(anchorId(version));
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${anchorId(version)}`);
setActiveVersion(version);
if (navLockRef.current !== null) {
window.clearTimeout(navLockRef.current);
}
navLockRef.current = window.setTimeout(() => {
navLockRef.current = null;
}, 800);
};
return (
<>
<LandingHeader variant="light" />
<main className="bg-white text-[#0a0d12]">
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="mx-auto max-w-[1080px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
<div className="lg:grid lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-16">
<aside className="hidden lg:block">
<nav
aria-label={t.changelog.toc}
className="sticky top-28 max-h-[calc(100vh-8rem)] overflow-y-auto pb-8 pr-2"
>
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#0a0d12]/50">
{t.changelog.toc}
</h3>
<div className="mt-16 space-y-16">
{t.changelog.entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
<div className="relative mt-5">
<span
aria-hidden="true"
className="pointer-events-none absolute left-[4px] top-7 bottom-2 w-px bg-[#0a0d12]/10"
/>
return (
<div key={release.version} className="relative">
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{release.date}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
<ol className="space-y-5">
{groups.map((group) => (
<li key={group.key}>
<p className="ml-6 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#0a0d12]/45">
{monthYearLabel(group.year, group.month, locale)}
</p>
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
)}
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
</div>
) : (
<ChangeList items={release.changes} />
)}
<ol className="mt-1.5">
{group.entries.map((release) => {
const isActive =
release.version === activeVersion;
const { day } = parseDate(release.date);
return (
<li key={release.version}>
<a
href={`#${anchorId(release.version)}`}
onClick={jumpTo(release.version)}
aria-current={isActive ? "true" : undefined}
className={[
"group relative flex items-center gap-3 rounded-md py-1 pr-2 text-[13px] transition-colors",
isActive
? "text-[#0a0d12]"
: "text-[#0a0d12]/55 hover:text-[#0a0d12]/80",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"relative z-10 block size-[9px] shrink-0 rounded-full border transition-all duration-200",
isActive
? "border-[#0a0d12] bg-[#0a0d12] ring-4 ring-[#0a0d12]/8"
: "border-[#0a0d12]/25 bg-white group-hover:border-[#0a0d12]/60",
].join(" ")}
/>
<span
className={[
"w-[1.25rem] shrink-0 text-right tabular-nums",
isActive
? "font-semibold"
: "font-medium",
].join(" ")}
>
{day}
</span>
<span className="tabular-nums text-[11px] text-[#0a0d12]/35">
v{release.version}
</span>
</a>
</li>
);
})}
</ol>
</li>
))}
</ol>
</div>
);
})}
</nav>
</aside>
<div className="mx-auto min-w-0 max-w-[720px] lg:mx-0">
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
{t.changelog.title}
</h1>
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
{t.changelog.subtitle}
</p>
<div className="mt-16 space-y-16">
{entries.map((release) => {
const hasCategorized =
release.features || release.improvements || release.fixes;
return (
<section
key={release.version}
id={anchorId(release.version)}
data-version={release.version}
className="relative scroll-mt-28"
>
<div className="flex items-baseline gap-3">
<span className="text-[13px] font-semibold tabular-nums">
v{release.version}
</span>
<span className="text-[13px] text-[#0a0d12]/40">
{fullDateLabel(release.date, locale)}
</span>
</div>
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
{release.title}
</h2>
{hasCategorized ? (
<div className="mt-4 space-y-5">
{release.features && release.features.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.features}
</h3>
<ChangeList items={release.features} />
</div>
)}
{release.improvements &&
release.improvements.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.improvements}
</h3>
<ChangeList items={release.improvements} />
</div>
)}
{release.fixes && release.fixes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-[#0a0d12]/50">
{categoryLabels.fixes}
</h3>
<ChangeList items={release.fixes} />
</div>
)}
</div>
) : (
<ChangeList items={release.changes} />
)}
</section>
);
})}
</div>
</div>
</div>
</div>
</main>

View File

@@ -275,6 +275,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
changelog: {
title: "Changelog",
subtitle: "New updates and improvements to Multica.",
toc: "All releases",
categories: {
features: "New Features",
improvements: "Improvements",

View File

@@ -86,6 +86,7 @@ export type LandingDict = {
changelog: {
title: string;
subtitle: string;
toc: string;
categories: {
features: string;
improvements: string;

View File

@@ -275,6 +275,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
changelog: {
title: "\u66f4\u65b0\u65e5\u5fd7",
subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002",
toc: "\u5386\u53f2\u7248\u672c",
categories: {
features: "新功能",
improvements: "改进",