(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 767 lines 32 kB view raw
1import React, { useEffect, useState } from "react"; 2import { useStore } from "@nanostores/react"; 3import { useTranslation } from "react-i18next"; 4import { languages } from "virtual:i18n-languages"; 5import { $user, logout } from "../../store/auth"; 6import { $theme, setTheme, type Theme } from "../../store/theme"; 7import { 8 $preferences, 9 loadPreferences, 10 addLabeler, 11 removeLabeler, 12 setLabelVisibility, 13 getLabelVisibility, 14 setDisableExternalLinkWarning, 15 setEnableCommunityBookmarks, 16} from "../../store/preferences"; 17import { 18 getAPIKeys, 19 createAPIKey, 20 deleteAPIKey, 21 getBlocks, 22 getMutes, 23 unblockUser, 24 unmuteUser, 25 getLabelerInfo, 26 type APIKey, 27} from "../../api/client"; 28import type { 29 BlockedUser, 30 MutedUser, 31 LabelerInfo, 32 LabelVisibility as LabelVisibilityType, 33 ContentLabelValue, 34} from "../../types"; 35import { 36 Copy, 37 Trash2, 38 Key, 39 Plus, 40 Check, 41 Sun, 42 Moon, 43 Monitor, 44 LogOut, 45 ChevronRight, 46 ShieldBan, 47 VolumeX, 48 ShieldOff, 49 Volume2, 50 Shield, 51 Eye, 52 EyeOff, 53 XCircle, 54 Upload, 55} from "lucide-react"; 56import { 57 Avatar, 58 Button, 59 Input, 60 Skeleton, 61 EmptyState, 62 Switch, 63} from "../../components/ui"; 64import { AppleIcon } from "../../components/common/Icons"; 65import { HighlightImporter } from "./HighlightImporter"; 66import IOSShortcutModal from "../../components/modals/IOSShortcutModal"; 67import { analytics } from "../../lib/analytics"; 68 69export default function Settings() { 70 const { t } = useTranslation(); 71 const user = useStore($user); 72 const theme = useStore($theme); 73 const [keys, setKeys] = useState<APIKey[]>([]); 74 const [loading, setLoading] = useState(true); 75 const [newKeyName, setNewKeyName] = useState(""); 76 const [createdKey, setCreatedKey] = useState<string | null>(null); 77 const [justCopied, setJustCopied] = useState(false); 78 const [creating, setCreating] = useState(false); 79 const [blocks, setBlocks] = useState<BlockedUser[]>([]); 80 const [mutes, setMutes] = useState<MutedUser[]>([]); 81 const [modLoading, setModLoading] = useState(true); 82 const [labelerInfo, setLabelerInfo] = useState<LabelerInfo | null>(null); 83 const [newLabelerDid, setNewLabelerDid] = useState(""); 84 const [addingLabeler, setAddingLabeler] = useState(false); 85 const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); 86 const preferences = useStore($preferences); 87 const { i18n: i18nInstance } = useTranslation(); 88 const [currentLanguage, setCurrentLanguage] = useState( 89 () => i18nInstance.resolvedLanguage ?? i18nInstance.language ?? "en", 90 ); 91 92 useEffect(() => { 93 const handler = (lng: string) => setCurrentLanguage(lng); 94 i18nInstance.on("languageChanged", handler); 95 return () => { 96 i18nInstance.off("languageChanged", handler); 97 }; 98 }, [i18nInstance]); 99 100 useEffect(() => { 101 const loadKeys = async () => { 102 setLoading(true); 103 const data = await getAPIKeys(); 104 setKeys(data); 105 setLoading(false); 106 }; 107 loadKeys(); 108 109 const loadModeration = async () => { 110 setModLoading(true); 111 const [blocksData, mutesData] = await Promise.all([ 112 getBlocks(), 113 getMutes(), 114 ]); 115 setBlocks(blocksData); 116 setMutes(mutesData); 117 setModLoading(false); 118 }; 119 loadModeration(); 120 121 loadPreferences(); 122 getLabelerInfo().then(setLabelerInfo); 123 }, []); 124 125 const handleCreate = async (e: React.FormEvent) => { 126 e.preventDefault(); 127 if (!newKeyName.trim()) return; 128 129 setCreating(true); 130 const res = await createAPIKey(newKeyName); 131 if (res) { 132 setKeys([res, ...keys]); 133 setCreatedKey(res.key || null); 134 setNewKeyName(""); 135 analytics.capture("api_key_created"); 136 } 137 setCreating(false); 138 }; 139 140 const handleDelete = async (id: string) => { 141 if (window.confirm(t("settings.apiKeys.revokeConfirm"))) { 142 const success = await deleteAPIKey(id); 143 if (success) { 144 setKeys((prev) => prev.filter((k) => k.id !== id)); 145 } 146 } 147 }; 148 149 const copyToClipboard = async (text: string) => { 150 await navigator.clipboard.writeText(text); 151 setJustCopied(true); 152 setTimeout(() => setJustCopied(false), 2000); 153 }; 154 155 if (!user) return null; 156 157 const themeOptions: { value: Theme; label: string; icon: typeof Sun }[] = [ 158 { value: "light", label: t("nav.themeLight"), icon: Sun }, 159 { value: "dark", label: t("nav.themeDark"), icon: Moon }, 160 { value: "system", label: t("nav.themeSystem"), icon: Monitor }, 161 ]; 162 163 return ( 164 <div className="max-w-2xl mx-auto animate-slide-up"> 165 <h1 className="text-3xl font-display font-bold text-surface-900 dark:text-white mb-8"> 166 {t("settings.title")} 167 </h1> 168 169 <div className="space-y-6"> 170 <section className="card p-5"> 171 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 172 {t("settings.sections.profile")} 173 </h2> 174 <div className="flex gap-4 items-center"> 175 <Avatar did={user.did} avatar={user.avatar} size="lg" /> 176 <div className="flex-1"> 177 <p className="font-semibold text-surface-900 dark:text-white text-lg"> 178 {user.displayName || user.handle} 179 </p> 180 <p className="text-surface-500 dark:text-surface-400"> 181 @{user.handle} 182 </p> 183 </div> 184 <ChevronRight 185 className="text-surface-300 dark:text-surface-600" 186 size={20} 187 /> 188 </div> 189 </section> 190 191 <section className="card p-5"> 192 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 193 {t("settings.sections.appearance")} 194 </h2> 195 <div className="flex gap-2"> 196 {themeOptions.map((opt) => ( 197 <button 198 key={opt.value} 199 onClick={() => { 200 setTheme(opt.value); 201 analytics.capture("theme_changed", { 202 theme: opt.value, 203 }); 204 }} 205 className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${ 206 theme === opt.value 207 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20" 208 : "border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600" 209 }`} 210 > 211 <opt.icon 212 size={24} 213 className={ 214 theme === opt.value 215 ? "text-primary-600 dark:text-primary-400" 216 : "text-surface-400 dark:text-surface-500" 217 } 218 /> 219 <span 220 className={`text-sm font-medium ${theme === opt.value ? "text-primary-600 dark:text-primary-400" : "text-surface-600 dark:text-surface-400"}`} 221 > 222 {opt.label} 223 </span> 224 </button> 225 ))} 226 </div> 227 228 <div className="mt-6 flex items-center justify-between gap-4"> 229 <div className="min-w-0 flex-1"> 230 <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 231 {t("settings.appearance.disableExternalLinkWarning")} 232 </h3> 233 <p className="text-sm text-surface-500 dark:text-surface-400"> 234 {t("settings.appearance.disableExternalLinkWarningDesc")} 235 </p> 236 </div> 237 <Switch 238 checked={preferences.disableExternalLinkWarning} 239 onCheckedChange={setDisableExternalLinkWarning} 240 className="shrink-0" 241 /> 242 </div> 243 244 <div className="mt-6 flex items-center justify-between gap-4"> 245 <div className="min-w-0 flex-1"> 246 <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 247 {t("settings.appearance.communityBookmarks")} 248 </h3> 249 <p className="text-sm text-surface-500 dark:text-surface-400"> 250 {t("settings.appearance.communityBookmarksDesc")} 251 </p> 252 </div> 253 <Switch 254 checked={preferences.enableCommunityBookmarks} 255 onCheckedChange={setEnableCommunityBookmarks} 256 className="shrink-0" 257 /> 258 </div> 259 </section> 260 261 {languages.length > 1 && ( 262 <section className="card p-5"> 263 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4"> 264 {t("settings.sections.language")} 265 </h2> 266 <div> 267 <p className="text-sm text-surface-500 dark:text-surface-400 mb-3"> 268 {t("settings.language.description")} 269 </p> 270 <div className="flex flex-wrap gap-2"> 271 {languages.map((lang) => ( 272 <button 273 key={lang.code} 274 onClick={() => i18nInstance.changeLanguage(lang.code)} 275 className={`px-4 py-2 rounded-xl text-sm font-medium border-2 transition-all ${ 276 currentLanguage.startsWith(lang.code) 277 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300" 278 : "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:border-surface-300 dark:hover:border-surface-600" 279 }`} 280 > 281 {lang.nativeName} 282 {lang.nativeName !== lang.name && ( 283 <span className="ml-1.5 text-xs opacity-60"> 284 ({lang.name}) 285 </span> 286 )} 287 </button> 288 ))} 289 </div> 290 </div> 291 </section> 292 )} 293 294 <section className="card p-5"> 295 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-4 flex items-center gap-2"> 296 <Upload size={16} /> 297 {t("settings.sections.batchImport")} 298 </h2> 299 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4"> 300 {t("settings.batchImport.description")} 301 </p> 302 <HighlightImporter /> 303 </section> 304 305 <section className="card p-5"> 306 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 307 {t("settings.sections.apiKeys")} 308 </h2> 309 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 310 {t("settings.apiKeys.description")} 311 </p> 312 313 <form onSubmit={handleCreate} className="flex gap-2 mb-5"> 314 <div className="flex-1"> 315 <Input 316 value={newKeyName} 317 onChange={(e) => setNewKeyName(e.target.value)} 318 placeholder={t("settings.apiKeys.keyNamePlaceholder")} 319 /> 320 </div> 321 <Button 322 type="submit" 323 disabled={!newKeyName.trim()} 324 loading={creating} 325 icon={<Plus size={16} />} 326 > 327 {t("settings.apiKeys.generate")} 328 </Button> 329 </form> 330 331 {createdKey && ( 332 <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"> 333 <div className="flex items-start gap-3"> 334 <div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg"> 335 <Key 336 size={16} 337 className="text-green-600 dark:text-green-400" 338 /> 339 </div> 340 <div className="flex-1 min-w-0"> 341 <p className="text-green-800 dark:text-green-200 text-sm font-medium mb-2"> 342 {t("settings.apiKeys.copyNow")} 343 </p> 344 <div className="flex items-center gap-2"> 345 <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"> 346 {createdKey} 347 </code> 348 <Button 349 variant="ghost" 350 size="sm" 351 onClick={() => copyToClipboard(createdKey)} 352 icon={ 353 justCopied ? <Check size={16} /> : <Copy size={16} /> 354 } 355 /> 356 </div> 357 </div> 358 </div> 359 </div> 360 )} 361 362 {loading ? ( 363 <div className="space-y-3"> 364 <Skeleton className="h-16 rounded-xl" /> 365 <Skeleton className="h-16 rounded-xl" /> 366 </div> 367 ) : keys.length === 0 ? ( 368 <EmptyState 369 icon={<Key size={40} />} 370 message={t("settings.apiKeys.empty")} 371 /> 372 ) : ( 373 <div className="space-y-2"> 374 {keys.map((key) => ( 375 <div 376 key={key.id} 377 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" 378 > 379 <div className="flex items-center gap-3"> 380 <div className="p-2 bg-surface-200 dark:bg-surface-700 rounded-lg"> 381 <Key 382 size={16} 383 className="text-surface-500 dark:text-surface-400" 384 /> 385 </div> 386 <div> 387 <p className="font-medium text-surface-900 dark:text-white"> 388 {key.name} 389 </p> 390 <p className="text-xs text-surface-500 dark:text-surface-400"> 391 {t("settings.apiKeys.created", { 392 date: new Date(key.createdAt).toLocaleDateString(), 393 })} 394 </p> 395 </div> 396 </div> 397 <button 398 onClick={() => handleDelete(key.id)} 399 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" 400 > 401 <Trash2 size={18} /> 402 </button> 403 </div> 404 ))} 405 </div> 406 )} 407 </section> 408 409 <section className="card p-5"> 410 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 411 {t("settings.sections.moderation")} 412 </h2> 413 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 414 {t("settings.moderation.description")} 415 </p> 416 417 {modLoading ? ( 418 <div className="space-y-3"> 419 <Skeleton className="h-14 rounded-xl" /> 420 <Skeleton className="h-14 rounded-xl" /> 421 </div> 422 ) : ( 423 <div className="space-y-4"> 424 <div> 425 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2"> 426 <ShieldBan size={14} /> 427 {t("settings.moderation.blockedAccounts", { 428 count: blocks.length, 429 })} 430 </h3> 431 {blocks.length === 0 ? ( 432 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6"> 433 {t("settings.moderation.noBlocked")} 434 </p> 435 ) : ( 436 <div className="space-y-1.5"> 437 {blocks.map((b) => ( 438 <div 439 key={b.did} 440 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" 441 > 442 <a 443 href={`/profile/${b.did}`} 444 className="flex items-center gap-3 min-w-0 flex-1" 445 > 446 <Avatar 447 did={b.did} 448 avatar={b.author?.avatar} 449 size="sm" 450 /> 451 <div className="min-w-0"> 452 <p className="font-medium text-surface-900 dark:text-white text-sm truncate"> 453 {b.author?.displayName || 454 b.author?.handle || 455 b.did} 456 </p> 457 {b.author?.handle && ( 458 <p className="text-xs text-surface-400 dark:text-surface-500 truncate"> 459 @{b.author.handle} 460 </p> 461 )} 462 </div> 463 </a> 464 <button 465 onClick={async () => { 466 await unblockUser(b.did); 467 setBlocks((prev) => 468 prev.filter((x) => x.did !== b.did), 469 ); 470 }} 471 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" 472 > 473 <ShieldOff size={12} /> 474 {t("settings.moderation.unblock")} 475 </button> 476 </div> 477 ))} 478 </div> 479 )} 480 </div> 481 482 <div> 483 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-2 flex items-center gap-2"> 484 <VolumeX size={14} /> 485 {t("settings.moderation.mutedAccounts", { 486 count: mutes.length, 487 })} 488 </h3> 489 {mutes.length === 0 ? ( 490 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6"> 491 {t("settings.moderation.noMuted")} 492 </p> 493 ) : ( 494 <div className="space-y-1.5"> 495 {mutes.map((m) => ( 496 <div 497 key={m.did} 498 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" 499 > 500 <a 501 href={`/profile/${m.did}`} 502 className="flex items-center gap-3 min-w-0 flex-1" 503 > 504 <Avatar 505 did={m.did} 506 avatar={m.author?.avatar} 507 size="sm" 508 /> 509 <div className="min-w-0"> 510 <p className="font-medium text-surface-900 dark:text-white text-sm truncate"> 511 {m.author?.displayName || 512 m.author?.handle || 513 m.did} 514 </p> 515 {m.author?.handle && ( 516 <p className="text-xs text-surface-400 dark:text-surface-500 truncate"> 517 @{m.author.handle} 518 </p> 519 )} 520 </div> 521 </a> 522 <button 523 onClick={async () => { 524 await unmuteUser(m.did); 525 setMutes((prev) => 526 prev.filter((x) => x.did !== m.did), 527 ); 528 }} 529 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" 530 > 531 <Volume2 size={12} /> 532 {t("settings.moderation.unmute")} 533 </button> 534 </div> 535 ))} 536 </div> 537 )} 538 </div> 539 </div> 540 )} 541 </section> 542 543 <section className="card p-5"> 544 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 545 {t("settings.sections.contentFiltering")} 546 </h2> 547 <p className="text-sm text-surface-400 dark:text-surface-500 mb-5"> 548 {t("settings.contentFiltering.description")} 549 </p> 550 551 <div className="space-y-5"> 552 <div> 553 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2"> 554 <Shield size={14} /> 555 {t("settings.contentFiltering.subscribedLabelers")} 556 </h3> 557 558 {preferences.subscribedLabelers.length === 0 ? ( 559 <p className="text-sm text-surface-400 dark:text-surface-500 pl-6 mb-3"> 560 {t("settings.contentFiltering.noLabelers")} 561 </p> 562 ) : ( 563 <div className="space-y-1.5 mb-3"> 564 {preferences.subscribedLabelers.map((labeler) => ( 565 <div 566 key={labeler.did} 567 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" 568 > 569 <div className="flex items-center gap-3 min-w-0 flex-1"> 570 <div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg"> 571 <Shield 572 size={14} 573 className="text-primary-600 dark:text-primary-400" 574 /> 575 </div> 576 <div className="min-w-0"> 577 <p className="font-medium text-surface-900 dark:text-white text-sm truncate"> 578 {labelerInfo?.did === labeler.did 579 ? labelerInfo.name 580 : labeler.did} 581 </p> 582 <p className="text-xs text-surface-400 dark:text-surface-500 truncate font-mono"> 583 {labeler.did} 584 </p> 585 </div> 586 </div> 587 <button 588 onClick={() => removeLabeler(labeler.did)} 589 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" 590 > 591 <XCircle size={12} /> 592 {t("settings.contentFiltering.remove")} 593 </button> 594 </div> 595 ))} 596 </div> 597 )} 598 599 <form 600 onSubmit={async (e) => { 601 e.preventDefault(); 602 if (!newLabelerDid.trim()) return; 603 setAddingLabeler(true); 604 await addLabeler(newLabelerDid.trim()); 605 setNewLabelerDid(""); 606 setAddingLabeler(false); 607 }} 608 className="flex gap-2" 609 > 610 <div className="flex-1"> 611 <Input 612 value={newLabelerDid} 613 onChange={(e) => setNewLabelerDid(e.target.value)} 614 placeholder={t( 615 "settings.contentFiltering.labelerDidPlaceholder", 616 )} 617 /> 618 </div> 619 <Button 620 type="submit" 621 disabled={!newLabelerDid.trim()} 622 loading={addingLabeler} 623 icon={<Plus size={16} />} 624 > 625 {t("settings.contentFiltering.add")} 626 </Button> 627 </form> 628 </div> 629 630 {preferences.subscribedLabelers.length > 0 && ( 631 <div> 632 <h3 className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 flex items-center gap-2"> 633 <Eye size={14} /> 634 {t("settings.contentFiltering.labelVisibility")} 635 </h3> 636 <p className="text-xs text-surface-400 dark:text-surface-500 mb-3 pl-6"> 637 {t("settings.contentFiltering.labelVisibilityDesc")} 638 </p> 639 640 <div className="space-y-4"> 641 {preferences.subscribedLabelers.map((labeler) => { 642 const labels: ContentLabelValue[] = [ 643 "sexual", 644 "nudity", 645 "violence", 646 "gore", 647 "spam", 648 "misleading", 649 ]; 650 return ( 651 <div 652 key={labeler.did} 653 className="bg-surface-50 dark:bg-surface-800 rounded-xl p-4" 654 > 655 <p className="text-sm font-medium text-surface-700 dark:text-surface-300 mb-3 truncate"> 656 {labelerInfo?.did === labeler.did 657 ? labelerInfo.name 658 : labeler.did} 659 </p> 660 <div className="space-y-2"> 661 {labels.map((label) => { 662 const current = getLabelVisibility( 663 labeler.did, 664 label, 665 ); 666 const options: { 667 value: LabelVisibilityType; 668 label: string; 669 icon: typeof Eye; 670 }[] = [ 671 { 672 value: "warn", 673 label: t("settings.contentFiltering.warn"), 674 icon: EyeOff, 675 }, 676 { 677 value: "hide", 678 label: t("settings.contentFiltering.hide"), 679 icon: XCircle, 680 }, 681 { 682 value: "ignore", 683 label: t("settings.contentFiltering.ignore"), 684 icon: Eye, 685 }, 686 ]; 687 return ( 688 <div 689 key={label} 690 className="flex items-center justify-between gap-2 py-1.5" 691 > 692 <span className="text-sm text-surface-600 dark:text-surface-400 min-w-0 flex-1"> 693 {t(`card.labelDescriptions.${label}`)} 694 </span> 695 <div className="flex gap-1"> 696 {options.map((opt) => ( 697 <button 698 key={opt.value} 699 onClick={() => 700 setLabelVisibility( 701 labeler.did, 702 label, 703 opt.value, 704 ) 705 } 706 className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-all flex items-center gap-1 ${ 707 current === opt.value 708 ? opt.value === "hide" 709 ? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400" 710 : opt.value === "warn" 711 ? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400" 712 : "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" 713 : "text-surface-400 dark:text-surface-500 hover:bg-surface-200 dark:hover:bg-surface-700" 714 }`} 715 > 716 <opt.icon size={12} /> 717 {opt.label} 718 </button> 719 ))} 720 </div> 721 </div> 722 ); 723 })} 724 </div> 725 </div> 726 ); 727 })} 728 </div> 729 </div> 730 )} 731 </div> 732 </section> 733 734 <section className="card p-5"> 735 <h2 className="text-xs font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wider mb-1"> 736 {t("settings.sections.iosShortcut")} 737 </h2> 738 <p className="text-sm text-surface-400 dark:text-surface-500 mb-4"> 739 {t("settings.iosShortcut.description")} 740 </p> 741 <button 742 onClick={() => setIsShortcutModalOpen(true)} 743 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" 744 > 745 <AppleIcon size={16} /> 746 {t("settings.iosShortcut.setupButton")} 747 </button> 748 </section> 749 750 <section className="card p-5"> 751 <button 752 onClick={logout} 753 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" 754 > 755 <LogOut size={20} /> 756 <span className="font-medium">{t("settings.logout")}</span> 757 </button> 758 </section> 759 </div> 760 761 <IOSShortcutModal 762 isOpen={isShortcutModalOpen} 763 onClose={() => setIsShortcutModalOpen(false)} 764 /> 765 </div> 766 ); 767}