@vendure/dashboard 3.2.2 → 3.2.4

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.
Files changed (92) hide show
  1. package/dist/plugin/utils/ast-utils.d.ts +10 -0
  2. package/dist/plugin/utils/ast-utils.js +96 -0
  3. package/dist/plugin/utils/ast-utils.spec.d.ts +1 -0
  4. package/dist/plugin/utils/ast-utils.spec.js +120 -0
  5. package/dist/plugin/{config-loader.d.ts → utils/config-loader.d.ts} +22 -8
  6. package/dist/plugin/utils/config-loader.js +325 -0
  7. package/dist/plugin/{schema-generator.d.ts → utils/schema-generator.d.ts} +5 -0
  8. package/dist/plugin/{schema-generator.js → utils/schema-generator.js} +6 -0
  9. package/dist/plugin/{ui-config.js → utils/ui-config.js} +2 -2
  10. package/dist/plugin/vite-plugin-admin-api-schema.js +2 -2
  11. package/dist/plugin/vite-plugin-config-loader.d.ts +2 -3
  12. package/dist/plugin/vite-plugin-config-loader.js +18 -9
  13. package/dist/plugin/vite-plugin-dashboard-metadata.js +12 -14
  14. package/dist/plugin/vite-plugin-gql-tada.js +2 -2
  15. package/dist/plugin/vite-plugin-ui-config.js +3 -2
  16. package/package.json +8 -6
  17. package/src/app/app-providers.tsx +8 -8
  18. package/src/app/main.tsx +1 -1
  19. package/src/app/routes/_authenticated/_assets/assets.graphql.ts +26 -0
  20. package/src/app/routes/_authenticated/_assets/assets.tsx +2 -2
  21. package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +156 -0
  22. package/src/app/routes/_authenticated/_orders/components/customer-address-selector.tsx +104 -0
  23. package/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx +228 -0
  24. package/src/app/routes/_authenticated/_orders/components/money-gross-net.tsx +18 -0
  25. package/src/app/routes/_authenticated/_orders/components/order-address.tsx +2 -1
  26. package/src/app/routes/_authenticated/_orders/components/order-line-custom-fields-form.tsx +38 -0
  27. package/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx +53 -0
  28. package/src/app/routes/_authenticated/_orders/components/order-table.tsx +8 -49
  29. package/src/app/routes/_authenticated/_orders/components/shipping-method-selector.tsx +65 -0
  30. package/src/app/routes/_authenticated/_orders/orders.graphql.ts +187 -1
  31. package/src/app/routes/_authenticated/_orders/orders.tsx +39 -18
  32. package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +31 -9
  33. package/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx +418 -0
  34. package/src/app/routes/_authenticated/_products/products.tsx +1 -1
  35. package/src/app/routes/_authenticated.tsx +12 -1
  36. package/src/lib/components/data-table/add-filter-menu.tsx +61 -0
  37. package/src/lib/components/data-table/data-table-column-header.tsx +0 -13
  38. package/src/lib/components/data-table/data-table-filter-badge.tsx +75 -0
  39. package/src/lib/components/data-table/data-table-filter-dialog.tsx +27 -28
  40. package/src/lib/components/data-table/data-table-types.ts +1 -0
  41. package/src/lib/components/data-table/data-table-view-options.tsx +72 -23
  42. package/src/lib/components/data-table/data-table.tsx +23 -24
  43. package/src/lib/components/data-table/filters/data-table-boolean-filter.tsx +57 -0
  44. package/src/lib/components/data-table/filters/data-table-datetime-filter.tsx +93 -0
  45. package/src/lib/components/data-table/filters/data-table-id-filter.tsx +58 -0
  46. package/src/lib/components/data-table/filters/data-table-number-filter.tsx +119 -0
  47. package/src/lib/components/data-table/filters/data-table-string-filter.tsx +62 -0
  48. package/src/lib/components/data-table/human-readable-operator.tsx +65 -0
  49. package/src/lib/components/layout/nav-user.tsx +4 -4
  50. package/src/lib/components/shared/asset/asset-focal-point-editor.tsx +93 -0
  51. package/src/lib/components/shared/{asset-gallery.tsx → asset/asset-gallery.tsx} +51 -20
  52. package/src/lib/components/shared/{asset-picker-dialog.tsx → asset/asset-picker-dialog.tsx} +1 -1
  53. package/src/lib/components/shared/{asset-preview-dialog.tsx → asset/asset-preview-dialog.tsx} +1 -7
  54. package/src/lib/components/shared/asset/asset-preview-selector.tsx +34 -0
  55. package/src/lib/components/shared/asset/asset-preview.tsx +128 -0
  56. package/src/lib/components/shared/asset/asset-properties.tsx +46 -0
  57. package/src/lib/components/shared/{focal-point-control.tsx → asset/focal-point-control.tsx} +1 -1
  58. package/src/lib/components/shared/custom-fields-form.tsx +4 -3
  59. package/src/lib/components/shared/customer-selector.tsx +13 -14
  60. package/src/lib/components/shared/detail-page-button.tsx +2 -2
  61. package/src/lib/components/shared/entity-assets.tsx +3 -3
  62. package/src/lib/components/shared/navigation-confirmation.tsx +39 -0
  63. package/src/lib/components/shared/paginated-list-data-table.tsx +9 -1
  64. package/src/lib/components/shared/product-variant-selector.tsx +111 -0
  65. package/src/lib/components/shared/vendure-image.tsx +1 -1
  66. package/src/lib/components/ui/calendar.tsx +508 -63
  67. package/src/lib/framework/document-introspection/get-document-structure.spec.ts +113 -3
  68. package/src/lib/framework/document-introspection/get-document-structure.ts +70 -11
  69. package/src/lib/framework/form-engine/use-generated-form.tsx +8 -7
  70. package/src/lib/framework/layout-engine/page-layout.tsx +4 -0
  71. package/src/lib/framework/page/list-page.tsx +23 -4
  72. package/src/lib/framework/page/use-detail-page.ts +1 -0
  73. package/src/lib/graphql/fragments.tsx +8 -0
  74. package/src/lib/index.ts +5 -5
  75. package/src/lib/providers/auth.tsx +12 -9
  76. package/src/lib/providers/channel-provider.tsx +1 -0
  77. package/src/lib/providers/server-config.tsx +7 -1
  78. package/src/lib/providers/user-settings.tsx +24 -0
  79. package/vite/utils/ast-utils.spec.ts +128 -0
  80. package/vite/utils/ast-utils.ts +119 -0
  81. package/vite/utils/config-loader.ts +410 -0
  82. package/vite/{schema-generator.ts → utils/schema-generator.ts} +7 -1
  83. package/vite/{ui-config.ts → utils/ui-config.ts} +2 -2
  84. package/vite/vite-plugin-admin-api-schema.ts +2 -2
  85. package/vite/vite-plugin-config-loader.ts +25 -13
  86. package/vite/vite-plugin-dashboard-metadata.ts +19 -15
  87. package/vite/vite-plugin-gql-tada.ts +2 -2
  88. package/vite/vite-plugin-ui-config.ts +3 -2
  89. package/dist/plugin/config-loader.js +0 -141
  90. package/src/lib/components/shared/asset-preview.tsx +0 -345
  91. package/vite/config-loader.ts +0 -181
  92. /package/dist/plugin/{ui-config.d.ts → utils/ui-config.d.ts} +0 -0
