Merge pull request #304 from trails-cool/feat/personal-dashboard
Personal activity dashboard for signed-in users
This commit is contained in:
commit
9709a0c150
4 changed files with 193 additions and 74 deletions
|
|
@ -6,7 +6,7 @@ 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 { listActivities, listRecentPublicActivities } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
|
|
@ -17,6 +17,22 @@ export function meta(_args: Route.MetaArgs) {
|
|||
];
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -33,16 +49,30 @@ 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) => ({
|
||||
// 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,
|
||||
|
|
@ -53,7 +83,15 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -118,53 +156,24 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
}
|
||||
}, [user]);
|
||||
|
||||
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>
|
||||
|
||||
{user ? (
|
||||
<div className="mt-6 text-gray-700">
|
||||
// ---------- 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>
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<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>
|
||||
|
||||
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
|
||||
<div className="mt-6 rounded-md bg-blue-50 p-4">
|
||||
|
|
@ -190,6 +199,92 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
<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
|
||||
|
|
@ -245,14 +340,18 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
<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>
|
||||
{" · "}
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -1,33 +1,40 @@
|
|||
## 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.
|
||||
The Journal home page (`/`) serves three audiences with one route: anonymous visitors arriving at the flagship trails.cool instance, anyone landing on a self-hosted Journal instance, and signed-in users returning to *their own* home. It introduces the product to visitors on the flagship, shows a public activity feed on every instance for visitors, and swaps in a personal activity stream for signed-in users so "home" means "your stuff."
|
||||
|
||||
## 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.
|
||||
### Requirement: Home behavior depends on session
|
||||
The `/` route SHALL render one of two distinct layouts based on whether the request carries a valid session. Signed-in users see a personal activity dashboard; signed-out visitors see the marketing + public-feed layout. The two layouts SHALL NOT render simultaneously — a signed-in user does not see the marketing blurbs, auth CTAs, or public instance feed on their home; a signed-out visitor does not see anyone's personal stream.
|
||||
|
||||
#### Scenario: Logged-out visitor sees auth CTAs
|
||||
#### Scenario: Signed-in user gets the personal dashboard
|
||||
- **WHEN** a request to `/` carries a valid session
|
||||
- **THEN** the response is the personal dashboard layout (welcome + New Activity CTA + own activity stream)
|
||||
|
||||
#### Scenario: Anonymous visitor gets the visitor home
|
||||
- **WHEN** a request to `/` carries no session
|
||||
- **THEN** the response is the visitor home layout (hero + auth CTAs + flagship-conditional marketing + public-feed)
|
||||
|
||||
### Requirement: Visitor home hero and auth CTAs
|
||||
For signed-out visitors, the home page SHALL render a hero section with a product-describing headline, a short tagline, and 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: Auth CTAs are primary
|
||||
- **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.
|
||||
The visitor home 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`
|
||||
- **WHEN** the visitor home 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
|
||||
- **WHEN** the visitor home 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.
|
||||
### Requirement: Public activity feed on visitor home
|
||||
The visitor home 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
|
||||
|
|
@ -35,4 +42,15 @@ The home page SHALL list the most recent public activities on the instance (see
|
|||
|
||||
#### 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
|
||||
- **THEN** the visitor home shows an empty-state message instead of the feed list; all other sections (hero, marketing, CTAs) remain unchanged
|
||||
|
||||
### Requirement: Personal dashboard for signed-in users
|
||||
For signed-in users, the home page SHALL render a personal activity dashboard: a welcome line linking to the user's profile, a "New Activity" CTA, and a reverse-chronological list of the user's own most recent activities (including `private`, `unlisted`, and `public`). The public instance feed SHALL NOT be rendered on this view; users who want it can browse other users' profiles or the dedicated activities/routes pages.
|
||||
|
||||
#### Scenario: Dashboard shows user's own activities
|
||||
- **WHEN** a signed-in user loads the home page
|
||||
- **THEN** they see their own activities in reverse-chronological order, regardless of visibility, up to 20 entries
|
||||
|
||||
#### Scenario: Dashboard empty state
|
||||
- **WHEN** a signed-in user has no activities yet
|
||||
- **THEN** the dashboard shows an empty-state message pointing to activity creation, while the welcome line and New Activity CTA remain visible
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export default {
|
|||
heading: "Aktuelle Aktivitäten",
|
||||
empty: "Noch keine öffentlichen Aktivitäten.",
|
||||
},
|
||||
dashboardEmpty: "Noch keine Aktivitäten. Erfasse oder importiere deine erste im Aktivitäten-Tab.",
|
||||
poweredBy: "Powered by trails.cool — über das Projekt",
|
||||
},
|
||||
routes: {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export default {
|
|||
heading: "Recent activity",
|
||||
empty: "No public activities yet.",
|
||||
},
|
||||
dashboardEmpty: "No activities yet. Record or import your first one from the Activities tab.",
|
||||
poweredBy: "Powered by trails.cool — about the project",
|
||||
},
|
||||
routes: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue