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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user