Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import { useStore } from "@nanostores/react";
3import { $user, logout } from "../../store/auth";
4import { $theme, setTheme, type Theme } from "../../store/theme";
5import {
6 $preferences,
7 loadPreferences,
8 addLabeler,
9 removeLabeler,
10 setLabelVisibility,
11 getLabelVisibility,
12 setDisableExternalLinkWarning,
13} from "../../store/preferences";
14import {
15 getAPIKeys,
16 createAPIKey,
17 deleteAPIKey,
18 getBlocks,
19 getMutes,
20 unblockUser,
21 unmuteUser,
22 getLabelerInfo,
23 type APIKey,
24} from "../../api/client";
25import type {
26 BlockedUser,
27 MutedUser,
28 LabelerInfo,
29 LabelVisibility as LabelVisibilityType,
30 ContentLabelValue,
31} from "../../types";
32import {
33 Copy,
34 Trash2,
35 Key,
36 Plus,
37 Check,
38 Sun,
39 Moon,
40 Monitor,
41 LogOut,
42 ChevronRight,
43 ShieldBan,
44 VolumeX,
45 ShieldOff,
46 Volume2,
47 Shield,
48 Eye,
49 EyeOff,
50 XCircle,
51 Upload,
52} from "lucide-react";
53import {
54 Avatar,
55 Button,
56 Input,
57 Skeleton,
58 EmptyState,
59 Switch,
60} from "../../components/ui";
61import { AppleIcon } from "../../components/common/Icons";
62import { Link } from "react-router-dom";
63import { HighlightImporter } from "./HighlightImporter";
64import IOSShortcutModal from "../../components/modals/IOSShortcutModal";
65
66export default function Settings() {
67 const user = useStore($user);
68 const theme = useStore($theme);
69 const [keys, setKeys] = useState<APIKey[]>([]);
70 const [loading, setLoading] = useState(true);
71 const [newKeyName, setNewKeyName] = useState("");
72 const [createdKey, setCreatedKey] = useState<string | null>(null);
73 const [justCopied, setJustCopied] = useState(false);
74 const [creating, setCreating] = useState(false);
75 const [blocks, setBlocks] = useState<BlockedUser[]>([]);
76 const [mutes, setMutes] = useState<MutedUser[]>([]);
77 const [modLoading, setModLoading] = useState(true);
78 const [labelerInfo, setLabelerInfo] = useState<LabelerInfo | null>(null);
79 const [newLabelerDid, setNewLabelerDid] = useState("");
80 const [addingLabeler, setAddingLabeler] = useState(false);
81 const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
82 const preferences = useStore($preferences);
83
84 useEffect(() => {
85 const loadKeys = async () => {
86 setLoading(true);
87 const data = await getAPIKeys();
88 setKeys(data);
89 setLoading(false);
90 };
91 loadKeys();
92
93 const loadModeration = async () => {
94 setModLoading(true);
95 const [blocksData, mutesData] = await Promise.all([
96 getBlocks(),
97 getMutes(),
98 ]);
99 setBlocks(blocksData);
100 setMutes(mutesData);
101 setModLoading(false);
102 };
103 loadModeration();
104
105 loadPreferences();
106 getLabelerInfo().then(setLabelerInfo);
107 }, []);
108
109 const handleCreate = async (e: React.FormEvent) => {
110 e.preventDefault();
111 if (!newKeyName.trim()) return;
112
113 setCreating(true);
114 const res = await createAPIKey(newKeyName);
115 if (res) {
116 setKeys([res, ...keys]);
117 setCreatedKey(res.key || null);
118 setNewKeyName("");
119 }
120 setCreating(false);
121 };
122
123 const handleDelete = async (id: string) => {
124 if (window.confirm("Revoke this key? Apps using it will stop working.")) {
125 const success = await deleteAPIKey(id);
126 if (success) {
127 setKeys((prev) => prev.filter((k) => k.id !== id));
128 }
129 }
130 };
131
132 const copyToClipboard = async (text: string) => {
133 await navigator.clipboard.writeText(text);
134 setJustCopied(true);
135 setTimeout(() => setJustCopied(false), 2000);
136 };
137
138 if (!user) return null;
139
140 const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [
141 { value: "light", label: "Light", icon: Sun },
142 { value: "dark", label: "Dark", icon: Moon },
143 { value: "system", label: "System", icon: Monitor },
144 ];
145
146 return (
147 <div className="max-w-2xl mx-auto animate-slide-up">
148 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-8">
149 Settings
150 </h1>
151
152 <div className="space-y-6">
153 <section className="card p-5">
154 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4">
155 Profile
156 </h2>
157 <div className="flex gap-4 items-center">
158 <Avatar did={user.did} avatar={user.avatar} size="lg" />
159 <div className="flex-1">
160 <p className="font-semibold text-surface-900 dark:text-white text-lg">
161 {user.displayName || user.handle}
162 </p>
163 <p className="text-surface-500 dark:text-surface-400">
164 @{user.handle}
165 </p>
166 </div>
167 <ChevronRight
168 className="text-surface-300 dark:text-surface-600"
169 size={20}
170 />
171 </div>
172 </section>
173
174 <section className="card p-5">
175 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4">
176 Appearance
177 </h2>
178 <div className="flex gap-2">
179 {themeOptions.map((opt) => (
180 <button
181 key={opt.value}
182 onClick={() => setTheme(opt.value)}
183 className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${
184 theme === opt.value
185 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20"
186 : "border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600"
187 }`}
188 >
189 <opt.icon
190 size={24}
191 className={
192 theme === opt.value
193 ? "text-primary-600 dark:text-primary-400"
194 : "text-surface-400 dark:text-surface-500"
195 }
196 />
197 <span
198 className={`text-sm font-medium ${theme === opt.value ? "text-primary-600 dark:text-primary-400" : "text-surface-600 dark:text-surface-400"}`}
199 >
200 {opt.label}
201 </span>
202 </button>
203 ))}
204 </div>
205
206 <div className="mt-6 flex items-center justify-between">
207 <div>
208 <h3 className="text-sm font-medium text-surface-900 dark:text-white">
209 Disable external link warning
210 </h3>
211 <p className="text-sm text-surface-500 dark:text-surface-400">
212 Don't ask for confirmation when opening external links
213 </p>
214 </div>
215 <Switch
216 checked={preferences.disableExternalLinkWarning}
217 onCheckedChange={setDisableExternalLinkWarning}
218 />
219 </div>
220 </section>
221
222 <section className="card p-5">
223 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4 flex items-center gap-2">
224 <Upload size={16} />
225 Batch Import Highlights
226 </h2>
227 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4">
228 Upload highlights from CSV. Required: url, text. Optional: title,
229 tags, color, created_at
230 </p>
231 <HighlightImporter />
232 </section>
233
234 <section className="card p-5">
235 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">
236 API Keys
237 </h2>
238 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5">
239 For the iOS shortcut and other apps
240 </p>
241
242 <form onSubmit={handleCreate} className="flex gap-2 mb-5">
243 <div className="flex-1">
244 <Input
245 value={newKeyName}
246 onChange={(e) => setNewKeyName(e.target.value)}
247 placeholder="Key name, e.g. iOS Shortcut"
248 />
249 </div>
250 <Button
251 type="submit"
252 disabled={!newKeyName.trim()}
253 loading={creating}
254 icon={<Plus size={16} />}
255 >
256 Generate
257 </Button>
258 </form>
259
260 {createdKey && (
261 <div className="mb-5 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl animate-scale-in">
262 <div className="flex items-start gap-3">
263 <div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg">
264 <Key
265 size={16}
266 className="text-green-600 dark:text-green-400"
267 />
268 </div>
269 <div className="flex-1 min-w-0">
270 <p className="text-green-800 dark:text-green-200 text-sm font-medium mb-2">
271 Copy now - you won't see this again!
272 </p>
273 <div className="flex items-center gap-2">
274 <code className="flex-1 bg-white dark:bg-surface-900 border border-green-200 dark:border-green-800 px-3 py-2 rounded-lg text-xs font-mono text-green-900 dark:text-green-100 break-all">
275 {createdKey}
276 </code>
277 <Button
278 variant="ghost"
279 size="sm"
280 onClick={() => copyToClipboard(createdKey)}
281 icon={
282 justCopied ? <Check size={16} /> : <Copy size={16} />
283 }
284 />
285 </div>
286 </div>
287 </div>
288 </div>
289 )}
290
291 {loading ? (
292 <div className="space-y-3">
293 <Skeleton className="h-16 rounded-xl" />
294 <Skeleton className="h-16 rounded-xl" />
295 </div>
296 ) : keys.length === 0 ? (
297 <EmptyState
298 icon={<Key size={40} />}
299 message="No API keys yet. Create one to use with the browser extension."
300 />
301 ) : (
302 <div className="space-y-2">
303 {keys.map((key) => (
304 <div
305 key={key.id}
306 className="flex items-center justify-between p-4 bg-surface-50 dark:bg-surface-800 rounded-xl group transition-all hover:bg-surface-100 dark:hover:bg-surface-700"
307 >
308 <div className="flex items-center gap-3">
309 <div className="p-2 bg-surface-200 dark:bg-surface-700 rounded-lg">
310 <Key
311 size={16}
312 className="text-surface-500 dark:text-surface-400"
313 />
314 </div>
315 <div>
316 <p className="font-medium text-surface-900 dark:text-white">
317 {key.name}
318 </p>
319 <p className="text-xs text-surface-500 dark:text-surface-400">
320 Created {new Date(key.createdAt).toLocaleDateString()}
321 </p>
322 </div>
323 </div>
324 <button
325 onClick={() => handleDelete(key.id)}
326 className="p-2 text-surface-400 dark:text-surface-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
327 >
328 <Trash2 size={18} />
329 </button>
330 </div>
331 ))}
332 </div>
333 )}
334 </section>
335
336 <section className="card p-5">
337 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">
338 Moderation
339 </h2>
340 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5">
341 Manage blocked and muted accounts
342 </p>
343
344 {modLoading ? (
345 <div className="space-y-3">
346 <Skeleton className="h-14 rounded-xl" />
347 <Skeleton className="h-14 rounded-xl" />
348 </div>
349 ) : (
350 <div className="space-y-4">
351 <div>
352 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2">
353 <ShieldBan size={14} />
354 Blocked accounts ({blocks.length})
355 </h3>
356 {blocks.length === 0 ? (
357 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6">
358 No blocked accounts
359 </p>
360 ) : (
361 <div className="space-y-1.5">
362 {blocks.map((b) => (
363 <div
364 key={b.did}
365 className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl group hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
366 >
367 <Link
368 to={`/profile/${b.did}`}
369 className="flex items-center gap-3 min-w-0 flex-1"
370 >
371 <Avatar
372 did={b.did}
373 avatar={b.author?.avatar}
374 size="sm"
375 />
376 <div className="min-w-0">
377 <p className="font-medium text-surface-900 dark:text-white text-sm truncate">
378 {b.author?.displayName ||
379 b.author?.handle ||
380 b.did}
381 </p>
382 {b.author?.handle && (
383 <p className="text-xs text-surface-400 dark:text-surface-500 truncate">
384 @{b.author.handle}
385 </p>
386 )}
387 </div>
388 </Link>
389 <button
390 onClick={async () => {
391 await unblockUser(b.did);
392 setBlocks((prev) =>
393 prev.filter((x) => x.did !== b.did),
394 );
395 }}
396 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
397 >
398 <ShieldOff size={12} />
399 Unblock
400 </button>
401 </div>
402 ))}
403 </div>
404 )}
405 </div>
406
407 <div>
408 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2">
409 <VolumeX size={14} />
410 Muted accounts ({mutes.length})
411 </h3>
412 {mutes.length === 0 ? (
413 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6">
414 No muted accounts
415 </p>
416 ) : (
417 <div className="space-y-1.5">
418 {mutes.map((m) => (
419 <div
420 key={m.did}
421 className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl group hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
422 >
423 <Link
424 to={`/profile/${m.did}`}
425 className="flex items-center gap-3 min-w-0 flex-1"
426 >
427 <Avatar
428 did={m.did}
429 avatar={m.author?.avatar}
430 size="sm"
431 />
432 <div className="min-w-0">
433 <p className="font-medium text-surface-900 dark:text-white text-sm truncate">
434 {m.author?.displayName ||
435 m.author?.handle ||
436 m.did}
437 </p>
438 {m.author?.handle && (
439 <p className="text-xs text-surface-400 dark:text-surface-500 truncate">
440 @{m.author.handle}
441 </p>
442 )}
443 </div>
444 </Link>
445 <button
446 onClick={async () => {
447 await unmuteUser(m.did);
448 setMutes((prev) =>
449 prev.filter((x) => x.did !== m.did),
450 );
451 }}
452 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
453 >
454 <Volume2 size={12} />
455 Unmute
456 </button>
457 </div>
458 ))}
459 </div>
460 )}
461 </div>
462 </div>
463 )}
464 </section>
465
466 <section className="card p-5">
467 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">
468 Content Filtering
469 </h2>
470 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5">
471 Subscribe to labelers and configure how labeled content appears
472 </p>
473
474 <div className="space-y-5">
475 <div>
476 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2">
477 <Shield size={14} />
478 Subscribed Labelers
479 </h3>
480
481 {preferences.subscribedLabelers.length === 0 ? (
482 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6 mb-3">
483 No labelers subscribed
484 </p>
485 ) : (
486 <div className="space-y-1.5 mb-3">
487 {preferences.subscribedLabelers.map((labeler) => (
488 <div
489 key={labeler.did}
490 className="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl group hover:bg-surface-100 dark:hover:bg-surface-700 transition-all"
491 >
492 <div className="flex items-center gap-3 min-w-0 flex-1">
493 <div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
494 <Shield
495 size={14}
496 className="text-primary-600 dark:text-primary-400"
497 />
498 </div>
499 <div className="min-w-0">
500 <p className="font-medium text-surface-900 dark:text-white text-sm truncate">
501 {labelerInfo?.did === labeler.did
502 ? labelerInfo.name
503 : labeler.did}
504 </p>
505 <p className="text-xs text-surface-400 dark:text-surface-500 truncate font-mono">
506 {labeler.did}
507 </p>
508 </div>
509 </div>
510 <button
511 onClick={() => removeLabeler(labeler.did)}
512 className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-surface-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-all opacity-0 group-hover:opacity-100"
513 >
514 <XCircle size={12} />
515 Remove
516 </button>
517 </div>
518 ))}
519 </div>
520 )}
521
522 <form
523 onSubmit={async (e) => {
524 e.preventDefault();
525 if (!newLabelerDid.trim()) return;
526 setAddingLabeler(true);
527 await addLabeler(newLabelerDid.trim());
528 setNewLabelerDid("");
529 setAddingLabeler(false);
530 }}
531 className="flex gap-2"
532 >
533 <div className="flex-1">
534 <Input
535 value={newLabelerDid}
536 onChange={(e) => setNewLabelerDid(e.target.value)}
537 placeholder="did:plc:... (labeler DID)"
538 />
539 </div>
540 <Button
541 type="submit"
542 disabled={!newLabelerDid.trim()}
543 loading={addingLabeler}
544 icon={<Plus size={16} />}
545 >
546 Add
547 </Button>
548 </form>
549 </div>
550
551 {preferences.subscribedLabelers.length > 0 && (
552 <div>
553 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2">
554 <Eye size={14} />
555 Label Visibility
556 </h3>
557 <p className="text-xs text-surface-400 dark:text-surface-500 mb-3 pl-6">
558 Choose how to handle each label type: <strong>Warn</strong>{" "}
559 shows a blur overlay, <strong>Hide</strong> removes content
560 entirely, <strong>Ignore</strong> shows content normally.
561 </p>
562
563 <div className="space-y-4">
564 {preferences.subscribedLabelers.map((labeler) => {
565 const labels: ContentLabelValue[] = [
566 "sexual",
567 "nudity",
568 "violence",
569 "gore",
570 "spam",
571 "misleading",
572 ];
573 return (
574 <div
575 key={labeler.did}
576 className="bg-surface-50 dark:bg-surface-800 rounded-xl p-4"
577 >
578 <p className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 truncate">
579 {labelerInfo?.did === labeler.did
580 ? labelerInfo.name
581 : labeler.did}
582 </p>
583 <div className="space-y-2">
584 {labels.map((label) => {
585 const current = getLabelVisibility(
586 labeler.did,
587 label,
588 );
589 const options: {
590 value: LabelVisibilityType;
591 label: string;
592 icon: typeof Eye;
593 }[] = [
594 { value: "warn", label: "Warn", icon: EyeOff },
595 { value: "hide", label: "Hide", icon: XCircle },
596 { value: "ignore", label: "Ignore", icon: Eye },
597 ];
598 return (
599 <div
600 key={label}
601 className="flex items-center justify-between py-1.5"
602 >
603 <span className="text-sm text-surface-600 dark:text-surface-400 capitalize">
604 {label}
605 </span>
606 <div className="flex gap-1">
607 {options.map((opt) => (
608 <button
609 key={opt.value}
610 onClick={() =>
611 setLabelVisibility(
612 labeler.did,
613 label,
614 opt.value,
615 )
616 }
617 className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all flex items-center gap-1 ${
618 current === opt.value
619 ? opt.value === "hide"
620 ? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
621 : opt.value === "warn"
622 ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
623 : "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
624 : "text-surface-400 dark:text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-700"
625 }`}
626 >
627 <opt.icon size={12} />
628 {opt.label}
629 </button>
630 ))}
631 </div>
632 </div>
633 );
634 })}
635 </div>
636 </div>
637 );
638 })}
639 </div>
640 </div>
641 )}
642 </div>
643 </section>
644
645 <section className="card p-5">
646 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1">
647 iOS Shortcut
648 </h2>
649 <p className="text-sm text-surface-400 dark:text-surface-500 mb-4">
650 Save pages to Margin from Safari on iPhone and iPad
651 </p>
652 <button
653 onClick={() => setIsShortcutModalOpen(true)}
654 className="inline-flex items-center gap-2.5 px-4 py-2.5 bg-surface-900 dark:bg-white text-white dark:text-surface-900 rounded-xl font-medium text-sm transition-all hover:opacity-90"
655 >
656 <AppleIcon size={16} />
657 Setup iOS Shortcut
658 </button>
659 </section>
660
661 <section className="card p-5">
662 <button
663 onClick={logout}
664 className="flex items-center gap-3 w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 p-3 -m-3 rounded-xl transition-colors"
665 >
666 <LogOut size={20} />
667 <span className="font-medium">Log out</span>
668 </button>
669 </section>
670 </div>
671
672 <IOSShortcutModal
673 isOpen={isShortcutModalOpen}
674 onClose={() => setIsShortcutModalOpen(false)}
675 />
676 </div>
677 );
678}