Redesign Settings with Apple-inspired grouped UI
Rework the Settings hub and sub-pages into iOS-style grouped inset cards:
- Hub groups rows into Account / App / System sections, each row gets a
colored gradient icon tile, with a rounded bg-card container and hairline
dividers instead of the flat full-width list.
- Appearance and Language pickers use the same inset-card list rather than
bordered selectable buttons.
- Network and Advanced replace the heavy accent-bar section headers with
uppercase group labels above clean bg-card panels.
- Add settings.groups.{account,app,system} strings across all locales.
This commit is contained in:
@@ -913,6 +913,11 @@
|
||||
"title": "الإعدادات",
|
||||
"description": "إدارة إعدادات {{appName}} الخاصة بك",
|
||||
"deleteAccount": "حذف الحساب",
|
||||
"groups": {
|
||||
"account": "الحساب",
|
||||
"app": "التطبيق",
|
||||
"system": "النظام"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "الملف الشخصي",
|
||||
"profileDesc": "اسم العرض والسيرة الذاتية والصورة الرمزية والتحقق.",
|
||||
|
||||
@@ -1376,6 +1376,11 @@
|
||||
"title": "Settings",
|
||||
"description": "Manage your {{appName}} settings",
|
||||
"deleteAccount": "Delete account",
|
||||
"groups": {
|
||||
"account": "Account",
|
||||
"app": "App",
|
||||
"system": "System"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Profile",
|
||||
"profileDesc": "Display name, bio, avatar, and verification.",
|
||||
|
||||
@@ -923,6 +923,11 @@
|
||||
"title": "Ajustes",
|
||||
"description": "Administra tus ajustes de {{appName}}",
|
||||
"deleteAccount": "Eliminar cuenta",
|
||||
"groups": {
|
||||
"account": "Cuenta",
|
||||
"app": "Aplicación",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Perfil",
|
||||
"profileDesc": "Nombre, biografía, avatar y verificación.",
|
||||
|
||||
@@ -923,6 +923,11 @@
|
||||
"title": "تنظیمات",
|
||||
"description": "مدیریت تنظیمات {{appName}} شما",
|
||||
"deleteAccount": "حذف حساب",
|
||||
"groups": {
|
||||
"account": "حساب",
|
||||
"app": "برنامه",
|
||||
"system": "سیستم"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "نمایه",
|
||||
"profileDesc": "نام نمایشی، بیوگرافی، آواتار و تأیید هویت.",
|
||||
|
||||
@@ -1362,6 +1362,11 @@
|
||||
"title": "Paramètres",
|
||||
"description": "Gérez vos paramètres {{appName}}",
|
||||
"deleteAccount": "Supprimer le compte",
|
||||
"groups": {
|
||||
"account": "Compte",
|
||||
"app": "Application",
|
||||
"system": "Système"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Profil",
|
||||
"profileDesc": "Nom d'affichage, biographie, avatar et vérification.",
|
||||
|
||||
@@ -1365,6 +1365,11 @@
|
||||
"title": "सेटिंग्स",
|
||||
"description": "अपनी {{appName}} सेटिंग्स प्रबंधित करें",
|
||||
"deleteAccount": "अकाउंट डिलीट करें",
|
||||
"groups": {
|
||||
"account": "खाता",
|
||||
"app": "ऐप",
|
||||
"system": "सिस्टम"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "प्रोफ़ाइल",
|
||||
"profileDesc": "डिस्प्ले नाम, बायो, अवतार, और सत्यापन।",
|
||||
|
||||
@@ -1365,6 +1365,11 @@
|
||||
"title": "Pengaturan",
|
||||
"description": "Kelola pengaturan {{appName}} Anda",
|
||||
"deleteAccount": "Hapus akun",
|
||||
"groups": {
|
||||
"account": "Akun",
|
||||
"app": "Aplikasi",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Profil",
|
||||
"profileDesc": "Nama tampilan, bio, avatar, dan verifikasi.",
|
||||
|
||||
@@ -923,6 +923,11 @@
|
||||
"title": "ការកំណត់",
|
||||
"description": "គ្រប់គ្រងការកំណត់ {{appName}} របស់អ្នក",
|
||||
"deleteAccount": "លុបគណនី",
|
||||
"groups": {
|
||||
"account": "គណនី",
|
||||
"app": "កម្មវិធី",
|
||||
"system": "ប្រព័ន្ធ"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "ប្រវត្តិរូប",
|
||||
"profileDesc": "ឈ្មោះបង្ហាញ ប្រវត្តិរូបសង្ខេប រូបតំណាង និងការផ្ទៀងផ្ទាត់។",
|
||||
|
||||
@@ -925,6 +925,11 @@
|
||||
"title": "تنظیمات",
|
||||
"description": "د {{appName}} تنظیماتو اداره کول",
|
||||
"deleteAccount": "حساب ړنګول",
|
||||
"groups": {
|
||||
"account": "حساب",
|
||||
"app": "اپلیکیشن",
|
||||
"system": "سیسټم"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "پروفایل",
|
||||
"profileDesc": "د ښودنې نوم، بیوګرافي، اواتار، او تصدیق.",
|
||||
|
||||
@@ -1367,6 +1367,11 @@
|
||||
"title": "Configurações",
|
||||
"description": "Gerencie suas configurações do {{appName}}",
|
||||
"deleteAccount": "Excluir conta",
|
||||
"groups": {
|
||||
"account": "Conta",
|
||||
"app": "Aplicativo",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Perfil",
|
||||
"profileDesc": "Nome de exibição, biografia, avatar e verificação.",
|
||||
|
||||
@@ -1367,6 +1367,11 @@
|
||||
"title": "Настройки",
|
||||
"description": "Управляйте настройками {{appName}}",
|
||||
"deleteAccount": "Удалить аккаунт",
|
||||
"groups": {
|
||||
"account": "Аккаунт",
|
||||
"app": "Приложение",
|
||||
"system": "Система"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Профиль",
|
||||
"profileDesc": "Имя для отображения, биография, аватар и верификация.",
|
||||
|
||||
@@ -925,6 +925,11 @@
|
||||
"title": "Marongero",
|
||||
"description": "Tarisira marongero ako e{{appName}}",
|
||||
"deleteAccount": "Bvisa akaunti",
|
||||
"groups": {
|
||||
"account": "Akaundi",
|
||||
"app": "Puroguramu",
|
||||
"system": "Sisitimu"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Profile",
|
||||
"profileDesc": "Zita rinoratidzwa, nhoroondo, mufananidzo, nokutsigirwa.",
|
||||
|
||||
@@ -1364,6 +1364,11 @@
|
||||
"title": "Mipangilio",
|
||||
"description": "Simamia mipangilio yako ya {{appName}}",
|
||||
"deleteAccount": "Futa akaunti",
|
||||
"groups": {
|
||||
"account": "Akaunti",
|
||||
"app": "Programu",
|
||||
"system": "Mfumo"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Wasifu",
|
||||
"profileDesc": "Jina la kuonyesha, wasifu, picha, na uthibitisho.",
|
||||
|
||||
@@ -1366,6 +1366,11 @@
|
||||
"title": "Ayarlar",
|
||||
"description": "{{appName}} ayarlarınızı yönetin",
|
||||
"deleteAccount": "Hesabı sil",
|
||||
"groups": {
|
||||
"account": "Hesap",
|
||||
"app": "Uygulama",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "Profil",
|
||||
"profileDesc": "Görünen ad, biyografi, avatar ve doğrulama.",
|
||||
|
||||
@@ -925,6 +925,11 @@
|
||||
"title": "設定",
|
||||
"description": "管理你的 {{appName}} 設定",
|
||||
"deleteAccount": "刪除賬戶",
|
||||
"groups": {
|
||||
"account": "帳戶",
|
||||
"app": "應用程式",
|
||||
"system": "系統"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "個人資料",
|
||||
"profileDesc": "顯示名稱、簡介、頭像和驗證。",
|
||||
|
||||
@@ -925,6 +925,11 @@
|
||||
"title": "设置",
|
||||
"description": "管理你的 {{appName}} 设置",
|
||||
"deleteAccount": "删除账户",
|
||||
"groups": {
|
||||
"account": "账户",
|
||||
"app": "应用",
|
||||
"system": "系统"
|
||||
},
|
||||
"sections": {
|
||||
"profile": "个人资料",
|
||||
"profileDesc": "显示名称、简介、头像和验证。",
|
||||
|
||||
@@ -37,11 +37,11 @@ export function AdvancedSettingsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 max-w-2xl mx-auto w-full">
|
||||
{/* Intro */}
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">{t('settings.advanced.powerUserHeading')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
<div className="px-1 pt-1 pb-4">
|
||||
<h2 className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80">{t('settings.advanced.powerUserHeading')}</h2>
|
||||
<p className="text-[13px] text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{t('settings.advanced.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { Check, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -60,52 +60,47 @@ export function AppearanceSettingsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 max-w-2xl mx-auto w-full">
|
||||
{/* Intro */}
|
||||
<div className="px-3 pt-2 pb-6">
|
||||
<h2 className="text-sm font-semibold">{t('settings.appearance.colorMode')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
<div className="px-1 pt-1 pb-4">
|
||||
<h2 className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80">{t('settings.appearance.colorMode')}</h2>
|
||||
<p className="text-[13px] text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{t('settings.appearance.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Theme options */}
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-hidden rounded-2xl bg-card border border-border/60 shadow-sm divide-y divide-border/50">
|
||||
{themeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-4 px-4 py-3.5 rounded-xl border-2 transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
theme === option.value
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border/50 hover:border-primary/40 hover:bg-muted/30',
|
||||
'w-full flex items-center gap-3.5 px-3.5 py-3 transition-colors',
|
||||
'hover:bg-muted/50 active:bg-muted/70',
|
||||
'focus-visible:outline-none focus-visible:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center size-10 rounded-lg transition-colors',
|
||||
'flex items-center justify-center size-9 rounded-[10px] transition-colors',
|
||||
theme === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className={cn(
|
||||
'text-sm font-semibold',
|
||||
theme === option.value ? 'text-foreground' : 'text-foreground',
|
||||
)}>
|
||||
<p className="text-[15px] font-medium leading-tight text-foreground">
|
||||
{t(option.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<p className="text-[13px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{t(option.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
{theme === option.value && (
|
||||
<div className="size-2.5 rounded-full bg-primary shrink-0 animate-in fade-in zoom-in duration-200" />
|
||||
<Check className="size-[18px] text-primary shrink-0 animate-in fade-in zoom-in duration-200" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -63,14 +63,18 @@ export function LanguageSettingsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="px-3 pt-2 pb-6">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<div className="p-4 max-w-2xl mx-auto w-full">
|
||||
<div className="px-1 pt-1 pb-4">
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">
|
||||
{t('language.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2" role="radiogroup" aria-label={t('language.title')}>
|
||||
<ul
|
||||
className="overflow-hidden rounded-2xl bg-card border border-border/60 shadow-sm divide-y divide-border/50"
|
||||
role="radiogroup"
|
||||
aria-label={t('language.title')}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((language) => {
|
||||
const selected = language.code === currentLng;
|
||||
const rtl = isRTLLanguage(language.code);
|
||||
@@ -82,34 +86,25 @@ export function LanguageSettingsPage() {
|
||||
aria-checked={selected}
|
||||
onClick={() => handleSelect(language.code)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-4 px-4 py-3.5 rounded-xl border-2 transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
selected
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border/50 hover:border-primary/40 hover:bg-muted/30',
|
||||
'w-full flex items-center gap-4 px-3.5 py-3 transition-colors',
|
||||
'hover:bg-muted/50 active:bg-muted/70',
|
||||
'focus-visible:outline-none focus-visible:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 text-left min-w-0',
|
||||
// Force native-name rendering in the locale's own
|
||||
// direction so RTL scripts (Arabic, Farsi, Pashto)
|
||||
// display correctly even when the surrounding UI is LTR.
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p
|
||||
className="text-base font-semibold truncate"
|
||||
className="text-[15px] font-medium leading-tight truncate"
|
||||
dir={rtl ? 'rtl' : 'ltr'}
|
||||
lang={language.code}
|
||||
>
|
||||
{language.nativeName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 uppercase tracking-wide">
|
||||
<p className="text-[12px] text-muted-foreground mt-0.5 uppercase tracking-wide">
|
||||
{language.code}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<Check className="size-5 text-primary shrink-0" aria-hidden="true" />
|
||||
<Check className="size-[18px] text-primary shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
@@ -117,7 +112,7 @@ export function LanguageSettingsPage() {
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-6 px-3 leading-relaxed">
|
||||
<p className="text-[13px] text-muted-foreground mt-5 px-1 leading-relaxed">
|
||||
{t('language.translationNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -50,28 +50,25 @@ export function NetworkSettingsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="p-4 max-w-2xl mx-auto w-full space-y-7">
|
||||
{/* Intro */}
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">{t('settings.network.connectionsHeading')}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
<div className="px-1 pt-1">
|
||||
<h2 className="text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80">{t('settings.network.connectionsHeading')}</h2>
|
||||
<p className="text-[13px] text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{t('settings.network.connectionsIntro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Low-Bandwidth Mode */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">{t('settings.network.lowBandwidthHeading')}</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pt-4 pb-4 px-3 space-y-4">
|
||||
<section>
|
||||
<h2 className="px-1 pb-1.5 text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80">{t('settings.network.lowBandwidthHeading')}</h2>
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label htmlFor="low-bandwidth" className="text-sm font-medium">
|
||||
<Label htmlFor="low-bandwidth" className="text-[15px] font-medium">
|
||||
{t('settings.network.reduceDataUsage')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">
|
||||
{t('settings.network.reduceDataUsageDesc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,13 +83,13 @@ export function NetworkSettingsPage() {
|
||||
|
||||
{/* Image Proxy — independent of Low-Bandwidth. Controls whether
|
||||
images are fetched from a downsizing proxy. */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 pt-4 border-t border-border/50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label htmlFor="image-proxy" className="text-sm font-medium">
|
||||
<Label htmlFor="image-proxy" className="text-[15px] font-medium">
|
||||
{t('settings.network.useImageProxy')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">
|
||||
{t('settings.network.useImageProxyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,40 +153,27 @@ export function NetworkSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Relays */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold flex items-center gap-1.5">{t('settings.network.relays')} <HelpTip faqId="what-are-relays" /></h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<RelayListManager />
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h2 className="px-1 pb-1.5 text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80 flex items-center gap-1.5">{t('settings.network.relays')} <HelpTip faqId="what-are-relays" /></h2>
|
||||
<RelayListManager />
|
||||
</section>
|
||||
|
||||
{/* Blossom Servers */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold flex items-center gap-1.5">{t('settings.network.blossomServers')} <HelpTip faqId="what-are-blossom" /></h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<BlossomSettings />
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h2 className="px-1 pb-1.5 text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80 flex items-center gap-1.5">{t('settings.network.blossomServers')} <HelpTip faqId="what-are-blossom" /></h2>
|
||||
<BlossomSettings />
|
||||
</section>
|
||||
|
||||
{/* Image Upload Quality */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">{t('settings.network.imageUploads')}</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pt-4 pb-4 px-3 space-y-3">
|
||||
<section>
|
||||
<h2 className="px-1 pb-1.5 text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80">{t('settings.network.imageUploads')}</h2>
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm p-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">{t('settings.network.uploadQuality')}</Label>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<Label className="text-[15px] font-medium">{t('settings.network.uploadQuality')}</Label>
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">
|
||||
{t('settings.network.uploadQualityDesc')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -210,7 +194,7 @@ export function NetworkSettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
+103
-31
@@ -1,12 +1,24 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { ChevronRight, Settings } from 'lucide-react';
|
||||
import {
|
||||
ChevronRight,
|
||||
Settings,
|
||||
User,
|
||||
BadgeCheck,
|
||||
Palette,
|
||||
Languages,
|
||||
Wifi,
|
||||
Bell,
|
||||
SlidersHorizontal,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { isAdmin } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const RequestToVanishDialog = lazy(() => import('@/components/RequestToVanishDialog').then(m => ({ default: m.RequestToVanishDialog })));
|
||||
|
||||
@@ -17,6 +29,12 @@ interface SettingsSection {
|
||||
/** i18n key under `settings.sections.*` for the row description. */
|
||||
descriptionKey: string;
|
||||
path: string;
|
||||
/** Icon rendered in the colored leading tile. */
|
||||
icon: React.ReactNode;
|
||||
/** Tailwind classes for the icon tile background gradient. */
|
||||
tile: string;
|
||||
/** Which visual group this row belongs to. */
|
||||
group: 'account' | 'app' | 'system';
|
||||
requiresAuth?: boolean;
|
||||
/** When true, only shown to platform admins (see `isAdmin` in `@/lib/admins`). */
|
||||
requiresAdmin?: boolean;
|
||||
@@ -28,6 +46,9 @@ const settingsSections: SettingsSection[] = [
|
||||
labelKey: 'settings.sections.profile',
|
||||
descriptionKey: 'settings.sections.profileDesc',
|
||||
path: '/settings/profile',
|
||||
icon: <User className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-blue-400 to-blue-600',
|
||||
group: 'account',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
@@ -35,6 +56,9 @@ const settingsSections: SettingsSection[] = [
|
||||
labelKey: 'settings.sections.verifier',
|
||||
descriptionKey: 'settings.sections.verifierDesc',
|
||||
path: '/settings/verifier',
|
||||
icon: <BadgeCheck className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-emerald-400 to-emerald-600',
|
||||
group: 'account',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
@@ -42,25 +66,37 @@ const settingsSections: SettingsSection[] = [
|
||||
labelKey: 'settings.sections.appearance',
|
||||
descriptionKey: 'settings.sections.appearanceDesc',
|
||||
path: '/settings/appearance',
|
||||
icon: <Palette className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-violet-400 to-violet-600',
|
||||
group: 'app',
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
labelKey: 'settings.sections.language',
|
||||
descriptionKey: 'settings.sections.languageDesc',
|
||||
path: '/settings/language',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
labelKey: 'settings.sections.network',
|
||||
descriptionKey: 'settings.sections.networkDesc',
|
||||
path: '/settings/network',
|
||||
requiresAuth: true,
|
||||
icon: <Languages className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-sky-400 to-sky-600',
|
||||
group: 'app',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
labelKey: 'settings.sections.notifications',
|
||||
descriptionKey: 'settings.sections.notificationsDesc',
|
||||
path: '/settings/notifications',
|
||||
icon: <Bell className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-rose-400 to-rose-600',
|
||||
group: 'app',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
labelKey: 'settings.sections.network',
|
||||
descriptionKey: 'settings.sections.networkDesc',
|
||||
path: '/settings/network',
|
||||
icon: <Wifi className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-amber-400 to-amber-600',
|
||||
group: 'system',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
@@ -68,17 +104,25 @@ const settingsSections: SettingsSection[] = [
|
||||
labelKey: 'settings.sections.advanced',
|
||||
descriptionKey: 'settings.sections.advancedDesc',
|
||||
path: '/settings/advanced',
|
||||
icon: <SlidersHorizontal className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-slate-400 to-slate-600',
|
||||
group: 'system',
|
||||
},
|
||||
{
|
||||
id: 'organizers',
|
||||
labelKey: 'settings.sections.organizers',
|
||||
descriptionKey: 'settings.sections.organizersDesc',
|
||||
path: '/organizers',
|
||||
icon: <ShieldCheck className="size-[18px]" />,
|
||||
tile: 'bg-gradient-to-b from-teal-400 to-teal-600',
|
||||
group: 'system',
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_ORDER: Array<SettingsSection['group']> = ['account', 'app', 'system'];
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
@@ -86,7 +130,6 @@ export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
||||
|
||||
|
||||
useSeoMeta({
|
||||
title: `${t('settings.title')} | ${config.appName}`,
|
||||
description: t('settings.description', { appName: config.appName }),
|
||||
@@ -98,31 +141,60 @@ export function SettingsPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
const groups = GROUP_ORDER
|
||||
.map((group) => ({ group, items: visibleSections.filter((s) => s.group === group) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<PageHeader title={t('settings.title')} icon={<Settings className="size-5" />} backTo="/" />
|
||||
|
||||
{/* Settings list */}
|
||||
<nav aria-label={t('settings.title')} className="px-4 sm:px-6 pt-2">
|
||||
<ul className="divide-y divide-border">
|
||||
{visibleSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(section.path)}
|
||||
className="flex w-full items-center gap-4 px-2 py-4 text-left transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">{t(section.labelKey)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t(section.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Grouped settings list */}
|
||||
<nav aria-label={t('settings.title')} className="px-4 sm:px-6 pt-2 space-y-7 max-w-2xl mx-auto w-full">
|
||||
{groups.map(({ group, items }) => (
|
||||
<section key={group}>
|
||||
<h2 className="px-3.5 pb-1.5 text-[12px] font-semibold uppercase tracking-wider text-muted-foreground/80">
|
||||
{t(`settings.groups.${group}`)}
|
||||
</h2>
|
||||
<ul className="overflow-hidden rounded-2xl bg-card border border-border/60 shadow-sm divide-y divide-border/50">
|
||||
{items.map((section) => (
|
||||
<li key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(section.path)}
|
||||
className={cn(
|
||||
'group flex w-full items-center gap-3.5 px-3.5 py-3 text-left',
|
||||
'transition-colors hover:bg-muted/50 active:bg-muted/70',
|
||||
'focus-visible:bg-muted/50 focus-visible:outline-none',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-[9px] text-white shadow-sm',
|
||||
section.tile,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{section.icon}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[15px] font-medium leading-tight text-foreground">
|
||||
{t(section.labelKey)}
|
||||
</span>
|
||||
<span className="block text-[13px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{t(section.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5 rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Delete account */}
|
||||
@@ -131,7 +203,7 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
className="text-xs font-medium text-destructive hover:underline focus-visible:underline focus-visible:outline-none"
|
||||
className="text-[13px] font-medium text-destructive hover:underline focus-visible:underline focus-visible:outline-none"
|
||||
>
|
||||
{t('settings.deleteAccount')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user