(READ ONLY) Margin is an open annotation layer for 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 { 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}