From f0ef989ac3e3eb0f84f6285352b9acaa468ada58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 24 Apr 2026 19:35:45 +0200 Subject: [PATCH] Rebuild the Journal home around a public feed and flagship marketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old home was an h1, subtitle, and two auth buttons — visitors arriving at trails.cool had no idea what it was, and self-hosted instances had nothing interesting to land on. The new home is one layout that serves both audiences: - Hero: a product-describing h1 ("Federated outdoor journal") + tagline. The site name already lives in the top banner and nav brand, so the h1 carries the pitch instead of triplicating "trails.cool". - Auth CTAs: Register (blue) + Sign In (outlined) get primary weight. Planner demotes to a small "Or try the Planner without an account →" link below them — it's a nice escape hatch but not the goal. - Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards for Planner / Journal / Federation / Ownership, matching the Planner home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get a "Powered by trails.cool — about the project" link back to the flagship instead, so they don't have to write their own marketing. - Public feed: reverse-chronological list of the 20 most recent public activities on the instance, with owner + distance + date. Empty state for fresh installs. Plumbing: - `listRecentPublicActivities(limit)` in activities.server.ts joins users so the feed can render "by " in one query. - `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`, so self-hosters (who deploy without these workflows) default to off. Specs: - `activity-feed`: new "Instance-wide public activity feed" requirement so the visibility contract is pinned (public in feed, unlisted + private are not). - `journal-landing`: new small spec capturing the hero / marketing / feed layout contract and the `IS_FLAGSHIP` toggle, so future instance-branding or empty-state changes have a stable anchor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cd-apps.yml | 2 + .github/workflows/cd-infra.yml | 4 + apps/journal/app/lib/activities.server.ts | 34 ++- apps/journal/app/routes/home.tsx | 246 ++++++++++++++++------ e2e/journal.test.ts | 4 +- infrastructure/docker-compose.yml | 5 + openspec/specs/activity-feed/spec.md | 11 + openspec/specs/journal-landing/spec.md | 38 ++++ packages/i18n/src/locales/de.ts | 30 +++ packages/i18n/src/locales/en.ts | 30 +++ 10 files changed, 339 insertions(+), 65 deletions(-) create mode 100644 openspec/specs/journal-landing/spec.md diff --git a/.github/workflows/cd-apps.yml b/.github/workflows/cd-apps.yml index fb709f0..5ba0f06 100644 --- a/.github/workflows/cd-apps.yml +++ b/.github/workflows/cd-apps.yml @@ -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 diff --git a/.github/workflows/cd-infra.yml b/.github/workflows/cd-infra.yml index 3ac4b65..d317ae1 100644 --- a/.github/workflows/cd-infra.yml +++ b/.github/workflows/cd-infra.yml @@ -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 diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index f2ea007..4918894 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -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 " 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 diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index 4c3f8e2..f3d9802 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -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 ( -
-

{t("title")}

-

{t("subtitle")}

+
+ {/* 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. */} +
+

+ {t("home.heroTitle")} +

+

{t("home.heroSubtitle")}

- {user ? ( -
-

- {t("welcome")} {user.displayName ?? user.username} -

- - {showAddPasskey && !passkeyDone && supportsPasskey === true && ( -
-

- {t("addPasskeyPrompt")} -

- {error &&

{error}

} -
+ ) : ( + <> + {/* Primary auth CTAs — the two actions we actually want most + visitors to take. */} + - )} + {/* Demoted escape hatch: the Planner is anonymous and useful + on its own, but shouldn't compete visually with sign-up. */} +

+ {t("home.tryPlannerPrefix")} + + {t("home.tryPlannerLink")} + + {t("home.tryPlannerSuffix")} +

+ + )} - {showAddPasskey && !passkeyDone && supportsPasskey === false && ( -
-

- {t("addPasskeyPrompt")} -

-

- {t("auth.passkeyNotSupported")} + {showAddPasskey && !passkeyDone && supportsPasskey === true && ( +

+

{t("addPasskeyPrompt")}

+ {error &&

{error}

} + +
+ )} + {showAddPasskey && !passkeyDone && supportsPasskey === false && ( +
+

{t("addPasskeyPrompt")}

+

{t("auth.passkeyNotSupported")}

+
+ )} + {passkeyDone && ( +
+

{t("passkeyAdded")}

+
+ )} +
+ + {/* 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 && ( +
+ {[ + { key: "planner", icon: "🗺️" }, + { key: "journal", icon: "📓" }, + { key: "federation", icon: "🌐" }, + { key: "ownership", icon: "🔓" }, + ].map(({ key, icon }) => ( +
+ +

+ {t(`home.marketing.${key}.title`)} +

+

+ {t(`home.marketing.${key}.body`)}

- )} - - {passkeyDone && ( -
-

- {t("passkeyAdded")} -

-
- )} - -
- ) : ( - + ))} + )} - + {/* Public activity feed */} +
+

{t("home.feed.heading")}

+ + {activities.length === 0 ? ( +

{t("home.feed.empty")}

+ ) : ( + + )} +
+ + {/* Self-hosted instances link back to the flagship for project info. + Flagship instances already show the marketing section above. */} + {!isFlagship && ( +

+ + {t("home.poweredBy")} + +

+ )}
); } diff --git a/e2e/journal.test.ts b/e2e/journal.test.ts index c6fa7a1..2ed192c 100644 --- a/e2e/journal.test.ts +++ b/e2e/journal.test.ts @@ -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 }) => { diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 2d844e0..ff7c592 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -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} diff --git a/openspec/specs/activity-feed/spec.md b/openspec/specs/activity-feed/spec.md index 21f2c7b..d7e01d5 100644 --- a/openspec/specs/activity-feed/spec.md +++ b/openspec/specs/activity-feed/spec.md @@ -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 diff --git a/openspec/specs/journal-landing/spec.md b/openspec/specs/journal-landing/spec.md new file mode 100644 index 0000000..9699f71 --- /dev/null +++ b/openspec/specs/journal-landing/spec.md @@ -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 diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index fc7afc6..2540763 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 83e8c6c..5d6ae44 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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",