@spree/dashboard-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +86 -0
- package/src/hooks/use-copy-to-clipboard.ts +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/hooks/use-scrolled.ts +30 -0
- package/src/index.ts +99 -0
- package/src/lib/date-locale.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/validation-messages.ts +19 -0
- package/src/spree/address-block.tsx +74 -0
- package/src/spree/back-button.tsx +48 -0
- package/src/spree/bulk-dialog.tsx +71 -0
- package/src/spree/bulk-price-table.tsx +254 -0
- package/src/spree/calculator-summary.tsx +120 -0
- package/src/spree/color-picker.tsx +202 -0
- package/src/spree/confirm-dialog.tsx +82 -0
- package/src/spree/copy-to-clipboard-button.tsx +90 -0
- package/src/spree/country-flag.tsx +30 -0
- package/src/spree/data-grid/cells.tsx +415 -0
- package/src/spree/data-grid/context.ts +59 -0
- package/src/spree/data-grid/data-grid.tsx +214 -0
- package/src/spree/data-grid/fill-handle.tsx +153 -0
- package/src/spree/data-grid/helpers.ts +18 -0
- package/src/spree/data-grid/index.ts +5 -0
- package/src/spree/data-grid/types.ts +36 -0
- package/src/spree/data-grid/use-data-grid-keyboard.ts +254 -0
- package/src/spree/data-grid/use-stable-cell-registration.ts +45 -0
- package/src/spree/drag-handle.tsx +51 -0
- package/src/spree/form-actions.tsx +140 -0
- package/src/spree/json-preview-drawer.tsx +254 -0
- package/src/spree/json-value-view.tsx +149 -0
- package/src/spree/language-menu-items.tsx +54 -0
- package/src/spree/metadata/metadata-card.tsx +62 -0
- package/src/spree/relative-time.tsx +49 -0
- package/src/spree/resource-combobox.tsx +108 -0
- package/src/spree/resource-layout.tsx +31 -0
- package/src/spree/resource-multi-autocomplete.tsx +180 -0
- package/src/spree/resource-name-cell.tsx +44 -0
- package/src/spree/route-error-boundary.tsx +102 -0
- package/src/spree/row-actions.tsx +85 -0
- package/src/spree/row-click-bridge.tsx +17 -0
- package/src/spree/secret-input.tsx +137 -0
- package/src/spree/storefront-visible-switch.tsx +58 -0
- package/src/spree/tag-list.tsx +26 -0
- package/src/spree/theme-provider.tsx +83 -0
- package/src/spree/theme-toggle.tsx +35 -0
- package/src/styles.css +338 -0
- package/src/ui/attachment.tsx +187 -0
- package/src/ui/avatar.tsx +94 -0
- package/src/ui/badge.tsx +161 -0
- package/src/ui/breadcrumb.tsx +109 -0
- package/src/ui/button.tsx +60 -0
- package/src/ui/calendar.tsx +66 -0
- package/src/ui/card.tsx +72 -0
- package/src/ui/chart.tsx +94 -0
- package/src/ui/checkbox.tsx +50 -0
- package/src/ui/collapsible.tsx +18 -0
- package/src/ui/combobox.tsx +270 -0
- package/src/ui/command.tsx +172 -0
- package/src/ui/data-table.tsx +60 -0
- package/src/ui/date-picker.tsx +253 -0
- package/src/ui/date-range-picker.tsx +150 -0
- package/src/ui/dialog.tsx +214 -0
- package/src/ui/dropdown-menu.tsx +345 -0
- package/src/ui/empty.tsx +94 -0
- package/src/ui/field.tsx +221 -0
- package/src/ui/input-group.tsx +141 -0
- package/src/ui/input.tsx +19 -0
- package/src/ui/label.tsx +29 -0
- package/src/ui/pagination.tsx +123 -0
- package/src/ui/popover.tsx +76 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/rich-text-editor.tsx +263 -0
- package/src/ui/select.tsx +213 -0
- package/src/ui/separator.tsx +31 -0
- package/src/ui/sheet.tsx +208 -0
- package/src/ui/sidebar.tsx +679 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/slot.tsx +35 -0
- package/src/ui/sonner.tsx +47 -0
- package/src/ui/switch.tsx +39 -0
- package/src/ui/textarea.tsx +18 -0
- package/src/ui/tooltip.tsx +100 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import { Field, FieldLabel } from '../ui/field'
|
|
6
|
+
import { Input } from '../ui/input'
|
|
7
|
+
|
|
8
|
+
// Server masks stored secrets as `••••…` on read so admins can see "yes,
|
|
9
|
+
// something is stored" without exposing the actual value. Detecting that
|
|
10
|
+
// prefix tells us whether the field is round-tripping the existing secret
|
|
11
|
+
// (the backend has a guard that ignores writes still carrying the mask).
|
|
12
|
+
const PREFERENCE_MASK_TOKEN = '••••'
|
|
13
|
+
function isMaskedSecret(value: unknown): value is string {
|
|
14
|
+
return typeof value === 'string' && value.startsWith(PREFERENCE_MASK_TOKEN)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SecretInputProps {
|
|
18
|
+
id: string
|
|
19
|
+
label: string
|
|
20
|
+
value: unknown
|
|
21
|
+
onChange: (value: unknown) => void
|
|
22
|
+
/**
|
|
23
|
+
* When true, a value that arrives carrying the server's mask token
|
|
24
|
+
* (`••••3K9z`) is treated as "already stored" — show the masked badge
|
|
25
|
+
* with a Replace button instead of an editable input. Cancel restores
|
|
26
|
+
* the original masked value so the backend's round-trip guard keeps
|
|
27
|
+
* the existing secret.
|
|
28
|
+
*/
|
|
29
|
+
redactWhenMasked?: boolean
|
|
30
|
+
/** Replaces the default "Stored on the server. Click Replace to rotate." caption. */
|
|
31
|
+
helpText?: string
|
|
32
|
+
/** Placeholder for the editable input — only shown when not redacted. */
|
|
33
|
+
placeholder?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stripe-style credential field. When the value comes back from the API
|
|
38
|
+
* masked (`••••3K9z`), display it as a read-only badge with a "Replace"
|
|
39
|
+
* button. Replace switches to a password input pre-filled empty so the
|
|
40
|
+
* admin opts in to rotation; Cancel reverts to the masked badge so the
|
|
41
|
+
* next save preserves the existing secret via the backend's masked
|
|
42
|
+
* round-trip guard.
|
|
43
|
+
*
|
|
44
|
+
* Domain-agnostic — used by `<PreferencesForm>` for `:password`-typed
|
|
45
|
+
* preferences, but also suitable for any other secret that round-trips
|
|
46
|
+
* through the API (webhook signing keys, OAuth client secrets, etc).
|
|
47
|
+
*/
|
|
48
|
+
export function SecretInput({
|
|
49
|
+
id,
|
|
50
|
+
label,
|
|
51
|
+
value,
|
|
52
|
+
onChange,
|
|
53
|
+
redactWhenMasked = false,
|
|
54
|
+
helpText,
|
|
55
|
+
placeholder,
|
|
56
|
+
}: SecretInputProps) {
|
|
57
|
+
const { t } = useTranslation()
|
|
58
|
+
const storedMask = isMaskedSecret(value) ? value : null
|
|
59
|
+
// Captured at click time so Cancel can restore the original mask even
|
|
60
|
+
// after `onChange('')` has cleared the parent value.
|
|
61
|
+
const [pendingMask, setPendingMask] = useState<string | null>(null)
|
|
62
|
+
const [revealed, setRevealed] = useState(false)
|
|
63
|
+
const replacing = pendingMask !== null
|
|
64
|
+
|
|
65
|
+
if (redactWhenMasked && storedMask && !replacing) {
|
|
66
|
+
return (
|
|
67
|
+
<Field>
|
|
68
|
+
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
69
|
+
<div className="flex items-center gap-2">
|
|
70
|
+
<div className="flex min-h-8 w-full min-w-0 items-center rounded-lg border border-border bg-muted/40 px-2.5 py-1.5 shadow-xs">
|
|
71
|
+
<span className="font-mono text-sm tabular-nums text-muted-foreground" id={id}>
|
|
72
|
+
{storedMask}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
<Button
|
|
76
|
+
type="button"
|
|
77
|
+
size="sm"
|
|
78
|
+
variant="outline"
|
|
79
|
+
onClick={() => {
|
|
80
|
+
setPendingMask(storedMask)
|
|
81
|
+
onChange('')
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{t('admin.components.secret_input.replace')}
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
<span className="text-xs text-muted-foreground">
|
|
88
|
+
{helpText ?? t('admin.components.secret_input.stored_help')}
|
|
89
|
+
</span>
|
|
90
|
+
</Field>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Field>
|
|
96
|
+
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
|
97
|
+
<div className="flex items-center gap-2">
|
|
98
|
+
<div className="relative flex-1">
|
|
99
|
+
<Input
|
|
100
|
+
id={id}
|
|
101
|
+
type={revealed ? 'text' : 'password'}
|
|
102
|
+
autoComplete="new-password"
|
|
103
|
+
placeholder={
|
|
104
|
+
replacing ? t('admin.components.secret_input.new_value_placeholder') : placeholder
|
|
105
|
+
}
|
|
106
|
+
value={(value as string) ?? ''}
|
|
107
|
+
onChange={(e) => onChange(e.target.value)}
|
|
108
|
+
className="pr-9"
|
|
109
|
+
/>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-label={revealed ? t('admin.a11y.hide_value') : t('admin.a11y.show_value')}
|
|
113
|
+
aria-pressed={revealed}
|
|
114
|
+
onClick={() => setRevealed((v) => !v)}
|
|
115
|
+
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
|
|
116
|
+
>
|
|
117
|
+
{revealed ? <EyeOffIcon className="size-4" /> : <EyeIcon className="size-4" />}
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
{replacing && (
|
|
121
|
+
<Button
|
|
122
|
+
type="button"
|
|
123
|
+
size="sm"
|
|
124
|
+
variant="ghost"
|
|
125
|
+
onClick={() => {
|
|
126
|
+
onChange(pendingMask)
|
|
127
|
+
setPendingMask(null)
|
|
128
|
+
setRevealed(false)
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{t('admin.actions.cancel')}
|
|
132
|
+
</Button>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</Field>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Control, Controller, type FieldPath, type FieldValues } from 'react-hook-form'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Field, FieldLabel } from '../ui/field'
|
|
4
|
+
import { Switch } from '../ui/switch'
|
|
5
|
+
|
|
6
|
+
interface StorefrontVisibleSwitchProps<TFieldValues extends FieldValues> {
|
|
7
|
+
control: Control<TFieldValues>
|
|
8
|
+
name: FieldPath<TFieldValues>
|
|
9
|
+
/** Defaults to "Visible on storefront". */
|
|
10
|
+
label?: string
|
|
11
|
+
/** Helper text under the row. Defaults to the on/off explanation. */
|
|
12
|
+
description?: string
|
|
13
|
+
/** Switch id — defaults to the field name. */
|
|
14
|
+
id?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Canonical 5.5+ "Visible on storefront" control. Wraps a `Switch` with
|
|
19
|
+
* a labelled row mirroring the Active / Auto-capture rows on the same
|
|
20
|
+
* forms. Used by every resource that exposes `storefront_visible` on
|
|
21
|
+
* the API (PaymentMethod today; ShippingMethod/DeliveryMethod when
|
|
22
|
+
* those admin pages land — see `docs/plans/5.5-6.0-display-on-to-boolean.md`).
|
|
23
|
+
*
|
|
24
|
+
* For column-cell rendering use `<ActiveBadge active={…} activeLabel="Visible"
|
|
25
|
+
* inactiveLabel="Admin only" />` so the visibility column matches the
|
|
26
|
+
* Active column's visual treatment.
|
|
27
|
+
*/
|
|
28
|
+
export function StorefrontVisibleSwitch<TFieldValues extends FieldValues>({
|
|
29
|
+
control,
|
|
30
|
+
name,
|
|
31
|
+
label,
|
|
32
|
+
description,
|
|
33
|
+
id,
|
|
34
|
+
}: StorefrontVisibleSwitchProps<TFieldValues>) {
|
|
35
|
+
const { t } = useTranslation()
|
|
36
|
+
const resolvedLabel = label ?? t('admin.fields.storefront_visible.label')
|
|
37
|
+
const resolvedDescription = description ?? t('admin.fields.storefront_visible.help')
|
|
38
|
+
const fieldId = id ?? name
|
|
39
|
+
return (
|
|
40
|
+
<Field>
|
|
41
|
+
<div className="flex items-start justify-between gap-4">
|
|
42
|
+
<div className="flex flex-col">
|
|
43
|
+
<FieldLabel htmlFor={fieldId} className="cursor-pointer">
|
|
44
|
+
{resolvedLabel}
|
|
45
|
+
</FieldLabel>
|
|
46
|
+
<span className="text-xs text-muted-foreground">{resolvedDescription}</span>
|
|
47
|
+
</div>
|
|
48
|
+
<Controller
|
|
49
|
+
name={name}
|
|
50
|
+
control={control}
|
|
51
|
+
render={({ field }) => (
|
|
52
|
+
<Switch id={fieldId} checked={!!field.value} onCheckedChange={field.onChange} />
|
|
53
|
+
)}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
</Field>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Badge } from '../ui/badge'
|
|
2
|
+
|
|
3
|
+
interface TagListProps {
|
|
4
|
+
tags: string[] | null | undefined
|
|
5
|
+
/** Rendered when there are no tags. Defaults to em-dash to match other empty cells. */
|
|
6
|
+
empty?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders a list of tag names as small secondary badges, wrapping when the
|
|
11
|
+
* column is narrow. Used as the default cell renderer for the `tags` column
|
|
12
|
+
* on resource tables that expose taggable resources (products, customers,
|
|
13
|
+
* orders).
|
|
14
|
+
*/
|
|
15
|
+
export function TagList({ tags, empty = '—' }: TagListProps) {
|
|
16
|
+
if (!tags || tags.length === 0) return <>{empty}</>
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex flex-wrap gap-1">
|
|
19
|
+
{tags.map((tag) => (
|
|
20
|
+
<Badge key={tag} variant="secondary">
|
|
21
|
+
{tag}
|
|
22
|
+
</Badge>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
|
|
11
|
+
type ThemeMode = 'light' | 'dark' | 'system'
|
|
12
|
+
type ResolvedTheme = 'light' | 'dark'
|
|
13
|
+
|
|
14
|
+
interface ThemeContextValue {
|
|
15
|
+
mode: ThemeMode
|
|
16
|
+
resolved: ResolvedTheme
|
|
17
|
+
setMode: (mode: ThemeMode) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STORAGE_KEY = 'spree-admin-theme'
|
|
21
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
22
|
+
|
|
23
|
+
function readStoredMode(): ThemeMode {
|
|
24
|
+
if (typeof localStorage === 'undefined') return 'system'
|
|
25
|
+
const value = localStorage.getItem(STORAGE_KEY)
|
|
26
|
+
return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function systemPrefersDark(): boolean {
|
|
30
|
+
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
|
34
|
+
if (mode === 'system') return systemPrefersDark() ? 'dark' : 'light'
|
|
35
|
+
return mode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function applyTheme(theme: ResolvedTheme) {
|
|
39
|
+
const root = document.documentElement
|
|
40
|
+
if (theme === 'dark') root.classList.add('dark')
|
|
41
|
+
else root.classList.remove('dark')
|
|
42
|
+
root.style.colorScheme = theme
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
46
|
+
const [mode, setModeState] = useState<ThemeMode>(() => readStoredMode())
|
|
47
|
+
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(readStoredMode()))
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const next = resolveTheme(mode)
|
|
51
|
+
setResolved(next)
|
|
52
|
+
applyTheme(next)
|
|
53
|
+
}, [mode])
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (mode !== 'system') return
|
|
57
|
+
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
|
58
|
+
const onChange = () => {
|
|
59
|
+
const next: ResolvedTheme = mql.matches ? 'dark' : 'light'
|
|
60
|
+
setResolved(next)
|
|
61
|
+
applyTheme(next)
|
|
62
|
+
}
|
|
63
|
+
mql.addEventListener('change', onChange)
|
|
64
|
+
return () => mql.removeEventListener('change', onChange)
|
|
65
|
+
}, [mode])
|
|
66
|
+
|
|
67
|
+
const setMode = useCallback((next: ThemeMode) => {
|
|
68
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, next)
|
|
69
|
+
setModeState(next)
|
|
70
|
+
}, [])
|
|
71
|
+
|
|
72
|
+
const value = useMemo<ThemeContextValue>(
|
|
73
|
+
() => ({ mode, resolved, setMode }),
|
|
74
|
+
[mode, resolved, setMode],
|
|
75
|
+
)
|
|
76
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useTheme(): ThemeContextValue {
|
|
80
|
+
const ctx = useContext(ThemeContext)
|
|
81
|
+
if (!ctx) throw new Error('useTheme must be used within <ThemeProvider>')
|
|
82
|
+
return ctx
|
|
83
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from 'lucide-react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '../ui/dropdown-menu'
|
|
4
|
+
import { useTheme } from './theme-provider'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Theme picker rendered inside the user dropdown menu. Three-way (Light / Dark
|
|
8
|
+
* / System) — System follows `prefers-color-scheme`.
|
|
9
|
+
*/
|
|
10
|
+
export function ThemeMenuItems() {
|
|
11
|
+
const { mode, setMode } = useTheme()
|
|
12
|
+
const { t } = useTranslation()
|
|
13
|
+
|
|
14
|
+
const items = [
|
|
15
|
+
{ value: 'light', label: t('admin.components.theme_toggle.light'), icon: SunIcon },
|
|
16
|
+
{ value: 'dark', label: t('admin.components.theme_toggle.dark'), icon: MoonIcon },
|
|
17
|
+
{ value: 'system', label: t('admin.components.theme_toggle.system'), icon: MonitorIcon },
|
|
18
|
+
] as const
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<DropdownMenuLabel className="text-xs">
|
|
23
|
+
{t('admin.components.theme_toggle.label')}
|
|
24
|
+
</DropdownMenuLabel>
|
|
25
|
+
{items.map(({ value, label, icon: Icon }) => (
|
|
26
|
+
<DropdownMenuItem key={value} closeOnClick={false} onClick={() => setMode(value)}>
|
|
27
|
+
<Icon className="size-4" />
|
|
28
|
+
{label}
|
|
29
|
+
{mode === value && <CheckIcon className="ml-auto size-3.5" />}
|
|
30
|
+
</DropdownMenuItem>
|
|
31
|
+
))}
|
|
32
|
+
<DropdownMenuSeparator />
|
|
33
|
+
</>
|
|
34
|
+
)
|
|
35
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
@import "shadcn/tailwind.css";
|
|
4
|
+
@import "@fontsource-variable/geist";
|
|
5
|
+
@import "@fontsource-variable/geist-mono";
|
|
6
|
+
|
|
7
|
+
/* Tailwind v4 only scans the consuming project's own source by default.
|
|
8
|
+
* `@source` lets us point it at additional roots — the consumer's own
|
|
9
|
+
* sources are auto-detected; this directive teaches Tailwind to also scan
|
|
10
|
+
* the dashboard-ui source files that live alongside this CSS.
|
|
11
|
+
*
|
|
12
|
+
* Path is relative to this CSS file (`packages/dashboard-ui/src/styles.css`)
|
|
13
|
+
* so it resolves correctly whether dashboard-ui is consumed via the workspace
|
|
14
|
+
* symlink (`node_modules/@spree/dashboard-ui` → `packages/dashboard-ui`) or
|
|
15
|
+
* eventually from a published npm package (where the same relative layout
|
|
16
|
+
* holds inside the tarball).
|
|
17
|
+
*/
|
|
18
|
+
@source "./ui/**/*.{ts,tsx}";
|
|
19
|
+
@source "./spree/**/*.{ts,tsx}";
|
|
20
|
+
/* Country flag icons — `<CountryFlag iso="US" />` resolves to `.fi.fi-us`,
|
|
21
|
+
which lazy-loads `flags/4x3/us.svg` via background-image. Only flags
|
|
22
|
+
actually rendered hit the network. */
|
|
23
|
+
@import "flag-icons/css/flag-icons.min.css";
|
|
24
|
+
|
|
25
|
+
@custom-variant dark (&:is(.dark *));
|
|
26
|
+
|
|
27
|
+
/* ============================================================================
|
|
28
|
+
Spree Admin Theme
|
|
29
|
+
Adapted from spree/admin Tailwind design system to match look & feel.
|
|
30
|
+
============================================================================ */
|
|
31
|
+
|
|
32
|
+
:root {
|
|
33
|
+
/* Vercel/Geist-style neutral palette — pure greyscale. */
|
|
34
|
+
--background: oklch(0.985 0 0); /* #fafafa — gray-50 */
|
|
35
|
+
--foreground: oklch(0.145 0 0); /* near-black */
|
|
36
|
+
--card: oklch(1 0 0);
|
|
37
|
+
--card-foreground: oklch(0.145 0 0);
|
|
38
|
+
--popover: oklch(1 0 0);
|
|
39
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
40
|
+
--primary: oklch(0 0 0); /* pure black for primary CTAs */
|
|
41
|
+
--primary-foreground: oklch(1 0 0);
|
|
42
|
+
--secondary: oklch(0.97 0 0); /* #f5f5f5 — gray-100 */
|
|
43
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
44
|
+
--muted: oklch(0.985 0 0); /* #fafafa — gray-50 */
|
|
45
|
+
--muted-foreground: oklch(0.4685 0 0); /* #737373 — Vercel #666 */
|
|
46
|
+
--accent: oklch(0.97 0 0); /* #f5f5f5 */
|
|
47
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
48
|
+
--destructive: oklch(0.58 0.22 27); /* red-600 */
|
|
49
|
+
--destructive-foreground: oklch(1 0 0); /* #ffffff */
|
|
50
|
+
--border: oklch(0.928 0 0); /* #eaeaea — Vercel border */
|
|
51
|
+
--input: oklch(0.928 0 0);
|
|
52
|
+
--ring: oklch(0.546 0.245 262.881); /* blue-500 focus ring (UNCHANGED) */
|
|
53
|
+
--chart-1: oklch(0.809 0.105 251.813);
|
|
54
|
+
--chart-2: oklch(0.623 0.214 259.815);
|
|
55
|
+
--chart-3: oklch(0.546 0.245 262.881);
|
|
56
|
+
--chart-4: oklch(0.488 0.243 264.376);
|
|
57
|
+
--chart-5: oklch(0.424 0.199 265.638);
|
|
58
|
+
--radius: 0.75rem;
|
|
59
|
+
|
|
60
|
+
/* Sidebar — slightly muted off-white surface. */
|
|
61
|
+
--sidebar: oklch(0.985 0 0); /* #fafafa */
|
|
62
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
63
|
+
--sidebar-primary: oklch(0 0 0);
|
|
64
|
+
--sidebar-primary-foreground: oklch(1 0 0);
|
|
65
|
+
--sidebar-accent: oklch(0.945 0 0); /* slightly darker than sidebar bg for hover */
|
|
66
|
+
--sidebar-accent-foreground: oklch(0.145 0 0);
|
|
67
|
+
--sidebar-border: oklch(0.928 0 0);
|
|
68
|
+
--sidebar-ring: oklch(0.546 0.245 262.881); /* blue-500 focus ring (UNCHANGED) */
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.dark {
|
|
72
|
+
/* Vercel-style "true black" base, with surfaces stepping up in lightness. */
|
|
73
|
+
--background: oklch(0 0 0); /* #000000 */
|
|
74
|
+
--foreground: oklch(0.95 0 0); /* #ededed */
|
|
75
|
+
--card: oklch(0.145 0 0); /* #0a0a0a — surface */
|
|
76
|
+
--card-foreground: oklch(0.95 0 0);
|
|
77
|
+
--popover: oklch(0.145 0 0);
|
|
78
|
+
--popover-foreground: oklch(0.95 0 0);
|
|
79
|
+
--primary: oklch(0.95 0 0); /* light-on-dark primary */
|
|
80
|
+
--primary-foreground: oklch(0 0 0);
|
|
81
|
+
--secondary: oklch(0.205 0 0); /* #1a1a1a */
|
|
82
|
+
--secondary-foreground: oklch(0.95 0 0);
|
|
83
|
+
--muted: oklch(0.18 0 0); /* #161616 */
|
|
84
|
+
--muted-foreground: oklch(0.62 0 0); /* #888 */
|
|
85
|
+
--accent: oklch(0.27 0 0); /* hover surface */
|
|
86
|
+
--accent-foreground: oklch(0.95 0 0);
|
|
87
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
88
|
+
--border: oklch(1 0 0 / 12%);
|
|
89
|
+
--input: oklch(1 0 0 / 15%);
|
|
90
|
+
--ring: oklch(0.552 0.016 285); /* same blue ring as light, for focus parity */
|
|
91
|
+
--chart-1: oklch(0.809 0.105 251.813);
|
|
92
|
+
--chart-2: oklch(0.623 0.214 259.815);
|
|
93
|
+
--chart-3: oklch(0.546 0.245 262.881);
|
|
94
|
+
--chart-4: oklch(0.488 0.243 264.376);
|
|
95
|
+
--chart-5: oklch(0.424 0.199 265.638);
|
|
96
|
+
--sidebar: oklch(0.145 0 0); /* #0a0a0a — slightly elevated from background */
|
|
97
|
+
--sidebar-foreground: oklch(0.95 0 0);
|
|
98
|
+
--sidebar-primary: oklch(0.95 0 0);
|
|
99
|
+
--sidebar-primary-foreground: oklch(0 0 0);
|
|
100
|
+
--sidebar-accent: oklch(0.27 0 0);
|
|
101
|
+
--sidebar-accent-foreground: oklch(0.95 0 0);
|
|
102
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
103
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@theme inline {
|
|
107
|
+
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
|
|
108
|
+
--font-mono: "Geist Mono Variable", ui-monospace, monospace;
|
|
109
|
+
|
|
110
|
+
/* Typography scale — matches Spree admin (smaller than Tailwind defaults) */
|
|
111
|
+
--text-xs: 0.75rem;
|
|
112
|
+
--text-sm: 0.8125rem;
|
|
113
|
+
--text-base: 0.875rem;
|
|
114
|
+
--text-lg: 1rem;
|
|
115
|
+
--text-xl: 1.25rem;
|
|
116
|
+
--text-2xl: 1.5rem;
|
|
117
|
+
--text-3xl: 1.75rem;
|
|
118
|
+
--text-4xl: 2rem;
|
|
119
|
+
|
|
120
|
+
/* Shadows — subtle like Rails admin */
|
|
121
|
+
--shadow-xs: rgba(43, 34, 51, 0.05) 0px 1px 1px;
|
|
122
|
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
123
|
+
--shadow-nav: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.04) 0px 0px 0px 1px, rgba(0, 0, 0, 0.04) 0px 1px 2px -1px, rgba(0, 0, 0, 0.02) 0px 2px 4px 0px;
|
|
124
|
+
|
|
125
|
+
/* Sidebar + header tokens */
|
|
126
|
+
--spacing-sidebar-width: 220px;
|
|
127
|
+
--spacing-header-height: 58px;
|
|
128
|
+
|
|
129
|
+
/* Color mappings */
|
|
130
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
131
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
132
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
133
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
134
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
135
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
136
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
137
|
+
--color-sidebar: var(--sidebar);
|
|
138
|
+
--color-chart-5: var(--chart-5);
|
|
139
|
+
--color-chart-4: var(--chart-4);
|
|
140
|
+
--color-chart-3: var(--chart-3);
|
|
141
|
+
--color-chart-2: var(--chart-2);
|
|
142
|
+
--color-chart-1: var(--chart-1);
|
|
143
|
+
--color-ring: var(--ring);
|
|
144
|
+
--color-input: var(--input);
|
|
145
|
+
--color-border: var(--border);
|
|
146
|
+
--color-destructive: var(--destructive);
|
|
147
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
148
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
149
|
+
--color-accent: var(--accent);
|
|
150
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
151
|
+
--color-muted: var(--muted);
|
|
152
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
153
|
+
--color-secondary: var(--secondary);
|
|
154
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
155
|
+
--color-primary: var(--primary);
|
|
156
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
157
|
+
--color-popover: var(--popover);
|
|
158
|
+
--color-card-foreground: var(--card-foreground);
|
|
159
|
+
--color-card: var(--card);
|
|
160
|
+
--color-foreground: var(--foreground);
|
|
161
|
+
--color-background: var(--background);
|
|
162
|
+
|
|
163
|
+
/* Spree status colors — for badges & alerts */
|
|
164
|
+
--color-success: var(--color-green-500);
|
|
165
|
+
--color-warning: var(--color-yellow-500);
|
|
166
|
+
--color-info: var(--color-blue-500);
|
|
167
|
+
--color-danger: var(--color-red-500);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@utility focus-ring {
|
|
171
|
+
/* Unified focus treatment — matches the form-input focus (soft blue glow,
|
|
172
|
+
no hard outline). Apply to buttons, links, menu triggers, and any other
|
|
173
|
+
interactive control so every focusable element looks the same on
|
|
174
|
+
keyboard focus. Inputs/textarea/select keep their inline equivalent. */
|
|
175
|
+
outline: none;
|
|
176
|
+
&:focus-visible {
|
|
177
|
+
border-color: var(--ring);
|
|
178
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 15%, transparent);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@utility themed-scrollbar {
|
|
183
|
+
/* Theme-aware thin scrollbar utility. Track is transparent, thumb uses
|
|
184
|
+
CSS vars defined in :root and .dark, so it adapts to the active theme.
|
|
185
|
+
Apply explicitly to any element you want styled (e.g. the JSON preview
|
|
186
|
+
drawer); floating surfaces like Sheet/Dialog/Dropdown pick it up via
|
|
187
|
+
the [data-slot=...] selectors below. */
|
|
188
|
+
scrollbar-color: var(--scrollbar-thumb) transparent;
|
|
189
|
+
scrollbar-width: thin;
|
|
190
|
+
|
|
191
|
+
&::-webkit-scrollbar {
|
|
192
|
+
width: 10px;
|
|
193
|
+
height: 10px;
|
|
194
|
+
}
|
|
195
|
+
&::-webkit-scrollbar-track {
|
|
196
|
+
background: transparent;
|
|
197
|
+
}
|
|
198
|
+
&::-webkit-scrollbar-thumb {
|
|
199
|
+
background-color: var(--scrollbar-thumb);
|
|
200
|
+
border: 2px solid transparent;
|
|
201
|
+
border-radius: 9999px;
|
|
202
|
+
background-clip: padding-box;
|
|
203
|
+
}
|
|
204
|
+
&::-webkit-scrollbar-thumb:hover {
|
|
205
|
+
background-color: var(--scrollbar-thumb-hover);
|
|
206
|
+
}
|
|
207
|
+
&::-webkit-scrollbar-corner {
|
|
208
|
+
background: transparent;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@layer base {
|
|
213
|
+
:root {
|
|
214
|
+
--scrollbar-thumb: oklch(0.7 0 0); /* gray-400-ish */
|
|
215
|
+
--scrollbar-thumb-hover: oklch(0.55 0 0);
|
|
216
|
+
}
|
|
217
|
+
.dark {
|
|
218
|
+
--scrollbar-thumb: oklch(0.35 0 0); /* zinc-700-ish */
|
|
219
|
+
--scrollbar-thumb-hover: oklch(0.45 0 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Themed scrollbar inside floating surfaces only — page scrollbar stays
|
|
223
|
+
browser default. Add the .themed-scrollbar utility class to opt other
|
|
224
|
+
elements in. */
|
|
225
|
+
[data-slot='dialog-content'],
|
|
226
|
+
[data-slot='sheet-content'],
|
|
227
|
+
[data-slot='dropdown-menu-content'],
|
|
228
|
+
[data-slot='dropdown-menu-sub-content'],
|
|
229
|
+
[data-slot='popover-content'] {
|
|
230
|
+
& *,
|
|
231
|
+
& *::before,
|
|
232
|
+
& *::after {
|
|
233
|
+
scrollbar-color: var(--scrollbar-thumb) transparent;
|
|
234
|
+
scrollbar-width: thin;
|
|
235
|
+
}
|
|
236
|
+
& *::-webkit-scrollbar {
|
|
237
|
+
width: 10px;
|
|
238
|
+
height: 10px;
|
|
239
|
+
}
|
|
240
|
+
& *::-webkit-scrollbar-track {
|
|
241
|
+
background: transparent;
|
|
242
|
+
}
|
|
243
|
+
& *::-webkit-scrollbar-thumb {
|
|
244
|
+
background-color: var(--scrollbar-thumb);
|
|
245
|
+
border: 2px solid transparent;
|
|
246
|
+
border-radius: 9999px;
|
|
247
|
+
background-clip: padding-box;
|
|
248
|
+
}
|
|
249
|
+
& *::-webkit-scrollbar-thumb:hover {
|
|
250
|
+
background-color: var(--scrollbar-thumb-hover);
|
|
251
|
+
}
|
|
252
|
+
& *::-webkit-scrollbar-corner {
|
|
253
|
+
background: transparent;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
* {
|
|
258
|
+
@apply border-border outline-ring/50;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* Floating-surface popup containers (menu/popover/select/dialog) receive
|
|
262
|
+
focus when they open. Base UI marks that focus as `:focus-visible`, so the
|
|
263
|
+
universal `outline-ring/50` above renders as a stray blue ring around the
|
|
264
|
+
whole surface. These are containers — focus belongs to the items inside —
|
|
265
|
+
so suppress the container outline entirely. */
|
|
266
|
+
[data-slot='dropdown-menu-content'],
|
|
267
|
+
[data-slot='dropdown-menu-sub-content'],
|
|
268
|
+
[data-slot='popover-content'],
|
|
269
|
+
[data-slot='select-content'] {
|
|
270
|
+
outline: none;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
html {
|
|
274
|
+
@apply font-sans antialiased;
|
|
275
|
+
text-rendering: optimizeLegibility;
|
|
276
|
+
overflow-y: scroll;
|
|
277
|
+
/* Clip horizontal overflow at the document root so wide tables on mobile
|
|
278
|
+
* can't widen the page. Must live on <html>, not <body>: `overflow-x:
|
|
279
|
+
* hidden` on body makes `overflow-y` compute to `auto`, turning body into
|
|
280
|
+
* a scroll container while the document actually scrolls on html — which
|
|
281
|
+
* breaks `position: sticky` for PageHeader and other descendants. */
|
|
282
|
+
overflow-x: clip;
|
|
283
|
+
max-width: 100vw;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
body {
|
|
287
|
+
@apply bg-background text-foreground text-base;
|
|
288
|
+
line-height: 1.5;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Headings — semibold (600) like Rails admin */
|
|
292
|
+
h1,
|
|
293
|
+
h2,
|
|
294
|
+
h3,
|
|
295
|
+
h4,
|
|
296
|
+
h5,
|
|
297
|
+
h6 {
|
|
298
|
+
@apply font-semibold leading-tight;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
h1 {
|
|
302
|
+
@apply text-4xl;
|
|
303
|
+
}
|
|
304
|
+
h2 {
|
|
305
|
+
@apply text-3xl;
|
|
306
|
+
}
|
|
307
|
+
h3 {
|
|
308
|
+
@apply text-2xl;
|
|
309
|
+
}
|
|
310
|
+
h4 {
|
|
311
|
+
@apply text-xl;
|
|
312
|
+
}
|
|
313
|
+
h5 {
|
|
314
|
+
@apply text-lg;
|
|
315
|
+
}
|
|
316
|
+
h6 {
|
|
317
|
+
@apply text-base;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@layer components {
|
|
322
|
+
/* Checkerboard for the color picker's "no color selected" swatch.
|
|
323
|
+
Two-tone diagonal pattern using `--border` + `--background` so it's
|
|
324
|
+
visible in both light and dark themes. */
|
|
325
|
+
.color-picker-empty-swatch {
|
|
326
|
+
background-color: var(--background);
|
|
327
|
+
background-image: linear-gradient(45deg, var(--border) 25%, transparent 25%),
|
|
328
|
+
linear-gradient(-45deg, var(--border) 25%, transparent 25%),
|
|
329
|
+
linear-gradient(45deg, transparent 75%, var(--border) 75%),
|
|
330
|
+
linear-gradient(-45deg, transparent 75%, var(--border) 75%);
|
|
331
|
+
background-size: 10px 10px;
|
|
332
|
+
background-position:
|
|
333
|
+
0 0,
|
|
334
|
+
0 5px,
|
|
335
|
+
5px -5px,
|
|
336
|
+
-5px 0;
|
|
337
|
+
}
|
|
338
|
+
}
|