Selaa lähdekoodia

feat(dashboard): Create asset components

Michael Bromley 10 kuukautta sitten
vanhempi
sitoutus
533e3b6654

+ 132 - 0
package-lock.json

@@ -1789,6 +1789,17 @@
         "node": ">=8"
       }
     },
+    "node_modules/@atlaskit/pragmatic-drag-and-drop": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.5.1.tgz",
+      "integrity": "sha512-budAbyQPy6GGlNAA6ZOOX79OwWuM88tLkRi7fp49I8o0+milIkvGDqu2gHfsqMLDmiiRKXdRToBL8IGmmO4PhA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@babel/runtime": "^7.0.0",
+        "bind-event-listener": "^3.0.0",
+        "raf-schd": "^4.0.3"
+      }
+    },
     "node_modules/@aws-crypto/crc32": {
       "version": "5.2.0",
       "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -4952,6 +4963,59 @@
         "node": ">=10.0.0"
       }
     },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.1",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.3.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/@elastic/elasticsearch": {
       "version": "7.9.1",
       "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.9.1.tgz",
@@ -11393,6 +11457,36 @@
         }
       }
     },
+    "node_modules/@radix-ui/react-checkbox": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz",
+      "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==",
+      "license": "MIT",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.1",
+        "@radix-ui/react-compose-refs": "1.1.1",
+        "@radix-ui/react-context": "1.1.1",
+        "@radix-ui/react-presence": "1.1.2",
+        "@radix-ui/react-primitive": "2.0.2",
+        "@radix-ui/react-use-controllable-state": "1.1.0",
+        "@radix-ui/react-use-previous": "1.1.0",
+        "@radix-ui/react-use-size": "1.1.0"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@radix-ui/react-collapsible": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz",
@@ -17731,6 +17825,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/bind-event-listener": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
+      "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==",
+      "license": "MIT"
+    },
     "node_modules/bindings": {
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -31083,6 +31183,16 @@
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
       "license": "MIT"
     },
+    "node_modules/next-themes": {
+      "version": "0.4.6",
+      "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+      "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+      }
+    },
     "node_modules/ngx-pagination": {
       "version": "6.0.3",
       "resolved": "https://registry.npmjs.org/ngx-pagination/-/ngx-pagination-6.0.3.tgz",
@@ -34231,6 +34341,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/raf-schd": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
+      "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
+      "license": "MIT"
+    },
     "node_modules/ramda": {
       "version": "0.29.1",
       "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.1.tgz",
@@ -36321,6 +36437,16 @@
         "node": ">= 14"
       }
     },
+    "node_modules/sonner": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.1.tgz",
+      "integrity": "sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+        "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+      }
+    },
     "node_modules/sort-keys": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz",