@@ -0,0 +1,62 @@
1
+ import { Trans } from "@/lib/trans.js";
2
+
3
+ import { Select, SelectValue, SelectTrigger, SelectItem } from "@/components/ui/select.js";
4
+
5
+ import { SelectContent } from "@/components/ui/select.js";
6
+ import { Input } from "@/components/ui/input.js";
7
+ import { useEffect, useState } from "react";
8
+ import { HumanReadableOperator } from "../human-readable-operator.js";
9
+
10
+ export interface DataTableStringFilterProps {
11
+ value: Record<string, any> | undefined;
12
+ onChange: (filter: Record<string, any>) => void;
13
+ }
14
+
15
+ export const STRING_OPERATORS = ['eq', 'notEq', 'contains', 'notContains', 'in', 'notIn', 'regex', 'isNull'] as const;
16
+
17
+ export function DataTableStringFilter({ value: incomingValue, onChange }: DataTableStringFilterProps) {
18
+ const initialOperator = incomingValue ? Object.keys(incomingValue)[0] : 'contains';
19
+ const initialValue = incomingValue ? Object.values(incomingValue)[0] : '';
20
+ const [operator, setOperator] = useState<string>(initialOperator ?? 'contains');
21
+ const [value, setValue] = useState((initialValue as string) ?? '');
22
+
23
+ useEffect(() => {
24
+ if (operator === 'isNull') {
25
+ onChange({ [operator]: true });
26
+ } else if (operator === 'in' || operator === 'notIn') {
27
+ // Split by comma and trim whitespace
28
+ if (typeof value === 'string') {
29
+ const values = value.split(',').map(v => v.trim()).filter(v => v);
30
+ onChange({ [operator]: values });
31
+ } else {
32
+ onChange({ [operator]: [] });
33
+ }
34
+ } else {
35
+ onChange({ [operator]: value });
36
+ }
37
+ }, [operator, value]);
38
+
39
+ return (
40
+ <div className="flex flex-col md:flex-row gap-2">
41
+ <Select value={operator} onValueChange={value => setOperator(value)}>
42
+ <SelectTrigger>
43
+ <SelectValue placeholder="Select operator" />
44
+ </SelectTrigger>
45
+ <SelectContent>
46
+ {STRING_OPERATORS.map(op => (
47
+ <SelectItem key={op} value={op}>
48
+ <HumanReadableOperator operator={op} />
49
+ </SelectItem>
50
+ ))}
51
+ </SelectContent>
52
+ </Select>
53
+ {operator !== 'isNull' && (
54
+ <Input
55
+ placeholder={operator === 'in' || operator === 'notIn' ? "Enter comma-separated values..." : "Enter filter value..."}
56
+ value={value}
57
+ onChange={e => setValue(e.target.value)}
58
+ />
59
+ )}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,65 @@
1
+ import { DATETIME_OPERATORS } from './filters/data-table-datetime-filter.js';
2
+ import { BOOLEAN_OPERATORS } from './filters/data-table-boolean-filter.js';
3
+ import { ID_OPERATORS } from './filters/data-table-id-filter.js';
4
+ import { NUMBER_OPERATORS } from './filters/data-table-number-filter.js';
5
+ import { STRING_OPERATORS } from './filters/data-table-string-filter.js';
6
+ import { Trans } from '@/lib/trans.js';
7
+
8
+ type Operator =
9
+ | (typeof DATETIME_OPERATORS)[number]
10
+ | (typeof BOOLEAN_OPERATORS)[number]
11
+ | (typeof ID_OPERATORS)[number]
12
+ | (typeof NUMBER_OPERATORS)[number]
13
+ | (typeof STRING_OPERATORS)[number];
14
+
15
+ export function HumanReadableOperator({
16
+ operator,
17
+ mode = 'long',
18
+ }: {
19
+ operator: Operator;
20
+ mode?: 'short' | 'long';
21
+ }) {
22
+ switch (operator) {
23
+ case 'eq':
24
+ return mode === 'short' ? <Trans>=</Trans> : <Trans>is equal to</Trans>;
25
+ case 'notEq':
26
+ return mode === 'short' ? <Trans>!=</Trans> : <Trans>is not equal to</Trans>;
27
+ case 'before':
28
+ return mode === 'short' ? <Trans>before</Trans> : <Trans>is before</Trans>;
29
+ case 'after':
30
+ return mode === 'short' ? <Trans>after</Trans> : <Trans>is after</Trans>;
31
+ case 'between':
32
+ return mode === 'short' ? <Trans>between</Trans> : <Trans>is between</Trans>;
33
+ case 'isNull':
34
+ return mode === 'short' ? <Trans>is null</Trans> : <Trans>is null</Trans>;
35
+ case 'in':
36
+ return mode === 'short' ? <Trans>in</Trans> : <Trans>is in</Trans>;
37
+ case 'notIn':
38
+ return mode === 'short' ? <Trans>not in</Trans> : <Trans>is not in</Trans>;
39
+ case 'gt':
40
+ return mode === 'short' ? <Trans>greater than</Trans> : <Trans>is greater than</Trans>;
41
+ case 'gte':
42
+ return mode === 'short' ? (
43
+ <Trans>greater than or equal</Trans>
44
+ ) : (
45
+ <Trans>is greater than or equal to</Trans>
46
+ );
47
+ case 'lt':
48
+ return mode === 'short' ? <Trans>less than</Trans> : <Trans>is less than</Trans>;
49
+ case 'lte':
50
+ return mode === 'short' ? (
51
+ <Trans>less than or equal</Trans>
52
+ ) : (
53
+ <Trans>is less than or equal to</Trans>
54
+ );
55
+ case 'contains':
56
+ return mode === 'short' ? <Trans>contains</Trans> : <Trans>contains</Trans>;
57
+ case 'notContains':
58
+ return mode === 'short' ? <Trans>does not contain</Trans> : <Trans>does not contain</Trans>;
59
+ case 'regex':
60
+ return mode === 'short' ? <Trans>matches regex</Trans> : <Trans>matches regex</Trans>;
61
+ default:
62
+ operator satisfies never;
63
+ return <Trans>{operator}</Trans>;
64
+ }
65
+ }
@@ -44,14 +44,14 @@ export function NavUser() {
44
44
  });
45
45
  };
