From cad659b75f35b967ff4e0e4651cc6f59fe0167f6 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 20:35:23 -0500 Subject: [PATCH] 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. --- backend/apps/follows/views.py | 7 +- frontend/src/features/online/OnlinePage.tsx | 119 +++++++++++++++++--- frontend/src/lib/e621.ts | 12 ++ frontend/src/store/e621.ts | 21 +++- 4 files changed, 142 insertions(+), 17 deletions(-) diff --git a/backend/apps/follows/views.py b/backend/apps/follows/views.py index 72d274f..6ec0299 100644 --- a/backend/apps/follows/views.py +++ b/backend/apps/follows/views.py @@ -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( diff --git a/frontend/src/features/online/OnlinePage.tsx b/frontend/src/features/online/OnlinePage.tsx index 5a8aa88..4b6dc49 100644 --- a/frontend/src/features/online/OnlinePage.tsx +++ b/frontend/src/features/online/OnlinePage.tsx @@ -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(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() { Your blacklist - {blacklist.length > 0 ? ( -
- {blacklist.map((tag) => ( - + {blacklistEntries.length > 0 ? ( +
+ {blacklistEntries.map(({ line, index }) => ( + + {line} + + + ))} +
+ ) : ( +

+ No blacklisted tags. +

+ )} +
{ + event.preventDefault(); + addBlacklistEntry(); + }} + className="flex gap-2" + > + setBlacklistDraft(event.target.value)} + /> +
+ {blacklistMutation.isPending ? ( + + ) : ( + "Add" + )} + + + {blacklistMutation.isError ? ( +

+ {e621ErrorMessage(blacklistMutation.error)} +

+ ) : null} +

+ Saved to your e621 account. +

+ ) : ( -

No blacklisted tags.

+

+ + Add e621 credentials + {" "} + to manage your blacklist. +

)} diff --git a/frontend/src/lib/e621.ts b/frontend/src/lib/e621.ts index 41e5e8a..ebfc8bd 100644 --- a/frontend/src/lib/e621.ts +++ b/frontend/src/lib/e621.ts @@ -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 { + await e621Request(credentials, `/users/${userId}.json`, { + method: "PATCH", + body: new URLSearchParams({ "user[blacklisted_tags]": blacklist }), + }); +} + export interface E621Pool { id: number; name: string; diff --git a/frontend/src/store/e621.ts b/frontend/src/store/e621.ts index e6cbdc7..1bef1f3 100644 --- a/frontend/src/store/e621.ts +++ b/frontend/src/store/e621.ts @@ -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; loadBlacklist: () => Promise; + saveBlacklist: (lines: string[]) => Promise; save: (values: { username: string; api_key: string; @@ -24,6 +28,7 @@ interface E621State { export const useE621 = create((set, get) => ({ credentials: null, + profile: null, blacklist: [], loading: false, ready: false, @@ -48,12 +53,23 @@ export const useE621 = create((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("/api/auth/e621/", { method: "PUT", @@ -64,5 +80,6 @@ export const useE621 = create((set, get) => ({ return credentials; }, - clear: () => set({ credentials: null, blacklist: [], ready: false }), + clear: () => + set({ credentials: null, profile: null, blacklist: [], ready: false }), }));