chore: starter template
This commit is contained in:
57
packages/components/src/BarChart.tsx
Normal file
57
packages/components/src/BarChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { Box, Group, Stack, Text } from '@mantine/core'
|
||||
|
||||
export interface BarChartDatum {
|
||||
label: React.ReactNode
|
||||
value: number
|
||||
/** Mantine color key for the bar (e.g. 'teal', 'red'). Defaults to the theme primary. */
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface BarChartProps {
|
||||
data: BarChartDatum[]
|
||||
/** Format the numeric value shown at the end of each bar. */
|
||||
valueFormatter?: (value: number) => string
|
||||
}
|
||||
|
||||
// A dependency-free horizontal bar chart (CSS only, theme colours). Feed it
|
||||
// aggregated counts from a stats RPC — no charting library to install or break.
|
||||
export function BarChart({ data, valueFormatter }: BarChartProps) {
|
||||
const max = Math.max(1, ...data.map((d) => d.value))
|
||||
const fmt = valueFormatter ?? ((n: number) => String(n))
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{data.map((datum, index) => (
|
||||
<Stack key={index} gap={4}>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="sm">{datum.label}</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmt(datum.value)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 'var(--mantine-radius-xl)',
|
||||
background: 'var(--mantine-color-default-hover)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${(datum.value / max) * 100}%`,
|
||||
borderRadius: 'var(--mantine-radius-xl)',
|
||||
background: datum.color
|
||||
? `var(--mantine-color-${datum.color}-6)`
|
||||
: 'var(--mantine-primary-color-6)',
|
||||
transition: 'width 200ms ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
85
packages/components/src/DataTable.tsx
Normal file
85
packages/components/src/DataTable.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react'
|
||||
import { Center, Loader, Table, Text } from '@mantine/core'
|
||||
|
||||
export interface DataTableColumn<Row> {
|
||||
/** Stable column id; also the default cell value (row[key]) when no render is given. */
|
||||
key: string
|
||||
header: React.ReactNode
|
||||
/** Custom cell renderer. Defaults to String(row[key]). */
|
||||
render?: (row: Row) => React.ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
width?: number | string
|
||||
}
|
||||
|
||||
export interface DataTableProps<Row> {
|
||||
columns: DataTableColumn<Row>[]
|
||||
rows: Row[]
|
||||
/** Stable key per row (e.g. r => r.taskId). */
|
||||
rowKey: (row: Row) => string
|
||||
loading?: boolean
|
||||
/** Shown when there are no rows (pass an <EmptyState/> for a rich version). */
|
||||
empty?: React.ReactNode
|
||||
onRowClick?: (row: Row) => void
|
||||
}
|
||||
|
||||
// A generic, typed table. Wire it to any list RPC: implement listX, pass its
|
||||
// rows here, and describe the columns. Pure presentation — no data fetching.
|
||||
export function DataTable<Row>({
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
loading,
|
||||
empty,
|
||||
onRowClick,
|
||||
}: DataTableProps<Row>) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
{empty ?? (
|
||||
<Text c="dimmed" size="sm">
|
||||
Nothing here yet.
|
||||
</Text>
|
||||
)}
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Table highlightOnHover={!!onRowClick} verticalSpacing="sm" horizontalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map((col) => (
|
||||
<Table.Th key={col.key} style={{ textAlign: col.align ?? 'left', width: col.width }}>
|
||||
{col.header}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => (
|
||||
<Table.Tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
style={onRowClick ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<Table.Td key={col.key} style={{ textAlign: col.align ?? 'left' }}>
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: String((row as Record<string, unknown>)[col.key] ?? '')}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
58
packages/components/src/EmptyState.stories.tsx
Normal file
58
packages/components/src/EmptyState.stories.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AlertCircle, Boxes, FolderOpen } from 'lucide-react'
|
||||
import type { Story, StoryMeta } from './csf.types'
|
||||
import { EmptyState } from './EmptyState'
|
||||
|
||||
const meta: StoryMeta = {
|
||||
title: 'EmptyState',
|
||||
component: EmptyState,
|
||||
tags: ['feedback'],
|
||||
argTypes: {
|
||||
icon: { description: 'Lucide icon component displayed above the title', control: false },
|
||||
title: { description: 'Primary heading text', control: 'text' },
|
||||
description: { description: 'Supporting text below the title', control: 'text' },
|
||||
action: { description: 'Label for the primary action button', control: 'text' },
|
||||
onAction: { description: 'Callback fired when the action button is clicked', control: false },
|
||||
compact: {
|
||||
description: 'Reduces padding and icon size for inline contexts',
|
||||
control: 'boolean',
|
||||
},
|
||||
docsHref: { description: 'Optional link shown as a Docs button', control: 'text' },
|
||||
},
|
||||
}
|
||||
export default meta
|
||||
|
||||
function DefaultStory() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No items yet"
|
||||
description="Create your first item to get started."
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const Default: Story = { render: DefaultStory }
|
||||
|
||||
function WithActionStory() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="No projects"
|
||||
description="You don't have any projects yet."
|
||||
action="New project"
|
||||
onAction={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const WithAction: Story = { render: WithActionStory }
|
||||
|
||||
function CompactStory() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Nothing here"
|
||||
description="This section is currently empty."
|
||||
compact
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const Compact: Story = { render: CompactStory }
|
||||
77
packages/components/src/EmptyState.tsx
Normal file
77
packages/components/src/EmptyState.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { Button, Center, Group, Stack, Text } from '@mantine/core'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: React.ComponentType<{ size?: number; strokeWidth?: number }>
|
||||
title: string
|
||||
description: React.ReactNode
|
||||
action?: string
|
||||
onAction?: () => void
|
||||
actionIcon?: React.ReactNode
|
||||
actionLoading?: boolean
|
||||
secondaryText?: React.ReactNode
|
||||
docsHref?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
onAction,
|
||||
actionIcon,
|
||||
actionLoading,
|
||||
secondaryText,
|
||||
docsHref,
|
||||
compact = false,
|
||||
}: EmptyStateProps) {
|
||||
const Icon = icon
|
||||
|
||||
return (
|
||||
<Center flex={1}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={compact ? 'sm' : 'md'}
|
||||
py={compact ? 'lg' : 'xl'}
|
||||
style={{ minHeight: compact ? undefined : '60vh', width: '100%' }}
|
||||
>
|
||||
<Icon size={compact ? 36 : 48} strokeWidth={1} />
|
||||
<Text size={compact ? 'lg' : 'xl'} fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text c="dimmed" ta="center" maw={500}>
|
||||
{description}
|
||||
</Text>
|
||||
{docsHref || (action && onAction) ? (
|
||||
<Group justify="center">
|
||||
{docsHref ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={docsHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<ExternalLink size={16} />}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
) : null}
|
||||
{action && onAction ? (
|
||||
<Button onClick={onAction} loading={actionLoading} leftSection={actionIcon}>
|
||||
{action}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
{secondaryText ? (
|
||||
<Text c="dimmed" size="sm" ta="center" maw={500}>
|
||||
{secondaryText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
25
packages/components/src/NotFoundState.stories.tsx
Normal file
25
packages/components/src/NotFoundState.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Story, StoryMeta } from './csf.types'
|
||||
import { NotFoundState } from './NotFoundState'
|
||||
|
||||
const meta: StoryMeta = {
|
||||
title: 'NotFoundState',
|
||||
component: NotFoundState,
|
||||
tags: ['feedback'],
|
||||
argTypes: {
|
||||
title: { description: 'Override the "Page not found" heading', control: 'text' },
|
||||
description: { description: 'Override the default description text', control: 'text' },
|
||||
},
|
||||
}
|
||||
export default meta
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
function CustomStory() {
|
||||
return (
|
||||
<NotFoundState
|
||||
title="Board not found"
|
||||
description="The board you were looking for has been removed or never existed."
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const Custom: Story = { render: CustomStory }
|
||||
40
packages/components/src/NotFoundState.tsx
Normal file
40
packages/components/src/NotFoundState.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Center, Stack, Text } from '@mantine/core'
|
||||
|
||||
interface NotFoundStateProps {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function NotFoundState({
|
||||
title = 'Page not found',
|
||||
description = "The resource you're looking for doesn't exist or has been removed.",
|
||||
}: NotFoundStateProps) {
|
||||
return (
|
||||
<Center flex={1}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
py="xl"
|
||||
style={{ minHeight: '60vh', width: '100%' }}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 72,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.06em',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
404
|
||||
</Text>
|
||||
<Text size="xl" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text ta="center" maw={520} c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
32
packages/components/src/PageHeader.stories.tsx
Normal file
32
packages/components/src/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button } from '@mantine/core'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { Story, StoryMeta } from './csf.types'
|
||||
import { PageHeader } from './PageHeader'
|
||||
|
||||
const meta: StoryMeta = {
|
||||
title: 'PageHeader',
|
||||
component: PageHeader,
|
||||
tags: ['layout'],
|
||||
argTypes: {
|
||||
title: { description: 'Page title', control: 'text' },
|
||||
description: { description: 'Optional sub-text', control: 'text' },
|
||||
actions: { description: 'Right-aligned actions slot', control: false },
|
||||
},
|
||||
}
|
||||
export default meta
|
||||
|
||||
function DefaultStory() {
|
||||
return <PageHeader title="Tasks" description="Everything on your plate, in one place." />
|
||||
}
|
||||
export const Default: Story = { render: DefaultStory }
|
||||
|
||||
function WithActionStory() {
|
||||
return (
|
||||
<PageHeader
|
||||
title="Tasks"
|
||||
description="Everything on your plate, in one place."
|
||||
actions={<Button leftSection={<Plus size={16} />}>New task</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const WithAction: Story = { render: WithActionStory }
|
||||
32
packages/components/src/PageHeader.tsx
Normal file
32
packages/components/src/PageHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { Box, Group, Stack, Text, Title } from '@mantine/core'
|
||||
|
||||
export interface PageHeaderProps {
|
||||
/** Page title. Pass an i18n string from the app (m.foo()). */
|
||||
title: React.ReactNode
|
||||
/** Optional one-line description under the title. */
|
||||
description?: React.ReactNode
|
||||
/** Right-aligned actions (buttons, menus). */
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
// The single shared page header every screen uses: a title, optional
|
||||
// description, and a right-aligned action slot. Keeps headers consistent across
|
||||
// pages — compose it, don't hand-roll a per-page header.
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-end" wrap="nowrap" mb="xl">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Title order={1} fz={26} fw={680} style={{ letterSpacing: '-0.03em', lineHeight: 1.1 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{description ? (
|
||||
<Text c="dimmed" size="sm" style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{actions ? <Box style={{ flexShrink: 0 }}>{actions}</Box> : null}
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
11
packages/components/src/PageLoader.stories.tsx
Normal file
11
packages/components/src/PageLoader.stories.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Story, StoryMeta } from './csf.types'
|
||||
import { PageLoader } from './PageLoader'
|
||||
|
||||
const meta: StoryMeta = {
|
||||
title: 'PageLoader',
|
||||
component: PageLoader,
|
||||
tags: ['loading'],
|
||||
}
|
||||
export default meta
|
||||
|
||||
export const Default: Story = {}
|
||||
16
packages/components/src/PageLoader.tsx
Normal file
16
packages/components/src/PageLoader.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Center, Loader } from '@mantine/core'
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<Center
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
48
packages/components/src/Panel.tsx
Normal file
48
packages/components/src/Panel.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { Card, Group, Stack, Text, Title } from '@mantine/core'
|
||||
|
||||
export interface PanelProps {
|
||||
/** Optional panel heading. */
|
||||
title?: React.ReactNode
|
||||
/** Optional sub-text under the heading. */
|
||||
description?: React.ReactNode
|
||||
/** Right-aligned header actions. */
|
||||
actions?: React.ReactNode
|
||||
/** Panel body. */
|
||||
children: React.ReactNode
|
||||
/** Remove inner padding (e.g. for a flush table). */
|
||||
noPadding?: boolean
|
||||
}
|
||||
|
||||
// A bordered content section with an optional titled header. The workhorse
|
||||
// container — group related content into Panels instead of loose Cards.
|
||||
export function Panel({ title, description, actions, children, noPadding }: PanelProps) {
|
||||
return (
|
||||
<Card withBorder radius="lg" padding={noPadding ? 0 : 'lg'}>
|
||||
{title || actions ? (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-end"
|
||||
wrap="nowrap"
|
||||
p={noPadding ? 'lg' : 0}
|
||||
pb={noPadding ? 'sm' : 'md'}
|
||||
>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
{title ? (
|
||||
<Title order={2} fz={16} fw={620} style={{ letterSpacing: '-0.02em' }}>
|
||||
{title}
|
||||
</Title>
|
||||
) : null}
|
||||
{description ? (
|
||||
<Text c="dimmed" size="xs">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{actions ? <div style={{ flexShrink: 0 }}>{actions}</div> : null}
|
||||
</Group>
|
||||
) : null}
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
47
packages/components/src/ServerErrorState.stories.tsx
Normal file
47
packages/components/src/ServerErrorState.stories.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import type { Story, StoryMeta } from './csf.types'
|
||||
import { ServerErrorState } from './ServerErrorState'
|
||||
|
||||
const meta: StoryMeta = {
|
||||
title: 'ServerErrorState',
|
||||
component: ServerErrorState,
|
||||
tags: ['feedback'],
|
||||
argTypes: {
|
||||
title: { description: 'Override the "Something went wrong" heading', control: 'text' },
|
||||
description: { description: 'Override the default error description', control: 'text' },
|
||||
onRetry: { description: 'Callback fired when the retry button is clicked', control: false },
|
||||
retrying: {
|
||||
description: 'Shows a loading spinner on the retry button',
|
||||
control: 'boolean',
|
||||
},
|
||||
retryLabel: { description: 'Label for the retry button', control: 'text' },
|
||||
glyph: {
|
||||
description: 'Custom element displayed above the title instead of "500"',
|
||||
control: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
export default meta
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
function WithRetryStory() {
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const handleRetry = () => {
|
||||
setRetrying(true)
|
||||
setTimeout(() => setRetrying(false), 1500)
|
||||
}
|
||||
return <ServerErrorState onRetry={handleRetry} retrying={retrying} />
|
||||
}
|
||||
export const WithRetry: Story = { render: WithRetryStory }
|
||||
|
||||
function CustomGlyphStory() {
|
||||
return (
|
||||
<ServerErrorState
|
||||
glyph={<span style={{ fontSize: 64, lineHeight: 1 }}>⚠️</span>}
|
||||
title="Unexpected error"
|
||||
description="Please try again or contact support if the problem persists."
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const CustomGlyph: Story = { render: CustomGlyphStory }
|
||||
70
packages/components/src/ServerErrorState.tsx
Normal file
70
packages/components/src/ServerErrorState.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Center, Stack, Text } from '@mantine/core'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
|
||||
interface ServerErrorStateProps {
|
||||
title?: string
|
||||
description?: string
|
||||
onRetry?: () => void
|
||||
retrying?: boolean
|
||||
retryLabel?: string
|
||||
glyph?: ReactNode
|
||||
}
|
||||
|
||||
export function ServerErrorState({
|
||||
title = 'Something went wrong',
|
||||
description = 'The server returned an error while loading this page.',
|
||||
onRetry,
|
||||
retrying = false,
|
||||
retryLabel = 'Retry',
|
||||
glyph,
|
||||
}: ServerErrorStateProps) {
|
||||
const normalizedDescription = typeof description === 'string' ? description.trim() : ''
|
||||
const showDescription =
|
||||
normalizedDescription.length > 0 &&
|
||||
normalizedDescription !== 'HTTP 500' &&
|
||||
normalizedDescription !== 'HTTP 500.'
|
||||
|
||||
return (
|
||||
<Center flex={1}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
py="xl"
|
||||
style={{ minHeight: '60vh', width: '100%' }}
|
||||
>
|
||||
{glyph ?? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 72,
|
||||
lineHeight: 1,
|
||||
letterSpacing: '-0.06em',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
500
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xl" fw={600}>
|
||||
{title}
|
||||
</Text>
|
||||
{showDescription ? (
|
||||
<Text ta="center" maw={520} c="dimmed">
|
||||
{normalizedDescription}
|
||||
</Text>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
loading={retrying}
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
variant="default"
|
||||
>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
37
packages/components/src/StatCard.tsx
Normal file
37
packages/components/src/StatCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { Card, Group, Stack, Text, ThemeIcon } from '@mantine/core'
|
||||
|
||||
export interface StatCardProps {
|
||||
/** Metric label, e.g. "Open tasks". */
|
||||
label: React.ReactNode
|
||||
/** The big number / value. */
|
||||
value: React.ReactNode
|
||||
/** Optional Lucide icon component. */
|
||||
icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>
|
||||
/** Theme color for the icon tile (Mantine color key). Defaults to primary. */
|
||||
color?: string
|
||||
}
|
||||
|
||||
// A single metric tile: label, large value, optional icon. Compose several in a
|
||||
// StatGrid for a dashboard summary row.
|
||||
export function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
|
||||
return (
|
||||
<Card withBorder radius="lg" padding="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" fw={550} tt="uppercase" style={{ letterSpacing: '0.04em' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={30} fw={700} lh={1} style={{ letterSpacing: '-0.03em' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
{Icon ? (
|
||||
<ThemeIcon variant="light" color={color} radius="md" size={38}>
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
</ThemeIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
21
packages/components/src/StatGrid.tsx
Normal file
21
packages/components/src/StatGrid.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { SimpleGrid } from '@mantine/core'
|
||||
import { StatCard, type StatCardProps } from './StatCard'
|
||||
|
||||
export interface StatGridProps {
|
||||
stats: StatCardProps[]
|
||||
/** Columns on wide screens (responsive down to 1). Defaults to stats.length capped at 4. */
|
||||
columns?: number
|
||||
}
|
||||
|
||||
// A responsive row of StatCards. Feed it the metrics from a stats RPC
|
||||
// (e.g. getTaskStats) mapped to {label, value, icon}.
|
||||
export function StatGrid({ stats, columns }: StatGridProps) {
|
||||
const cols = columns ?? Math.min(stats.length || 1, 4)
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: cols }} spacing="md">
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard key={index} {...stat} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
||||
83
packages/components/src/UserCard.tsx
Normal file
83
packages/components/src/UserCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Avatar, Badge, Card, Group, Loader, Stack, Text } from '@mantine/core'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
status: 'active' | 'away' | 'offline'
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<User['status'], string> = {
|
||||
active: 'green',
|
||||
away: 'yellow',
|
||||
offline: 'gray',
|
||||
}
|
||||
|
||||
export type UserCardProps = {
|
||||
query: UseQueryResult<User | null>
|
||||
}
|
||||
|
||||
export const UserCard: React.FC<UserCardProps> = ({ query }) => {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group>
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading user…
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap={4}>
|
||||
<Text fw={600} c="red">
|
||||
Couldn’t load user
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{query.error instanceof Error ? query.error.message : 'Unknown error'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const user = query.data
|
||||
if (!user) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
No user to show.
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Avatar color="initials" name={user.name} radius="xl" />
|
||||
<Stack gap={0}>
|
||||
<Text fw={600}>{user.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[user.status]} variant="light">
|
||||
{user.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" mt="md">
|
||||
{user.role}
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
21
packages/components/src/csf.types.ts
Normal file
21
packages/components/src/csf.types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export interface ArgType {
|
||||
description?: string
|
||||
control?: string | false
|
||||
defaultValue?: unknown
|
||||
}
|
||||
|
||||
export interface StoryMeta {
|
||||
title: string
|
||||
component: ComponentType<any>
|
||||
description?: string
|
||||
tags?: string[]
|
||||
argTypes?: Record<string, ArgType>
|
||||
}
|
||||
|
||||
export interface Story {
|
||||
args?: Record<string, unknown>
|
||||
render?: ComponentType<any>
|
||||
name?: string
|
||||
}
|
||||
69
packages/components/src/fixtures/stubMutation.ts
Normal file
69
packages/components/src/fixtures/stubMutation.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { UseMutationResult } from '@tanstack/react-query'
|
||||
|
||||
function build<TData, TVariables>(
|
||||
partial: Record<string, unknown>,
|
||||
): UseMutationResult<TData, Error, TVariables> {
|
||||
return {
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
isPaused: false,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
variables: undefined,
|
||||
mutate: () => undefined,
|
||||
mutateAsync: () => Promise.resolve(undefined as never),
|
||||
reset: () => undefined,
|
||||
...partial,
|
||||
} as unknown as UseMutationResult<TData, Error, TVariables>
|
||||
}
|
||||
|
||||
export const stubMutation = {
|
||||
idle<TData = unknown, TVariables = unknown>(): UseMutationResult<TData, Error, TVariables> {
|
||||
return build<TData, TVariables>({
|
||||
status: 'idle',
|
||||
isIdle: true,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
pending<TData = unknown, TVariables = unknown>(): UseMutationResult<TData, Error, TVariables> {
|
||||
return build<TData, TVariables>({
|
||||
status: 'pending',
|
||||
isIdle: false,
|
||||
isPending: true,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
error<TData = unknown, TVariables = unknown>(
|
||||
error: Error,
|
||||
): UseMutationResult<TData, Error, TVariables> {
|
||||
return build<TData, TVariables>({
|
||||
status: 'error',
|
||||
isIdle: false,
|
||||
isPending: false,
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error,
|
||||
})
|
||||
},
|
||||
success<TData = unknown, TVariables = unknown>(
|
||||
data: TData,
|
||||
): UseMutationResult<TData, Error, TVariables> {
|
||||
return build<TData, TVariables>({
|
||||
status: 'success',
|
||||
isIdle: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
}
|
||||
66
packages/components/src/fixtures/stubQuery.ts
Normal file
66
packages/components/src/fixtures/stubQuery.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
|
||||
function build<T>(partial: Record<string, unknown>): UseQueryResult<T> {
|
||||
return {
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
errorUpdateCount: 0,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isFetching: partial.fetchStatus === 'fetching',
|
||||
isPaused: false,
|
||||
isPlaceholderData: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isInitialLoading: partial.isLoading === true,
|
||||
isLoadingError: false,
|
||||
isRefetchError: false,
|
||||
dataUpdatedAt: 0,
|
||||
errorUpdatedAt: 0,
|
||||
refetch: () => Promise.resolve(undefined as never),
|
||||
promise: Promise.resolve(undefined as never),
|
||||
...partial,
|
||||
} as unknown as UseQueryResult<T>
|
||||
}
|
||||
|
||||
export const stubQuery = {
|
||||
loading<T>(): UseQueryResult<T> {
|
||||
return build<T>({
|
||||
status: 'pending',
|
||||
fetchStatus: 'fetching',
|
||||
isPending: true,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
error<T>(error: Error): UseQueryResult<T> {
|
||||
return build<T>({
|
||||
status: 'error',
|
||||
fetchStatus: 'idle',
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error,
|
||||
})
|
||||
},
|
||||
success<T>(data: T): UseQueryResult<T> {
|
||||
return build<T>({
|
||||
status: 'success',
|
||||
fetchStatus: 'idle',
|
||||
isPending: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: true,
|
||||
data,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
empty<T>(): UseQueryResult<T> {
|
||||
return stubQuery.success<T>(null as T)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user