Merge pull request #303 from trails-cool/feat/journal-home-feed

Rebuild Journal home: flagship marketing + public activity feed
This commit is contained in:
Ullrich Schäfer 2026-04-24 20:03:28 +02:00 committed by GitHub
commit cabfff6b22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 339 additions and 65 deletions

View file

@ -68,6 +68,8 @@ jobs:
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/app.env
echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env
echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
- name: Copy files to server
uses: appleboy/scp-action@v1

View file

@ -32,6 +32,10 @@ jobs:
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/.env
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> infrastructure/.env
echo "DOMAIN=trails.cool" >> infrastructure/.env
# Flagship marker — the Journal home renders the project
# marketing block when this is "true" and the self-host
# footer link when it's unset.
echo "IS_FLAGSHIP=true" >> infrastructure/.env
- name: Copy configs to server
uses: appleboy/scp-action@v1

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, syncImports } from "@trails-cool/db/schema/journal";
import { activities, routes, syncImports, users } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
@ -134,6 +134,38 @@ export async function listPublicActivitiesForOwner(ownerId: string) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
/**
* Instance-wide public activity feed. Joins users so the caller can
* render "by <displayName>" without a second round-trip. Used by the
* Journal home to give arriving visitors something concrete to look at.
* Unlisted activities are excluded: they're reachable by direct URL
* only and shouldn't surface in any listing.
*/
export async function listRecentPublicActivities(limit: number = 20) {
const db = getDb();
const rows = await db
.select({
id: activities.id,
name: activities.name,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
})
.from(activities)
.innerJoin(users, eq(activities.ownerId, users.id))
.where(eq(activities.visibility, "public"))
.orderBy(desc(activities.createdAt))
.limit(limit);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
const db = getDb();
await db

View file

@ -6,11 +6,14 @@ import type { Route } from "./+types/home";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { credentials } from "@trails-cool/db/schema/journal";
import { listRecentPublicActivities } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
export function meta(_args: Route.MetaArgs) {
return [
{ title: "trails.cool" },
{ name: "description", content: "Your outdoor activity journal" },
{ name: "description", content: "Federated outdoor journal. Plan routes, record activities, own your data." },
];
}
@ -30,14 +33,32 @@ export async function loader({ request }: Route.LoaderArgs) {
showAddPasskey = (row?.count ?? 0) === 0;
}
const recent = await listRecentPublicActivities(20);
const plannerUrl = process.env.PLANNER_URL ?? "https://planner.trails.cool";
const isFlagship = process.env.IS_FLAGSHIP === "true";
return data({
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
showAddPasskey,
plannerUrl,
isFlagship,
activities: recent.map((a) => ({
id: a.id,
name: a.name,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
ownerUsername: a.ownerUsername,
ownerDisplayName: a.ownerDisplayName,
})),
});
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { user, showAddPasskey } = loaderData;
const { user, showAddPasskey, plannerUrl, isFlagship, activities } = loaderData;
const { t } = useTranslation("journal");
const [addingPasskey, setAddingPasskey] = useState(false);
const [passkeyDone, setPasskeyDone] = useState(false);
@ -58,7 +79,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
setError(null);
try {
// Get registration options for existing user
const startResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -99,74 +119,174 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}, [user]);
return (
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold text-gray-900">{t("title")}</h1>
<p className="mt-4 text-lg text-gray-600">{t("subtitle")}</p>
<div className="mx-auto max-w-4xl px-4 py-12">
{/* Hero. The site name lives in the top banner + nav brand, so the
h1 carries the product pitch instead to avoid "trails.cool"
triplication on a narrow strip. */}
<section>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
{t("home.heroTitle")}
</h1>
<p className="mt-4 text-lg text-gray-600">{t("home.heroSubtitle")}</p>
{user ? (
<div className="mt-8">
<p className="text-gray-700">
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
</p>
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
<div className="mt-6 rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">
{t("addPasskeyPrompt")}
</p>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<button
onClick={handleAddPasskey}
disabled={addingPasskey}
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
{user ? (
<div className="mt-6 text-gray-700">
{t("welcome")}{" "}
<a href={`/users/${user.username}`} className="text-blue-600 hover:underline">
{user.displayName ?? user.username}
</a>
</div>
) : (
<>
{/* Primary auth CTAs the two actions we actually want most
visitors to take. */}
<div className="mt-8 flex flex-wrap gap-3">
<a
href="/auth/register"
className="rounded-md bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
{addingPasskey ? t("settingUp") : t("addPasskey")}
</button>
{t("auth.register")}
</a>
<a
href="/auth/login"
className="rounded-md border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{t("auth.login")}
</a>
</div>
)}
{/* Demoted escape hatch: the Planner is anonymous and useful
on its own, but shouldn't compete visually with sign-up. */}
<p className="mt-3 text-sm text-gray-500">
{t("home.tryPlannerPrefix")}
<a href={plannerUrl} className="text-blue-600 hover:underline">
{t("home.tryPlannerLink")}
</a>
{t("home.tryPlannerSuffix")}
</p>
</>
)}
{showAddPasskey && !passkeyDone && supportsPasskey === false && (
<div className="mt-6 rounded-md bg-amber-50 p-4">
<p className="text-sm text-amber-800">
{t("addPasskeyPrompt")}
</p>
<p className="mt-2 text-sm text-amber-600">
{t("auth.passkeyNotSupported")}
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
<div className="mt-6 rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">{t("addPasskeyPrompt")}</p>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<button
onClick={handleAddPasskey}
disabled={addingPasskey}
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{addingPasskey ? t("settingUp") : t("addPasskey")}
</button>
</div>
)}
{showAddPasskey && !passkeyDone && supportsPasskey === false && (
<div className="mt-6 rounded-md bg-amber-50 p-4">
<p className="text-sm text-amber-800">{t("addPasskeyPrompt")}</p>
<p className="mt-2 text-sm text-amber-600">{t("auth.passkeyNotSupported")}</p>
</div>
)}
{passkeyDone && (
<div className="mt-6 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">{t("passkeyAdded")}</p>
</div>
)}
</section>
{/* Marketing blurbs flagship only. Self-hosted instances link out
via the "Powered by trails.cool" footer line below the feed.
Card styling mirrors the Planner's feature grid (emoji icon,
bordered card) so the two home pages feel consistent. */}
{isFlagship && (
<section className="mt-12 grid gap-4 border-t border-gray-200 pt-10 sm:grid-cols-2">
{[
{ key: "planner", icon: "🗺️" },
{ key: "journal", icon: "📓" },
{ key: "federation", icon: "🌐" },
{ key: "ownership", icon: "🔓" },
].map(({ key, icon }) => (
<div key={key} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="text-2xl" aria-hidden="true">{icon}</div>
<h2 className="mt-2 text-base font-semibold text-gray-900">
{t(`home.marketing.${key}.title`)}
</h2>
<p className="mt-1.5 text-sm text-gray-600">
{t(`home.marketing.${key}.body`)}
</p>
</div>
)}
{passkeyDone && (
<div className="mt-6 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
{t("passkeyAdded")}
</p>
</div>
)}
</div>
) : (
<div className="mt-8 flex gap-4">
<a
href="/auth/register"
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
{t("auth.register")}
</a>
<a
href="/auth/login"
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
>
{t("auth.login")}
</a>
</div>
))}
</section>
)}
<footer className="mt-16 border-t border-gray-200 pt-6">
<a href="/privacy" className="text-sm text-gray-400 hover:text-gray-600">
Privacy
</a>
</footer>
{/* Public activity feed */}
<section className="mt-12">
<h2 className="text-xl font-semibold text-gray-900">{t("home.feed.heading")}</h2>
{activities.length === 0 ? (
<p className="mt-4 text-sm text-gray-500">{t("home.feed.empty")}</p>
) : (
<ul className="mt-6 space-y-4">
{activities.map((a) => (
<li key={a.id}>
<a
href={`/activities/${a.id}`}
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
>
<div className="flex gap-4">
<div className="w-40 shrink-0">
{a.geojson ? (
<ClientMap geojson={a.geojson} />
) : (
<div className="flex h-28 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
{t("routes.noMapPreview")}
</div>
)}
</div>
<div className="flex flex-1 flex-col justify-between">
<div>
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
<div className="mt-1 text-sm text-gray-500">
<a
href={`/users/${a.ownerUsername}`}
className="hover:text-gray-700 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{a.ownerDisplayName ?? a.ownerUsername}
</a>
{" · "}
<ClientDate iso={a.startedAt ?? a.createdAt} />
</div>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{a.distance != null && (
<span>{(a.distance / 1000).toFixed(1)} km</span>
)}
{a.elevationGain != null && (
<span> {Math.round(a.elevationGain)} m</span>
)}
</div>
</div>
</div>
</div>
</a>
</li>
))}
</ul>
)}
</section>
{/* Self-hosted instances link back to the flagship for project info.
Flagship instances already show the marketing section above. */}
{!isFlagship && (
<p className="mt-8 text-sm text-gray-500">
<a
href="https://trails.cool"
className="hover:text-gray-700 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{t("home.poweredBy")}
</a>
</p>
)}
</div>
);
}

