Add a blacklist editor that saves to e621

The Online sidebar's "Your blacklist" section is editable now: typing a
tag appends it and each entry gets an x to remove it. Changes are written
straight to the e621 account (PATCH /users/{id}.json with
user[blacklisted_tags]); the store keeps the fetched profile for the id,
updates the list optimistically and reloads it from e621. The Followed
page's blacklist cloud is rebuilt afterwards via the new
/api/follows/cloud/?refresh=1 force flag.

Verified against the live API with a reversible add/verify/restore test.
This commit is contained in:
2026-09-17 20:35:23 -05:00
parent f7e2242e5f
commit cad659b75f
4 changed files with 142 additions and 17 deletions
+106 -13
View File
@@ -1,10 +1,11 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
import { TagChip } from "@/components/TagChip";
import { Button, EmptyState, Spinner } from "@/components/ui";
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
import { PostCard } from "@/features/online/PostCard";
import { TagAutocomplete } from "@/features/online/TagAutocomplete";
import { api } from "@/lib/api";
@@ -30,9 +31,46 @@ export default function OnlinePage() {
const credentials = useE621((state) => state.credentials);
const blacklist = useE621((state) => state.blacklist);
const saveBlacklist = useE621((state) => state.saveBlacklist);
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const [blacklistDraft, setBlacklistDraft] = useState("");
const tagInput = draft ?? tags;
const blacklistMutation = useMutation({
mutationFn: (lines: string[]) => saveBlacklist(lines),
onSuccess: () => {
setBlacklistDraft("");
// The Followed page's cloud counted the old list; rebuild it.
void api("/api/follows/cloud/?refresh=1")
.catch(() => undefined)
.finally(() => {
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
});
},
});
const blacklistEntries = useMemo(
() =>
blacklist
.map((line, index) => ({ line, index }))
.filter(({ line }) => !line.startsWith("#")),
[blacklist],
);
function addBlacklistEntry() {
const tag = blacklistDraft.trim().toLowerCase();
if (!tag || blacklist.includes(tag)) {
setBlacklistDraft("");
return;
}
blacklistMutation.mutate([...blacklist, tag]);
}
function removeBlacklistEntry(index: number) {
blacklistMutation.mutate(blacklist.filter((_, position) => position !== index));
}
const postsQuery = useQuery({
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
queryFn: ({ signal }) =>
@@ -180,19 +218,74 @@ export default function OnlinePage() {
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Your blacklist
</span>
{blacklist.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blacklist.map((tag) => (
<span
key={tag}
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
{credentials?.configured ? (
<>
{blacklistEntries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blacklistEntries.map(({ line, index }) => (
<span
key={`${line}-${index}`}
className="inline-flex items-center gap-1 rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
>
{line}
<button
type="button"
title={`Remove ${line}`}
disabled={blacklistMutation.isPending}
onClick={() => removeBlacklistEntry(index)}
className="text-ctp-red/70 transition hover:text-ctp-red disabled:opacity-40"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<p className="text-xs text-ctp-overlay0">
No blacklisted tags.
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
addBlacklistEntry();
}}
className="flex gap-2"
>
<input
className={cn(inputClass, "font-mono")}
placeholder="Add a tag…"
value={blacklistDraft}
onChange={(event) => setBlacklistDraft(event.target.value)}
/>
<Button
type="submit"
variant="secondary"
disabled={!blacklistDraft.trim() || blacklistMutation.isPending}
>
{tag}
</span>
))}
</div>
{blacklistMutation.isPending ? (
<Spinner className="h-3.5 w-3.5" />
) : (
"Add"
)}
</Button>
</form>
{blacklistMutation.isError ? (
<p className="text-xs text-ctp-red">
{e621ErrorMessage(blacklistMutation.error)}
</p>
) : null}
<p className="text-[11px] text-ctp-overlay0">
Saved to your e621 account.
</p>
</>
) : (
<p className="text-xs text-ctp-overlay0">No blacklisted tags.</p>
<p className="text-xs text-ctp-overlay0">
<Link to="/account" className="text-ctp-blue hover:underline">
Add e621 credentials
</Link>{" "}
to manage your blacklist.
</p>
)}
</div>