Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
1import { useState } from "react";
2import { useSuspenseQuery } from "@tanstack/react-query";
3import { useAuth } from "../lib/auth";
4import { bbsQuery, sysopModerationQuery } from "../lib/queries";
5import { bbsUrl } from "../lib/routes";
6import HandleInput from "../components/form/HandleInput";
7import { Button } from "../components/form/Form";
8import { useBreadcrumb } from "../hooks/useBreadcrumb";
9import { usePageTitle } from "../hooks/usePageTitle";
10import { useModerationMutations } from "../hooks/useModerationMutations";
11
12interface ModerationListItemProps {
13 label: string;
14 href: string;
15 title: string;
16 actionLabel: string;
17 onAction?: () => void;
18}
19
20function ModerationListItem({
21 label,
22 href,
23 title,
24 actionLabel,
25 onAction,
26}: ModerationListItemProps) {
27 return (
28 <div
29 title={title}
30 className="flex items-center justify-between gap-3 px-3 py-2 -mx-3 rounded hover:bg-neutral-800"
31 >
32 <a
33 href={href}
34 target="_blank"
35 rel="noreferrer"
36 aria-label={`${label} (opens in new tab)`}
37 className="truncate text-neutral-300 hover:text-neutral-200"
38 >
39 {label}
40 </a>
41 {onAction && (
42 <button
43 onClick={onAction}
44 className="text-xs text-neutral-400 hover:text-red-400 shrink-0"
45 >
46 {actionLabel}
47 </button>
48 )}
49 </div>
50 );
51}
52
53export default function SysopModerate() {
54 const { user } = useAuth();
55 const [identifier, setIdentifier] = useState("");
56 const [hideUri, setHideUri] = useState("");
57 usePageTitle("Moderate community — atbbs");
58
59 // requireAuthLoader guarantees user is present at render time.
60 const { data: bbs } = useSuspenseQuery(bbsQuery(user!.handle));
61 const { data: moderation } = useSuspenseQuery(
62 sysopModerationQuery(user!.pdsUrl, user!.did),
63 );
64 const { banRkeys, bannedHandles, hideRkeys, hidden } = moderation;
65
66 useBreadcrumb(
67 [
68 { label: bbs.site.name, to: bbsUrl(user!.handle) },
69 { label: "Moderate" },
70 ],
71 [bbs, user!.handle],
72 );
73
74 const { ban, unban, hide, unhide } = useModerationMutations();
75
76 function onBan() {
77 const id = identifier.trim();
78 if (!id) return;
79 ban.mutate(id, { onSuccess: () => setIdentifier("") });
80 }
81
82 function onUnban(rkey: string) {
83 if (!confirm("Unban this user?")) return;
84 unban.mutate(rkey);
85 }
86
87 function onHide() {
88 const uri = hideUri.trim();
89 if (!uri.startsWith("at://")) {
90 alert("Enter a valid AT-URI.");
91 return;
92 }
93 hide.mutate(uri, { onSuccess: () => setHideUri("") });
94 }
95
96 function onUnhide(rkey: string) {
97 if (!confirm("Unhide this post?")) return;
98 unhide.mutate(rkey);
99 }
100
101 return (
102 <>
103 <h1 className="text-lg text-neutral-200 mb-1">Moderate community</h1>
104 <p className="text-neutral-400 mb-6">
105 Manage banned users and hidden posts for {bbs.site.name}.
106 </p>
107
108 <div className="space-y-8">
109 <div>
110 <label className="block text-neutral-400 mb-3">Banned Users</label>
111 <div className="space-y-1 mb-3">
112 {Object.keys(banRkeys).map((did) => (
113 <ModerationListItem
114 key={did}
115 title={did}
116 label={bannedHandles[did] ?? did}
117 href={`https://pdsls.dev/at/${did}`}
118 actionLabel="unban"
119 onAction={
120 banRkeys[did] ? () => onUnban(banRkeys[did]) : undefined
121 }
122 />
123 ))}
124 </div>
125 <div className="flex gap-2">
126 <HandleInput
127 name="ban-handle"
128 value={identifier}
129 onChange={setIdentifier}
130 className="flex-1"
131 />
132 <Button onClick={onBan}>ban</Button>
133 </div>
134 </div>
135
136 <div>
137 <label className="block text-neutral-400 mb-3">Hidden Posts</label>
138 <div className="space-y-1 mb-3">
139 {hidden.map((post) => (
140 <ModerationListItem
141 key={post.uri}
142 title={post.uri}
143 label={`${post.handle} — ${post.title || post.body}`}
144 href={`https://pdsls.dev/${post.uri}`}
145 actionLabel="unhide"
146 onAction={
147 hideRkeys[post.uri]
148 ? () => onUnhide(hideRkeys[post.uri])
149 : undefined
150 }
151 />
152 ))}
153 </div>
154 <div className="flex gap-2">
155 <input
156 name="hide-uri"
157 type="text"
158 value={hideUri}
159 onChange={(e) => setHideUri(e.target.value)}
160 placeholder="at://did/collection/rkey"
161 aria-label="Post URI to hide"
162 className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600"
163 />
164 <Button onClick={onHide}>hide</Button>
165 </div>
166 </div>
167 </div>
168 </>
169 );
170}