Merge pull request #303 from trails-cool/feat/journal-home-feed
Rebuild Journal home: flagship marketing + public activity feed
This commit is contained in:
commit
cabfff6b22
10 changed files with 339 additions and 65 deletions
2
.github/workflows/cd-apps.yml
vendored
2
.github/workflows/cd-apps.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/cd-infra.yml
vendored
4
.github/workflows/cd-infra.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
openspec/specs/journal-landing/spec.md
Normal file
38
openspec/specs/journal-landing/spec.md
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue