Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.
Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
federation forward-compat. Local IRIs look like
`${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
'public'). Existing users land 'public' via the default; current
effective behavior is unchanged.
Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
countFollowers / countFollowing / listFollowers / listFollowing.
Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
follows → activities WHERE visibility='public', reverse-chrono.
Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.
UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
"New Activity".
Privacy manifest updated to document the new follows relation and
profile_visibility setting.
Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.
Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
import { useState, useCallback, useEffect } from "react";
|
|
import { data } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { eq, count } from "drizzle-orm";
|
|
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 { listActivities, 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: "Federated outdoor journal. Plan routes, record activities, own your data." },
|
|
];
|
|
}
|
|
|
|
interface ActivityCard {
|
|
id: string;
|
|
name: string;
|
|
distance: number | null;
|
|
elevationGain: number | null;
|
|
duration: number | null;
|
|
startedAt: string | null;
|
|
createdAt: string;
|
|
geojson: string | null;
|
|
// Populated only for the public (logged-out) feed, where the card
|
|
// needs to attribute the activity to an owner. Personal feed skips
|
|
// these because it's always "you".
|
|
ownerUsername: string | null;
|
|
ownerDisplayName: string | null;
|
|
}
|
|
|
|
export async function loader({ request }: Route.LoaderArgs) {
|
|
const user = await getSessionUser(request);
|
|
const url = new URL(request.url);
|
|
const addPasskeyParam = url.searchParams.get("add-passkey") === "1" && user !== null;
|
|
|
|
// Only show the add-passkey prompt if the user has no passkeys yet
|
|
let showAddPasskey = false;
|
|
if (addPasskeyParam && user) {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select({ count: count() })
|
|
.from(credentials)
|
|
.where(eq(credentials.userId, user.id));
|
|
showAddPasskey = (row?.count ?? 0) === 0;
|
|
}
|
|
|
|
const plannerUrl = process.env.PLANNER_URL ?? "https://planner.trails.cool";
|
|
const isFlagship = process.env.IS_FLAGSHIP === "true";
|
|
|
|
// Logged-in users get their own recent activities as the home feed —
|
|
// "home" should mean your stuff, not the instance's public stream.
|
|
// Logged-out visitors get the instance-wide public feed instead.
|
|
let activities: ActivityCard[];
|
|
if (user) {
|
|
const rows = await listActivities(user.id);
|
|
activities = rows.slice(0, 20).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: null,
|
|
ownerDisplayName: null,
|
|
}));
|
|
} else {
|
|
const rows = await listRecentPublicActivities(20);
|
|
activities = rows.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,
|
|
}));
|
|
}
|
|
|
|
return data({
|
|
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
|
|
showAddPasskey,
|
|
plannerUrl,
|
|
isFlagship,
|
|
activities,
|
|
});
|
|
}
|
|
|
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
|
const { user, showAddPasskey, plannerUrl, isFlagship, activities } = loaderData;
|
|
const { t } = useTranslation("journal");
|
|
const [addingPasskey, setAddingPasskey] = useState(false);
|
|
const [passkeyDone, setPasskeyDone] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [supportsPasskey, setSupportsPasskey] = useState<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (showAddPasskey) {
|
|
import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => {
|
|
setSupportsPasskey(browserSupportsWebAuthn());
|
|
});
|
|
}
|
|
}, [showAddPasskey]);
|
|
|
|
const handleAddPasskey = useCallback(async () => {
|
|
if (!user) return;
|
|
setAddingPasskey(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const startResp = await fetch("/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ step: "add-passkey", userId: user.id }),
|
|
});
|
|
const startData = await startResp.json();
|
|
|
|
if (startData.error) {
|
|
setError(startData.error);
|
|
return;
|
|
}
|
|
|
|
const { startRegistration } = await import("@simplewebauthn/browser");
|
|
const webAuthnResp = await startRegistration(startData.options);
|
|
|
|
const finishResp = await fetch("/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
step: "finish-add-passkey",
|
|
userId: user.id,
|
|
response: webAuthnResp,
|
|
challenge: startData.options.challenge,
|
|
}),
|
|
});
|
|
|
|
const finishData = await finishResp.json();
|
|
if (finishData.error) {
|
|
setError(finishData.error);
|
|
} else {
|
|
setPasskeyDone(true);
|
|
}
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setAddingPasskey(false);
|
|
}
|
|
}, [user]);
|
|
|
|
// ---------- Logged-in: personal activity stream ----------
|
|
if (user) {
|
|
return (
|
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-gray-900">
|
|
{t("welcome")}{" "}
|
|
<a href={`/users/${user.username}`} className="text-blue-600 hover:underline">
|
|
{user.displayName ?? user.username}
|
|
</a>
|
|
</h1>
|
|
<div className="flex items-center gap-3">
|
|
<a
|
|
href="/feed"
|
|
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
{t("social.feed.title")}
|
|
</a>
|
|
<a
|
|
href="/activities/new"
|
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
>
|
|
{t("activities.new")}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{activities.length === 0 ? (
|
|
<p className="mt-12 text-center text-gray-500">
|
|
{t("home.dashboardEmpty")}
|
|
</p>
|
|
) : (
|
|
<ul className="mt-8 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-48 shrink-0">
|
|
{a.geojson ? (
|
|
<ClientMap geojson={a.geojson} />
|
|
) : (
|
|
<div className="flex h-36 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>
|
|
<h2 className="text-lg font-medium text-gray-900">{a.name}</h2>
|
|
<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>
|
|
<span className="text-sm text-gray-400">
|
|
<ClientDate iso={a.startedAt ?? a.createdAt} />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------- Logged-out: visitor home (hero + marketing + public feed) ----------
|
|
return (
|
|
<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>
|
|
|
|
{/* 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"
|
|
>
|
|
{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>
|
|
</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>
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{/* 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.ownerUsername && (
|
|
<>
|
|
<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>
|
|
);
|
|
}
|