@spree/dashboard-ui 0.13.1 → 1.0.0-beta.2
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/README.md +1 -1
- package/package.json +13 -9
- package/src/hooks/use-prefers-reduced-motion.ts +26 -0
- package/src/hooks/use-scrolled.ts +34 -14
- package/src/index.ts +38 -1
- package/src/lib/date-range-presets.ts +79 -0
- package/src/lib/same-rich-text.ts +145 -0
- package/src/spree/address-block.tsx +5 -4
- package/src/spree/address-book-row.tsx +96 -0
- package/src/spree/back-button.tsx +14 -9
- package/src/spree/bulk-dialog.tsx +2 -2
- package/src/spree/bulk-price-table.tsx +145 -14
- package/src/spree/bulk-variants-table.tsx +434 -0
- package/src/spree/calculator-summary.tsx +5 -1
- package/src/spree/claim-resolve-dialog.tsx +219 -0
- package/src/spree/confirm-dialog.tsx +1 -1
- package/src/spree/copy-to-clipboard-button.tsx +1 -1
- package/src/spree/data-grid/cells.tsx +199 -25
- package/src/spree/data-grid/context.ts +6 -0
- package/src/spree/data-grid/data-grid.tsx +149 -7
- package/src/spree/data-grid/helpers.ts +6 -2
- package/src/spree/data-grid/index.ts +9 -1
- package/src/spree/data-grid/use-data-grid-keyboard.ts +4 -2
- package/src/spree/delivery-form-dialog.tsx +166 -0
- package/src/spree/drag-handle.tsx +2 -2
- package/src/spree/editable-note-card.tsx +103 -0
- package/src/spree/external-link.tsx +56 -0
- package/src/spree/file-type-icon.tsx +68 -0
- package/src/spree/form-actions.tsx +2 -3
- package/src/spree/form-section.tsx +46 -0
- package/src/spree/fulfill-items-form.tsx +282 -0
- package/src/spree/fulfillment-edit-dialog.tsx +211 -0
- package/src/spree/fulfillment-item-list.tsx +61 -0
- package/src/spree/fulfillment-panel.tsx +53 -0
- package/src/spree/icons.ts +167 -0
- package/src/spree/json-preview-drawer.tsx +4 -4
- package/src/spree/label-upload-dialog.tsx +195 -0
- package/src/spree/language-menu-items.tsx +37 -30
- package/src/spree/metadata/metadata-card.tsx +1 -1
- package/src/spree/order-cancel-dialog.tsx +149 -0
- package/src/spree/post-sale-create-dialogs.tsx +253 -0
- package/src/spree/post-sale-fields.tsx +133 -0
- package/src/spree/product-membership-list.tsx +453 -0
- package/src/spree/products/status-card.tsx +79 -0
- package/src/spree/quantity-tier-editor.tsx +162 -0
- package/src/spree/resource-combobox.tsx +12 -1
- package/src/spree/resource-name-cell.tsx +21 -4
- package/src/spree/return-dialogs.tsx +238 -0
- package/src/spree/route-error-boundary.tsx +1 -1
- package/src/spree/row-actions.tsx +2 -2
- package/src/spree/search-input.tsx +76 -0
- package/src/spree/secret-input.tsx +26 -40
- package/src/spree/tax-identifiers-card.tsx +418 -0
- package/src/spree/theme-toggle.tsx +36 -23
- package/src/spree/wizard.tsx +254 -0
- package/src/styles.css +750 -75
- package/src/ui/accordion.tsx +83 -0
- package/src/ui/alert.tsx +79 -0
- package/src/ui/attachment.tsx +1 -1
- package/src/ui/badge.tsx +198 -56
- package/src/ui/breadcrumb.tsx +1 -1
- package/src/ui/button.tsx +22 -10
- package/src/ui/calendar.tsx +1 -1
- package/src/ui/card.tsx +52 -6
- package/src/ui/checkbox.tsx +13 -5
- package/src/ui/combobox.tsx +125 -6
- package/src/ui/command.tsx +149 -23
- package/src/ui/context-menu.tsx +99 -0
- package/src/ui/data-table.tsx +448 -9
- package/src/ui/date-picker.tsx +18 -3
- package/src/ui/date-range-picker.tsx +47 -60
- package/src/ui/dialog.tsx +6 -6
- package/src/ui/dropdown-menu.tsx +18 -11
- package/src/ui/field.tsx +10 -2
- package/src/ui/input-group.tsx +1 -1
- package/src/ui/input.tsx +1 -1
- package/src/ui/media-preview.tsx +105 -0
- package/src/ui/pagination.tsx +19 -11
- package/src/ui/popover.tsx +4 -2
- package/src/ui/progress.tsx +5 -2
- package/src/ui/radio-group.tsx +4 -4
- package/src/ui/rich-text-editor.tsx +214 -88
- package/src/ui/scroll-area.tsx +65 -0
- package/src/ui/select.tsx +17 -10
- package/src/ui/separator.tsx +1 -1
- package/src/ui/sheet.tsx +49 -7
- package/src/ui/sidebar.tsx +138 -37
- package/src/ui/skeleton.tsx +3 -1
- package/src/ui/tabs.tsx +84 -0
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/thumbnail.tsx +80 -0
- package/src/ui/toast.tsx +112 -0
- package/src/ui/tooltip.tsx +3 -1
- package/src/ui/sonner.tsx +0 -47
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { UseFormReturn } from 'react-hook-form'
|
|
2
|
+
import { Controller } from 'react-hook-form'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { Button } from '../ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Combobox,
|
|
7
|
+
ComboboxButtonTrigger,
|
|
8
|
+
ComboboxContent,
|
|
9
|
+
ComboboxEmpty,
|
|
10
|
+
ComboboxItem,
|
|
11
|
+
ComboboxList,
|
|
12
|
+
ComboboxSearch,
|
|
13
|
+
ComboboxTriggerPlaceholder,
|
|
14
|
+
ComboboxValue,
|
|
15
|
+
} from '../ui/combobox'
|
|
16
|
+
import {
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogBody,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogDescription,
|
|
21
|
+
DialogFooter,
|
|
22
|
+
DialogHeader,
|
|
23
|
+
DialogTitle,
|
|
24
|
+
} from '../ui/dialog'
|
|
25
|
+
import { Field, FieldError, FieldLabel } from '../ui/field'
|
|
26
|
+
import { Input } from '../ui/input'
|
|
27
|
+
|
|
28
|
+
/** The form shape both panels bind for one consignment. */
|
|
29
|
+
export type DeliveryFormFields = {
|
|
30
|
+
tracking_number: string
|
|
31
|
+
carrier: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Records a tracked consignment on a parcel, or corrects one. A parcel can
|
|
36
|
+
* carry several — three boxes, or a freight PRO number covering a pallet —
|
|
37
|
+
* and each travels on its own carrier status.
|
|
38
|
+
*
|
|
39
|
+
* Shared by the operator's order page and the seller's. Headless: the caller
|
|
40
|
+
* owns the form and the submit, so each panel keeps its own mutation, while
|
|
41
|
+
* the carrier field's contract lives here — the value stored is the carrier's
|
|
42
|
+
* key rather than its display name, and anything off the list stays as typed
|
|
43
|
+
* because a forwarder's own name has to be enterable.
|
|
44
|
+
*/
|
|
45
|
+
export function DeliveryFormDialog({
|
|
46
|
+
form,
|
|
47
|
+
open,
|
|
48
|
+
onOpenChange,
|
|
49
|
+
editing,
|
|
50
|
+
carrierKeys,
|
|
51
|
+
carrierLabel,
|
|
52
|
+
onSubmit,
|
|
53
|
+
pending = false,
|
|
54
|
+
}: {
|
|
55
|
+
form: UseFormReturn<DeliveryFormFields>
|
|
56
|
+
open: boolean
|
|
57
|
+
onOpenChange: (open: boolean) => void
|
|
58
|
+
/** Correcting an existing consignment rather than adding one. */
|
|
59
|
+
editing: boolean
|
|
60
|
+
carrierKeys: string[]
|
|
61
|
+
carrierLabel: (key: string) => string
|
|
62
|
+
onSubmit: (values: DeliveryFormFields) => void | Promise<void>
|
|
63
|
+
pending?: boolean
|
|
64
|
+
}) {
|
|
65
|
+
const { t } = useTranslation()
|
|
66
|
+
const { errors } = form.formState
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
70
|
+
<DialogContent>
|
|
71
|
+
<form
|
|
72
|
+
onSubmit={(event) => {
|
|
73
|
+
// The order page renders its cards inside their own forms — without
|
|
74
|
+
// stopping the bubble the browser submits the outer one.
|
|
75
|
+
form.handleSubmit(onSubmit)(event)
|
|
76
|
+
event.stopPropagation()
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<DialogHeader>
|
|
80
|
+
<DialogTitle>
|
|
81
|
+
{editing
|
|
82
|
+
? t('admin.orders.detail.fulfillments.edit_tracking_title')
|
|
83
|
+
: t('admin.orders.detail.fulfillments.add_tracking_title')}
|
|
84
|
+
</DialogTitle>
|
|
85
|
+
<DialogDescription>
|
|
86
|
+
{t('admin.orders.detail.fulfillments.tracking_description')}
|
|
87
|
+
</DialogDescription>
|
|
88
|
+
</DialogHeader>
|
|
89
|
+
|
|
90
|
+
<DialogBody className="flex flex-col gap-4">
|
|
91
|
+
{errors.root?.message && (
|
|
92
|
+
<p className="text-destructive text-sm" role="alert">
|
|
93
|
+
{errors.root.message}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
98
|
+
<Field>
|
|
99
|
+
<FieldLabel htmlFor="delivery-tracking-number">
|
|
100
|
+
{t('admin.orders.fulfill.tracking_label')}
|
|
101
|
+
</FieldLabel>
|
|
102
|
+
<Input
|
|
103
|
+
id="delivery-tracking-number"
|
|
104
|
+
placeholder={t('admin.orders.fulfill.tracking_placeholder')}
|
|
105
|
+
aria-invalid={!!errors.tracking_number}
|
|
106
|
+
{...form.register('tracking_number')}
|
|
107
|
+
/>
|
|
108
|
+
<FieldError errors={[errors.tracking_number]} />
|
|
109
|
+
</Field>
|
|
110
|
+
|
|
111
|
+
<Field>
|
|
112
|
+
<FieldLabel htmlFor="delivery-carrier">
|
|
113
|
+
{t('admin.orders.detail.fulfillments.carrier_label')}
|
|
114
|
+
</FieldLabel>
|
|
115
|
+
<Controller
|
|
116
|
+
control={form.control}
|
|
117
|
+
name="carrier"
|
|
118
|
+
render={({ field }) => (
|
|
119
|
+
<Combobox
|
|
120
|
+
items={carrierKeys}
|
|
121
|
+
value={field.value}
|
|
122
|
+
onValueChange={(value: string | null) => field.onChange(value ?? '')}
|
|
123
|
+
itemToStringLabel={carrierLabel}
|
|
124
|
+
>
|
|
125
|
+
<ComboboxButtonTrigger id="delivery-carrier" onBlur={field.onBlur}>
|
|
126
|
+
{field.value ? (
|
|
127
|
+
<ComboboxValue />
|
|
128
|
+
) : (
|
|
129
|
+
<ComboboxTriggerPlaceholder>
|
|
130
|
+
{t('admin.orders.detail.fulfillments.carrier_auto')}
|
|
131
|
+
</ComboboxTriggerPlaceholder>
|
|
132
|
+
)}
|
|
133
|
+
</ComboboxButtonTrigger>
|
|
134
|
+
<ComboboxContent>
|
|
135
|
+
<ComboboxSearch
|
|
136
|
+
placeholder={t('admin.orders.detail.fulfillments.carrier_search')}
|
|
137
|
+
/>
|
|
138
|
+
<ComboboxEmpty>{t('admin.common.no_results')}</ComboboxEmpty>
|
|
139
|
+
<ComboboxList>
|
|
140
|
+
{(value: string) => (
|
|
141
|
+
<ComboboxItem key={value} value={value}>
|
|
142
|
+
{carrierLabel(value)}
|
|
143
|
+
</ComboboxItem>
|
|
144
|
+
)}
|
|
145
|
+
</ComboboxList>
|
|
146
|
+
</ComboboxContent>
|
|
147
|
+
</Combobox>
|
|
148
|
+
)}
|
|
149
|
+
/>
|
|
150
|
+
</Field>
|
|
151
|
+
</div>
|
|
152
|
+
</DialogBody>
|
|
153
|
+
|
|
154
|
+
<DialogFooter>
|
|
155
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
156
|
+
{t('admin.actions.cancel')}
|
|
157
|
+
</Button>
|
|
158
|
+
<Button type="submit" disabled={pending}>
|
|
159
|
+
{pending ? t('admin.actions.saving') : t('admin.actions.save')}
|
|
160
|
+
</Button>
|
|
161
|
+
</DialogFooter>
|
|
162
|
+
</form>
|
|
163
|
+
</DialogContent>
|
|
164
|
+
</Dialog>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'
|
|
2
|
-
import { GripVerticalIcon } from 'lucide-react'
|
|
3
2
|
import type { ComponentProps } from 'react'
|
|
4
3
|
import { useTranslation } from 'react-i18next'
|
|
5
4
|
import { cn } from '../lib/utils'
|
|
5
|
+
import { GripVerticalIcon } from './icons'
|
|
6
6
|
|
|
7
7
|
interface DragHandleProps extends Omit<ComponentProps<'button'>, 'type'> {
|
|
8
8
|
/** `attributes` from `useSortable` / `useDraggable`. */
|
|
@@ -38,7 +38,7 @@ export function DragHandle({ attributes, listeners, className, ...props }: DragH
|
|
|
38
38
|
type="button"
|
|
39
39
|
aria-label={t('admin.a11y.drag_to_reorder')}
|
|
40
40
|
className={cn(
|
|
41
|
-
'flex h-full w-full items-center justify-center rounded-lg p-2 text-muted-foreground cursor-grab active:cursor-grabbing hover:bg-accent',
|
|
41
|
+
'flex h-full w-full items-center justify-center rounded-lg p-2 text-muted-foreground cursor-grab active:cursor-grabbing hover:bg-accent-strong hover:text-accent-foreground',
|
|
42
42
|
className,
|
|
43
43
|
)}
|
|
44
44
|
{...attributes}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Button } from '../ui/button'
|
|
4
|
+
import { Card, CardAction, CardContent, CardHeader, CardTitle } from '../ui/card'
|
|
5
|
+
import { PencilIcon } from './icons'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A note on a record: shown as read-only text, edited in place behind a
|
|
9
|
+
* pencil, and reading "None" while empty.
|
|
10
|
+
*
|
|
11
|
+
* Shared by every note card on an order — the buyer's instructions, the
|
|
12
|
+
* merchant's own working note — so they cannot drift into different shapes.
|
|
13
|
+
* The editor itself comes from the caller, because a plain note and a
|
|
14
|
+
* rich-text one need different ones.
|
|
15
|
+
*/
|
|
16
|
+
export function EditableNoteCard({
|
|
17
|
+
title,
|
|
18
|
+
editing,
|
|
19
|
+
onEditingChange,
|
|
20
|
+
children,
|
|
21
|
+
editor,
|
|
22
|
+
onSave,
|
|
23
|
+
pending = false,
|
|
24
|
+
}: {
|
|
25
|
+
title: string
|
|
26
|
+
editing: boolean
|
|
27
|
+
onEditingChange: (editing: boolean) => void
|
|
28
|
+
/** The read view. Falsy renders the empty state instead. */
|
|
29
|
+
children?: ReactNode
|
|
30
|
+
editor: ReactNode
|
|
31
|
+
onSave: () => void
|
|
32
|
+
pending?: boolean
|
|
33
|
+
}) {
|
|
34
|
+
const { t } = useTranslation()
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Card>
|
|
38
|
+
<CardHeader>
|
|
39
|
+
<CardTitle>{title}</CardTitle>
|
|
40
|
+
<CardAction>
|
|
41
|
+
<Button
|
|
42
|
+
// Explicit: a card on the order page sits inside that page's own
|
|
43
|
+
// form, and a button with no type submits it.
|
|
44
|
+
type="button"
|
|
45
|
+
variant="ghost"
|
|
46
|
+
size="icon-sm"
|
|
47
|
+
onClick={() => onEditingChange(!editing)}
|
|
48
|
+
aria-label={t('admin.actions.edit')}
|
|
49
|
+
>
|
|
50
|
+
<PencilIcon className="size-4" />
|
|
51
|
+
</Button>
|
|
52
|
+
</CardAction>
|
|
53
|
+
</CardHeader>
|
|
54
|
+
<CardContent>
|
|
55
|
+
{editing ? (
|
|
56
|
+
<form
|
|
57
|
+
className="flex flex-col gap-3"
|
|
58
|
+
onSubmit={(event) => {
|
|
59
|
+
// The order page renders its cards inside their own forms —
|
|
60
|
+
// without stopping the bubble the browser submits the outer one.
|
|
61
|
+
event.preventDefault()
|
|
62
|
+
event.stopPropagation()
|
|
63
|
+
onSave()
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{editor}
|
|
67
|
+
<div className="flex justify-end gap-2">
|
|
68
|
+
<Button
|
|
69
|
+
type="button"
|
|
70
|
+
variant="outline"
|
|
71
|
+
size="sm"
|
|
72
|
+
onClick={() => onEditingChange(false)}
|
|
73
|
+
>
|
|
74
|
+
{t('admin.actions.cancel')}
|
|
75
|
+
</Button>
|
|
76
|
+
<Button type="submit" size="sm" disabled={pending}>
|
|
77
|
+
{pending ? t('admin.actions.saving') : t('admin.actions.save')}
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
</form>
|
|
81
|
+
) : (
|
|
82
|
+
(children ?? <p className="text-muted-foreground text-sm">{t('admin.common.none')}</p>)
|
|
83
|
+
)}
|
|
84
|
+
</CardContent>
|
|
85
|
+
</Card>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A plain-text note as it reads when not being edited. */
|
|
90
|
+
export function NoteText({ value }: { value: string }) {
|
|
91
|
+
return <p className="whitespace-pre-wrap text-muted-foreground text-sm">{value}</p>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A rich-text note as it reads when not being edited. */
|
|
95
|
+
export function NoteHtml({ html }: { html: string }) {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground"
|
|
99
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is sanitized server-side via the rich-text pipeline
|
|
100
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type * as React from 'react'
|
|
2
|
+
import { cn } from '../lib/utils'
|
|
3
|
+
import { ExternalLinkIcon } from './icons'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A link that leaves the dashboard — documentation, a storefront, a carrier's
|
|
7
|
+
* tracking page, a provider's console.
|
|
8
|
+
*
|
|
9
|
+
* The trailing arrow is the point: it tells the reader this one opens a new tab
|
|
10
|
+
* before they click it, which a bare link cannot. Four hand-rolled versions of
|
|
11
|
+
* this had grown across the two panels, each with its own colour, gap and icon
|
|
12
|
+
* size, so a docs link looked like one thing next to a title and another inside
|
|
13
|
+
* a card.
|
|
14
|
+
*
|
|
15
|
+
* `rel="noopener noreferrer"` is not optional on a `_blank` link: without
|
|
16
|
+
* `noopener` the opened page can reach back through `window.opener` and
|
|
17
|
+
* navigate this one. `target` and `rel` are therefore not part of the public
|
|
18
|
+
* props — a caller cannot spread them back in and undo the isolation.
|
|
19
|
+
*/
|
|
20
|
+
export function ExternalLink({
|
|
21
|
+
className,
|
|
22
|
+
children,
|
|
23
|
+
// Pulled out of `props` so they cannot be spread back in below, and typed
|
|
24
|
+
// `never` so a caller is told at compile time too. Types alone would not be
|
|
25
|
+
// enough: they vanish at runtime, so an untyped caller or a plain-JS
|
|
26
|
+
// consumer of this source-shipped package could still land its own
|
|
27
|
+
// `target`/`rel` after ours and undo the isolation.
|
|
28
|
+
target: _target,
|
|
29
|
+
rel: _rel,
|
|
30
|
+
...props
|
|
31
|
+
}: Omit<React.ComponentProps<'a'>, 'target' | 'rel'> & {
|
|
32
|
+
href: string
|
|
33
|
+
target?: never
|
|
34
|
+
rel?: never
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<a
|
|
38
|
+
data-slot="external-link"
|
|
39
|
+
target="_blank"
|
|
40
|
+
rel="noopener noreferrer"
|
|
41
|
+
className={cn(
|
|
42
|
+
// `--link` rather than the accent: this is body-copy text, and the
|
|
43
|
+
// accent fails 4.5:1 on both the card and the page.
|
|
44
|
+
'inline-flex items-center gap-1 text-link transition-colors duration-100 ease-out hover:text-link-hover',
|
|
45
|
+
// Sized relative to the text it sits in, so the arrow stays in
|
|
46
|
+
// proportion whether the link is in a heading or a caption.
|
|
47
|
+
'[&>svg]:size-[1em] [&>svg]:shrink-0',
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
<ExternalLinkIcon aria-hidden />
|
|
54
|
+
</a>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
FileArchiveIcon,
|
|
4
|
+
FileAudioIcon,
|
|
5
|
+
FileCodeIcon,
|
|
6
|
+
FileIcon,
|
|
7
|
+
FileImageIcon,
|
|
8
|
+
FileSpreadsheetIcon,
|
|
9
|
+
FileTextIcon,
|
|
10
|
+
FileVideoIcon,
|
|
11
|
+
} from './icons'
|
|
12
|
+
|
|
13
|
+
type IconComponent = ComponentType<{ className?: string }>
|
|
14
|
+
|
|
15
|
+
// Matched on the MIME type where it is specific enough, then on the extension —
|
|
16
|
+
// servers routinely hand back `application/octet-stream` for anything they do
|
|
17
|
+
// not recognise, which would otherwise flatten every row to a blank page icon.
|
|
18
|
+
const BY_SUBTYPE: Array<[RegExp, IconComponent]> = [
|
|
19
|
+
[/^image\//, FileImageIcon],
|
|
20
|
+
[/^audio\//, FileAudioIcon],
|
|
21
|
+
[/^video\//, FileVideoIcon],
|
|
22
|
+
[/(zip|compressed|tar|gzip|rar|7z)/, FileArchiveIcon],
|
|
23
|
+
[/(spreadsheet|excel|csv)/, FileSpreadsheetIcon],
|
|
24
|
+
[/(json|xml|javascript|typescript|html|x-ruby|x-python|x-sh)/, FileCodeIcon],
|
|
25
|
+
[/(pdf|word|document|text|rtf|epub)/, FileTextIcon],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const BY_EXTENSION: Array<[RegExp, IconComponent]> = [
|
|
29
|
+
[/\.(png|jpe?g|gif|webp|svg|avif|heic|tiff?)$/i, FileImageIcon],
|
|
30
|
+
[/\.(mp3|wav|flac|aac|ogg|m4a|aiff?)$/i, FileAudioIcon],
|
|
31
|
+
[/\.(mp4|mov|avi|mkv|webm|m4v)$/i, FileVideoIcon],
|
|
32
|
+
[/\.(zip|rar|7z|tar|gz|bz2|dmg|iso)$/i, FileArchiveIcon],
|
|
33
|
+
[/\.(csv|tsv|xlsx?|numbers|ods)$/i, FileSpreadsheetIcon],
|
|
34
|
+
[/\.(json|xml|ya?ml|js|ts|tsx|rb|py|sh|css|html?)$/i, FileCodeIcon],
|
|
35
|
+
[/\.(pdf|docx?|txt|rtf|md|epub|pages)$/i, FileTextIcon],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Picks the icon for a downloadable file. Falls back to a plain page rather
|
|
40
|
+
* than guessing, so an unknown type reads as "a file" instead of the wrong one.
|
|
41
|
+
*/
|
|
42
|
+
export function fileTypeIcon(filename?: string | null, contentType?: string | null): IconComponent {
|
|
43
|
+
const type = contentType?.toLowerCase() ?? ''
|
|
44
|
+
for (const [pattern, icon] of BY_SUBTYPE) {
|
|
45
|
+
if (pattern.test(type)) return icon
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const name = filename ?? ''
|
|
49
|
+
for (const [pattern, icon] of BY_EXTENSION) {
|
|
50
|
+
if (pattern.test(name)) return icon
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return FileIcon
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Convenience wrapper for the common `<FileTypeIcon .../>` call site. */
|
|
57
|
+
export function FileTypeIcon({
|
|
58
|
+
filename,
|
|
59
|
+
contentType,
|
|
60
|
+
className,
|
|
61
|
+
}: {
|
|
62
|
+
filename?: string | null
|
|
63
|
+
contentType?: string | null
|
|
64
|
+
className?: string
|
|
65
|
+
}) {
|
|
66
|
+
const Icon = fileTypeIcon(filename, contentType)
|
|
67
|
+
return <Icon className={className} />
|
|
68
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useHotkey } from '@tanstack/react-hotkeys'
|
|
2
|
-
import { Loader2Icon } from 'lucide-react'
|
|
3
2
|
import { useEffect } from 'react'
|
|
4
3
|
import { type FieldValues, type UseFormReturn, useFormState } from 'react-hook-form'
|
|
5
4
|
import { useTranslation } from 'react-i18next'
|
|
6
5
|
import { Button } from '../ui/button'
|
|
6
|
+
import { Loader2Icon } from './icons'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Submit button wired to a `react-hook-form` instance. Always rendered,
|
|
@@ -31,7 +31,7 @@ export function FormSaveButton<TFieldValues extends FieldValues>({
|
|
|
31
31
|
const { isDirty, isSubmitting } = useFormState({ control: form.control })
|
|
32
32
|
|
|
33
33
|
return (
|
|
34
|
-
<Button type="submit"
|
|
34
|
+
<Button type="submit" disabled={!isDirty || isSubmitting}>
|
|
35
35
|
{isSubmitting && <Loader2Icon className="animate-spin" />}
|
|
36
36
|
{isSubmitting
|
|
37
37
|
? (savingLabel ?? t('admin.actions.saving'))
|
|
@@ -65,7 +65,6 @@ export function FormDiscardButton<TFieldValues extends FieldValues>({
|
|
|
65
65
|
return (
|
|
66
66
|
<Button
|
|
67
67
|
type="button"
|
|
68
|
-
size="sm"
|
|
69
68
|
variant="outline"
|
|
70
69
|
disabled={isSubmitting}
|
|
71
70
|
onClick={onDiscard ?? (() => form.reset())}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { FieldGroup, FieldLegend, FieldSet } from '../ui/field'
|
|
3
|
+
|
|
4
|
+
interface FormSectionProps {
|
|
5
|
+
/** Names the group. Rendered as a legend, so it labels the fields inside it
|
|
6
|
+
* rather than merely preceding them. */
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
/** Right-aligned beside the title — an "Add" button, a count, a toggle. */
|
|
10
|
+
action?: ReactNode
|
|
11
|
+
children: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A titled group of form fields.
|
|
16
|
+
*
|
|
17
|
+
* Built on FieldSet/FieldLegend rather than a heading beside a div: a heading
|
|
18
|
+
* only precedes the inputs, while a legend is announced as the group they
|
|
19
|
+
* belong to — the difference between "Pickup" read once and every control
|
|
20
|
+
* under it being understood as part of pickup.
|
|
21
|
+
*/
|
|
22
|
+
export function FormSection({ title, description, action, children }: FormSectionProps) {
|
|
23
|
+
const hasHeading = title || description || action
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<FieldSet className="gap-3">
|
|
27
|
+
{hasHeading && (
|
|
28
|
+
<div className="flex items-start justify-between gap-2">
|
|
29
|
+
<div className="flex flex-col gap-0.5">
|
|
30
|
+
{title && (
|
|
31
|
+
<FieldLegend variant="label" className="mb-0">
|
|
32
|
+
{title}
|
|
33
|
+
</FieldLegend>
|
|
34
|
+
)}
|
|
35
|
+
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
|
36
|
+
</div>
|
|
37
|
+
{action}
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
{/* The group paints the card that separates one section from the next.
|
|
41
|
+
Sections are siblings, never nested, so no card ever sits inside
|
|
42
|
+
another. */}
|
|
43
|
+
<FieldGroup>{children}</FieldGroup>
|
|
44
|
+
</FieldSet>
|
|
45
|
+
)
|
|
46
|
+
}
|