Replace the ad-hoc "workspace" abstraction with a focused, URL-driven
"sidebar view" registry that implements the modern Vercel / Cloudflare
drill-in pattern: clicking a top-level entry (e.g. System Settings)
swaps the sidebar to a contextual workspace, with a `← Back to
Dashboard` affordance, instead of stacking sub-navigation in the root.
Architecture
------------
- types.ts
+ SidebarView — declarative nested view config
(id, pathPattern, parent, getNavGroups)
+ SidebarViewParent — back-navigation descriptor
+ ResolvedSidebarView — { key, view, navGroups } returned by hook
+ SidebarData — slimmed to { navGroups } only
- Workspace — removed (logo/plan never rendered)
- lib/sidebar-view-registry.ts (new, replaces workspace-registry.ts)
+ SIDEBAR_VIEWS array — single source of truth for nested views
+ resolveSidebarView(pathname)
+ getNavGroupsForPath(pathname, t) — back-compat helper for the
command palette
- config/system-settings.config.ts
Refactored to export a single SYSTEM_SETTINGS_VIEW (SidebarView)
with parent `/dashboard/overview` + label `Back to Dashboard`.
- components/sidebar-view-header.tsx (new)
Renders only the back affordance (chevron + label). Uses the
default SidebarMenuButton size so its typography matches the
nav items below; collapses gracefully into icon mode via the
existing tooltip behavior. The redundant "title + icon" row was
removed — workspace context is already carried by the nav groups.
- hooks/use-sidebar-view.ts (new)
Encapsulates view resolution and root-nav filtering:
· matched view → returns its nav groups verbatim (route-level
beforeLoad guards already enforce access);
· no match → returns root nav groups, narrowed by user
role (admin gate) and useSidebarConfig
(admin × user sidebar_modules overlay).
- components/app-sidebar.tsx
Now a thin presentation layer: reads { key, view, navGroups }
from useSidebarView() and orchestrates the view transition via
AnimatePresence + MOTION_VARIANTS.sidebarSlide (respects
prefers-reduced-motion). No logic, no role checks, no path
matching — those live in the hook.
- components/command-menu.tsx
Switched to the new getNavGroupsForPath() API; behavior preserved.
Cleanup
-------
- Deleted layout/context/workspace-context.tsx (zero consumers).
- Deleted layout/lib/workspace-registry.ts and its
workspace-registry.example.ts companion (over-abstracted: name/id
metadata, isInWorkspace / getAllWorkspaces / WORKSPACE_IDS were
registered but never read).
- Removed `workspaces` field from useSidebarData (never consumed
after the top-switcher was dropped).
- Dropped WorkspaceProvider from authenticated-layout.tsx.
- Trimmed dead `Manage and configure` translation key from all six
locale files and from static-keys.ts.
i18n
----
Added the `Back to Dashboard` key to en, zh, fr, ja, ru, vi, and
registered it in static-keys.ts under "Sidebar views".
Verification
------------
- bun run typecheck: passes
- Lint: no new warnings/errors on the touched files
- Adding a new drill-in workspace now only requires registering a
SidebarView in SIDEBAR_VIEWS — no changes to AppSidebar required.
258 lines
7.8 KiB
TypeScript
Vendored
258 lines
7.8 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useId, type ReactNode } from 'react'
|
|
import { type LucideIcon } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
|
|
type StatCardTone = 'rose' | 'teal' | 'gray'
|
|
type StatCardSparklineVariant = 'bars' | 'line'
|
|
type StatCardDetailTone =
|
|
| 'default'
|
|
| 'muted'
|
|
| 'success'
|
|
| 'warning'
|
|
| 'destructive'
|
|
|
|
export interface StatCardDetail {
|
|
label: string
|
|
value: string
|
|
tone?: StatCardDetailTone
|
|
}
|
|
|
|
interface StatCardProps {
|
|
title: string
|
|
value: string | number
|
|
description: string
|
|
icon: LucideIcon
|
|
sparkline?: number[]
|
|
sparklineVariant?: StatCardSparklineVariant
|
|
details?: StatCardDetail[]
|
|
tone?: StatCardTone
|
|
loading?: boolean
|
|
error?: boolean
|
|
action?: ReactNode
|
|
}
|
|
|
|
const TONE_CLASSES: Record<StatCardTone, string> = {
|
|
rose: 'from-rose-500/80 via-rose-300/70 to-rose-200/20 dark:from-rose-400/70 dark:via-rose-500/30 dark:to-rose-500/5',
|
|
teal: 'from-teal-500/80 via-teal-300/70 to-teal-200/20 dark:from-teal-400/70 dark:via-teal-500/30 dark:to-teal-500/5',
|
|
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20',
|
|
}
|
|
|
|
const LINE_TONE_CLASSES: Record<StatCardTone, string> = {
|
|
rose: 'text-warning',
|
|
teal: 'text-primary',
|
|
gray: 'text-muted-foreground',
|
|
}
|
|
|
|
const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
|
|
default: 'text-foreground',
|
|
muted: 'text-muted-foreground',
|
|
success: 'text-success',
|
|
warning: 'text-warning',
|
|
destructive: 'text-destructive',
|
|
}
|
|
|
|
function normalizeSparkline(values?: number[]): number[] {
|
|
if (!values?.length) return []
|
|
|
|
const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
|
|
const max = Math.max(...sanitized)
|
|
if (max <= 0) return sanitized.map(() => 0)
|
|
|
|
return sanitized.map((value) => Math.max(8, (value / max) * 100))
|
|
}
|
|
|
|
function buildLineSparkline(values?: number[]) {
|
|
if (!values?.length) return null
|
|
|
|
const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
|
|
const width = 160
|
|
const height = 36
|
|
const padding = 3
|
|
const max = Math.max(...sanitized)
|
|
const min = Math.min(...sanitized)
|
|
const range = max - min
|
|
|
|
const points = sanitized.map((value, index) => {
|
|
const x =
|
|
sanitized.length === 1
|
|
? width / 2
|
|
: (index / (sanitized.length - 1)) * width
|
|
const normalized = range > 0 ? (value - min) / range : max > 0 ? 0.5 : 0
|
|
const y = height - padding - normalized * (height - padding * 2)
|
|
|
|
return { x, y }
|
|
})
|
|
|
|
const linePath = points
|
|
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
|
.join(' ')
|
|
const firstPoint = points[0]
|
|
const lastPoint = points[points.length - 1]
|
|
const areaPath = `${linePath} L ${lastPoint.x} ${height} L ${firstPoint.x} ${height} Z`
|
|
|
|
return {
|
|
areaPath,
|
|
linePath,
|
|
}
|
|
}
|
|
|
|
function LineSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
|
const rawGradientId = useId()
|
|
const gradientId = `stat-card-line-${rawGradientId.replace(/:/g, '')}`
|
|
const paths = buildLineSparkline(props.values)
|
|
|
|
if (!paths) return <div className='h-8' aria-hidden='true' />
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'relative h-8 overflow-hidden rounded-lg',
|
|
LINE_TONE_CLASSES[props.tone]
|
|
)}
|
|
aria-hidden='true'
|
|
>
|
|
<svg
|
|
viewBox='0 0 160 36'
|
|
preserveAspectRatio='none'
|
|
className='size-full'
|
|
>
|
|
<defs>
|
|
<linearGradient id={gradientId} x1='0' x2='0' y1='0' y2='1'>
|
|
<stop offset='0%' stopColor='currentColor' stopOpacity='0.24' />
|
|
<stop offset='100%' stopColor='currentColor' stopOpacity='0' />
|
|
</linearGradient>
|
|
</defs>
|
|
<path d={paths.areaPath} fill={`url(#${gradientId})`} />
|
|
<path
|
|
d={paths.linePath}
|
|
fill='none'
|
|
stroke='currentColor'
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
strokeWidth='2.25'
|
|
vectorEffect='non-scaling-stroke'
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BarSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
|
const sparkline = normalizeSparkline(props.values)
|
|
|
|
return (
|
|
<div className='flex h-8 items-end gap-1' aria-hidden='true'>
|
|
{sparkline.map((height, index) => (
|
|
<span
|
|
key={`spark-${index}`}
|
|
className={cn(
|
|
'flex-1 rounded-t-sm bg-linear-to-t',
|
|
height <= 0 && 'opacity-20',
|
|
TONE_CLASSES[props.tone]
|
|
)}
|
|
style={{ height: `${height}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatCardDetails(props: { details: StatCardDetail[] }) {
|
|
return (
|
|
<div className='grid grid-cols-2 gap-2'>
|
|
{props.details.map((detail) => (
|
|
<div
|
|
key={detail.label}
|
|
className='bg-muted/40 rounded-lg border border-transparent px-2.5 py-2'
|
|
>
|
|
<div className='text-muted-foreground truncate text-[11px] leading-none font-medium'>
|
|
{detail.label}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'mt-1.5 truncate text-xs font-semibold tabular-nums',
|
|
DETAIL_TONE_CLASSES[detail.tone ?? 'default']
|
|
)}
|
|
title={detail.value}
|
|
>
|
|
{detail.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function StatCard(props: StatCardProps) {
|
|
const Icon = props.icon
|
|
const tone = props.tone ?? 'gray'
|
|
const sparklineVariant = props.sparklineVariant ?? 'bars'
|
|
|
|
return (
|
|
<div className='group flex min-h-32 flex-col justify-between gap-3'>
|
|
<div className='flex items-start justify-between gap-1'>
|
|
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium sm:gap-2'>
|
|
<Icon
|
|
className='text-muted-foreground/60 size-3.5 shrink-0'
|
|
aria-hidden='true'
|
|
/>
|
|
<span className='line-clamp-2 leading-snug'>{props.title}</span>
|
|
</div>
|
|
{props.action && <div className='shrink-0'>{props.action}</div>}
|
|
</div>
|
|
|
|
{props.loading ? (
|
|
<div className='flex flex-col gap-1.5'>
|
|
<Skeleton className='h-7 w-24' />
|
|
<Skeleton className='h-3.5 w-32' />
|
|
</div>
|
|
) : props.error ? (
|
|
<div className='flex flex-col gap-1'>
|
|
<div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
|
--
|
|
</div>
|
|
<p className='text-muted-foreground/60 text-xs'>
|
|
{props.description}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className='flex flex-col gap-1'>
|
|
<div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
|
{props.value}
|
|
</div>
|
|
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
|
{props.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{props.details?.length ? (
|
|
<StatCardDetails details={props.details} />
|
|
) : sparklineVariant === 'line' ? (
|
|
<LineSparkline values={props.sparkline} tone={tone} />
|
|
) : (
|
|
<BarSparkline values={props.sparkline} tone={tone} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|