@@ -44988,10 +45114,14 @@
       "name": "@vendure/dashboard",
       "version": "0.0.0",
       "dependencies": {
+        "@atlaskit/pragmatic-drag-and-drop": "^1.5.1",
+        "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/sortable": "^10.0.0",
         "@hookform/resolvers": "^4.1.3",
         "@lingui/core": "^5.2.0",
         "@lingui/react": "^5.2.0",
         "@radix-ui/react-avatar": "^1.1.3",
+        "@radix-ui/react-checkbox": "^1.1.4",
         "@radix-ui/react-collapsible": "^1.1.3",
         "@radix-ui/react-dialog": "^1.1.6",
         "@radix-ui/react-dropdown-menu": "^2.1.6",
@@ -45012,9 +45142,11 @@
         "graphql": "~16.10.0",
         "graphql-request": "^7.1.2",
         "lucide-react": "^0.475.0",
+        "next-themes": "^0.4.6",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
         "react-hook-form": "^7.54.2",
+        "sonner": "^2.0.1",
         "tailwind-merge": "^3.0.1",
         "tailwindcss": "^4.0.6",
         "tailwindcss-animate": "^1.0.7",

+ 6 - 0
packages/dashboard/package.json

@@ -23,10 +23,14 @@
     "lingui.config.js"
   ],
   "dependencies": {
+    "@atlaskit/pragmatic-drag-and-drop": "^1.5.1",
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/sortable": "^10.0.0",
     "@hookform/resolvers": "^4.1.3",
     "@lingui/core": "^5.2.0",
     "@lingui/react": "^5.2.0",
     "@radix-ui/react-avatar": "^1.1.3",
+    "@radix-ui/react-checkbox": "^1.1.4",
     "@radix-ui/react-collapsible": "^1.1.3",
     "@radix-ui/react-dialog": "^1.1.6",
     "@radix-ui/react-dropdown-menu": "^2.1.6",
@@ -47,9 +51,11 @@
     "graphql": "~16.10.0",
     "graphql-request": "^7.1.2",
     "lucide-react": "^0.475.0",
+    "next-themes": "^0.4.6",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-hook-form": "^7.54.2",
+    "sonner": "^2.0.1",
     "tailwind-merge": "^3.0.1",
     "tailwindcss": "^4.0.6",
     "tailwindcss-animate": "^1.0.7",

+ 344 - 0
packages/dashboard/src/components/asset-gallery.tsx

@@ -0,0 +1,344 @@
+import React, { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { graphql } from '@/graphql/graphql.js';
+import { VendureImage } from '@/components/vendure-image.js';
+import { api } from '@/graphql/api.js';
+import { useDebounce } from 'use-debounce';
+import { Card, CardContent } from '@/components/ui/card.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { Input } from '@/components/ui/input.js';
+import { Button } from '@/components/ui/button.js';
+import {
+    Pagination,
+    PaginationContent,
+    PaginationEllipsis,
+    PaginationItem,
+    PaginationLink,
+    PaginationNext,
+    PaginationPrevious,
+} from '@/components/ui/pagination.js';
+import { Checkbox } from '@/components/ui/checkbox.js';
+import { Loader2, X, Search } from 'lucide-react';
+
+const getAssetListDocument = graphql(`
+    query GetAssetList($options: AssetListOptions) {
+        assets(options: $options) {
+            items {
+                id
+                name
+                preview
+                fileSize
+                mimeType
+                type
+                source
+                focalPoint {
+                    x
+                    y
+                }
+            }
+            totalItems
+        }
+    }
+`);
+
+const AssetType = {
+    ALL: 'ALL',
+    IMAGE: 'IMAGE',
+    VIDEO: 'VIDEO',
+    BINARY: 'BINARY',
+} as const;
+
+export interface Asset {
+    id: string;
+    name: string;
+    preview: string;
+    fileSize?: number;
+    mimeType?: string;
+    source: string;
+    focalPoint?: { x: number; y: number } | null;
+}
+
+export interface AssetGalleryProps {
+    onSelect: (assets: Asset[]) => void;
+    multiSelect?: boolean;
+    initialSelectedAssets?: Asset[];
+    pageSize?: number;
+    fixedHeight?: boolean;
+    showHeader?: boolean;
+    className?: string;
+}
+
+export function AssetGallery({
+    onSelect,
+    multiSelect = false,
+    initialSelectedAssets = [],
+    pageSize = 24,
+    fixedHeight = false,
+    showHeader = true,
+    className = '',
+}: AssetGalleryProps) {
+    // State
+    const [page, setPage] = useState(1);
+    const [search, setSearch] = useState('');
+    const [debouncedSearch] = useDebounce(search, 500);
+    const [assetType, setAssetType] = useState<string>(AssetType.ALL);
+    const [selected, setSelected] = useState<Asset[]>(initialSelectedAssets || []);
+
+    // Query for assets
+    const { data, isLoading } = useQuery({
+        queryKey: ['AssetGallery', page, pageSize, debouncedSearch, assetType],
+        queryFn: () => {
+            const filter: Record<string, any> = {};
+
+            if (debouncedSearch) {
+                filter.name = { contains: debouncedSearch };
+            }
+
+            if (assetType !== AssetType.ALL) {
+                filter.type = { eq: assetType };
+            }
+
+            return api.query(getAssetListDocument, {
+                options: {
+                    skip: (page - 1) * pageSize,
+                    take: pageSize,
+                    filter: Object.keys(filter).length > 0 ? filter : undefined,
+                    sort: { createdAt: 'DESC' },
+                },
+            });
+        },
+    });
+
+    // Calculate total pages
+    const totalItems = data?.assets.totalItems || 0;
+    const totalPages = Math.ceil(totalItems / pageSize);
+
+    // Handle selection
+    const handleSelect = (asset: Asset) => {
+        if (!multiSelect) {
+            setSelected([asset]);
+            onSelect([asset]);
+            return;
+        }
+
+        const isSelected = selected.some(a => a.id === asset.id);
+        let newSelected: Asset[];
+
+        if (isSelected) {
+            newSelected = selected.filter(a => a.id !== asset.id);
+        } else {
+            newSelected = [...selected, asset];
+        }
+
+        setSelected(newSelected);
+        onSelect(newSelected);
+    };
+
+    // Check if an asset is selected
+    const isSelected = (asset: Asset) => selected.some(a => a.id === asset.id);
+
+    // Clear filters
+    const clearFilters = () => {
+        setSearch('');
+        setAssetType(AssetType.ALL);
+        setPage(1);
+    };
+
+    // Go to specific page
+    const goToPage = (newPage: number) => {
+        if (newPage < 1 || newPage > totalPages) return;
+        setPage(newPage);
+    };
+
+    return (
+        <div className={`flex flex-col ${fixedHeight ? 'h-[600px]' : ''} ${className}`}>
+            {showHeader && (
+                <div className="flex flex-col md:flex-row gap-2 mb-4">
+                    <div className="relative flex-grow">
+                        <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+                        <Input
+                            placeholder="Search assets..."
+                            value={search}
+                            onChange={e => setSearch(e.target.value)}
+                            className="pl-8"
+                        />
+                    </div>
+                    <Select value={assetType} onValueChange={setAssetType}>
+                        <SelectTrigger className="w-full md:w-[180px]">
+                            <SelectValue placeholder="Asset type" />
+                        </SelectTrigger>
+                        <SelectContent>
+                            <SelectItem value={AssetType.ALL}>All types</SelectItem>
+                            <SelectItem value={AssetType.IMAGE}>Images</SelectItem>
+                            <SelectItem value={AssetType.VIDEO}>Video</SelectItem>
+                            <SelectItem value={AssetType.BINARY}>Binary</SelectItem>
+                        </SelectContent>
+                    </Select>
+                    {(search || assetType) && (
+                        <Button variant="ghost" size="sm" onClick={clearFilters} className="flex-shrink-0">
+                            <X className="h-4 w-4 mr-1" /> Clear filters
+                        </Button>
+                    )}
+                </div>
+            )}
+
+            <div
+                className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 ${fixedHeight ? 'overflow-y-auto flex-grow' : ''}`}
+            >
+                {isLoading ? (
+                    <div className="col-span-full flex justify-center py-12">
+                        <Loader2 className="h-8 w-8 animate-spin text-primary" />
+                    </div>
+                ) : (
+                    data?.assets.items.map(asset => (
+                        <Card
+                            key={asset.id}
+                            className={`
+                overflow-hidden cursor-pointer transition-all hover:ring-2 hover:ring-primary/20
+                ${isSelected(asset as Asset) ? 'ring-2 ring-primary' : ''}
+              `}
+                            onClick={() => handleSelect(asset as Asset)}
+                        >
+                            <div className="relative aspect-square bg-muted/30">
+                                <VendureImage
+                                    asset={asset}
+                                    preset="thumb"
+                                    className="w-full h-full object-contain"
+                                />
+                                <div className="absolute top-2 left-2">
+                                    <Checkbox checked={isSelected(asset as Asset)} />
+                                </div>
+                            </div>
+                            <CardContent className="p-2">
+                                <p className="text-xs truncate" title={asset.name}>
+                                    {asset.name}
+                                </p>
+                            </CardContent>
+                        </Card>
+                    ))
+                )}
+
+                {!isLoading && data?.assets.items.length === 0 && (
+                    <div className="col-span-full text-center py-12 text-muted-foreground">
+                        No assets found. Try adjusting your filters.
+                    </div>
+                )}
+            </div>
+
+            {totalPages > 1 && (
+                <Pagination className="mt-4">
+                    <PaginationContent>
+                        <PaginationItem>
+                            <PaginationPrevious
+                                href="#"
+                                size="default"
+                                onClick={e => {
+                                    e.preventDefault();
+                                    goToPage(page - 1);
+                                }}
+                                className={page === 1 ? 'pointer-events-none opacity-50' : ''}
+                            />
+                        </PaginationItem>
+
+                        {/* First page */}
+                        {page > 2 && (
+                            <PaginationItem>
+                                <PaginationLink
+                                    href="#"
+                                    onClick={e => {
+                                        e.preventDefault();
+                                        goToPage(1);
+                                    }}
+                                >
+                                    1
+                                </PaginationLink>
+                            </PaginationItem>
+                        )}
+
+                        {/* Ellipsis if needed */}
+                        {page > 3 && (
+                            <PaginationItem>
+                                <PaginationEllipsis />
+                            </PaginationItem>
+                        )}
+
+                        {/* Previous page */}
+                        {page > 1 && (
+                            <PaginationItem>
+                                <PaginationLink
+                                    href="#"
+                                    onClick={e => {
+                                        e.preventDefault();
+                                        goToPage(page - 1);
+                                    }}
+                                >
+                                    {page - 1}
+                                </PaginationLink>
+                            </PaginationItem>
+                        )}
+
+                        {/* Current page */}
+                        <PaginationItem>
+                            <PaginationLink href="#" isActive>
+                                {page}
+                            </PaginationLink>
+                        </PaginationItem>
+
+                        {/* Next page */}
+                        {page < totalPages && (
+                            <PaginationItem>
+                                <PaginationLink
+                                    href="#"
+                                    onClick={e => {
+                                        e.preventDefault();
+                                        goToPage(page + 1);
+                                    }}
+                                >
+                                    {page + 1}
+                                </PaginationLink>
+                            </PaginationItem>
+                        )}
+
+                        {/* Ellipsis if needed */}
+                        {page < totalPages - 2 && (
+                            <PaginationItem>
+                                <PaginationEllipsis />
+                            </PaginationItem>
+                        )}
+
+                        {/* Last page */}
+                        {page < totalPages - 1 && (
+                            <PaginationItem>
+                                <PaginationLink
+                                    href="#"
+                                    onClick={e => {
+                                        e.preventDefault();
+                                        goToPage(totalPages);
+                                    }}
+                                >
+                                    {totalPages}
+                                </PaginationLink>
+                            </PaginationItem>
+                        )}
+
+                        <PaginationItem>
+                            <PaginationNext
+                                href="#"
+                                onClick={e => {
+                                    e.preventDefault();
+                                    goToPage(page + 1);
+                                }}
+                                className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
+                            />
+                        </PaginationItem>
+                    </PaginationContent>
+                </Pagination>
+            )}
+
+            <div className="mt-2 text-xs text-muted-foreground">
+                {totalItems} {totalItems === 1 ? 'asset' : 'assets'} found
+                {selected.length > 0 && `, ${selected.length} selected`}
+            </div>
+        </div>
+    );
+}

+ 72 - 0
packages/dashboard/src/components/asset-picker-dialog.tsx

@@ -0,0 +1,72 @@
+import React, { useState } from 'react';
+import { AssetGallery, Asset } from './asset-gallery.js';
+import { 
+  Dialog, 
+  DialogContent, 
+  DialogHeader, 
+  DialogTitle, 
+  DialogFooter 
+} from '@/components/ui/dialog.js';
+import { Button } from '@/components/ui/button.js';
+
+interface AssetPickerDialogProps {
+  open: boolean;
+  onClose: () => void;
+  onSelect: (assets: Asset[]) => void;
+  multiSelect?: boolean;
+  initialSelectedAssets?: Asset[];
+  title?: string;
+}
+
+export function AssetPickerDialog({
+  open,
+  onClose,
+  onSelect,
+  multiSelect = false,
+  initialSelectedAssets = [],
+  title = "Select Assets",
+}: AssetPickerDialogProps) {
+  const [selectedAssets, setSelectedAssets] = useState<Asset[]>(initialSelectedAssets);
+  
+  const handleAssetSelect = (assets: Asset[]) => {
+    setSelectedAssets(assets);
+  };
+  
+  const handleConfirm = () => {
+    onSelect(selectedAssets);
+    onClose();
+  };
+  
+  return (
+    <Dialog open={open} onOpenChange={onClose}>
+      <DialogContent className="sm:max-w-[800px] lg:max-w-[1000px] max-h-[80vh] flex flex-col">
+        <DialogHeader>
+          <DialogTitle>{multiSelect ? title : title.replace('Assets', 'Asset')}</DialogTitle>
+        </DialogHeader>
+        
+        <div className="flex-grow overflow-hidden py-4">
+          <AssetGallery 
+            onSelect={handleAssetSelect}
+            multiSelect={multiSelect}
+            initialSelectedAssets={initialSelectedAssets}
+            fixedHeight={true}
+          />
+        </div>
+        
+        <DialogFooter>
+          <Button variant="outline" onClick={onClose}>
+            Cancel
+          </Button>
+          <Button 
+            onClick={handleConfirm} 
+            disabled={selectedAssets.length === 0}
+          >
+            {selectedAssets.length > 0 && multiSelect 
+              ? `Select ${selectedAssets.length} Asset${selectedAssets.length > 1 ? 's' : ''}`
+              : 'Select Asset'}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 351 - 0
packages/dashboard/src/components/entity-assets.tsx

@@ -0,0 +1,351 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import {
+    DndContext,
+    closestCenter,
+    KeyboardSensor,
+    PointerSensor,
+    useSensor,
+    useSensors,
+    DragEndEvent,
+} from '@dnd-kit/core';
+import {
+    arrayMove,
+    SortableContext,
+    sortableKeyboardCoordinates,
+    useSortable,
+    horizontalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { Button } from '@/components/ui/button.js';
+import { ImageIcon, PaperclipIcon } from 'lucide-react';
+import { VendureImage } from './vendure-image.js';
+import { AssetPickerDialog } from './asset-picker-dialog.js';
+
+interface Asset {
+    id: string;
+    name?: string | null;
+    preview: string;
+    focalPoint?: { x: number; y: number } | null;
+}
+
+export interface EntityAssetValue {
+    assetIds?: string[] | null;
+    featuredAssetId?: string | null;
+}
+
+interface EntityAssetsProps {
+    assets?: Asset[];
+    featuredAsset?: Asset | null;
+    compact?: boolean;
+    updatePermissions?: boolean;
+    multiSelect?: boolean;
+    value?: EntityAssetValue;
+    onBlur?: () => void;
+    onChange?: (change: EntityAssetValue) => void;
+}
+
+// Sortable asset item component
+function SortableAsset({
+    asset,
+    compact,
+    isFeatured,
+    updatePermissions,
+    onPreview,
+    onSetAsFeatured,
+    onRemove,
+}: {
+    asset: Asset;
+    compact: boolean;
+    isFeatured: boolean;
+    updatePermissions: boolean;
+    onPreview: (asset: Asset) => void;
+    onSetAsFeatured: (asset: Asset) => void;
+    onRemove: (asset: Asset) => void;
+}) {
+    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+        id: asset.id,
+        disabled: !updatePermissions,
+    });
+
+    const style = {
+        transform: CSS.Transform.toString(transform),
+        transition,
+        opacity: isDragging ? 0.5 : 1,
+        zIndex: isDragging ? 1 : 0,
+    };
+
+    return (
+        <div ref={setNodeRef} style={style} className="relative" {...attributes}>
+            <DropdownMenu>
+                <DropdownMenuTrigger asChild>
+                    <div
+                        {...listeners}
+                        className={`
+              flex items-center justify-center 
+              ${compact ? 'w-12 h-12' : 'w-16 h-16'} 
+              border rounded-md overflow-hidden cursor-pointer
+              ${isFeatured ? 'border-primary ring-1 ring-primary/30' : 'border-border'}
+              ${updatePermissions ? 'hover:border-muted-foreground' : ''}
+            `}
+                        tabIndex={0}
+                    >
+                        <VendureImage asset={asset} mode="crop" preset="tiny" />
+                    </div>
+                </DropdownMenuTrigger>
+                <DropdownMenuContent align="end">
+                    <DropdownMenuItem onClick={() => onPreview(asset)}>Preview</DropdownMenuItem>
+                    <DropdownMenuItem
+                        disabled={isFeatured || !updatePermissions}
+                        onClick={() => onSetAsFeatured(asset)}
+                    >
+                        Set as featured asset
+                    </DropdownMenuItem>
+                    <DropdownMenuItem
+                        className="text-destructive"
+                        disabled={!updatePermissions}
+                        onClick={() => onRemove(asset)}
+                    >
+                        Remove asset
+                    </DropdownMenuItem>
+                </DropdownMenuContent>
+            </DropdownMenu>
+        </div>
+    );
+}
+
+export function EntityAssets({
+    assets: initialAssets = [],
+    featuredAsset: initialFeaturedAsset,
+    compact = false,
+    updatePermissions = true,
+    multiSelect = true,
+    onChange,
+}: EntityAssetsProps) {
+    const [assets, setAssets] = useState<Asset[]>([...initialAssets]);
+    const [featuredAsset, setFeaturedAsset] = useState<Asset | undefined | null>(initialFeaturedAsset);
+    const [isPickerOpen, setIsPickerOpen] = useState(false);
+    const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
+
+    // Setup sensors for drag and drop
+    const sensors = useSensors(
+        useSensor(PointerSensor),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        }),
+    );
+
+    // Update internal state when props change
+    useEffect(() => {
+        setAssets([...initialAssets]);
+    }, [initialAssets]);
+
+    useEffect(() => {
+        setFeaturedAsset(initialFeaturedAsset);
+    }, [initialFeaturedAsset]);
+
+    const emitChange = useCallback(
+        (newAssets: Asset[], newFeaturedAsset: Asset | undefined | null) => {
+            onChange?.({
+                assetIds: newAssets.map(a => a.id),
+                featuredAssetId: newFeaturedAsset?.id ?? undefined,
+            });
+        },
+        [onChange],
+    );
+
+    const handleSelectAssets = useCallback(() => {
+        setIsPickerOpen(true);
+    }, []);
+
+    const handleAssetsPicked = useCallback(
+        (selectedAssets: Asset[]) => {
+            if (selectedAssets.length) {
+                // Remove duplicates
+                const uniqueAssets = multiSelect
+                    ? [...new Map([...assets, ...selectedAssets].map(item => [item.id, item])).values()]
+                    : selectedAssets;
+
+                const newFeaturedAsset = !featuredAsset || !multiSelect ? selectedAssets[0] : featuredAsset;
+
+                setAssets(uniqueAssets);
+                setFeaturedAsset(newFeaturedAsset);
+                emitChange(uniqueAssets, newFeaturedAsset);
+            }
+            setIsPickerOpen(false);
+        },
+        [assets, featuredAsset, multiSelect, emitChange],
+    );
+
+    const handleSetAsFeatured = useCallback(
+        (asset: Asset) => {
+            setFeaturedAsset(asset);
+            emitChange(assets, asset);
+        },
+        [assets, emitChange],
+    );
+
+    const handleRemoveAsset = useCallback(
+        (asset: Asset) => {
+            const newAssets = assets.filter(a => a.id !== asset.id);
+            let newFeaturedAsset = featuredAsset;
+
+            if (featuredAsset && featuredAsset.id === asset.id) {
+                newFeaturedAsset = newAssets.length > 0 ? newAssets[0] : undefined;
+            }
+
+            setAssets(newAssets);
+            setFeaturedAsset(newFeaturedAsset);
+            emitChange(newAssets, newFeaturedAsset);
+        },
+        [assets, featuredAsset, emitChange],
+    );
+
+    const handleDragEnd = useCallback(
+        (event: DragEndEvent) => {
+            const { active, over } = event;
+
+            if (over && active.id !== over.id) {
+                setAssets(items => {
+                    const oldIndex = items.findIndex(item => item.id === active.id);
+                    const newIndex = items.findIndex(item => item.id === over.id);
+
+                    const newAssets = arrayMove(items, oldIndex, newIndex);
+                    emitChange(newAssets, featuredAsset);
+                    return newAssets;
+                });
+            }
+        },
+        [emitChange, featuredAsset],
+    );
+
+    const isFeatured = useCallback(
+        (asset: Asset) => {
+            return !!featuredAsset && featuredAsset.id === asset.id;
+        },
+        [featuredAsset],
+    );
+
+    const renderAssetList = () => (
+        <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
+            <div className={`${compact ? 'max-h-32' : ''} overflow-auto p-1`}>
+                <SortableContext
+                    items={assets.map(asset => asset.id)}
+                    strategy={horizontalListSortingStrategy}
+                >
+                    <div className="flex flex-wrap gap-2">
+                        {assets.map(asset => (
+                            <SortableAsset
+                                key={asset.id}
+                                asset={asset}
+                                compact={compact}
+                                isFeatured={isFeatured(asset)}
+                                updatePermissions={updatePermissions}
+                                onPreview={setPreviewAsset}
+                                onSetAsFeatured={handleSetAsFeatured}
+                                onRemove={handleRemoveAsset}
+                            />
+                        ))}
+                    </div>
+                </SortableContext>
+            </div>
+        </DndContext>
+    );
+
+    const FeaturedAsset = () => (
+        <div
+            className={`flex items-center justify-center ${compact ? 'h-40' : 'h-64'} border border-dashed rounded-md`}
+        >
+            {featuredAsset ? (
+                <VendureImage
+                    asset={featuredAsset}
+                    mode="crop"
+                    preset="small"
+                    onClick={() => setPreviewAsset(featuredAsset)}
+                    className="max-w-full max-h-full object-contain cursor-pointer"
+                />
+            ) : (
+                <div
+                    className="flex flex-col items-center justify-center text-muted-foreground cursor-pointer"
+                    onClick={handleSelectAssets}
+                >
+                    <ImageIcon className={compact ? 'h-10 w-10' : 'h-16 w-16'} />
+                    {!compact && <div className="mt-2">No featured asset</div>}
+                </div>
+            )}
+        </div>
+    );
+
+    // AddAssetButton component
+    const AddAssetButton = () =>
+        updatePermissions && (
+            <Button
+                variant="outline"
+                size={compact ? 'sm' : 'default'}
+                className={compact ? 'w-full' : ''}
+                onClick={handleSelectAssets}
+            >
+                <PaperclipIcon className="mr-2 h-4 w-4" />
+                Add asset
+            </Button>
+        );
+
+    return (
+        <>
+            {compact ? (
+                <div className="flex flex-col gap-3">
+                    <FeaturedAsset />
+                    {renderAssetList()}
+                    <AddAssetButton />
+                </div>
+            ) : (
+                <div className="grid grid-cols-1 md:grid-cols-[256px_1fr] gap-4">
+                    <FeaturedAsset />
+                    <div className="flex flex-col gap-4">
+                        {renderAssetList()}
+                        <AddAssetButton />
+                    </div>
+                </div>
+            )}
+
+            {/* Dialogs - moved outside conditional rendering */}
+            {isPickerOpen && (
+                <AssetPickerDialog
+                    multiSelect={multiSelect}
+                    onSelect={handleAssetsPicked}
+                    onClose={() => setIsPickerOpen(false)}
+                    open={isPickerOpen}
+                />
+            )}
+
+            {previewAsset && (
+                <AssetPreviewDialog
+                    asset={previewAsset}
+                    assets={assets}
+                    onClose={() => setPreviewAsset(null)}
+                    open={!!previewAsset}
+                />
+            )}
+        </>
+    );
+}
+
+// Placeholder component - would be implemented separately
+function AssetPreviewDialog({
+    asset,
+    assets,
+    onClose,
+    open,
+}: {
+    asset: Asset;
+    assets: Asset[];
+    onClose: () => void;
+    open: boolean;
+}) {
+    return null; // Implement this component separately
+}

+ 30 - 0
packages/dashboard/src/components/ui/checkbox.tsx

@@ -0,0 +1,30 @@
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+  className,
+  ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+  return (
+    <CheckboxPrimitive.Root
+      data-slot="checkbox"
+      className={cn(
+        "peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className
+      )}
+      {...props}
+    >
+      <CheckboxPrimitive.Indicator
+        data-slot="checkbox-indicator"
+        className="flex items-center justify-center text-current transition-none"
+      >
+        <CheckIcon className="size-3.5" />
+      </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+  )
+}
+
+export { Checkbox }

+ 127 - 0
packages/dashboard/src/components/ui/pagination.tsx

@@ -0,0 +1,127 @@
+import * as React from "react"
+import {
+  ChevronLeftIcon,
+  ChevronRightIcon,
+  MoreHorizontalIcon,
+} from "lucide-react"
+
+import { cn } from "@/lib/utils.js"
+import { Button, buttonVariants } from "@/components/ui/button.js"
+
+function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
+  return (
+    <nav
+      role="navigation"
+      aria-label="pagination"
+      data-slot="pagination"
+      className={cn("mx-auto flex w-full justify-center", className)}
+      {...props}
+    />
+  )
+}
+
+function PaginationContent({
+  className,
+  ...props
+}: React.ComponentProps<"ul">) {
+  return (
+    <ul
+      data-slot="pagination-content"
+      className={cn("flex flex-row items-center gap-1", className)}
+      {...props}
+    />
+  )
+}
+
+function PaginationItem({ ...props }: React.ComponentProps<"li">) {
+  return <li data-slot="pagination-item" {...props} />
+}
+
+type PaginationLinkProps = {
+  isActive?: boolean
+} & Pick<React.ComponentProps<typeof Button>, "size"> &
+  React.ComponentProps<"a">
+
+function PaginationLink({
+  className,
+  isActive,
+  size = "icon",
+  ...props
+}: PaginationLinkProps) {
+  return (
+    <a
+      aria-current={isActive ? "page" : undefined}
+      data-slot="pagination-link"
+      data-active={isActive}
+      className={cn(
+        buttonVariants({
+          variant: isActive ? "outline" : "ghost",
+          size,
+        }),
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function PaginationPrevious({
+  className,
+  ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="Go to previous page"
+      size="default"
+      className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
+      {...props}
+    >
+      <ChevronLeftIcon />
+      <span className="hidden sm:block">Previous</span>
+    </PaginationLink>
+  )
+}
+
+function PaginationNext({
+  className,
+  ...props
+}: React.ComponentProps<typeof PaginationLink>) {
+  return (
+    <PaginationLink
+      aria-label="Go to next page"
+      size="default"
+      className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
+      {...props}
+    >
+      <span className="hidden sm:block">Next</span>
+      <ChevronRightIcon />
+    </PaginationLink>
+  )
+}
+
+function PaginationEllipsis({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      aria-hidden
+      data-slot="pagination-ellipsis"
+      className={cn("flex size-9 items-center justify-center", className)}
+      {...props}
+    >
+      <MoreHorizontalIcon className="size-4" />
+      <span className="sr-only">More pages</span>
+    </span>
+  )
+}
+
+export {
+  Pagination,
+  PaginationContent,
+  PaginationLink,
+  PaginationItem,
+  PaginationPrevious,
+  PaginationNext,
+  PaginationEllipsis,
+}

+ 27 - 0
packages/dashboard/src/components/ui/sonner.tsx

@@ -0,0 +1,27 @@
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+  const { theme = "system" } = useTheme()
+
+  return (
+    <Sonner
+      theme={theme as ToasterProps["theme"]}
+      className="toaster group"
+      toastOptions={{
+        classNames: {
+          toast:
+            "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
+          description: "group-[.toast]:text-muted-foreground",
+          actionButton:
+            "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
+          cancelButton:
+            "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
+        },
+      }}
+      {...props}
+    />
+  )
+}
+
+export { Toaster }

+ 117 - 0
packages/dashboard/src/components/vendure-image.tsx

@@ -0,0 +1,117 @@
+import React from 'react';
+
+export interface Asset {
+  id: string;
+  preview: string; // Base URL of the asset
+  name?: string | null;
+  focalPoint?: { x: number; y: number } | null;
+}
+
+export type ImagePreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | null;
+export type ImageFormat = 'jpg' | 'jpeg' | 'png' | 'webp' | 'avif' | null;
+export type ImageMode = 'crop' | 'resize' | null;
+
+export interface VendureImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
+  asset: Asset | null | undefined;
+  preset?: ImagePreset;
+  mode?: ImageMode;
+  width?: number;
+  height?: number;
+  format?: ImageFormat;
+  quality?: number;
+  useFocalPoint?: boolean; // Whether to use the asset's focal point in crop mode
+  fallback?: React.ReactNode; // What to show if no asset is provided
+}
+
+export function VendureImage({
+  asset,
+  preset = null,
+  mode = null,
+  width,
+  height,
+  format = null,
+  quality,
+  useFocalPoint = true,
+  fallback = null,
+  alt,
+  className,
+  style,
+  ...imgProps
+}: VendureImageProps) {
+  if (!asset) {
+    return fallback ? <>{fallback}</> : null;
+  }
+  
+  // Build the URL with query parameters
+  const url = new URL(asset.preview);
+  
+  // Handle preset if specified
+  if (preset) {
+    url.searchParams.set('preset', preset);
+  } else {
+    // Or handle custom dimensions and mode
+    if (width) url.searchParams.set('w', width.toString());
+    if (height) url.searchParams.set('h', height.toString());
+    if (mode) url.searchParams.set('mode', mode);
+  }
+  
+  // Add format if specified
+  if (format) {
+    url.searchParams.set('format', format);
+  }
+  
+  // Add quality if specified
+  if (quality) {
+    url.searchParams.set('q', quality.toString());
+  }
+  
+  // Apply focal point if available and requested
+  if (useFocalPoint && asset.focalPoint && mode === 'crop') {
+    url.searchParams.set('fpx', asset.focalPoint.x.toString());
+    url.searchParams.set('fpy', asset.focalPoint.y.toString());
+  }
+  
+  return (
+    <img 
+      src={url.toString()}
+      alt={alt || asset.name || ''}
+      className={className}
+      style={style}
+      loading="lazy"
+      {...imgProps}
+    />
+  );
+}
+
+// Convenience components for common use cases
+export function Thumbnail({ asset, size = 'thumb', ...props }: Omit<VendureImageProps, 'preset'> & { size?: ImagePreset }) {
+  return <VendureImage asset={asset} preset={size} {...props} />;
+}
+
+export function CroppedImage({ asset, width, height, ...props }: Omit<VendureImageProps, 'mode'>) {
+  return <VendureImage asset={asset} mode="crop" width={width} height={height} {...props} />;
+}
+
+export function ResponsiveImage({ 
+  asset, 
+  sizes = '100vw', 
+  ...props 
+}: VendureImageProps & { sizes?: string }) {
+  // Setup srcset with multiple sizes
+  const srcSet = [
+    `${asset?.preview}?w=320&mode=resize 320w`,
+    `${asset?.preview}?w=480&mode=resize 480w`,
+    `${asset?.preview}?w=640&mode=resize 640w`,
+    `${asset?.preview}?w=1024&mode=resize 1024w`,
+    `${asset?.preview}?w=1280&mode=resize 1280w`,
+  ].join(', ');
+
+  return (
+    <VendureImage 
+      asset={asset} 
+      srcSet={srcSet}
+      sizes={sizes}
+      {...props}
+    />
+  );
+}

+ 2 - 0
packages/dashboard/src/main.tsx

@@ -3,6 +3,7 @@ import { useAuth } from '@/providers/auth.js';
 import { useDashboardExtensions } from '@/framework/internal/extension-api/use-dashboard-extensions.js';
 import { useExtendedRouter } from '@/framework/internal/page/use-extended-router.js';
 import { defaultLocale, dynamicActivate } from '@/providers/i18n-provider.js';
+import { Toaster } from "@/components/ui/sonner.js"
 
 import '@/framework/defaults.js';
 import { RouterProvider } from '@tanstack/react-router';
@@ -30,6 +31,7 @@ function App() {
         extensionsLoaded && (
             <AppProviders>
                 <InnerApp />
+                <Toaster />
             </AppProviders>
         )
     );

+ 37 - 3
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -1,4 +1,5 @@
 import { ContentLanguageSelector } from '@/components/content-language-selector.js';
+import { EntityAssets } from '@/components/entity-assets.js';
 import { Button } from '@/components/ui/button.js';
 import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
 import {
@@ -18,10 +19,9 @@ import { useGeneratedForm } from '@/framework/internal/form-engine/use-generated
 import { DetailPage, getDetailQueryOptions } from '@/framework/internal/page/detail-page.js';
 import { api } from '@/graphql/api.js';
 import { graphql } from '@/graphql/graphql.js';
-import { useServerConfig } from '@/providers/server-config.js';
 import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
 import { createFileRoute } from '@tanstack/react-router';
-import React from 'react';
+import { toast } from 'sonner';
 
 export const Route = createFileRoute('/_authenticated/products_/$id')({
     component: ProductDetailPage,
@@ -45,10 +45,20 @@ const productDetailFragment = graphql(`
         featuredAsset {
             id
             preview
+            name
+            focalPoint {
+                x
+                y
+            }
         }
         assets {
             id
             preview
+            name
+            focalPoint {
+                x
+                y
+            }
         }
         translations {
             id
@@ -90,7 +100,9 @@ export function ProductDetailPage() {
     const updateMutation = useMutation({
         mutationFn: api.mutate(updateProductDocument),
         onSuccess: () => {
-            console.log(`Success`);
+            toast('Updated', {
+                position: 'top-right',
+            });
         },
         onError: err => {
             console.error(err);
@@ -198,6 +210,28 @@ export function ProductDetailPage() {
                             />
                         </CardContent>
                     </Card>
+                    <Card className="">
+                        <CardHeader />
+                        <CardContent>
+                            <FormItem>
+                                <FormLabel>Assets</FormLabel>
+                                <FormControl>
+                                    <EntityAssets
+                                        assets={entity?.assets}
+                                        featuredAsset={entity?.featuredAsset}
+                                        compact={false}
+                                        value={form.getValues()}
+                                        onChange={value => {
+                                            form.setValue('featuredAssetId', value.featuredAssetId);
+                                            form.setValue('assetIds', value.assetIds);
+                                        }}
+                                    />
+                                </FormControl>
+                                <FormDescription></FormDescription>
+                                <FormMessage />
+                            </FormItem>
+                        </CardContent>
+                    </Card>
                     <Button type="submit">Submit</Button>
                 </form>
             </Form>