Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Ullrich Schäfer
18e68bb176 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>
2026-04-07 13:49:52 +02:00
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>
);
}

View 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;
}

View file

@ -1,5 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link } from "react-router"; 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 { LinksFunction } from "react-router";
import type { Route } from "./+types/root"; import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
@ -34,12 +35,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const { getNewestDate } = await import("~/lib/changelog.server");
const user = await getSessionUser(request); const user = await getSessionUser(request);
const locale = detectLocale(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 { t } = useTranslation("journal");
const location = useLocation(); 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"> <Link to="/" className="text-lg font-semibold text-gray-900">
{t("title")} {t("title")}
</Link> </Link>
<ChangelogLink newestDate={newestChangelogDate} className={() => linkClass("/changelog")} />
{user && ( {user && (
<> <>
<Link to="/routes" className={linkClass("/routes")}> <Link to="/routes" className={linkClass("/routes")}>
@ -124,7 +127,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
return ( return (
<LocaleProvider locale={locale}> <LocaleProvider locale={locale}>
<NavBar user={user ?? null} /> <NavBar user={user ?? null} newestChangelogDate={loaderData?.newestChangelogDate ?? null} />
<Outlet /> <Outlet />
</LocaleProvider> </LocaleProvider>
); );

View file

@ -30,4 +30,7 @@ export default [
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
route("privacy", "routes/privacy.tsx"), 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; ] satisfies RouteConfig;

View 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>
);
}

View 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>
);
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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",
},
});
}

View 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.

View file

@ -16,7 +16,7 @@ produces shareable social links.
**Non-Goals:** **Non-Goals:**
- CMS or admin interface for writing entries - CMS or admin interface for writing entries
- RSS feed (future, not now) - RSS/Atom feed for subscribing to updates
- Email notifications for new entries - Email notifications for new entries
- Per-entry images or rich media (just markdown text) - Per-entry images or rich media (just markdown text)
- Bilingual entries (English only for now) - Bilingual entries (English only for now)
@ -50,11 +50,30 @@ Store `changelog:lastSeen` timestamp in localStorage. If the newest entry's
date is after this timestamp, show a dot on the nav "Changelog" link. Clicking date is after this timestamp, show a dot on the nav "Changelog" link. Clicking
the changelog page updates the timestamp. No server state needed. the changelog page updates the timestamp. No server state needed.
Users can permanently disable the "What's New" indicator by setting
`changelog:disabled` in localStorage. A "Don't show again" option is available
on the changelog page. When disabled, the dot never appears regardless of new
entries. The setting can be re-enabled from the changelog page itself via a
"Show new entry notifications" toggle. All client-side, no auth required.
### D4: Open Graph meta for social sharing ### D4: Open Graph meta for social sharing
Each `/changelog/:date` page sets `og:title`, `og:description` (first Each `/changelog/:date` page sets `og:title`, `og:description` (first
paragraph), and `og:url`. No og:image for now — text previews are fine. paragraph), and `og:url`. No og:image for now — text previews are fine.
### D5: RSS/Atom feed at `/changelog/feed.xml`
A standard Atom feed at `/changelog/feed.xml` so users and tools can subscribe.
Generated server-side from the same changelog entries used by the HTML pages.
Each `<entry>` includes the title, date, full markdown content rendered as HTML,
and a link to the individual changelog page.
The feed URL is advertised via a `<link rel="alternate" type="application/atom+xml">`
tag in the `<head>` of the changelog pages, so feed readers auto-discover it.
Atom over RSS 2.0 because Atom has a proper spec (RFC 4287), required dates,
and better content handling. All major feed readers support both.
## Risks / Trade-offs ## Risks / Trade-offs
- **Build-time loading means deploy to publish** → Acceptable. We deploy on - **Build-time loading means deploy to publish** → Acceptable. We deploy on

View file