View file

@ -4,7 +4,9 @@ test.describe("Journal", () => {
test("loads the home page", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle("trails.cool");
await expect(page.getByText("Your outdoor activity journal")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Federated outdoor journal" }),
).toBeVisible();
});
test("shows register and sign in when logged out", async ({ page }) => {

View file

@ -23,6 +23,11 @@ services:
DOMAIN: ${DOMAIN:-trails.cool}
ORIGIN: https://${DOMAIN:-trails.cool}
PLANNER_URL: https://planner.${DOMAIN:-trails.cool}
# "true" only on the flagship trails.cool instance. The Journal
# home renders the project marketing block when set, and the
# "Powered by trails.cool" footer link when not. Default empty =
# self-host, which is the safer default.
IS_FLAGSHIP: ${IS_FLAGSHIP:-}
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}

View file

@ -46,3 +46,14 @@ Activities SHALL be allowed to exist without a linked route.
#### Scenario: Standalone activity
- **WHEN** a user imports a GPX activity without linking to a route
- **THEN** the activity is stored with route_id as null
### Requirement: Instance-wide public activity feed
The Journal SHALL expose a reverse-chronological feed of every activity on the instance with visibility `public`. This feed powers the instance home page (see `journal-landing`). `private` and `unlisted` activities SHALL NOT appear in this feed; `unlisted` stays reachable by direct URL only.
#### Scenario: Public activity appears in the instance feed
- **WHEN** an owner marks an activity as `public` and another visitor loads the home page
- **THEN** the visitor sees the activity in the instance feed, with the owner's display name, distance, and start date
#### Scenario: Private or unlisted activities stay out of the feed
- **WHEN** an activity's visibility is `private` or `unlisted`
- **THEN** it does not appear in the instance feed, regardless of who is viewing

View file

@ -0,0 +1,38 @@
## Purpose
The Journal home page (`/`) serves two audiences with one layout: anonymous visitors arriving at the flagship trails.cool instance, and anyone landing on a self-hosted Journal instance. It introduces the product on the flagship, shows a public activity feed on every instance, and avoids asking self-hosters to write their own marketing copy.
## Requirements
### Requirement: Hero and auth CTAs
The home page SHALL render a hero section with a product-describing headline, a short tagline, and — for logged-out visitors — Register and Sign In as the primary CTAs. A link to the Planner SHALL be available but visually secondary to auth, because the main conversion goal is account creation.
#### Scenario: Logged-out visitor sees auth CTAs
- **WHEN** a visitor without a session loads the home page
- **THEN** Register (primary button) and Sign In (outlined button) appear together, with a smaller "Or try the Planner without an account →" link below them
#### Scenario: Logged-in user sees welcome instead of CTAs
- **WHEN** a signed-in user loads the home page
- **THEN** the auth CTAs are replaced by a welcome line linking to their profile
### Requirement: Flagship-only marketing section
The home page SHALL render a four-card marketing section (Planner / Journal / Federation / Ownership) if and only if the `IS_FLAGSHIP` environment variable is `"true"`. Self-hosted instances SHALL NOT render this block; instead they SHALL show a "Powered by trails.cool — about the project" link that points to `https://trails.cool` so operators don't have to author their own marketing copy.
#### Scenario: Flagship shows marketing
- **WHEN** the home page renders with `IS_FLAGSHIP=true`
- **THEN** the four marketing cards are shown and the "Powered by" link is suppressed
#### Scenario: Self-host shows the back-link
- **WHEN** the home page renders with `IS_FLAGSHIP` unset or empty
- **THEN** the marketing cards are not rendered and a "Powered by trails.cool — about the project" link appears below the feed
### Requirement: Public activity feed on home
The home page SHALL list the most recent public activities on the instance (see `activity-feed` spec, "Instance-wide public activity feed"). Each entry SHALL link to the activity detail page, show a map thumbnail when geometry is available, and display the owner's display name, distance, and start date.
#### Scenario: Feed populated
- **WHEN** public activities exist on the instance
- **THEN** the home renders a reverse-chronological list of at least the 20 most recent, each as a card with map thumbnail + owner + stats + date
#### Scenario: Empty feed
- **WHEN** the instance has no public activities
- **THEN** the home shows an empty-state message instead of the feed list; all other sections (hero, marketing, CTAs) remain unchanged

View file

@ -162,6 +162,36 @@ export default {
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
passkeyStatus: "{{count}} Passkey registriert",
passkeyStatus_other: "{{count}} Passkeys registriert",
home: {
heroTitle: "Föderiertes Outdoor-Tagebuch",
heroSubtitle: "Plane Routen, halte Aktivitäten fest, behalte deine Daten.",
tryPlannerPrefix: "Oder ",
tryPlannerLink: "öffne den Planer ohne Konto",
tryPlannerSuffix: " →",
marketing: {
planner: {
title: "Routen gemeinsam planen",
body: "Skizziere Wander- und Radrouten gemeinsam in Echtzeit. Ohne Konto.",
},
journal: {
title: "Aktivitäten festhalten",
body: "Halte deine Touren, Wanderungen und Spaziergänge fest. Privat, mit Freunden geteilt oder öffentlich.",
},
federation: {
title: "Föderiert by design",
body: "Deine Journal-Instanz spricht via ActivityPub mit anderen trails.cool-Servern. Folge Freundinnen und Freunden überall.",
},
ownership: {
title: "Deine Daten, immer",
body: "Keine Werbung, kein Tracking, kein Datenverkauf. Export als GPX oder JSON jederzeit. MIT-lizenziert und selbst hostbar.",
},
},
feed: {
heading: "Aktuelle Aktivitäten",
empty: "Noch keine öffentlichen Aktivitäten.",
},
poweredBy: "Powered by trails.cool — über das Projekt",
},
routes: {
title: "Routen",
myRoutes: "Meine Routen",

View file

@ -162,6 +162,36 @@ export default {
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
passkeyStatus: "{{count}} passkey registered",
passkeyStatus_other: "{{count}} passkeys registered",
home: {
heroTitle: "Federated outdoor journal",
heroSubtitle: "Plan routes, record activities, own your data.",
tryPlannerPrefix: "Or ",
tryPlannerLink: "try the Planner without an account",
tryPlannerSuffix: " →",
marketing: {
planner: {
title: "Plan routes together",
body: "Sketch hiking and cycling routes collaboratively in real time. No account needed.",
},
journal: {
title: "Record activities",
body: "Log your rides, hikes, and walks. Keep them private, share with friends, or publish to the world.",
},
federation: {
title: "Federated by design",
body: "Your Journal instance talks to other trails.cool servers via ActivityPub. Follow friends anywhere.",
},
ownership: {
title: "Your data, always",
body: "No ads, no tracking, no data sales. Export everything as GPX or JSON any time. MIT licensed and self-hostable.",
},
},
feed: {
heading: "Recent activity",
empty: "No public activities yet.",
},
poweredBy: "Powered by trails.cool — about the project",
},
routes: {
title: "Routes",
myRoutes: "My Routes",