Add Followed/Public toggle to /feed for signed-in users
Stream A from docs/information-architecture.md: signed-in users now
have a single /feed destination with two views — Followed (default,
people they accepted-follow) and Public (instance-wide). Logging in no
longer hides the public instance feed; switching is a query-param flip
that's bookmarkable and SSR-rendered.
- apps/journal/app/routes/feed.tsx — loader reads ?view=, branches
fetch (listSocialFeed vs listRecentPublicActivities, both already
exist), passes view to the component. Component renders a tab strip
at the top using plain <Link>s so the toggle works without JS. Per-
view <meta> title and empty state. The "see public feed" escape
from the empty Followed view now points at ?view=public instead of
/, keeping the user on /feed.
- packages/i18n/src/locales/{en,de}.ts — new social.feed.toggle.{ },
social.feed.public.{heading,empty}, and social.feed.seePublic
keys; old social.feed.publicFeedLink renamed to seePublic.
- openspec/specs/social-follows/spec.md — Social activity feed
requirement extended with the two-view structure, including the
Public view, the toggle, and the unrecognized-value fallback.
- openspec/specs/activity-feed/spec.md — Instance-wide public
activity feed requirement notes the Public view of /feed is now a
consumer alongside the visitor home.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d1999d037
commit
b6d8c621f8
5 changed files with 122 additions and 29 deletions
|
|
@ -1,17 +1,28 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/feed";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { listSocialFeed } from "~/lib/activities.server";
|
||||
import { listSocialFeed, listRecentPublicActivities } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
type View = "followed" | "public";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) throw redirect("/auth/login");
|
||||
|
||||
const rows = await listSocialFeed(user.id, 50);
|
||||
const url = new URL(request.url);
|
||||
const view: View = url.searchParams.get("view") === "public" ? "public" : "followed";
|
||||
|
||||
const rows =
|
||||
view === "public"
|
||||
? await listRecentPublicActivities(50)
|
||||
: await listSocialFeed(user.id, 50);
|
||||
|
||||
return data({
|
||||
view,
|
||||
activities: rows.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
|
|
@ -27,27 +38,77 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
export function meta(_args: Route.MetaArgs) {
|
||||
return [{ title: "Feed — trails.cool" }];
|
||||
export function meta({ data: loaderData }: Route.MetaArgs) {
|
||||
const view = loaderData?.view ?? "followed";
|
||||
const title = view === "public" ? "Public — trails.cool" : "Following — trails.cool";
|
||||
return [{ title }];
|
||||
}
|
||||
|
||||
function ToggleLink({
|
||||
to,
|
||||
active,
|
||||
label,
|
||||
}: {
|
||||
to: string;
|
||||
active: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`-mb-px inline-flex items-center border-b-2 px-1 py-3 text-sm font-medium ${
|
||||
active
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Feed({ loaderData }: Route.ComponentProps) {
|
||||
const { activities } = loaderData;
|
||||
const { view, activities } = loaderData;
|
||||
const { t } = useTranslation("journal");
|
||||
|
||||
const heading =
|
||||
view === "public"
|
||||
? t("social.feed.public.heading")
|
||||
: t("social.feed.heading");
|
||||
|
||||
const emptyMessage =
|
||||
view === "public"
|
||||
? t("social.feed.public.empty")
|
||||
: t("social.feed.empty");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t("social.feed.heading")}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{heading}</h1>
|
||||
|
||||
<div className="mt-4 flex gap-6 border-b border-gray-200">
|
||||
<ToggleLink
|
||||
to="/feed"
|
||||
active={view === "followed"}
|
||||
label={t("social.feed.toggle.followed")}
|
||||
/>
|
||||
<ToggleLink
|
||||
to="/feed?view=public"
|
||||
active={view === "public"}
|
||||
label={t("social.feed.toggle.public")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-gray-500">{t("social.feed.empty")}</p>
|
||||
<a
|
||||
href="/"
|
||||
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{t("social.feed.publicFeedLink")}
|
||||
</a>
|
||||
<p className="text-gray-500">{emptyMessage}</p>
|
||||
{view === "followed" && (
|
||||
<Link
|
||||
to="/feed?view=public"
|
||||
className="mt-3 inline-block text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{t("social.feed.seePublic")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue