Reorganize Settings into menu-based navigation with sub-pages

Replace the single tabbed Settings page with a landing menu that navigates
to dedicated sub-pages, following the Agora pattern:

- Settings landing: card-based menu with icons and descriptions
- Profile: wraps EditProfileForm (auth-gated)
- Appearance: Theme sub-tab with visual theme selector (light/dark/black/pink
  + custom placeholder) and Content sub-tab with existing content settings
- Wallet: dedicated page for NWC wallet management (auth-gated)
- Notifications: push notification enable/disable toggle (auth-gated)
- Advanced: network relays, stats source, and system settings (wallet
  section removed since it has its own page now)
This commit is contained in:
Mary Kate Fain
2026-02-24 19:42:40 -06:00
parent f4cbb71e38
commit 2ac9960b31
9 changed files with 561 additions and 86 deletions
+11 -1
View File
@@ -11,6 +11,11 @@ import { NIP19Page } from "./pages/NIP19Page";
import { NotificationsPage } from "./pages/NotificationsPage";
import { SearchPage } from "./pages/SearchPage";
import { SettingsPage } from "./pages/SettingsPage";
import { ProfileSettings } from "./pages/ProfileSettings";
import { AppearanceSettings } from "./pages/AppearanceSettings";
import { WalletSettingsPage } from "./pages/WalletSettingsPage";
import { NotificationSettings } from "./pages/NotificationSettings";
import { AdvancedSettingsPage } from "./pages/AdvancedSettingsPage";
import { HashtagPage } from "./pages/HashtagPage";
import { DomainFeedPage } from "./pages/DomainFeedPage";
import { BookmarksPage } from "./pages/BookmarksPage";
@@ -40,7 +45,12 @@ export function AppRouter() {
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/timeline/:domain" element={<DomainFeedPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/:section" element={<SettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/appearance" element={<AppearanceSettings />} />
<Route path="/settings/appearance/:tab" element={<AppearanceSettings />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route path="/settings/notifications" element={<NotificationSettings />} />
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
<Route path="/vines" element={<KindFeedPage kind={34236} title="Vines" icon={<Clapperboard className="size-5" />} />} />
<Route path="/polls" element={<KindFeedPage kind={1068} title="Polls" icon={<BarChart3 className="size-5" />} />} />
<Route path="/treasures" element={<TreasuresPage />} />
-31
View File
@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { WalletSettings } from '@/components/WalletSettings';
import { RelayListManager } from '@/components/RelayListManager';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
@@ -14,7 +13,6 @@ export function AdvancedSettings() {
const { user } = useCurrentUser();
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const [walletOpen, setWalletOpen] = useState(false);
const [relaysOpen, setRelaysOpen] = useState(false);
const [statsOpen, setStatsOpen] = useState(false);
const [servicesOpen, setServicesOpen] = useState(false);
@@ -74,35 +72,6 @@ export function AdvancedSettings() {
</p>
</div>
{/* Wallet Section — only when logged in */}
{user && (
<div>
<Collapsible open={walletOpen} onOpenChange={setWalletOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
>
<span className="text-base font-semibold">Wallet</span>
{walletOpen ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pb-4 pt-4">
<div className="px-3">
<WalletSettings />
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
{/* Network (Relays) Section — only when logged in */}
{user && (
<div>
+128
View File
@@ -0,0 +1,128 @@
import { Check, Plus } from 'lucide-react';
import { type Theme } from '@/contexts/AppContext';
import { useTheme } from '@/hooks/useTheme';
import { themes, type ThemeTokens } from '@/themes';
import { cn } from '@/lib/utils';
interface ThemeOption {
id: Theme | 'custom';
label: string;
tokens?: ThemeTokens;
}
const themeOptions: ThemeOption[] = [
{ id: 'light', label: 'Light', tokens: themes.light },
{ id: 'dark', label: 'Dark', tokens: themes.dark },
{ id: 'black', label: 'Black', tokens: themes.black },
{ id: 'pink', label: 'Pink', tokens: themes.pink },
{ id: 'custom', label: 'Custom' },
];
/** Extracts HSL color string from a theme token value like "258 70% 55%" */
function hsl(value: string): string {
return `hsl(${value})`;
}
export function ThemeSelector() {
const { theme, setTheme } = useTheme();
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{themeOptions.map((option) => {
if (option.id === 'custom') {
return (
<button
key="custom"
className={cn(
'relative group rounded-xl border-2 border-dashed border-muted-foreground/25 p-1 transition-all hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
onClick={() => {
// Custom theme builder placeholder -- navigates or opens modal in future
}}
title="Custom theme builder (coming soon)"
>
<div className="aspect-[4/3] rounded-lg bg-muted/30 flex flex-col items-center justify-center gap-1.5">
<Plus className="size-5 text-muted-foreground/50" />
<span className="text-[10px] font-medium text-muted-foreground/60">Coming soon</span>
</div>
<p className="mt-1.5 text-xs font-medium text-center text-muted-foreground">
{option.label}
</p>
</button>
);
}
const tokens = option.tokens!;
const isActive = theme === option.id;
return (
<button
key={option.id}
className={cn(
'relative group rounded-xl border-2 p-1 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive
? 'border-primary shadow-sm'
: 'border-border hover:border-primary/40',
)}
onClick={() => setTheme(option.id as Theme)}
>
{/* Mini preview */}
<div
className="aspect-[4/3] rounded-lg overflow-hidden relative"
style={{ backgroundColor: hsl(tokens.background) }}
>
{/* Simulated header bar */}
<div
className="h-2.5 w-full"
style={{ backgroundColor: hsl(tokens.card) }}
/>
{/* Content preview area */}
<div className="p-1.5 space-y-1">
{/* Simulated text lines */}
<div
className="h-1 w-3/4 rounded-full"
style={{ backgroundColor: hsl(tokens.foreground), opacity: 0.6 }}
/>
<div
className="h-1 w-1/2 rounded-full"
style={{ backgroundColor: hsl(tokens.mutedForeground), opacity: 0.4 }}
/>
{/* Simulated button */}
<div className="pt-0.5">
<div
className="h-2 w-8 rounded-sm"
style={{ backgroundColor: hsl(tokens.primary) }}
/>
</div>
</div>
{/* Simulated sidebar strip */}
<div
className="absolute right-0 top-0 bottom-0 w-4"
style={{ backgroundColor: hsl(tokens.sidebarBackground) }}
/>
{/* Active check mark */}
{isActive && (
<div className="absolute top-1 left-1 size-4 rounded-full flex items-center justify-center"
style={{ backgroundColor: hsl(tokens.primary) }}
>
<Check className="size-2.5" style={{ color: hsl(tokens.primaryForeground) }} />
</div>
)}
</div>
{/* Label */}
<p className={cn(
'mt-1.5 text-xs font-medium text-center transition-colors',
isActive ? 'text-foreground' : 'text-muted-foreground',
)}>
{option.label}
</p>
</button>
);
})}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { AdvancedSettings } from '@/components/AdvancedSettings';
export function AdvancedSettingsPage() {
useSeoMeta({
title: 'Advanced | Settings | Ditto',
description: 'Advanced settings for relays, upload servers, and system configuration',
});
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header with back link */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Advanced</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Relays, upload servers, and system settings
</p>
</div>
</div>
</div>
<div className="p-4">
<AdvancedSettings />
</div>
</main>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ThemeSelector } from '@/components/ThemeSelector';
import { ContentSettings } from '@/components/ContentSettings';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
type AppearanceTab = 'theme' | 'content';
export function AppearanceSettings() {
const { tab } = useParams<{ tab?: string }>();
const activeTab: AppearanceTab = tab === 'content' ? 'content' : 'theme';
useSeoMeta({
title: 'Appearance | Settings | Ditto',
description: 'Customize your display preferences and content settings',
});
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header with back link */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Appearance</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Theme, display preferences, and content settings
</p>
</div>
</div>
</div>
{/* Sub-section tabs */}
<div className={cn(STICKY_HEADER_CLASS, 'flex border-b border-border bg-background/80 backdrop-blur-md z-10')}>
<AppearanceTabLink to="/settings/appearance" label="Theme" active={activeTab === 'theme'} />
<AppearanceTabLink to="/settings/appearance/content" label="Content" active={activeTab === 'content'} />
</div>
{/* Content */}
<div className="p-4">
{activeTab === 'theme' ? (
<div className="space-y-6">
{/* Theme selector */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">Theme</CardTitle>
<p className="text-xs text-muted-foreground">
Choose a theme for the interface
</p>
</CardHeader>
<CardContent>
<ThemeSelector />
</CardContent>
</Card>
</div>
) : (
<ContentSettings />
)}
</div>
</main>
);
}
function AppearanceTabLink({ to, label, active }: { to: string; label: string; active: boolean }) {
return (
<Link
to={to}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
)}
</Link>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Bell, BellOff } from 'lucide-react';
import { Link, Navigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
export function NotificationSettings() {
const { user } = useCurrentUser();
const [pushEnabled, setPushEnabled] = useState(false);
const [permission, setPermission] = useState<NotificationPermission>('default');
useSeoMeta({
title: 'Notifications | Settings | Ditto',
description: 'Configure your notification preferences',
});
// Check current permission state on mount
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
setPushEnabled(Notification.permission === 'granted');
}
}, []);
const handleTogglePush = async (enabled: boolean) => {
if (!('Notification' in window)) return;
if (enabled) {
const result = await Notification.requestPermission();
setPermission(result);
setPushEnabled(result === 'granted');
} else {
// Browser doesn't allow revoking permissions programmatically,
// but we can track the user's preference
setPushEnabled(false);
}
};
if (!user) {
return <Navigate to="/settings" replace />;
}
const isSupported = 'Notification' in window;
const isDenied = permission === 'denied';
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header with back link */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Notifications</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Configure push notification preferences
</p>
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Push notifications toggle */}
<Card>
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-secondary shrink-0">
{pushEnabled ? (
<Bell className="size-5 text-muted-foreground" />
) : (
<BellOff className="size-5 text-muted-foreground" />
)}
</div>
<div>
<Label htmlFor="push-notifications" className="text-sm font-semibold cursor-pointer">
Push Notifications
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Receive notifications for mentions, replies, and zaps
</p>
</div>
</div>
<Switch
id="push-notifications"
checked={pushEnabled}
onCheckedChange={handleTogglePush}
disabled={!isSupported || isDenied}
/>
</div>
{/* Status info */}
{!isSupported && (
<div className="flex items-center gap-2 px-1">
<Badge variant="secondary" className="text-xs">
Not Supported
</Badge>
<p className="text-xs text-muted-foreground">
Your browser does not support push notifications.
</p>
</div>
)}
{isDenied && (
<div className="flex items-center gap-2 px-1">
<Badge variant="destructive" className="text-xs">
Blocked
</Badge>
<p className="text-xs text-muted-foreground">
Notifications are blocked. Update your browser settings to allow notifications from this site.
</p>
</div>
)}
</CardContent>
</Card>
</div>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link, Navigate } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { useCurrentUser } from '@/hooks/useCurrentUser';
export function ProfileSettings() {
const { user } = useCurrentUser();
useSeoMeta({
title: 'Profile | Settings | Ditto',
description: 'Edit your Ditto profile',
});
if (!user) {
return <Navigate to="/settings" replace />;
}
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header with back link */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Profile</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Edit your display name, bio, and avatar
</p>
</div>
</div>
</div>
<div className="p-4">
<EditProfileForm />
</div>
</main>
);
}
+99 -54
View File
@@ -1,73 +1,118 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { ContentSettings } from '@/components/ContentSettings';
import { AdvancedSettings } from '@/components/AdvancedSettings';
import { ArrowLeft, Bell, ChevronRight, Palette, Server, Settings as SettingsIcon, User, Wallet } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
interface SettingsSection {
id: string;
label: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
path: string;
requiresAuth?: boolean;
}
const sections: SettingsSection[] = [
{
id: 'profile',
label: 'Profile',
description: 'Edit your display name, bio, and avatar',
icon: User,
path: '/settings/profile',
requiresAuth: true,
},
{
id: 'appearance',
label: 'Appearance',
description: 'Theme, display preferences, and content settings',
icon: Palette,
path: '/settings/appearance',
},
{
id: 'wallet',
label: 'Wallet',
description: 'Manage wallet connections and payments',
icon: Wallet,
path: '/settings/wallet',
requiresAuth: true,
},
{
id: 'notifications',
label: 'Notifications',
description: 'Configure push notification preferences',
icon: Bell,
path: '/settings/notifications',
requiresAuth: true,
},
{
id: 'advanced',
label: 'Advanced',
description: 'Relays, upload servers, and system settings',
icon: Server,
path: '/settings/advanced',
},
];
export function SettingsPage() {
const { section } = useParams<{ section?: string }>();
const { user } = useCurrentUser();
const navigate = useNavigate();
useSeoMeta({
title: 'Settings | Ditto',
description: 'Manage your Ditto settings',
});
const activeSection = section || (user ? 'profile' : 'content');
const visibleSections = sections.filter(
(section) => !section.requiresAuth || user,
);
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<SettingsIcon className="size-5" />
<h1 className="text-xl font-bold">Settings</h1>
</div>
<p className="text-sm text-muted-foreground mt-0.5">Customize your profile, content, and advanced features</p>
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/" className="p-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<SettingsIcon className="size-5" />
<h1 className="text-xl font-bold">Settings</h1>
</div>
<p className="text-sm text-muted-foreground mt-0.5">
Customize your experience
</p>
</div>
</div>
</div>
{/* Tab navigation */}
<div className={cn(STICKY_HEADER_CLASS, 'flex border-b border-border bg-background/80 backdrop-blur-md z-10')}>
{user && <SettingsTab to="/settings/profile" label="Profile" active={activeSection === 'profile'} />}
<SettingsTab to="/settings/content" label="Content" active={activeSection === 'content'} />
<SettingsTab to="/settings/advanced" label="Advanced" active={activeSection === 'advanced'} />
</div>
<div className="p-4">
{activeSection === 'content' ? (
<ContentSettings />
) : activeSection === 'advanced' ? (
<AdvancedSettings />
) : user && activeSection === 'profile' ? (
<EditProfileForm />
) : null}
</div>
</main>
);
}
function SettingsTab({ to, label, active }: { to: string; label: string; active: boolean }) {
return (
<Link
to={to}
className={cn(
'flex-1 py-3.5 text-center text-sm font-medium transition-colors relative hover:bg-secondary/40',
active ? 'text-foreground' : 'text-muted-foreground',
)}
>
{label}
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-16 h-1 bg-primary rounded-full" />
)}
</Link>
{/* Settings menu */}
<div className="p-4 space-y-2">
{visibleSections.map((section) => {
const Icon = section.icon;
return (
<Card
key={section.id}
className="cursor-pointer transition-colors hover:bg-muted/40 active:bg-muted/60"
onClick={() => navigate(section.path)}
>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex items-center justify-center size-10 rounded-full bg-secondary shrink-0">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">{section.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{section.description}
</p>
</div>
<ChevronRight className="size-5 text-muted-foreground shrink-0" />
</CardContent>
</Card>
);
})}
</div>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link, Navigate } from 'react-router-dom';
import { WalletSettings } from '@/components/WalletSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
export function WalletSettingsPage() {
const { user } = useCurrentUser();
useSeoMeta({
title: 'Wallet | Settings | Ditto',
description: 'Manage your wallet connections',
});
if (!user) {
return <Navigate to="/settings" replace />;
}
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header with back link */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center gap-4">
<Link to="/settings" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-xl font-bold">Wallet</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage wallet connections and payments
</p>
</div>
</div>
</div>
<div className="p-4">
<WalletSettings />
</div>
</main>
);
}