feat: add expandable sidebar navigation to settings page
Replace direct SharedProfileSettings rendering with an expandable sidebar that shows Profile, Security, and Preferences as collapsible sub-items under Profile & Preferences. Matches the new settings pattern across all Ciphera frontends. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
150
app/settings/SettingsPageClient.tsx
Normal file
150
app/settings/SettingsPageClient.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import ProfileSettings from '@/components/settings/ProfileSettings'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
UserIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
} from '@ciphera-net/ui'
|
||||||
|
|
||||||
|
type ProfileSubTab = 'profile' | 'security' | 'preferences'
|
||||||
|
|
||||||
|
function SectionHeader({
|
||||||
|
expanded,
|
||||||
|
active,
|
||||||
|
onToggle,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
expanded: boolean
|
||||||
|
active: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
icon: React.ElementType
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`w-full flex items-start gap-3 px-4 py-3 text-left rounded-xl transition-all duration-200 ${
|
||||||
|
active
|
||||||
|
? 'bg-brand-orange/10 text-brand-orange'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
{description && (
|
||||||
|
<p className={`text-xs mt-0.5 ${active ? 'text-brand-orange/70' : 'text-neutral-500'}`}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`w-4 h-4 shrink-0 mt-1 transition-transform duration-200 ${
|
||||||
|
expanded ? '' : '-rotate-90'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubItem({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-2.5 pl-12 pr-4 py-2 text-sm text-left rounded-lg transition-all duration-150 ${
|
||||||
|
active
|
||||||
|
? 'text-brand-orange font-medium bg-brand-orange/5'
|
||||||
|
: 'text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-50 dark:hover:bg-neutral-800/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpandableSubItems({ expanded, children }: { expanded: boolean; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="py-1 space-y-0.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPageClient() {
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState<ProfileSubTab>('profile')
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">Settings</h1>
|
||||||
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||||
|
Manage your account settings and preferences.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
<nav className="w-full lg:w-72 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
expanded={expanded}
|
||||||
|
active={true}
|
||||||
|
onToggle={() => setExpanded(!expanded)}
|
||||||
|
icon={UserIcon}
|
||||||
|
label="Profile & Preferences"
|
||||||
|
description="Your profile and sharing defaults"
|
||||||
|
/>
|
||||||
|
<ExpandableSubItems expanded={expanded}>
|
||||||
|
<SubItem
|
||||||
|
active={activeSubTab === 'profile'}
|
||||||
|
onClick={() => setActiveSubTab('profile')}
|
||||||
|
label="Profile"
|
||||||
|
/>
|
||||||
|
<SubItem
|
||||||
|
active={activeSubTab === 'security'}
|
||||||
|
onClick={() => setActiveSubTab('security')}
|
||||||
|
label="Security"
|
||||||
|
/>
|
||||||
|
<SubItem
|
||||||
|
active={activeSubTab === 'preferences'}
|
||||||
|
onClick={() => setActiveSubTab('preferences')}
|
||||||
|
label="Preferences"
|
||||||
|
/>
|
||||||
|
</ExpandableSubItems>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<ProfileSettings activeTab={activeSubTab} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import ProfileSettings from '@/components/settings/ProfileSettings'
|
import SettingsPageClient from './SettingsPageClient'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Settings - Pulse',
|
title: 'Settings - Pulse',
|
||||||
@@ -8,7 +8,7 @@ export const metadata = {
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||||
<ProfileSettings />
|
<SettingsPageClient />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences, u
|
|||||||
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa'
|
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa'
|
||||||
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
|
import { registerPasskey, listPasskeys, deletePasskey } from '@/lib/api/webauthn'
|
||||||
|
|
||||||
export default function ProfileSettings() {
|
interface Props {
|
||||||
|
activeTab?: 'profile' | 'security' | 'preferences'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileSettings({ activeTab }: Props = {}) {
|
||||||
const { user, refresh, logout } = useAuth()
|
const { user, refresh, logout } = useAuth()
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
@@ -54,6 +58,8 @@ export default function ProfileSettings() {
|
|||||||
deriveAuthKey={deriveAuthKey}
|
deriveAuthKey={deriveAuthKey}
|
||||||
refreshUser={refresh}
|
refreshUser={refresh}
|
||||||
logout={logout}
|
logout={logout}
|
||||||
|
activeTab={activeTab}
|
||||||
|
hideNav={activeTab !== undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ciphera-net/ui": "^0.0.74",
|
"@ciphera-net/ui": "^0.0.75",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user