Simplify the Followed page layout

- Remove the follow-a-tag / follow-a-pool forms; follows happen from tag
  chips (+ on detail views) and the Follow pool button on pool pages, so
  the empty states now point there instead.
- Move the blacklisted-tag cloud to a full-width horizontal panel at the
  bottom of the page.
- Tag and pool card grids cap at two columns so the cover cards are
  bigger.
This commit is contained in:
2026-09-17 14:37:59 -05:00
parent 26f5069aa2
commit 4c0b8cb1d8
+256 -366
View File
@@ -1,10 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Plus, Star, StarOff } from "lucide-react"; import { Eye, StarOff } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui"; import { Button, EmptyState, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api"; import { api } from "@/lib/api";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import type { import type {
@@ -32,101 +32,6 @@ function unseenLabel(count: number): string {
return count > 99 ? "99+ unseen" : `${count} unseen`; return count > 99 ? "99+ unseen" : `${count} unseen`;
} }
function FollowForms() {
const queryClient = useQueryClient();
const [tag, setTag] = useState("");
const [pool, setPool] = useState("");
const [error, setError] = useState<string | null>(null);
const tagMutation = useMutation({
mutationFn: (value: string) =>
api<FollowedTag>("/api/follows/tags/", {
method: "POST",
json: { tag: value },
}),
onSuccess: () => {
setTag("");
setError(null);
invalidateFollows(queryClient);
},
onError: (err) => setError(errorMessage(err)),
});
const poolMutation = useMutation({
mutationFn: (value: string) =>
api<FollowedPool>("/api/follows/pools/", {
method: "POST",
json: { pool: value },
}),
onSuccess: () => {
setPool("");
setError(null);
invalidateFollows(queryClient);
},
onError: (err) => setError(errorMessage(err)),
});
function submitTag(event: FormEvent) {
event.preventDefault();
const value = tag.trim().toLowerCase();
if (value) tagMutation.mutate(value);
}
function submitPool(event: FormEvent) {
event.preventDefault();
const value = pool.trim();
if (value) poolMutation.mutate(value);
}
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<form
onSubmit={submitTag}
className="flex flex-wrap items-center gap-2"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Follow a tag
</span>
<input
className={cn(inputClass, "w-48 font-mono")}
placeholder="e.g. canine"
value={tag}
onChange={(event) => setTag(event.target.value)}
/>
<Button type="submit" disabled={tagMutation.isPending || !tag.trim()}>
{tagMutation.isPending ? <Spinner className="h-3.5 w-3.5" /> : <Star className="h-4 w-4" />}
Follow
</Button>
</form>
<form
onSubmit={submitPool}
className="mt-3 flex flex-wrap items-center gap-2"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Follow a pool
</span>
<input
className={cn(inputClass, "w-48 font-mono")}
placeholder="Pool ID or URL"
value={pool}
onChange={(event) => setPool(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={poolMutation.isPending || !pool.trim()}
>
{poolMutation.isPending ? <Spinner className="h-3.5 w-3.5" /> : <Plus className="h-4 w-4" />}
Follow
</Button>
</form>
{error ? <p className="mt-2 text-xs text-ctp-red">{error}</p> : null}
</section>
);
}
function FollowCard({ function FollowCard({
title, title,
meta, meta,
@@ -199,7 +104,10 @@ function FollowCard({
<div className="p-3"> <div className="p-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-mono text-sm text-ctp-text" title={title}> <p
className="truncate font-mono text-sm text-ctp-text"
title={title}
>
{title} {title}
</p> </p>
{metaLine ? ( {metaLine ? (
@@ -244,25 +152,25 @@ function BlacklistCloudPanel() {
return ( return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4"> <section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1"> <h2 className="text-sm font-semibold text-ctp-subtext1">
Blacklisted tags Blacklisted tags
</h2> </h2>
{cloud?.status === "building" ? ( <p className="text-xs text-ctp-overlay0">
<span className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0"> {cloud?.source === "user"
<Spinner className="h-3 w-3" /> building… ? "From your e621 blacklist"
</span> : "From e621's anonymous default blacklist"}
) : null} {cloud ? ` · ${cloud.blacklist_count} entries` : ""}
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
{cloud?.computed_at ? ` · computed ${formatDate(cloud.computed_at)}` : ""}
{cloud?.status === "building" ? (
<span className="ml-2 inline-flex items-center gap-1.5">
<Spinner className="h-3 w-3" /> building…
</span>
) : null}
</p>
</div> </div>
<p className="mt-1 text-xs text-ctp-overlay0">
{cloud?.source === "user"
? "From your e621 blacklist"
: "From e621's anonymous default blacklist"}
{cloud ? ` · ${cloud.blacklist_count} entries` : ""}
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
</p>
{query.isPending ? ( {query.isPending ? (
<div className="mt-3 flex justify-center py-4"> <div className="mt-3 flex justify-center py-4">
<Spinner className="h-4 w-4" /> <Spinner className="h-4 w-4" />
@@ -283,12 +191,6 @@ function BlacklistCloudPanel() {
No blacklisted tags in your feeds. No blacklisted tags in your feeds.
</p> </p>
)} )}
{cloud?.computed_at ? (
<p className="mt-3 font-mono text-[10px] text-ctp-overlay0">
computed {formatDate(cloud.computed_at)}
</p>
) : null}
</section> </section>
); );
} }
@@ -376,262 +278,250 @@ export default function FollowedPage() {
<header> <header>
<h1 className="text-lg font-semibold">Followed</h1> <h1 className="text-lg font-semibold">Followed</h1>
<p className="mt-1 text-sm text-ctp-overlay0"> <p className="mt-1 text-sm text-ctp-overlay0">
New posts from your followed e621 tags and pools. The periodic New posts from your followed e621 tags and pools. Follow tags with
commands refresh these feeds and their covers. the + on any tag chip in the online or library detail views, and
pools from a pool page. The periodic commands refresh these feeds
and their covers.
</p> </p>
</header> </header>
<FollowForms /> <section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Tags{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{tags.length}
</span>
</h2>
{tagsQuery.isPending ? (
<div className="flex justify-center py-8">
<Spinner className="h-5 w-5" />
</div>
) : tags.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
No followed tags yet — use the + on any tag chip.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{tags.map((follow) => (
<FollowCard
key={follow.id}
title={follow.tag}
meta={
follow.cover_data?.post_id
? `latest #${follow.cover_data.post_id}`
: ""
}
cover={follow.cover_data}
unseen={follow.unseen_count}
syncedAt={follow.last_synced_at}
selected={
feedSource.kind === "tag" && feedSource.id === follow.id
}
onSelect={() =>
selectSource({
kind: "tag",
id: follow.id,
label: `tag:${follow.tag}`,
})
}
onSeen={() => seenFollow.mutate({ kind: "tag", id: follow.id })}
onUnfollow={() => unfollow.mutate({ kind: "tag", id: follow.id })}
/>
))}
</div>
)}
</section>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]"> <section className="flex flex-col gap-3">
<div className="flex min-w-0 flex-col gap-6"> <h2 className="text-sm font-semibold text-ctp-subtext1">
<section className="flex flex-col gap-3"> Pools{" "}
<h2 className="text-sm font-semibold text-ctp-subtext1"> <span className="font-mono text-[11px] text-ctp-overlay0">
Tags{" "} {pools.length}
<span className="font-mono text-[11px] text-ctp-overlay0"> </span>
{tags.length} </h2>
</span> {poolsQuery.isPending ? (
</h2> <div className="flex justify-center py-8">
{tagsQuery.isPending ? ( <Spinner className="h-5 w-5" />
<div className="flex justify-center py-8"> </div>
<Spinner className="h-5 w-5" /> ) : pools.length === 0 ? (
</div> <p className="text-xs text-ctp-overlay0">
) : tags.length === 0 ? ( No followed pools yet — open a pool and use "Follow pool".
<p className="text-xs text-ctp-overlay0"> </p>
No followed tags yet — follow one above. ) : (
</p> <div className="grid gap-3 sm:grid-cols-2">
) : ( {pools.map((follow) => (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <FollowCard
{tags.map((follow) => ( key={follow.id}
<FollowCard title={follow.name || `Pool #${follow.pool_id}`}
key={follow.id} meta={`#${follow.pool_id}${
title={follow.tag} follow.cover_data?.post_id
meta={ ? ` · latest #${follow.cover_data.post_id}`
follow.cover_data?.post_id : ""
? `latest #${follow.cover_data.post_id}` }`}
: "" cover={follow.cover_data}
} unseen={follow.unseen_count}
cover={follow.cover_data} syncedAt={follow.last_synced_at}
unseen={follow.unseen_count} selected={
syncedAt={follow.last_synced_at} feedSource.kind === "pool" && feedSource.id === follow.id
selected={ }
feedSource.kind === "tag" && feedSource.id === follow.id onSelect={() =>
} selectSource({
onSelect={() => kind: "pool",
selectSource({ id: follow.id,
kind: "tag", label: follow.name || `Pool #${follow.pool_id}`,
id: follow.id, })
label: `tag:${follow.tag}`, }
}) onSeen={() =>
} seenFollow.mutate({ kind: "pool", id: follow.id })
onSeen={() => }
seenFollow.mutate({ kind: "tag", id: follow.id }) onUnfollow={() =>
} unfollow.mutate({ kind: "pool", id: follow.id })
onUnfollow={() =>
unfollow.mutate({ kind: "tag", id: follow.id })
}
/>
))}
</div>
)}
</section>
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Pools{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{pools.length}
</span>
</h2>
{poolsQuery.isPending ? (
<div className="flex justify-center py-8">
<Spinner className="h-5 w-5" />
</div>
) : pools.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
No followed pools yet — paste a pool ID or URL above.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{pools.map((follow) => (
<FollowCard
key={follow.id}
title={follow.name || `Pool #${follow.pool_id}`}
meta={`#${follow.pool_id}${
follow.cover_data?.post_id
? ` · latest #${follow.cover_data.post_id}`
: ""
}`}
cover={follow.cover_data}
unseen={follow.unseen_count}
syncedAt={follow.last_synced_at}
selected={
feedSource.kind === "pool" && feedSource.id === follow.id
}
onSelect={() =>
selectSource({
kind: "pool",
id: follow.id,
label: follow.name || `Pool #${follow.pool_id}`,
})
}
onSeen={() =>
seenFollow.mutate({ kind: "pool", id: follow.id })
}
onUnfollow={() =>
unfollow.mutate({ kind: "pool", id: follow.id })
}
/>
))}
</div>
)}
</section>
<section className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Feed{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{feed ? `${feed.count}` : "…"}
{feedSource.kind !== "all"
? ` · showing ${feedSource.label}`
: ""}
</span>
</h2>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setPage(1);
setUnseenOnly((value) => !value);
}}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
unseenOnly
? "border-ctp-blue/40 bg-ctp-blue/15 text-ctp-blue"
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
Unseen only
</button>
{feedSource.kind !== "all" ? (
<button
type="button"
onClick={() => {
setPage(1);
setFeedSource({ kind: "all" });
}}
className="text-xs text-ctp-blue hover:underline"
>
Clear filter
</button>
) : null}
</div>
</div>
{feedQuery.isPending ? (
<div className="flex justify-center py-12">
<Spinner className="h-5 w-5" />
</div>
) : !feed || feed.results.length === 0 ? (
<EmptyState
title="Nothing in the feed"
description={
unseenOnly
? "Everything here is marked seen."
: "Follow tags or pools, or run a sync command to fetch posts."
} }
/> />
) : ( ))}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4"> </div>
{feed.results.map((entry) => ( )}
<Link </section>
key={entry.post_id}
to={`/detail/${entry.post_id}`}
onClick={() => {
if (!entry.seen) seenPost.mutate(entry.post_id);
}}
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
>
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
{entry.data.preview?.url ? (
<img
src={entry.data.preview.url}
alt={`Post #${entry.post_id}`}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{!entry.seen ? (
<span
className="absolute left-2 top-2 h-2.5 w-2.5 rounded-full bg-ctp-blue ring-2 ring-ctp-crust"
title="Unseen"
/>
) : null}
{entry.data.rating ? (
<span
className={cn(
"absolute right-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
ratingPill[entry.data.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{entry.data.rating}
</span>
) : null}
</span>
<span className="block px-2 py-1.5">
<span className="block font-mono text-[11px] text-ctp-subtext0">
#{entry.post_id}
<span className="text-ctp-overlay0">
{" "}
▲ {entry.data.score?.total ?? 0}
{entry.data.fav_count
? ` · ${entry.data.fav_count} ★`
: ""}
</span>
</span>
<span className="block truncate text-[10px] text-ctp-overlay0">
{entry.sources.map((source) => source.label).join(", ")}
</span>
</span>
</Link>
))}
</div>
)}
{feed && (page > 1 || feed.has_next) ? ( <section className="flex flex-col gap-3">
<div className="flex items-center justify-center gap-3"> <div className="flex flex-wrap items-center justify-between gap-2">
<Button <h2 className="text-sm font-semibold text-ctp-subtext1">
variant="secondary" Feed{" "}
disabled={page <= 1 || feedQuery.isFetching} <span className="font-mono text-[11px] text-ctp-overlay0">
onClick={() => setPage((current) => Math.max(1, current - 1))} {feed ? `${feed.count}` : "…"}
> {feedSource.kind !== "all" ? ` · showing ${feedSource.label}` : ""}
Previous </span>
</Button> </h2>
<span className="font-mono text-xs text-ctp-subtext0"> <div className="flex items-center gap-2">
Page {page} <button
</span> type="button"
<Button onClick={() => {
variant="secondary" setPage(1);
disabled={!feed.has_next || feedQuery.isFetching} setUnseenOnly((value) => !value);
onClick={() => setPage((current) => current + 1)} }}
> className={cn(
Next "rounded-full border px-2.5 py-1 text-xs font-medium transition",
</Button> unseenOnly
</div> ? "border-ctp-blue/40 bg-ctp-blue/15 text-ctp-blue"
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
Unseen only
</button>
{feedSource.kind !== "all" ? (
<button
type="button"
onClick={() => {
setPage(1);
setFeedSource({ kind: "all" });
}}
className="text-xs text-ctp-blue hover:underline"
>
Clear filter
</button>
) : null} ) : null}
</section> </div>
</div> </div>
<aside className="flex flex-col gap-4"> {feedQuery.isPending ? (
<BlacklistCloudPanel /> <div className="flex justify-center py-12">
</aside> <Spinner className="h-5 w-5" />
</div> </div>
) : !feed || feed.results.length === 0 ? (
<EmptyState
title="Nothing in the feed"
description={
unseenOnly
? "Everything here is marked seen."
: "Follow tags or pools, or run a sync command to fetch posts."
}
/>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{feed.results.map((entry) => (
<Link
key={entry.post_id}
to={`/detail/${entry.post_id}`}
onClick={() => {
if (!entry.seen) seenPost.mutate(entry.post_id);
}}
className="group block overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
>
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
{entry.data.preview?.url ? (
<img
src={entry.data.preview.url}
alt={`Post #${entry.post_id}`}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
No preview
</span>
)}
{!entry.seen ? (
<span
className="absolute left-2 top-2 h-2.5 w-2.5 rounded-full bg-ctp-blue ring-2 ring-ctp-crust"
title="Unseen"
/>
) : null}
{entry.data.rating ? (
<span
className={cn(
"absolute right-2 top-2 rounded-full px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase",
ratingPill[entry.data.rating] ??
"bg-ctp-surface1 text-ctp-text",
)}
>
{entry.data.rating}
</span>
) : null}
</span>
<span className="block px-2 py-1.5">
<span className="block font-mono text-[11px] text-ctp-subtext0">
#{entry.post_id}
<span className="text-ctp-overlay0">
{" "}
▲ {entry.data.score?.total ?? 0}
{entry.data.fav_count
? ` · ${entry.data.fav_count} ★`
: ""}
</span>
</span>
<span className="block truncate text-[10px] text-ctp-overlay0">
{entry.sources.map((source) => source.label).join(", ")}
</span>
</span>
</Link>
))}
</div>
)}
{feed && (page > 1 || feed.has_next) ? (
<div className="flex items-center justify-center gap-3">
<Button
variant="secondary"
disabled={page <= 1 || feedQuery.isFetching}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</Button>
<span className="font-mono text-xs text-ctp-subtext0">
Page {page}
</span>
<Button
variant="secondary"
disabled={!feed.has_next || feedQuery.isFetching}
onClick={() => setPage((current) => current + 1)}
>
Next
</Button>
</div>
) : null}
</section>
<BlacklistCloudPanel />
</div> </div>
); );
} }