46
46
 
47
+ const avatarFallback = useMemo(() => {
48
+ return user?.firstName?.charAt(0) ?? '' + user?.lastName?.charAt(0) ?? '';
49
+ }, [user]);
50
+
47
51
  if (!user) {
48
52
  return <></>;
49
53
  }
50
54
 
51
- const avatarFallback = useMemo(() => {
52
- return user.firstName.charAt(0) + user.lastName.charAt(0);
53
- }, [user]);
54
-
55
55
  const isDevMode = (import.meta as any).env?.MODE === 'development';
56
56
 
57
57
  return (
@@ -0,0 +1,93 @@
1
+ import { Button } from "@/components/ui/button.js";
2
+ import { cn } from "@/lib/utils.js";
3
+ import { Crosshair, X } from "lucide-react";
4
+ import { useRef, useState } from "react";
5
+ import { DndContext, useDraggable } from "@dnd-kit/core";
6
+ import { restrictToParentElement } from "@dnd-kit/modifiers";
7
+ import { CSS } from "@dnd-kit/utilities";
8
+ import { Trans } from "@/lib/trans.js";
9
+
10
+ export interface AssetFocalPointEditorProps {
11
+ settingFocalPoint: boolean;
12
+ focalPoint: Point | undefined;
13
+ width: number;
14
+ height: number;
15
+ onFocalPointChange: (point: Point) => void;
16
+ onCancel: () => void;
17
+ children?: React.ReactNode;
18
+ }
19
+
20
+ interface Point {
21
+ x: number;
22
+ y: number;
23
+ }
24
+
25
+ function DraggableFocalPoint({ point }: { point: Point }) {
26
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
27
+ id: "focal-point",
28
+ });
29
+ const style = {
30
+ left: `${point.x * 100}%`,
31
+ top: `${point.y * 100}%`,
32
+ transform: isDragging ? `translate(-50%, -50%) ${CSS.Translate.toString(transform)}` : `translate(-50%, -50%)`,
33
+ };
34
+
35
+ return (
36
+ <div
37
+ ref={setNodeRef}
38
+ style={style}
39
+ {...listeners}
40
+ {...attributes}
41
+ className="absolute w-8 h-8 rounded-full border-4 border-white bg-brand/20 shadow-lg cursor-move"
42
+ />
43
+ );
44
+ }
45
+
46
+ export function AssetFocalPointEditor({ settingFocalPoint, focalPoint, width, height, onFocalPointChange, onCancel, children }: AssetFocalPointEditorProps) {
47
+
48
+ const [focalPointCurrent, setFocalPointCurrent] = useState<Point>(focalPoint ?? { x: 0.5, y: 0.5 });
49
+
50
+ const handleDragEnd = (event: any) => {
51
+ const { delta } = event;
52
+ const newX = Math.max(0, Math.min(1, focalPointCurrent.x + delta.x / width));
53
+ const newY = Math.max(0, Math.min(1, focalPointCurrent.y + delta.y / height));
54
+ const newPoint = { x: newX, y: newY };
55
+ setFocalPointCurrent(newPoint);
56
+ };
57
+
58
+ return (
59
+ <div
60
+ className={cn(
61
+ 'relative',
62
+ 'flex items-center justify-center',
63
+ settingFocalPoint && 'cursor-crosshair',
64
+ )}
65
+ >
66
+ <div className="relative" style={{ width: `${width}px`, height: `${height}px` }}>
67
+ {children}
68
+ {settingFocalPoint && (
69
+ <DndContext onDragEnd={handleDragEnd} modifiers={[restrictToParentElement]}>
70
+ <DraggableFocalPoint
71
+ point={focalPointCurrent}
72
+ />
73
+ </DndContext>
74
+ )}
75
+ </div>
76
+
77
+ {settingFocalPoint && (
78
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
79
+ <Button type="button" variant="secondary" onClick={onCancel}>
80
+ <X className="mr-2 h-4 w-4" />
81
+ <Trans>Cancel</Trans>
82
+ </Button>
83
+ <Button type="button" onClick={() => {
84
+ onFocalPointChange(focalPointCurrent);
85
+ }}>
86
+ <Crosshair className="mr-2 h-4 w-4" />
87
+ <Trans>Set Focal Point</Trans>
88
+ </Button>
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+ }
@@ -23,6 +23,7 @@ import { Loader2, Search, Upload, X } from 'lucide-react';
23
23
  import { useCallback, useState } from 'react';
24
24
  import { useDropzone } from 'react-dropzone';
25
25
  import { useDebounce } from '@uidotdev/usehooks';
26
+ import { DetailPageButton } from '../detail-page-button.js';
26
27
 
27
28
  const getAssetListDocument = graphql(
28
29
  `
@@ -72,7 +73,14 @@ export type Asset = AssetFragment;
72
73
  export interface AssetGalleryProps {
73
74
  onSelect?: (assets: Asset[]) => void;
74
75
  selectable?: boolean;
75
- multiSelect?: boolean;
76
+ /**
77
+ * @description
78
+ * Defines whether multiple assets can be selected.
79
+ *
80
+ * If set to 'auto', the asset selection will be toggled when the user clicks on an asset.
81
+ * If set to 'manual', multiple selection will occur only if the user holds down the control/cmd key.
82
+ */
83
+ multiSelect?: 'auto' | 'manual';
76
84
  initialSelectedAssets?: Asset[];
77
85
  pageSize?: number;
78
86
  fixedHeight?: boolean;
@@ -84,7 +92,7 @@ export interface AssetGalleryProps {
84
92
  export function AssetGallery({
85
93
  onSelect,
86
94
  selectable = true,
87
- multiSelect = false,
95
+ multiSelect = undefined,
88
96
  initialSelectedAssets = [],
89
97
  pageSize = 24,
90
98
  fixedHeight = false,
@@ -149,24 +157,44 @@ export function AssetGallery({
149
157
  const totalPages = Math.ceil(totalItems / pageSize);
150
158
 
151
159
  // Handle selection
152
- const handleSelect = (asset: Asset) => {
153
- if (!multiSelect) {
154
- setSelected([asset]);
155
- onSelect?.([asset]);
160
+ const handleSelect = (asset: Asset, event: React.MouseEvent) => {
161
+ if (multiSelect === 'auto') {
162
+ const isSelected = selected.some(a => a.id === asset.id);
163
+ let newSelected: Asset[];
164
+
165
+ if (isSelected) {
166
+ newSelected = selected.filter(a => a.id !== asset.id);
167
+ } else {
168
+ newSelected = [...selected, asset];
169
+ }
170
+
171
+ setSelected(newSelected);
172
+ onSelect?.(newSelected);
156
173
  return;
157
174
  }
158
175
 
159
- const isSelected = selected.some(a => a.id === asset.id);
160
- let newSelected: Asset[];
161
176
 
162
- if (isSelected) {
163
- newSelected = selected.filter(a => a.id !== asset.id);
177
+ // Manual mode - check for modifier key
178
+ const isModifierKeyPressed = event.metaKey || event.ctrlKey;
179
+
180
+ if (multiSelect === 'manual' && isModifierKeyPressed) {
181
+ // Toggle selection
182
+ const isSelected = selected.some(a => a.id === asset.id);
183
+ let newSelected: Asset[];
184
+
185
+ if (isSelected) {
186
+ newSelected = selected.filter(a => a.id !== asset.id);
187
+ } else {
188
+ newSelected = [...selected, asset];
189
+ }
190
+
191
+ setSelected(newSelected);
192
+ onSelect?.(newSelected);
164
193
  } else {
165
- newSelected = [...selected, asset];
194
+ // No modifier key - single select
195
+ setSelected([asset]);
196
+ onSelect?.([asset]);
166
197
  }
167
-
168
- setSelected(newSelected);
169
- onSelect?.(newSelected);
170
198
  };
171
199
 
172
200
  // Check if an asset is selected
@@ -272,7 +300,7 @@ export function AssetGallery({
272
300
  ${isSelected(asset as Asset) ? 'ring-2 ring-primary' : ''}
273
301
  flex flex-col min-w-[120px]
274
302
  `}
275
- onClick={() => handleSelect(asset as Asset)}
303
+ onClick={(e) => handleSelect(asset as Asset, e)}
276
304
  >
277
305
  <div
278
306
  className="relative w-full bg-muted/30"
@@ -296,11 +324,14 @@ export function AssetGallery({
296
324
  <p className="text-xs line-clamp-2 min-h-[2.5rem]" title={asset.name}>
297
325
  {asset.name}
298
326
  </p>
299
- {asset.fileSize && (
300
- <p className="text-xs text-muted-foreground mt-1">
301
- {formatFileSize(asset.fileSize)}
302
- </p>
303
- )}
327
+ <div className='flex justify-between items-center'>
328
+ {asset.fileSize && (
329
+ <p className="text-xs text-muted-foreground mt-1">
330
+ {formatFileSize(asset.fileSize)}
331
+ </p>
332
+ )}
333
+ <DetailPageButton id={asset.id} label={<Trans>Edit</Trans>} />
334
+ </div>
304
335
  </CardContent>
305
336
  </Card>
306
337
  ))
@@ -47,7 +47,7 @@ export function AssetPickerDialog({
47
47
  <div className="flex-grow py-4">
48
48
  <AssetGallery
49
49
  onSelect={handleAssetSelect}
50
- multiSelect={multiSelect}
50
+ multiSelect='manual'
51
51
  initialSelectedAssets={initialSelectedAssets}
52
52
  fixedHeight={true}
53
53
  />
@@ -12,9 +12,7 @@ interface AssetPreviewDialogProps {
12
12
  onOpenChange: (open: boolean) => void;
13
13
  asset: AssetWithTags;
14
14
  assets?: AssetWithTags[];
15
- editable?: boolean;
16
15
  customFields?: any[];
17
- onAssetChange?: (asset: Partial<AssetWithTags>) => void;
18
16
  }
19
17
 
20
18
  export function AssetPreviewDialog({
@@ -22,24 +20,20 @@ export function AssetPreviewDialog({
22
20
  onOpenChange,
23
21
  asset,
24
22
  assets,
25
- editable,
26
23
  customFields,
27
- onAssetChange,
28
24
  }: AssetPreviewDialogProps) {
29
25
  return (
30
26
  <Dialog open={open} onOpenChange={onOpenChange}>
31
27
  <DialogContent className="sm:max-w-[800px] lg:max-w-[95vw] w-[95vw] p-0">
32
28
  <DialogHeader className="p-6 pb-0">
33
29
  <DialogTitle>Asset</DialogTitle>
34
- <DialogDescription>Description goes here</DialogDescription>
30
+ <DialogDescription>Preview of {asset.name}</DialogDescription>
35
31
  </DialogHeader>
36
32
  <div className="h-full p-6">
37
33
  <AssetPreview
38
34
  asset={asset}
39
35
  assets={assets}
40
- editable={editable}
41
36
  customFields={customFields}
42
- onAssetChange={onAssetChange}
43
37
  />
44
38
  </div>
45
39
  </DialogContent>
@@ -0,0 +1,34 @@
1
+
2
+
3
+ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select.js";
4
+ import { PreviewPreset } from "./asset-preview.js";
5
+
6
+ export interface AssetPreviewSelectorProps {
7
+ size: PreviewPreset;
8
+ setSize: (size: PreviewPreset) => void;
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ export function AssetPreviewSelector({ size, setSize, width, height }: AssetPreviewSelectorProps) {
14
+ return (
15
+ <div className="flex items-center gap-2">
16
+ <Select value={size} onValueChange={value => setSize(value as PreviewPreset)}>
17
+ <SelectTrigger>
18
+ <SelectValue placeholder="Select size" />
19
+ </SelectTrigger>
20
+ <SelectContent>
21
+ <SelectItem value="tiny">Tiny</SelectItem>
22
+ <SelectItem value="thumb">Thumb</SelectItem>
23
+ <SelectItem value="small">Small</SelectItem>
24
+ <SelectItem value="medium">Medium</SelectItem>
25
+ <SelectItem value="large">Large</SelectItem>
26
+ <SelectItem value="full">Full Size</SelectItem>
27
+ </SelectContent>
28
+ </Select>
29
+ <p className="text-sm text-muted-foreground">
30
+ {width} x {height}
31
+ </p>
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,128 @@
1
+ import { VendureImage } from '@/components/shared/vendure-image.js';
2
+ import { Button } from '@/components/ui/button.js';
3
+ import { Card, CardContent } from '@/components/ui/card.js';
4
+ import { AssetFragment } from '@/graphql/fragments.js';
5
+ import { cn } from '@/lib/utils.js';
6
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
7
+ import { useEffect, useRef, useState } from 'react';
8
+ import { useForm } from 'react-hook-form';
9
+ import { AssetPreviewSelector } from './asset-preview-selector.js';
10
+ import { AssetProperties } from './asset-properties.js';
11
+
12
+ export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
13
+
14
+
15
+ export type AssetWithTags = AssetFragment & { tags?: { value: string }[] };
16
+
17
+ interface AssetPreviewProps {
18
+ asset: AssetWithTags;
19
+ assets?: AssetWithTags[];
20
+ customFields?: any[];
21
+ }
22
+
23
+ export function AssetPreview({
24
+ asset,
25
+ assets,
26
+ customFields = [],
27
+ }: AssetPreviewProps) {
28
+ const [size, setSize] = useState<PreviewPreset>('medium');
29
+ const [width, setWidth] = useState(0);
30
+ const [height, setHeight] = useState(0);
31
+ const [centered, setCentered] = useState(true);
32
+ const [assetIndex, setAssetIndex] = useState(assets?.indexOf(asset) || 0);
33
+
34
+ const imageRef = useRef<HTMLImageElement>(null);
35
+ const containerRef = useRef<HTMLDivElement>(null);
36
+
37
+ const form = useForm({
38
+ defaultValues: {
39
+ name: asset.name,
40
+ tags: asset.tags?.map(t => t.value) || [],
41
+ },
42
+ });
43
+ const activeAsset = assets?.[assetIndex] ?? asset;
44
+
45
+ useEffect(() => {
46
+ if (assets?.length) {
47
+ const index = assets.findIndex(a => a.id === asset.id);
48
+ setAssetIndex(index === -1 ? 0 : index);
49
+ }
50
+ }, [assets, asset.id]);
51
+
52
+ useEffect(() => {
53
+ const handleResize = () => {
54
+ updateDimensions();
55
+ };
56
+
57
+ window.addEventListener('resize', handleResize);
58
+ return () => window.removeEventListener('resize', handleResize);
59
+ }, []);
60
+
61
+ const updateDimensions = () => {
62
+ if (!imageRef.current || !containerRef.current) return;
63
+
64
+ const img = imageRef.current;
65
+ const container = containerRef.current;
66
+ const imgWidth = img.naturalWidth;
67
+ const imgHeight = img.naturalHeight;
68
+ const containerWidth = container.offsetWidth;
69
+ const containerHeight = container.offsetHeight;
70
+ setWidth(imgWidth);
71
+ setHeight(imgHeight);
72
+ setCentered(imgWidth <= containerWidth && imgHeight <= containerHeight);
73
+ };
74
+
75
+ return (
76
+ <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4 h-full">
77
+ <div className="space-y-4">
78
+ <Card>
79
+ <CardContent className="pt-6 space-y-4">
80
+ <AssetProperties asset={activeAsset} />
81
+ <AssetPreviewSelector size={size} setSize={setSize} width={width} height={height} />
82
+ </CardContent>
83
+ </Card>
84
+ </div>
85
+
86
+ <div className="relative flex items-center justify-center bg-muted/30 rounded-lg">
87
+ {assets && assets.length > 1 && (
88
+ <>
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ className="absolute left-4 z-10"
93
+ onClick={() => setAssetIndex(i => i - 1)}
94
+ disabled={assetIndex === 0}
95
+ >
96
+ <ChevronLeft className="h-4 w-4" />
97
+ </Button>
98
+ <Button
99
+ variant="ghost"
100
+ size="icon"
101
+ className="absolute right-4 z-10"
102
+ onClick={() => setAssetIndex(i => i + 1)}
103
+ disabled={assetIndex === assets.length - 1}
104
+ >
105
+ <ChevronRight className="h-4 w-4" />
106
+ </Button>
107
+ </>
108
+ )}
109
+ <div
110
+ ref={containerRef}
111
+ className={cn(
112
+ 'relative',
113
+ centered && 'flex items-center justify-center',
114
+ )}
115
+ >
116
+ <VendureImage
117
+ ref={imageRef}
118
+ asset={activeAsset}
119
+ preset={size || undefined}
120
+ mode="resize"
121
+ onLoad={updateDimensions}
122
+ className="max-w-full max-h-full object-contain"
123
+ />
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,46 @@
1
+ import { formatFileSize } from "@/lib/utils.js";
2
+
3
+ import { Label } from "@/components/ui/label.js";
4
+ import { AssetFragment } from "@/graphql/fragments.js";
5
+ import { ExternalLink } from "lucide-react";
6
+
7
+ export interface AssetPropertiesProps {
8
+ asset: AssetFragment;
9
+ }
10
+
11
+ export function AssetProperties({ asset }: AssetPropertiesProps) {
12
+ return (
13
+ <div className="space-y-4">
14
+ <div>
15
+ <Label>Name</Label>
16
+ <p className="truncate text-sm text-muted-foreground">{asset.name}</p>
17
+ </div>
18
+ <div>
19
+ <Label>Source File</Label>
20
+ <a
21
+ href={asset.source}
22
+ target="_blank"
23
+ rel="noopener noreferrer"
24
+ className="text-sm text-primary hover:underline"
25
+ >
26
+ {asset.source.split('/').pop()}
27
+ <ExternalLink className="ml-1 h-3 w-3 inline" />
28
+ </a>
29
+ </div>
30
+
31
+ <div>
32
+ <Label>File Size</Label>
33
+ <p className="text-sm text-muted-foreground">
34
+ {formatFileSize(asset.fileSize)}
35
+ </p>
36
+ </div>
37
+
38
+ <div>
39
+ <Label>Dimensions</Label>
40
+ <p className="text-sm text-muted-foreground">
41
+ {asset.width} x {asset.height}
42
+ </p>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { cn } from '@/lib/utils.js';
3
3
 
4
- interface Point {
4
+ export interface Point {
5
5
  x: number;
6
6
  y: number;
7
7
  }
@@ -22,9 +22,10 @@ type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
22
22
  interface CustomFieldsFormProps {
23
23
  entityType: string;
24
24
  control: Control<any, any>;
25
+ formPathPrefix?: string;
25
26
  }
26
27
 
27
- export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps) {
28
+ export function CustomFieldsForm({ entityType, control, formPathPrefix }: CustomFieldsFormProps) {
28
29
  const {
29
30
  settings: { displayLanguage },
30
31
  } = useUserSettings();
@@ -39,7 +40,7 @@ export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps)
39
40
  {fieldDef.type === 'localeString' || fieldDef.type === 'localeText' ? (
40
41
  <TranslatableFormField
41
42
  control={control}
42
- name={`customFields.${fieldDef.name}`}
43
+ name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
43
44
  render={({ field }) => (
44
45
  <FormItem>
45
46
  <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
@@ -52,7 +53,7 @@ export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps)
52
53
  ) : (
53
54
  <FormField
54
55
  control={control}
55
- name={`customFields.${fieldDef.name}`}
56
+ name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
56
57
  render={({ field }) => (
57
58
  <FormItem>
58
59
  <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>