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
+29 -139
View File
@@ -1,10 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Plus, Star, StarOff } from "lucide-react";
import { useState, type FormEvent } from "react";
import { Eye, StarOff } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { Button, EmptyState, Spinner } from "@/components/ui";
import { api } from "@/lib/api";
import { cn } from "@/lib/cn";
import { formatDate } from "@/lib/format";
import type {
@@ -32,101 +32,6 @@ function unseenLabel(count: number): string {
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({
title,
meta,
@@ -199,7 +104,10 @@ function FollowCard({
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<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}
</p>
{metaLine ? (
@@ -244,24 +152,24 @@ function BlacklistCloudPanel() {
return (
<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">
Blacklisted tags
</h2>
{cloud?.status === "building" ? (
<span className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0">
<Spinner className="h-3 w-3" /> building…
</span>
) : null}
</div>
<p className="mt-1 text-xs text-ctp-overlay0">
<p className="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` : ""}
{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>
{query.isPending ? (
<div className="mt-3 flex justify-center py-4">
@@ -283,12 +191,6 @@ function BlacklistCloudPanel() {
No blacklisted tags in your feeds.
</p>
)}
{cloud?.computed_at ? (
<p className="mt-3 font-mono text-[10px] text-ctp-overlay0">
computed {formatDate(cloud.computed_at)}
</p>
) : null}
</section>
);
}
@@ -376,15 +278,13 @@ export default function FollowedPage() {
<header>
<h1 className="text-lg font-semibold">Followed</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
New posts from your followed e621 tags and pools. The periodic
commands refresh these feeds and their covers.
New posts from your followed e621 tags and pools. Follow tags with
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>
</header>
<FollowForms />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="flex min-w-0 flex-col gap-6">
<section className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Tags{" "}
@@ -398,10 +298,10 @@ export default function FollowedPage() {
</div>
) : tags.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
No followed tags yet — follow one above.
No followed tags yet — use the + on any tag chip.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
{tags.map((follow) => (
<FollowCard
key={follow.id}
@@ -424,12 +324,8 @@ export default function FollowedPage() {
label: `tag:${follow.tag}`,
})
}
onSeen={() =>
seenFollow.mutate({ kind: "tag", id: follow.id })
}
onUnfollow={() =>
unfollow.mutate({ kind: "tag", id: follow.id })
}
onSeen={() => seenFollow.mutate({ kind: "tag", id: follow.id })}
onUnfollow={() => unfollow.mutate({ kind: "tag", id: follow.id })}
/>
))}
</div>
@@ -449,10 +345,10 @@ export default function FollowedPage() {
</div>
) : pools.length === 0 ? (
<p className="text-xs text-ctp-overlay0">
No followed pools yet — paste a pool ID or URL above.
No followed pools yet — open a pool and use "Follow pool".
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2">
{pools.map((follow) => (
<FollowCard
key={follow.id}
@@ -493,9 +389,7 @@ export default function FollowedPage() {
Feed{" "}
<span className="font-mono text-[11px] text-ctp-overlay0">
{feed ? `${feed.count}` : "…"}
{feedSource.kind !== "all"
? ` · showing ${feedSource.label}`
: ""}
{feedSource.kind !== "all" ? ` · showing ${feedSource.label}` : ""}
</span>
</h2>
<div className="flex items-center gap-2">
@@ -551,7 +445,7 @@ export default function FollowedPage() {
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"
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 ? (
@@ -626,12 +520,8 @@ export default function FollowedPage() {
</div>
) : null}
</section>
</div>
<aside className="flex flex-col gap-4">
<BlacklistCloudPanel />
</aside>
</div>
</div>
);
}