feat(admin): comma-separated bulk add on the Blacklist page

The add-entry input took the whole field as one literal value, so
typing "word1, word2" created a single entry matching that exact
phrase instead of two separate words. Splits on comma client-side and
calls the existing single-value createBlacklistEntry mutation once
per value (dedup'd), with one consolidated toast instead of one per
word. No backend change — reuses the existing retroactive-scan
mutation as-is.
This commit is contained in:
admin committed 2026-08-12 20:48:41 +02:00
1 parent ee04bf5432
commit 2085336748
1 file changed
+53 -25
+53 -25
View File
@@ -40,22 +40,8 @@ export default function AdminBlacklistPage() {
)
);
const createMutation = useMutation(
trpc.admin.createBlacklistEntry.mutationOptions({
onSuccess: ({ hiddenCount }) => {
void qc.invalidateQueries({ queryKey: trpc.admin.listBlacklist.queryKey() });
setNewValue("");
if (hiddenCount > 0) {
toast.success(`Added — ${hiddenCount} existing post${hiddenCount !== 1 ? "s" : ""} hidden retroactively.`);
} else {
toast.success("Blacklist entry added.");
}
},
onError: (err) => {
toast.error(`Failed to add entry: ${err.message}`);
},
})
);
const createMutation = useMutation(trpc.admin.createBlacklistEntry.mutationOptions());
const [isBulkAdding, setIsBulkAdding] = useState(false);
const deleteMutation = useMutation(
trpc.admin.deleteBlacklistEntry.mutationOptions({
@@ -66,10 +52,45 @@ export default function AdminBlacklistPage() {
})
);
function handleCreate() {
const value = newValue.trim();
if (!value) return;
createMutation.mutate({ type: newType, value });
/**
* Comma-separated bulk add — splits on "," so pasting "word1, word2, word3"
* creates one entry per word instead of a single literal-phrase entry.
* Reuses the existing single-value createBlacklistEntry mutation (with its
* retroactive scan) once per word rather than a new batch endpoint.
*/
async function handleCreate() {
const values = [...new Set(newValue.split(",").map((v) => v.trim()).filter(Boolean))];
if (values.length === 0) return;
setIsBulkAdding(true);
let addedCount = 0;
let hiddenTotal = 0;
let failedCount = 0;
for (const value of values) {
try {
const { hiddenCount } = await createMutation.mutateAsync({ type: newType, value });
addedCount++;
hiddenTotal += hiddenCount;
} catch {
failedCount++;
}
}
setIsBulkAdding(false);
void qc.invalidateQueries({ queryKey: trpc.admin.listBlacklist.queryKey() });
setNewValue("");
if (addedCount > 0) {
const suffix =
hiddenTotal > 0
? ` — ${hiddenTotal} existing post${hiddenTotal !== 1 ? "s" : ""} hidden retroactively.`
: "";
toast.success(`${addedCount} ${addedCount !== 1 ? "entries" : "entry"} added.${suffix}`);
}
if (failedCount > 0) {
toast.error(`${failedCount} ${failedCount !== 1 ? "entries" : "entry"} failed to add.`);
}
}
return (
@@ -79,6 +100,7 @@ export default function AdminBlacklistPage() {
<p className="text-sm text-zinc-500 mt-1">
Words, links, and hashtags that auto-hide matching posts — new posts are checked on
creation, and adding an entry here retroactively scans + hides existing matches.
Separate multiple values with a comma to add them all at once.
</p>
</div>
@@ -96,19 +118,25 @@ export default function AdminBlacklistPage() {
</Select>
<Input
placeholder={
newType === "WORD" ? "word…" : newType === "LINK" ? "domain, e.g. spam.example" : "hashtag (without #)"
newType === "WORD"
? "word1, word2, …"
: newType === "LINK"
? "domain1.example, domain2.example, …"
: "tag1, tag2 (without #)"
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
className="flex-1 min-w-[200px] bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<Button
onClick={handleCreate}
disabled={createMutation.isPending || !newValue.trim()}
onClick={() => void handleCreate()}
disabled={isBulkAdding || !newValue.trim()}
className="bg-zinc-700 hover:bg-zinc-600"
>
{createMutation.isPending ? (
{isBulkAdding ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
) : (
<Plus className="h-4 w-4 mr-1.5" />