Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 678 lines 28 kB view raw
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}