@ -22,7 +22,7 @@ project is alive and improving), and social followers (shareable updates).
### New Capabilities ### New Capabilities
- `changelog`: Public changelog with dated entries, shareable individual pages with OG tags, "what's new" indicator - `changelog`: Public changelog with dated entries, shareable individual pages with OG tags, "what's new" indicator, RSS/Atom feed
### Modified Capabilities ### Modified Capabilities
@ -30,8 +30,8 @@ project is alive and improving), and social followers (shareable updates).
## Impact ## Impact
- **Files**: New routes (`/changelog`, `/changelog/:slug`), markdown entry files - **Files**: New routes (`/changelog`, `/changelog/:slug`, `/changelog/feed.xml`),
in `docs/changelog/` or `apps/journal/changelog/`, nav bar update markdown entry files in `apps/journal/changelog/`, nav bar update
- **Dependencies**: A markdown renderer (could reuse `react-markdown` or render - **Dependencies**: A markdown renderer (could reuse `react-markdown` or render
at build time) at build time)
- **i18n**: Changelog entries written in English (primary audience), German - **i18n**: Changelog entries written in English (primary audience), German

View file

@ -35,3 +35,24 @@ The system SHALL show a visual indicator when there are changelog entries the us
#### Scenario: Indicator dismissed #### Scenario: Indicator dismissed
- **WHEN** a user visits the /changelog page - **WHEN** a user visits the /changelog page
- **THEN** the indicator is dismissed (localStorage timestamp updated) - **THEN** the indicator is dismissed (localStorage timestamp updated)
#### Scenario: Permanently disable indicator
- **WHEN** a user clicks "Don't show again" on the changelog page
- **THEN** `changelog:disabled` is set in localStorage
- **AND** the "What's New" dot no longer appears for any future entries
#### Scenario: Re-enable indicator
- **WHEN** a user toggles "Show new entry notifications" on the changelog page
- **THEN** `changelog:disabled` is removed from localStorage
- **AND** the indicator resumes normal behavior
### Requirement: RSS/Atom feed
The system SHALL provide an Atom feed at `/changelog/feed.xml` for subscribing to changelog updates.
#### Scenario: Subscribe to feed
- **WHEN** a feed reader requests `/changelog/feed.xml`
- **THEN** it receives a valid Atom feed with all changelog entries including title, date, content, and link
#### Scenario: Feed auto-discovery
- **WHEN** a browser or feed reader visits any `/changelog` page
- **THEN** a `<link rel="alternate" type="application/atom+xml">` tag in the page head points to the feed URL

View file

