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:
parent
8a076f8c4b
commit
18e68bb176
16 changed files with 1109 additions and 25 deletions
78
apps/journal/app/components/ChangelogIndicator.tsx
Normal file
78
apps/journal/app/components/ChangelogIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/journal/app/lib/changelog.server.ts
Normal file
73
apps/journal/app/lib/changelog.server.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
const modules = import.meta.glob("/changelog/*.md", {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
}) as Record<string, string>;
|
||||
|
||||
export interface ChangelogEntry {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
preview: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): {
|
||||
attrs: Record<string, string>;
|
||||
body: string;
|
||||
} {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { attrs: {}, body: raw };
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const line of match[1]!.split("\n")) {
|
||||
const idx = line.indexOf(":");
|
||||
if (idx === -1) continue;
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
|
||||
attrs[key] = value;
|
||||
}
|
||||
return { attrs, body: match[2]! };
|
||||
}
|
||||
|
||||
function firstParagraph(md: string): string {
|
||||
const trimmed = md.trim();
|
||||
const lines: string[] = [];
|
||||
for (const line of trimmed.split("\n")) {
|
||||
if (line.startsWith("#")) continue;
|
||||
if (lines.length > 0 && line.trim() === "") break;
|
||||
if (line.trim() !== "") lines.push(line);
|
||||
}
|
||||
return lines.join(" ").slice(0, 200);
|
||||
}
|
||||
|
||||
function parseEntry(path: string, raw: string): ChangelogEntry {
|
||||
const slug = path.replace(/^\/changelog\//, "").replace(/\.md$/, "");
|
||||
const { attrs, body } = parseFrontmatter(raw);
|
||||
return {
|
||||
slug,
|
||||
title: attrs.title ?? slug,
|
||||
date: attrs.date ?? slug,
|
||||
preview: firstParagraph(body),
|
||||
content: body,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedEntries: ChangelogEntry[] | null = null;
|
||||
|
||||
export function getAllEntries(): ChangelogEntry[] {
|
||||
if (!cachedEntries) {
|
||||
cachedEntries = Object.entries(modules)
|
||||
.map(([path, raw]) => parseEntry(path, raw))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
return cachedEntries;
|
||||
}
|
||||
|
||||
export function getEntry(slug: string): ChangelogEntry | undefined {
|
||||
return getAllEntries().find((e) => e.slug === slug);
|
||||
}
|
||||
|
||||
export function getNewestDate(): string | null {
|
||||
const entries = getAllEntries();
|
||||
return entries.length > 0 ? entries[0]!.date : null;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect } from "react";
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link } from "react-router";
|
||||
import { ChangelogLink } from "~/components/ChangelogIndicator";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import type { Route } from "./+types/root";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
|
@ -34,12 +35,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { getNewestDate } = await import("~/lib/changelog.server");
|
||||
const user = await getSessionUser(request);
|
||||
const locale = detectLocale(request);
|
||||
return { user: user ? { id: user.id, username: user.username } : null, locale };
|
||||
return { user: user ? { id: user.id, username: user.username } : null, locale, newestChangelogDate: getNewestDate() };
|
||||
}
|
||||
|
||||
function NavBar({ user }: { user: { id: string; username: string } | null }) {
|
||||
function NavBar({ user, newestChangelogDate }: { user: { id: string; username: string } | null; newestChangelogDate: string | null }) {
|
||||
const { t } = useTranslation("journal");
|
||||
const location = useLocation();
|
||||
|
||||
|
|
@ -60,6 +62,7 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
|
|||
<Link to="/" className="text-lg font-semibold text-gray-900">
|
||||
{t("title")}
|
||||
</Link>
|
||||
<ChangelogLink newestDate={newestChangelogDate} className={() => linkClass("/changelog")} />
|
||||
{user && (
|
||||
<>
|
||||
<Link to="/routes" className={linkClass("/routes")}>
|
||||
|
|
@ -124,7 +127,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
return (
|
||||
<LocaleProvider locale={locale}>
|
||||
<NavBar user={user ?? null} />
|
||||
<NavBar user={user ?? null} newestChangelogDate={loaderData?.newestChangelogDate ?? null} />
|
||||
<Outlet />
|
||||
</LocaleProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,4 +30,7 @@ export default [
|
|||
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
|
||||
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
|
||||
route("privacy", "routes/privacy.tsx"),
|
||||
route("changelog", "routes/changelog._index.tsx"),
|
||||
route("changelog/feed.xml", "routes/changelog.feed[.xml].ts"),
|
||||
route("changelog/:slug", "routes/changelog.$slug.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
50
apps/journal/app/routes/changelog.$slug.tsx
Normal file
50
apps/journal/app/routes/changelog.$slug.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { data } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import type { Route } from "./+types/changelog.$slug";
|
||||
import { getEntry } from "~/lib/changelog.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
||||
export function meta({ data: entry }: Route.MetaArgs) {
|
||||
if (!entry) return [{ title: "Not Found — trails.cool" }];
|
||||
return [
|
||||
{ title: `${entry.title} — Changelog — trails.cool` },
|
||||
{ name: "description", content: entry.preview },
|
||||
{ property: "og:title", content: entry.title },
|
||||
{ property: "og:description", content: entry.preview },
|
||||
{ property: "og:url", content: `https://trails.cool/changelog/${entry.slug}` },
|
||||
];
|
||||
}
|
||||
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "alternate", type: "application/atom+xml", title: "trails.cool Changelog", href: "/changelog/feed.xml" },
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const entry = getEntry(params.slug);
|
||||
if (!entry) throw data(null, { status: 404 });
|
||||
return entry;
|
||||
}
|
||||
|
||||
export default function ChangelogEntryPage({ loaderData: entry }: Route.ComponentProps) {
|
||||
const { t } = useTranslation("journal");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<a href="/changelog" className="text-sm text-blue-600 hover:underline">
|
||||
{t("changelog.backToList")}
|
||||
</a>
|
||||
<article className="mt-4">
|
||||
<time className="text-sm text-gray-500">
|
||||
<ClientDate iso={entry.date} />
|
||||
</time>
|
||||
<h1 className="mt-1 text-3xl font-bold text-gray-900">{entry.title}</h1>
|
||||
<div className="prose prose-gray mt-6 max-w-none">
|
||||
<Markdown>{entry.content}</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/journal/app/routes/changelog._index.tsx
Normal file
58
apps/journal/app/routes/changelog._index.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/changelog._index";
|
||||
import { getAllEntries, getNewestDate } from "~/lib/changelog.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ChangelogNotificationToggle } from "~/components/ChangelogIndicator";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: "Changelog — trails.cool" },
|
||||
{ name: "description", content: "What's new on trails.cool" },
|
||||
{ property: "og:title", content: "Changelog — trails.cool" },
|
||||
{ property: "og:description", content: "What's new on trails.cool" },
|
||||
];
|
||||
}
|
||||
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "alternate", type: "application/atom+xml", title: "trails.cool Changelog", href: "/changelog/feed.xml" },
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
return { entries: getAllEntries(), newestDate: getNewestDate() };
|
||||
}
|
||||
|
||||
export default function ChangelogListPage({ loaderData }: Route.ComponentProps) {
|
||||
const { entries } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t("changelog.title")}</h1>
|
||||
<p className="mt-1 text-gray-500">{t("changelog.subtitle")}</p>
|
||||
</div>
|
||||
<ChangelogNotificationToggle />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
{entries.map((entry) => (
|
||||
<Link
|
||||
key={entry.slug}
|
||||
to={`/changelog/${entry.slug}`}
|
||||
className="block rounded-lg border border-gray-200 bg-white p-6 transition hover:border-blue-300 hover:shadow-sm"
|
||||
>
|
||||
<time className="text-sm text-gray-500">
|
||||
<ClientDate iso={entry.date} />
|
||||
</time>
|
||||
<h2 className="mt-1 text-xl font-semibold text-gray-900">{entry.title}</h2>
|
||||
<p className="mt-2 text-gray-600">{entry.preview}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/journal/app/routes/changelog.feed[.xml].ts
Normal file
51
apps/journal/app/routes/changelog.feed[.xml].ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { getAllEntries } from "~/lib/changelog.server";
|
||||
import Markdown from "react-markdown";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createElement } from "react";
|
||||
|
||||
function renderMarkdown(md: string): string {
|
||||
return renderToStaticMarkup(createElement(Markdown, null, md));
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const entries = getAllEntries();
|
||||
const domain = process.env.DOMAIN ?? "trails.cool";
|
||||
const baseUrl = `https://${domain}`;
|
||||
const updated = entries[0]?.date ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
const atomEntries = entries.map((entry) => {
|
||||
const html = renderMarkdown(entry.content);
|
||||
return ` <entry>
|
||||
<title>${escapeXml(entry.title)}</title>
|
||||
<link href="${baseUrl}/changelog/${entry.slug}" rel="alternate"/>
|
||||
<id>${baseUrl}/changelog/${entry.slug}</id>
|
||||
<updated>${entry.date}T00:00:00Z</updated>
|
||||
<content type="html">${escapeXml(html)}</content>
|
||||
</entry>`;
|
||||
});
|
||||
|
||||
const feed = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>trails.cool Changelog</title>
|
||||
<link href="${baseUrl}/changelog" rel="alternate"/>
|
||||
<link href="${baseUrl}/changelog/feed.xml" rel="self"/>
|
||||
<id>${baseUrl}/changelog</id>
|
||||
<updated>${updated}T00:00:00Z</updated>
|
||||
${atomEntries.join("\n")}
|
||||
</feed>`;
|
||||
|
||||
return new Response(feed, {
|
||||
headers: {
|
||||
"Content-Type": "application/atom+xml; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
34
apps/journal/changelog/2026-03-25.md
Normal file
34
apps/journal/changelog/2026-03-25.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
title: "trails.cool is live"
|
||||
date: 2026-03-25
|
||||
---
|
||||
|
||||
We're excited to launch trails.cool — a federated, self-hostable platform for outdoor enthusiasts.
|
||||
|
||||
## Planner: Collaborative Route Planning
|
||||
|
||||
Plan hiking, cycling, and driving routes together in real-time. No account needed — just share a link and start editing.
|
||||
|
||||
- **Real-time collaboration** via Yjs — changes sync instantly across all participants
|
||||
- **Smart routing** powered by BRouter with profiles for hiking, fast cycling, safe cycling, shortest path, and car
|
||||
- **Elevation profiles** with total ascent and descent statistics
|
||||
- **Route coloring** by elevation gradient or surface type
|
||||
- **No-go areas** — draw polygons on the map to avoid specific regions
|
||||
- **GPX import and export** — bring routes from other apps or export for your GPS device
|
||||
- **Session notes** — shared text area for trip planning details
|
||||
- **Crash recovery** — unsaved work is preserved if your browser crashes
|
||||
|
||||
## Journal: Your Activity Hub
|
||||
|
||||
Track your routes and outdoor activities with a personal journal.
|
||||
|
||||
- **Route management** with GPX import/export and sequential versioning
|
||||
- **Activity tracking** — log activities with GPS traces, distances, and durations
|
||||
- **Wahoo integration** — automatic sync of workouts via webhook, plus manual import
|
||||
- **Map previews** on route and activity list pages
|
||||
- **Passkey authentication** — passwordless login, no passwords to remember
|
||||
- **Magic link fallback** for devices without passkey support
|
||||
|
||||
## Self-Hostable and Privacy-First
|
||||
|
||||
trails.cool is designed for data ownership. Host your own instance, keep your data, export everything as GPX or JSON. We collect zero analytics, zero tracking — your routes are yours.
|
||||
Loading…
Add table
Add a link
Reference in a new issue