import { Menu } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { type RailGroup, SettingsRail } from './settings-rail'
export interface SettingsShellSection {
id: string
labelKey: string
/** Group id this section belongs to. */
groupId: string
}
export interface SettingsShellGroup {
id: string
labelKey: string
}
interface SettingsShellProps {
groups: SettingsShellGroup[]
sections: SettingsShellSection[]
activeId: string
onNavigate: (id: string) => void
/** Map of sectionId → number of unsaved changes (drives the rail dot/badge). */
unsavedBySection: Record<string, number>
/**
* Open the save review flow. When provided, ⌘S / Ctrl+S triggers it.
* Omit (or pass undefined) when there is nothing to save.
*/
onReview?: () => void
/**
* Optional pre-rendered section header. Most callers render this
* themselves above `<SettingsShell>` so the page can control its
* outer padding. Defaults to `null`.
*/
children: React.ReactNode
}
/**
* 3-zone layout container for the `/settings` route.
*
* ```
* ┌──────────┬───────────────────────────────────┐
* │ │ │
* │ Rail │ Children (section cards) │
* │ │ │
* │ │ │
* └──────────┴───────────────────────────────────┘
* ```
*
* The rail is the **single** navigation surface — it lists every
* section grouped by category, and the active item stays visible via
* auto `scrollIntoView`. A previous top "section tabs" bar was
* removed because it duplicated the rail's content.
*/
export function SettingsShell({
groups,
sections,
activeId,
onNavigate,
unsavedBySection,
onReview,
children,
}: SettingsShellProps) {
const { t } = useTranslation()
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
// Flat ordered list of section ids — used for j/k navigation.
const orderedSectionIds = useMemo(() => sections.map((s) => s.id), [sections])
// Build rail groups from flat (group, sections) lists.
const railGroups: RailGroup[] = useMemo(() => {
return groups.map((g) => ({
id: g.id,
labelKey: g.labelKey,
items: sections
.filter((s) => s.groupId === g.id)
.map((s) => ({
id: s.id,
labelKey: s.labelKey,
status: (unsavedBySection[s.id] ?? 0) > 0 ? ('modified' as const) : ('default' as const),
badge: unsavedBySection[s.id],
})),
}))
}, [groups, sections, unsavedBySection])
// Reset search when active section changes (mobile drawer UX).
useEffect(() => {
setSearchQuery('')
}, [activeId])
// ── Keyboard shortcuts ────────────────────────────────────────
//
// ⌘K / Ctrl+K → focus the search input
// ⌘S / Ctrl+S → open the save review flow (when there are changes)
// j / k → next / previous section
// g g → first section
// G → last section
//
// j / k / g / G are suppressed while typing in a form field so they
// don't hijack text entry. The modifier shortcuts (⌘K, ⌘S) work
// everywhere.
const navigateByOffset = useCallback(
(offset: number) => {
const idx = orderedSectionIds.indexOf(activeId)
if (idx === -1) return
const next = orderedSectionIds[idx + offset]
if (next) onNavigate(next)
},
[orderedSectionIds, activeId, onNavigate],
)
const pendingGRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
// ⌘K / Ctrl+K → focus search.
if (mod && e.key.toLowerCase() === 'k') {
e.preventDefault()
searchInputRef.current?.focus()
searchInputRef.current?.select()
return
}
// ⌘S / Ctrl+S → review.
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault()
onReview?.()
return
}
// Ignore the single-key shortcuts while typing or using a modifier.
const target = e.target as HTMLElement | null
const isTyping =
!!target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
if (isTyping || mod || e.altKey) return
const key = e.key
if (key === 'j') {
e.preventDefault()
navigateByOffset(1)
} else if (key === 'k') {
e.preventDefault()
navigateByOffset(-1)
} else if (key === 'G') {
e.preventDefault()
const last = orderedSectionIds[orderedSectionIds.length - 1]
if (last) onNavigate(last)
} else if (key === 'g') {
e.preventDefault()
// Two `g` presses within 700ms → first section. A single `g`
// starts the pending timer; a second `g` cancels it and jumps.
if (pendingGRef.current) {
clearTimeout(pendingGRef.current)
pendingGRef.current = null
const first = orderedSectionIds[0]
if (first) onNavigate(first)
} else {
pendingGRef.current = setTimeout(() => {
pendingGRef.current = null
}, 700)
}
}
}
window.addEventListener('keydown', handler)
return () => {
window.removeEventListener('keydown', handler)
if (pendingGRef.current) clearTimeout(pendingGRef.current)
}
}, [navigateByOffset, onReview, onNavigate, orderedSectionIds])
return (
<div className="flex flex-col md:flex-row gap-6">
{/* Desktop rail (spec §5): visible from `md` (768px) up, widening
across three tiers — 200px → 240px → 280px. Below `md` the
rail is hidden behind the `MobileSheet` drawer. */}
<aside
className={cn('hidden md:block shrink-0', 'w-[200px] lg:w-[240px] xl:w-[280px]')}
aria-label={t('settings.title')}
>
<div className="sticky top-[5.5rem] max-h-[calc(100vh-6rem)]">
<SettingsRail
groups={railGroups}
activeId={activeId}
onNavigate={onNavigate}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
searchInputRef={searchInputRef}
/>
</div>
</aside>
{/* Mobile drawer trigger — only below `md` (phones). */}
<div className="md:hidden w-full">
<MobileSheet
railGroups={railGroups}
activeId={activeId}
onNavigate={onNavigate}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="space-y-4 animate-stagger">{children}</div>
</div>
</div>
)
}
// ─── Mobile sheet (drawer) ───────────────────────────────────────
function MobileSheet({
railGroups,
activeId,
onNavigate,
searchQuery,
onSearchChange,
}: {
railGroups: RailGroup[]
activeId: string
onNavigate: (id: string) => void
searchQuery: string
onSearchChange: (q: string) => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const active = railGroups.flatMap((g) => g.items).find((i) => i.id === activeId)
const activeLabel = active?.label ?? (active ? t(active.labelKey) : '')
return (
<Dialog open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-2 mb-3">
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Menu className="h-3.5 w-3.5" />
{t('settings.title')}
</Button>
</DialogTrigger>
<span className="text-sm text-muted-foreground truncate">{activeLabel}</span>
</div>
<DialogContent
showCloseButton={false}
className="max-w-sm p-0 gap-0 sm:rounded-xl left-0 top-0 translate-x-0 translate-y-0 h-screen max-h-screen w-72 sm:w-72 rounded-none border-r"
>
<DialogHeader className="px-4 pt-4 pb-2 border-b">
<DialogTitle>{t('settings.title')}</DialogTitle>
</DialogHeader>
<div className="px-3 pt-3 pb-4 h-[calc(100vh-4rem)]">
<SettingsRail
groups={railGroups}
activeId={activeId}
onNavigate={(id) => {
onNavigate(id)
setOpen(false)
}}
searchQuery={searchQuery}
onSearchChange={onSearchChange}
/>
</div>
</DialogContent>
</Dialog>
)
}