Files
accounted/components/settings/SettingsModal.tsx
T
Jakob Wennberg bb551d1d59 polish(ui): founder feedback batch - inset sidebar hairline, calmer dialogs, Discord mark, chat skeleton (#1158)
- Sidebar: the hairline above the user block is inset to the content
  edges instead of running edge-to-edge (concept language).
- User menu: the Discord community link renders the actual Discord mark
  (inlined simple-icons path, CC0) instead of lucide MessagesSquare.
- Dialogs: open/close animation toned down, zoom 98 instead of 95 and
  150ms instead of 200ms.
- Settings modal: switching tabs no longer remounts the intercepted
  route (and replayed the whole open animation on every click). The rail
  now swaps sections via history.replaceState, which Next syncs into
  usePathname(), so the dialog stays mounted and tab switches are
  instant. Verified via Playwright: dialog DOM node survives three tab
  switches, URL tracks the section, Esc still closes back.
- /chat loading: the shared dashboard skeleton stretched edge-to-edge in
  chat's full-bleed wrapper (chat's own layout is what suspends, so a
  chat/loading.tsx cannot catch it). The shared fallback is now route-
  aware and renders a two-pane chat silhouette for /chat.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:36:45 +02:00

74 lines
3.1 KiB
TypeScript

'use client'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog'
import { SETTINGS_SECTIONS } from './sections'
import { SettingsShell } from './SettingsShell'
import { ActiveCompanyBadge } from './ActiveCompanyBadge'
/**
* The settings popup. Rendered only by the intercepting route
* (`@settings/(.)settings/[[...section]]`) on in-app soft navigation, so its
* mere presence means "open". Closing pops the history entry that opened it,
* returning the user to the page they came from (which stayed mounted in the
* `children` slot behind the scrim). On hard load / refresh / deep-link the
* interceptor doesn't fire and the real full-page settings render instead.
*/
export function SettingsModal({ sectionId }: { sectionId?: string }) {
const router = useRouter()
const pathname = usePathname()
const { company } = useCompany()
const t = useTranslations('settings_modal')
// Tab clicks inside the modal update the URL shallowly (see SettingsRail),
// so the pathname is the live source of truth for the active section; the
// sectionId route param only covers the very first intercepted render.
// Bare /settings (or an unknown section) defaults to company, or to account
// when there is no active company (the no-company escape hatch).
const urlSection = pathname.split('/')[2] ?? sectionId
const resolved =
urlSection && SETTINGS_SECTIONS[urlSection]
? urlSection
: company
? 'company'
: 'account'
function onOpenChange(open: boolean) {
if (!open) router.back()
}
// Parallel-route safety net. This modal lives in the @settingsModal slot and
// should only ever show on /settings/* routes. On a soft navigation to a
// non-settings route (e.g. a cross-link inside the modal like "Kontoplan"),
// Next.js can keep this intercepted slot mounted over the new page. Once the
// URL is no longer a settings route, render nothing so those links actually
// leave the modal instead of appearing to do nothing.
if (!pathname.startsWith('/settings')) return null
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent
className="flex h-[100dvh] max-h-[100dvh] max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 md:h-auto md:max-h-[85dvh] md:max-w-4xl md:rounded-lg"
>
<div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-4">
<DialogTitle className="font-display text-lg tracking-tight">
{t('title')}
</DialogTitle>
{/* The modal covers the sidebar's CompanySwitcher, so the active
company must stay visible here (mr-6 clears the close button). */}
<ActiveCompanyBadge className="ml-auto mr-6" />
</div>
<DialogDescription className="sr-only">{t('description')}</DialogDescription>
<SettingsShell variant="modal" activeSection={resolved} />
</DialogContent>
</Dialog>
)
}