@ -1,29 +1,41 @@
## 1. Changelog Data Layer ## 1. Changelog Data Layer
- [ ] 1.1 Create `apps/journal/changelog/` directory with a sample entry (`2026-03-25.md`) covering the initial launch - [x] 1.1 Create `apps/journal/changelog/` directory with a sample entry (`2026-03-25.md`) covering the initial launch
- [ ] 1.2 Set up Vite `import.meta.glob` to load all `.md` files from the changelog directory with frontmatter parsing - [x] 1.2 Set up Vite `import.meta.glob` to load all `.md` files from the changelog directory with frontmatter parsing
- [ ] 1.3 Create `apps/journal/app/lib/changelog.server.ts` with functions: getAllEntries(), getEntry(date) returning parsed markdown + frontmatter - [x] 1.3 Create `apps/journal/app/lib/changelog.server.ts` with functions: getAllEntries(), getEntry(date) returning parsed markdown + frontmatter
## 2. Routes ## 2. Routes
- [ ] 2.1 Add `/changelog` route showing all entries (date, title, preview) newest-first - [x] 2.1 Add `/changelog` route showing all entries (date, title, preview) newest-first
- [ ] 2.2 Add `/changelog/:date` route rendering full markdown entry - [x] 2.2 Add `/changelog/:date` route rendering full markdown entry
- [ ] 2.3 Add Open Graph meta tags on entry pages (og:title, og:description, og:url) - [x] 2.3 Add Open Graph meta tags on entry pages (og:title, og:description, og:url)
- [ ] 2.4 Add both routes to `routes.ts` - [x] 2.4 Add both routes to `routes.ts`
## 3. What's New Indicator ## 3. RSS/Atom Feed
- [ ] 3.1 Add "Changelog" link to Journal nav bar - [x] 3.1 Add `/changelog/feed.xml` route that returns an Atom feed (application/atom+xml) with all entries
- [ ] 3.2 Track `changelog:lastSeen` in localStorage, show dot when newest entry is newer - [x] 3.2 Add `<link rel="alternate" type="application/atom+xml">` to changelog page heads for feed auto-discovery
- [ ] 3.3 Clear indicator when user visits /changelog
## 4. Content ## 4. What's New Indicator
- [ ] 4.1 Write initial changelog entry for the launch (features shipped in Phase 1) - [x] 4.1 Add "Changelog" link to Journal nav bar
- [ ] 4.2 Add markdown rendering (react-markdown or built-in) for entry content - [x] 4.2 Track `changelog:lastSeen` in localStorage, show dot when newest entry is newer
- [x] 4.3 Clear indicator when user visits /changelog
- [x] 4.4 Add "Don't show again" option on changelog page that sets `changelog:disabled` in localStorage
- [x] 4.5 Add "Show new entry notifications" toggle on changelog page to re-enable the indicator
- [x] 4.6 Skip indicator check when `changelog:disabled` is set
## 5. Verify ## 5. Content
- [ ] 5.1 Verify /changelog lists entries correctly - [x] 5.1 Write initial changelog entry for the launch (features shipped in Phase 1)
- [ ] 5.2 Verify /changelog/:date renders full entry with OG tags - [x] 5.2 Add markdown rendering (react-markdown or built-in) for entry content
- [ ] 5.3 Verify "What's New" dot appears and dismisses
## 6. Verify
- [x] 6.1 Verify /changelog lists entries correctly
- [x] 6.2 Verify /changelog/:date renders full entry with OG tags
- [x] 6.3 Verify "What's New" dot appears and dismisses
- [x] 6.6 Verify "Don't show again" disables indicator permanently
- [x] 6.7 Verify re-enabling notifications restores indicator behavior
- [x] 6.4 Verify /changelog/feed.xml returns valid Atom feed
- [x] 6.5 Verify feed auto-discovery link is present in page source

View file

@ -66,6 +66,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0",
"react-router": "catalog:", "react-router": "catalog:",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"turbo": "^2.9.3", "turbo": "^2.9.3",

View file

@ -192,9 +192,16 @@ export default {
previous: "Zurück", previous: "Zurück",
next: "Weiter", next: "Weiter",
}, },
changelog: {
title: "Änderungsprotokoll",
subtitle: "Was gibt es Neues auf trails.cool",
backToList: "Zurück zum Änderungsprotokoll",
showNotifications: "Über neue Einträge benachrichtigen",
},
nav: { nav: {
routes: "Routen", routes: "Routen",
activities: "Aktivitäten", activities: "Aktivitäten",
changelog: "Änderungen",
login: "Anmelden", login: "Anmelden",
register: "Registrieren", register: "Registrieren",
profile: "Profil", profile: "Profil",

View file

@ -192,9 +192,16 @@ export default {
previous: "Previous", previous: "Previous",
next: "Next", next: "Next",
}, },
changelog: {
title: "Changelog",
subtitle: "What's new on trails.cool",
backToList: "Back to Changelog",
showNotifications: "Notify me of new entries",
},
nav: { nav: {
routes: "Routes", routes: "Routes",
activities: "Activities", activities: "Activities",
changelog: "Changelog",
login: "Sign In", login: "Sign In",
register: "Register", register: "Register",
profile: "Profile", profile: "Profile",

667
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff