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]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
force = request.query_params.get("refresh") == "1"
|
||||||
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
|
cloud, _ = FollowCloud.objects.get_or_create(user=request.user)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
stale = cloud.computed_at is None or now - cloud.computed_at > (
|
stale = (
|
||||||
cloud_service.CLOUD_TTL
|
force
|
||||||
|
or cloud.computed_at is None
|
||||||
|
or now - cloud.computed_at > (cloud_service.CLOUD_TTL)
|
||||||
)
|
)
|
||||||
if stale:
|
if stale:
|
||||||
reclaim = Q(building=False) | Q(
|
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 { useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
|
import { CollapsibleSidebar } from "@/components/CollapsibleSidebar";
|
||||||
import { TagChip } from "@/components/TagChip";
|
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 { PostCard } from "@/features/online/PostCard";
|
||||||
import { TagAutocomplete } from "@/features/online/TagAutocomplete";
|
import { TagAutocomplete } from "@/features/online/TagAutocomplete";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -30,9 +31,46 @@ export default function OnlinePage() {
|
|||||||
|
|
||||||
const credentials = useE621((state) => state.credentials);
|
const credentials = useE621((state) => state.credentials);
|
||||||
const blacklist = useE621((state) => state.blacklist);
|
const blacklist = useE621((state) => state.blacklist);
|
||||||
|
const saveBlacklist = useE621((state) => state.saveBlacklist);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
|
const [blacklistDraft, setBlacklistDraft] = useState("");
|
||||||
const tagInput = draft ?? tags;
|
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({
|
const postsQuery = useQuery({
|
||||||
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
|
queryKey: ["e621", "posts", tags, page, credentials?.username ?? ""],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
@@ -180,19 +218,74 @@ export default function OnlinePage() {
|
|||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
Your blacklist
|
Your blacklist
|
||||||
</span>
|
</span>
|
||||||
{blacklist.length > 0 ? (
|
{credentials?.configured ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<>
|
||||||
{blacklist.map((tag) => (
|
{blacklistEntries.length > 0 ? (
|
||||||
<span
|
<div className="flex flex-wrap gap-1.5">
|
||||||
key={tag}
|
{blacklistEntries.map(({ line, index }) => (
|
||||||
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
<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}
|
{blacklistMutation.isPending ? (
|
||||||
</span>
|
<Spinner className="h-3.5 w-3.5" />
|
||||||
))}
|
) : (
|
||||||
</div>
|
"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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -316,6 +316,18 @@ export async function fetchPostsByMd5(
|
|||||||
return data.posts;
|
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 {
|
export interface E621Pool {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ import { create } from "zustand";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
effectiveCredentials,
|
effectiveCredentials,
|
||||||
|
updateBlacklist,
|
||||||
verifyE621Credentials,
|
verifyE621Credentials,
|
||||||
type E621Credentials,
|
type E621Credentials,
|
||||||
|
type E621User,
|
||||||
} from "@/lib/e621";
|
} from "@/lib/e621";
|
||||||
|
|
||||||
interface E621State {
|
interface E621State {
|
||||||
credentials: E621Credentials | null;
|
credentials: E621Credentials | null;
|
||||||
|
profile: E621User | null;
|
||||||
blacklist: string[];
|
blacklist: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
loadBlacklist: () => Promise<void>;
|
loadBlacklist: () => Promise<void>;
|
||||||
|
saveBlacklist: (lines: string[]) => Promise<void>;
|
||||||
save: (values: {
|
save: (values: {
|
||||||
username: string;
|
username: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
@@ -24,6 +28,7 @@ interface E621State {
|
|||||||
|
|
||||||
export const useE621 = create<E621State>((set, get) => ({
|
export const useE621 = create<E621State>((set, get) => ({
|
||||||
credentials: null,
|
credentials: null,
|
||||||
|
profile: null,
|
||||||
blacklist: [],
|
blacklist: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
ready: false,
|
ready: false,
|
||||||
@@ -48,12 +53,23 @@ export const useE621 = create<E621State>((set, get) => ({
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
set({ blacklist: tags });
|
set({ blacklist: tags, profile: user });
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the last known blacklist when e621 is unreachable.
|
// 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) => {
|
save: async (values) => {
|
||||||
const credentials = await api<E621Credentials>("/api/auth/e621/", {
|
const credentials = await api<E621Credentials>("/api/auth/e621/", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -64,5 +80,6 @@ export const useE621 = create<E621State>((set, get) => ({
|
|||||||
return credentials;
|
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