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
+5 -2
View File
@@ -282,10 +282,13 @@ class FollowCloudView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
force = request.query_params.get("refresh") == "1"
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
now = timezone.now()
stale = cloud.computed_at is None or now - cloud.computed_at > (
cloud_service.CLOUD_TTL
stale = (
force
or cloud.computed_at is None
or now - cloud.computed_at > (cloud_service.CLOUD_TTL)
)
if stale:
reclaim = Q(building=False) | Q(
+101 -8
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 ? (
{credentials?.configured ? (
<>
{blacklistEntries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blacklist.map((tag) => (
{blacklistEntries.map(({ line, index }) => (
<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"
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"
>
{tag}
{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>
<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}
>
{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">
<Link to="/account" className="text-ctp-blue hover:underline">
Add e621 credentials
</Link>{" "}
to manage your blacklist.
</p>
)}
</div>
+12
View File
@@ -316,6 +316,18 @@ export async function fetchPostsByMd5(
return data.posts;
}
/** Replace the account's blacklist (e621 stores it as one newline-joined string). */
export async function updateBlacklist(
credentials: E621Credentials,
userId: number,
blacklist: string,
): Promise<void> {
await e621Request(credentials, `/users/${userId}.json`, {
method: "PATCH",
body: new URLSearchParams({ "user[blacklisted_tags]": blacklist }),
});
}
export interface E621Pool {
id: number;
name: string;
+19 -2
View File
@@ -3,17 +3,21 @@ import { create } from "zustand";
import { api } from "@/lib/api";
import {
effectiveCredentials,
updateBlacklist,
verifyE621Credentials,
type E621Credentials,
type E621User,
} from "@/lib/e621";
interface E621State {
credentials: E621Credentials | null;
profile: E621User | null;
blacklist: string[];
loading: boolean;
ready: boolean;
load: () => Promise<void>;
loadBlacklist: () => Promise<void>;
saveBlacklist: (lines: string[]) => Promise<void>;
save: (values: {
username: string;
api_key: string;
@@ -24,6 +28,7 @@ interface E621State {
export const useE621 = create<E621State>((set, get) => ({
credentials: null,
profile: null,
blacklist: [],
loading: false,
ready: false,
@@ -48,12 +53,23 @@ export const useE621 = create<E621State>((set, get) => ({
.split("\n")
.map((tag) => tag.trim())
.filter(Boolean);
set({ blacklist: tags });
set({ blacklist: tags, profile: user });
} catch {
// Keep the last known blacklist when e621 is unreachable.
}
},
saveBlacklist: async (lines) => {
const credentials = get().credentials;
const profile = get().profile;
if (!credentials?.configured || !profile) {
throw new Error("Add e621 credentials in Account to edit your blacklist.");
}
await updateBlacklist(credentials, profile.id, lines.join("\n"));
set({ blacklist: lines });
await get().loadBlacklist();
},
save: async (values) => {
const credentials = await api<E621Credentials>("/api/auth/e621/", {
method: "PUT",
@@ -64,5 +80,6 @@ export const useE621 = create<E621State>((set, get) => ({
return credentials;
},
clear: () => set({ credentials: null, blacklist: [], ready: false }),
clear: () =>
set({ credentials: null, profile: null, blacklist: [], ready: false }),
}));