Forráskód Böngészése

feat(dashboard): Add asset preview

Michael Bromley 10 hónapja
szülő
commit
65ab40cd38

+ 68 - 52
packages/dashboard/src/components/asset-gallery.tsx

@@ -19,6 +19,18 @@ import {
 } from '@/components/ui/pagination.js';
 import { Checkbox } from '@/components/ui/checkbox.js';
 import { Loader2, X, Search } from 'lucide-react';
+import { AssetFragment } from '@/graphql/fragments.js';
+
+// Helper function to format file size
+function formatFileSize(bytes: number): string {
+    if (bytes === 0) return '0 Bytes';
+
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
 
 const getAssetListDocument = graphql(`
     query GetAssetList($options: AssetListOptions) {
@@ -48,15 +60,7 @@ const AssetType = {
     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 type Asset = AssetFragment;
 
 export interface AssetGalleryProps {
     onSelect: (assets: Asset[]) => void;
@@ -153,7 +157,7 @@ export function AssetGallery({
     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="flex flex-col md:flex-row gap-2 mb-4 flex-shrink-0">
                     <div className="relative flex-grow">
                         <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
                         <Input
@@ -182,51 +186,63 @@ export function AssetGallery({
                 </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 className={`${fixedHeight ? 'flex-grow overflow-y-auto' : ''}`}>
+                <div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 p-1">
+                    {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' : ''}
+                                    flex flex-col min-w-[120px]
+                                `}
+                                onClick={() => handleSelect(asset as Asset)}
+                            >
+                                <div
+                                    className="relative w-full bg-muted/30"
+                                    style={{
+                                        aspectRatio: '1/1',
+                                        minHeight: '120px', // Ensure minimum height for the image
+                                    }}
+                                >
+                                    <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>
-                            </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>
-                )}
+                                <CardContent className="p-2">
+                                    <p className="text-xs line-clamp-2 min-h-[2.5rem]" title={asset.name}>
+                                        {asset.name}
+                                    </p>
+                                    {asset.fileSize && (
+                                        <p className="text-xs text-muted-foreground mt-1">
+                                            {formatFileSize(asset.fileSize)}
+                                        </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>
             </div>
 
             {totalPages > 1 && (
-                <Pagination className="mt-4">
+                <Pagination className="mt-4 flex-shrink-0">
                     <PaginationContent>
                         <PaginationItem>
                             <PaginationPrevious
@@ -335,7 +351,7 @@ export function AssetGallery({
                 </Pagination>
             )}
 
-            <div className="mt-2 text-xs text-muted-foreground">
+            <div className="mt-2 text-xs text-muted-foreground flex-shrink-0">
                 {totalItems} {totalItems === 1 ? 'asset' : 'assets'} found
                 {selected.length > 0 && `, ${selected.length} selected`}
             </div>

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

@@ -44,7 +44,7 @@ export function AssetPickerDialog({
           <DialogTitle>{multiSelect ? title : title.replace('Assets', 'Asset')}</DialogTitle>
         </DialogHeader>
         
-        <div className="flex-grow overflow-hidden py-4">
+        <div className="flex-grow py-4">
           <AssetGallery 
             onSelect={handleAssetSelect}
             multiSelect={multiSelect}

+ 43 - 0
packages/dashboard/src/components/asset-preview-dialog.tsx

@@ -0,0 +1,43 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog.js';
+import { Asset, AssetPreview } from './asset-preview.js';
+import { SheetDescription } from './ui/sheet.js';
+
+interface AssetPreviewDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    asset: Asset;
+    assets?: Asset[];
+    editable?: boolean;
+    customFields?: any[];
+    onAssetChange?: (asset: Partial<Asset>) => void;
+}
+
+export function AssetPreviewDialog({
+    open,
+    onOpenChange,
+    asset,
+    assets,
+    editable,
+    customFields,
+    onAssetChange,
+}: AssetPreviewDialogProps) {
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="sm:max-w-[800px] lg:max-w-[95vw] w-[95vw] p-0">
+                <DialogHeader className="p-6 pb-0">
+                    <DialogTitle>Asset</DialogTitle>
+                    <DialogDescription>Description goes here</DialogDescription>
+                </DialogHeader>
+                <div className="h-full p-6">
+                    <AssetPreview
+                        asset={asset}
+                        assets={assets}
+                        editable={editable}
+                        customFields={customFields}
+                        onAssetChange={onAssetChange}
+                    />
+                </div>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 372 - 0
packages/dashboard/src/components/asset-preview.tsx

@@ -0,0 +1,372 @@
+import { useEffect, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button.js';
+import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
+import { Input } from '@/components/ui/input.js';
+import { Label } from '@/components/ui/label.js';
+import { Separator } from '@/components/ui/separator.js';
+import { Form, FormField, FormItem, FormLabel, FormControl } from '@/components/ui/form.js';
+import { useForm } from 'react-hook-form';
+import { VendureImage } from '@/components/vendure-image.js';
+import {
+    ChevronLeft,
+    ChevronRight,
+    Edit,
+    ExternalLink,
+    Tags,
+    X,
+    Crosshair
+} from 'lucide-react';
+import { cn } from '@/lib/utils.js';
+import { FocalPointControl } from './focal-point-control.js';
+import { AssetFragment } from '@/graphql/fragments.js';
+
+export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
+
+interface Point {
+    x: number;
+    y: number;
+}
+
+export type Asset = AssetFragment;
+
+interface AssetPreviewProps {
+    asset: Asset;
+    assets?: Asset[];
+    editable?: boolean;
+    customFields?: any[];
+    onAssetChange?: (asset: Partial<Asset>) => void;
+    onEditClick?: () => void;
+}
+
+export function AssetPreview({
+    asset,
+    assets,
+    editable = false,
+    customFields = [],
+    onAssetChange,
+    onEditClick,
+}: AssetPreviewProps) {
+    const [size, setSize] = useState<PreviewPreset>('medium');
+    const [width, setWidth] = useState(0);
+    const [height, setHeight] = useState(0);
+    const [centered, setCentered] = useState(true);
+    const [settingFocalPoint, setSettingFocalPoint] = useState(false);
+    const [lastFocalPoint, setLastFocalPoint] = useState<Point>();
+    const [assetIndex, setAssetIndex] = useState(assets?.indexOf(asset) || 0);
+
+    const imageRef = useRef<HTMLImageElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+    const sizePriorToFocalPoint = useRef<PreviewPreset>('medium');
+
+    const form = useForm({
+        defaultValues: {
+            name: asset.name,
+            tags: asset.tags?.map(t => t.value) || [],
+        },
+    });
+    const activeAsset = assets?.[assetIndex] ?? asset;
+
+    useEffect(() => {
+        if (assets?.length) {
+            const index = assets.findIndex(a => a.id === asset.id);
+            setAssetIndex(index === -1 ? 0 : index);
+        }
+    }, [assets, asset.id]);
+
+    useEffect(() => {
+        const handleResize = () => {
+            updateDimensions();
+        };
+
+        window.addEventListener('resize', handleResize);
+        return () => window.removeEventListener('resize', handleResize);
+    }, []);
+
+    const updateDimensions = () => {
+        if (!imageRef.current || !containerRef.current) return;
+
+        const img = imageRef.current;
+        const container = containerRef.current;
+        const imgWidth = img.naturalWidth;
+        const imgHeight = img.naturalHeight;
+        const containerWidth = container.offsetWidth;
+        const containerHeight = container.offsetHeight;
+
+        if (settingFocalPoint) {
+            const controlsMarginPx = 48 * 2;
+            const availableHeight = containerHeight - controlsMarginPx;
+            const availableWidth = containerWidth;
+            const hRatio = imgHeight / availableHeight;
+            const wRatio = imgWidth / availableWidth;
+
+            if (1 < hRatio || 1 < wRatio) {
+                const factor = hRatio < wRatio ? wRatio : hRatio;
+                setWidth(Math.round(imgWidth / factor));
+                setHeight(Math.round(imgHeight / factor));
+                setCentered(true);
+                return;
+            }
+        }
+
+        setWidth(imgWidth);
+        setHeight(imgHeight);
+        setCentered(imgWidth <= containerWidth && imgHeight <= containerHeight);
+    };
+
+    const handleFocalPointStart = () => {
+        sizePriorToFocalPoint.current = size;
+        setSize('medium');
+        setSettingFocalPoint(true);
+        setLastFocalPoint(asset.focalPoint || { x: 0.5, y: 0.5 });
+        updateDimensions();
+    };
+
+    const handleFocalPointChange = (point: Point) => {
+        setLastFocalPoint(point);
+    };
+
+    const handleFocalPointCancel = () => {
+        setSettingFocalPoint(false);
+        setLastFocalPoint(undefined);
+        setSize(sizePriorToFocalPoint.current);
+    };
+
+    const handleFocalPointSet = async () => {
+        if (!lastFocalPoint) return;
+
+        try {
+            // TODO: Implement API call to update focal point
+            await onAssetChange?.({
+                id: asset.id,
+                focalPoint: lastFocalPoint,
+            });
+            setSettingFocalPoint(false);
+            setSize(sizePriorToFocalPoint.current);
+            // Show success toast
+        } catch (err) {
+            // Show error toast
+        }
+    };
+
+    const handleRemoveFocalPoint = async () => {
+        try {
+            // TODO: Implement API call to remove focal point
+            await onAssetChange?.({
+                id: asset.id,
+                focalPoint: null,
+            });
+            // Show success toast
+        } catch (err) {
+            // Show error toast
+        }
+    };
+
+    return (
+        <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4 h-full">
+            <div className="space-y-4">
+                <Card>
+                    <CardContent className="space-y-4 pt-6">
+                        {!editable && onEditClick && (
+                            <Button
+                                variant="ghost"
+                                className="w-full justify-start"
+                                onClick={onEditClick}
+                            >
+                                <Edit className="mr-2 h-4 w-4" />
+                                Edit
+                                <ChevronRight className="ml-auto h-4 w-4" />
+                            </Button>
+                        )}
+
+                        {editable ? (
+                            <FormField
+                                control={form.control}
+                                name="name"
+                                render={({ field }) => (
+                                    <FormItem>
+                                        <FormLabel>Name</FormLabel>
+                                        <FormControl>
+                                            <Input {...field} />
+                                        </FormControl>
+                                    </FormItem>
+                                )}
+                            />
+                        ) : (
+                            <div>
+                                <Label>Name</Label>
+                                <p className="truncate text-sm text-muted-foreground">
+                                    {activeAsset.name}
+                                </p>
+                            </div>
+                        )}
+
+                        <div>
+                            <Label>Source File</Label>
+                            <a
+                                href={activeAsset.source}
+                                target="_blank"
+                                rel="noopener noreferrer"
+                                className="text-sm text-primary hover:underline flex items-center"
+                            >
+                                {activeAsset.source.split('/').pop()}
+                                <ExternalLink className="ml-1 h-3 w-3" />
+                            </a>
+                        </div>
+
+                        <div>
+                            <Label>File Size</Label>
+                            <p className="text-sm text-muted-foreground">
+                                {formatFileSize(activeAsset.fileSize)}
+                            </p>
+                        </div>
+
+                        <div>
+                            <Label>Dimensions</Label>
+                            <p className="text-sm text-muted-foreground">
+                                {activeAsset.width} x {activeAsset.height}
+                            </p>
+                        </div>
+
+                        <div>
+                            <Label>Focal Point</Label>
+                            <div className="space-y-2">
+                                <p className="text-sm text-muted-foreground">
+                                    {activeAsset.focalPoint ? (
+                                        <span className="flex items-center">
+                                            <Crosshair className="mr-1 h-4 w-4" />
+                                            x: {activeAsset.focalPoint.x.toFixed(2)}, y:{' '}
+                                            {activeAsset.focalPoint.y.toFixed(2)}
+                                        </span>
+                                    ) : (
+                                        'Not set'
+                                    )}
+                                </p>
+                                <div className="flex gap-2">
+                                    <Button
+                                        variant="secondary"
+                                        size="sm"
+                                        disabled={settingFocalPoint}
+                                        onClick={handleFocalPointStart}
+                                    >
+                                        {activeAsset.focalPoint ? 'Update' : 'Set'} Focal Point
+                                    </Button>
+                                    {activeAsset.focalPoint && (
+                                        <Button
+                                            variant="secondary"
+                                            size="sm"
+                                            disabled={settingFocalPoint}
+                                            onClick={handleRemoveFocalPoint}
+                                        >
+                                            Remove
+                                        </Button>
+                                    )}
+                                </div>
+                            </div>
+                        </div>
+                    </CardContent>
+                </Card>
+
+                <Card>
+                    <CardHeader>Preview Options</CardHeader>
+                    <CardContent className="space-y-4">
+                        <Select value={size} onValueChange={value => setSize(value as PreviewPreset)}>
+                            <SelectTrigger>
+                                <SelectValue placeholder="Select size" />
+                            </SelectTrigger>
+                            <SelectContent>
+                                <SelectItem value="tiny">Tiny</SelectItem>
+                                <SelectItem value="thumb">Thumb</SelectItem>
+                                <SelectItem value="small">Small</SelectItem>
+                                <SelectItem value="medium">Medium</SelectItem>
+                                <SelectItem value="large">Large</SelectItem>
+                                <SelectItem value="full">Full Size</SelectItem>
+                            </SelectContent>
+                        </Select>
+                        <p className="text-sm text-muted-foreground">
+                            {width} x {height}
+                        </p>
+                    </CardContent>
+                </Card>
+            </div>
+
+            <div className="relative flex items-center justify-center bg-muted/30 rounded-lg">
+                {assets && assets.length > 1 && (
+                    <>
+                        <Button
+                            variant="ghost"
+                            size="icon"
+                            className="absolute left-4 z-10"
+                            onClick={() => setAssetIndex(i => i - 1)}
+                            disabled={assetIndex === 0}
+                        >
+                            <ChevronLeft className="h-4 w-4" />
+                        </Button>
+                        <Button
+                            variant="ghost"
+                            size="icon"
+                            className="absolute right-4 z-10"
+                            onClick={() => setAssetIndex(i => i + 1)}
+                            disabled={assetIndex === assets.length - 1}
+                        >
+                            <ChevronRight className="h-4 w-4" />
+                        </Button>
+                    </>
+                )}
+
+                <div
+                    ref={containerRef}
+                    className={cn(
+                        'relative',
+                        centered && 'flex items-center justify-center',
+                        settingFocalPoint && 'cursor-crosshair'
+                    )}
+                >
+                    <div
+                        className="relative"
+                        style={{ width: `${width}px`, height: `${height}px` }}
+                    >
+                        <VendureImage
+                            ref={imageRef}
+                            asset={activeAsset}
+                            preset={size || undefined}
+                            mode="resize"
+                            onLoad={updateDimensions}
+                            className="max-w-full max-h-full object-contain"
+                        />
+                        {settingFocalPoint && lastFocalPoint && (
+                            <FocalPointControl
+                                width={width}
+                                height={height}
+                                point={lastFocalPoint}
+                                onChange={handleFocalPointChange}
+                            />
+                        )}
+                    </div>
+
+                    {settingFocalPoint && (
+                        <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
+                            <Button variant="secondary" onClick={handleFocalPointCancel}>
+                                <X className="mr-2 h-4 w-4" />
+                                Cancel
+                            </Button>
+                            <Button onClick={handleFocalPointSet}>
+                                <Crosshair className="mr-2 h-4 w-4" />
+                                Set Focal Point
+                            </Button>
+                        </div>
+                    )}
+                </div>
+            </div>
+        </div>
+    );
+}
+
+// Helper function to format file size
+function formatFileSize(bytes: number): string {
+    if (bytes === 0) return '0 Bytes';
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+}

+ 60 - 58
packages/dashboard/src/components/entity-assets.tsx

@@ -1,4 +1,5 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+import { EllipsisIcon } from 'lucide-react';
 import {
     DndContext,
     closestCenter,
@@ -26,13 +27,10 @@ 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';
+import { AssetPreviewDialog } from './asset-preview-dialog.js';
+import { AssetFragment } from '@/graphql/fragments.js';
 
-interface Asset {
-    id: string;
-    name?: string | null;
-    preview: string;
-    focalPoint?: { x: number; y: number } | null;
-}
+type Asset = AssetFragment;
 
 export interface EntityAssetValue {
     assetIds?: string[] | null;
@@ -76,45 +74,62 @@ function SortableAsset({
     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 ref={setNodeRef} style={style} className="relative group" {...attributes}>
+            {/* Draggable Image Area */}
+            <div
+                {...listeners}
+                className={`
+                    flex items-center justify-center 
+                    ${compact ? 'w-12 h-12' : 'w-16 h-16'} 
+                    border rounded-md overflow-hidden cursor-grab
+                    ${isFeatured ? 'border-primary ring-1 ring-primary/30' : 'border-border'}
+                    ${updatePermissions ? 'hover:border-muted-foreground' : ''}
+                    ${isDragging ? 'opacity-50 cursor-grabbing' : ''}
+                `}
+            >
+                <VendureImage 
+                    asset={asset} 
+                    mode="crop" 
+                    preset="tiny" 
+                />
+            </div>
+
+            {/* Menu Trigger */}
+            {updatePermissions && (
+                <div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
+                    <DropdownMenu>
+                        <DropdownMenuTrigger asChild>
+                            <Button 
+                                variant="secondary" 
+                                size="icon" 
+                                className="h-6 w-6 rounded-full shadow-md"
+                            >
+                                <EllipsisIcon className="h-4 w-4" />
+                            </Button>
+                        </DropdownMenuTrigger>
+                        <DropdownMenuContent align="end">
+                            <DropdownMenuItem onClick={() => onPreview(asset)}>
+                                Preview
+                            </DropdownMenuItem>
+                            <DropdownMenuItem
+                                disabled={isFeatured}
+                                onClick={() => onSetAsFeatured(asset)}
+                            >
+                                Set as featured asset
+                            </DropdownMenuItem>
+                            <DropdownMenuItem
+                                className="text-destructive"
+                                onClick={() => onRemove(asset)}
+                            >
+                                Remove asset
+                            </DropdownMenuItem>
+                        </DropdownMenuContent>
+                    </DropdownMenu>
+                </div>
+            )}
         </div>
     );
 }
@@ -327,7 +342,8 @@ export function EntityAssets({
                 <AssetPreviewDialog
                     asset={previewAsset}
                     assets={assets}
-                    onClose={() => setPreviewAsset(null)}
+                    onOpenChange={() => setPreviewAsset(null)}
+                    onAssetChange={asset => setPreviewAsset(asset)}
                     open={!!previewAsset}
                 />
             )}
@@ -335,17 +351,3 @@ export function EntityAssets({
     );
 }
 
-// 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
-}

+ 65 - 0
packages/dashboard/src/components/focal-point-control.tsx

@@ -0,0 +1,65 @@
+import { useEffect, useState } from 'react';
+import { cn } from '@/lib/utils.js';
+
+interface Point {
+    x: number;
+    y: number;
+}
+
+interface FocalPointControlProps {
+    width: number;
+    height: number;
+    point: Point;
+    onChange: (point: Point) => void;
+}
+
+export function FocalPointControl({
+    width,
+    height,
+    point,
+    onChange,
+}: FocalPointControlProps) {
+    const [dragging, setDragging] = useState(false);
+
+    useEffect(() => {
+        if (!dragging) return;
+
+        const handleMouseMove = (e: MouseEvent) => {
+            const rect = (e.target as HTMLDivElement)?.getBoundingClientRect();
+            const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+            const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
+            onChange({ x, y });
+        };
+
+        const handleMouseUp = () => {
+            setDragging(false);
+        };
+
+        document.addEventListener('mousemove', handleMouseMove);
+        document.addEventListener('mouseup', handleMouseUp);
+
+        return () => {
+            document.removeEventListener('mousemove', handleMouseMove);
+            document.removeEventListener('mouseup', handleMouseUp);
+        };
+    }, [dragging, onChange]);
+
+    return (
+        <div
+            className="absolute inset-0 cursor-crosshair"
+            onMouseDown={() => setDragging(true)}
+        >
+            <div
+                className={cn(
+                    'absolute w-6 h-6 border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2',
+                    'shadow-[0_0_0_1px_rgba(0,0,0,0.3)]',
+                    dragging && 'scale-75'
+                )}
+                style={{
+                    left: `${point.x * width}px`,
+                    top: `${point.y * height}px`,
+                }}
+            />
+        </div>
+    );
+}

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

@@ -21,6 +21,7 @@ export interface VendureImageProps extends React.ImgHTMLAttributes<HTMLImageElem
   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
+  ref: React.Ref<HTMLImageElement>;
 }
 
 export function VendureImage({
@@ -36,6 +37,7 @@ export function VendureImage({
   alt,
   className,
   style,
+  ref,
   ...imgProps
 }: VendureImageProps) {
   if (!asset) {
@@ -78,6 +80,7 @@ export function VendureImage({
       className={className}
       style={style}
       loading="lazy"
+      ref={ref}
       {...imgProps}
     />
   );

+ 1 - 1
packages/dashboard/src/framework/internal/page/list-page.tsx

@@ -234,7 +234,7 @@ export function ListPage<
  */
 function getColumnVisibility(
     fields: FieldInfo[],
-    defaultVisibility?: Record<string, boolean>,
+    defaultVisibility?: Record<string, boolean | undefined>,
 ): Record<string, boolean> {
     const allDefaultsTrue = defaultVisibility && Object.values(defaultVisibility).every(v => v === true);
     const allDefaultsFalse = defaultVisibility && Object.values(defaultVisibility).every(v => v === false);

+ 23 - 0
packages/dashboard/src/graphql/fragments.tsx

@@ -0,0 +1,23 @@
+import { graphql, ResultOf } from "./graphql.js";
+
+export const assetFragment = graphql(`
+    fragment Asset on Asset {
+        id
+        createdAt
+        updatedAt
+        name
+        fileSize
+        mimeType
+        type
+        preview
+        source
+        width
+        height
+        focalPoint {
+            x
+            y
+        }
+    }
+`);
+
+export type AssetFragment = ResultOf<typeof assetFragment>;

+ 4 - 15
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -18,6 +18,7 @@ import { useGeneratedForm } from '@/framework/internal/form-engine/use-generated
 import { TranslatableFormField } from '@/framework/internal/form/field.js';
 import { DetailPage, getDetailQueryOptions } from '@/framework/internal/page/detail-page.js';
 import { api } from '@/graphql/api.js';
+import { assetFragment } from '@/graphql/fragments.js';
 import { graphql } from '@/graphql/graphql.js';
 import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
 import { createFileRoute } from '@tanstack/react-router';
@@ -43,22 +44,10 @@ const productDetailFragment = graphql(`
         slug
         description
         featuredAsset {
-            id
-            preview
-            name
-            focalPoint {
-                x
-                y
-            }
+            ...Asset
         }
         assets {
-            id
-            preview
-            name
-            focalPoint {
-                x
-                y
-            }
+            ...Asset
         }
         translations {
             id
@@ -69,7 +58,7 @@ const productDetailFragment = graphql(`
             description
         }
     }
-`);
+`, [assetFragment]);
 
 const productDetailDocument = graphql(
     `

+ 5 - 3
packages/dashboard/vite/vite-plugin-ui-config.ts

@@ -1,5 +1,5 @@
 import { AdminUiPlugin, AdminUiPluginOptions } from '@vendure/admin-ui-plugin';
-import { AdminUiConfig, VendureConfig } from '@vendure/core';
+import { AdminUiConfig, Type, VendureConfig } from '@vendure/core';
 import { getPluginDashboardExtensions } from '@vendure/core';
 import path from 'path';
 import { Plugin } from 'vite';
@@ -34,13 +34,15 @@ export function uiConfigPlugin(): Plugin {
                     vendureConfig = await configLoaderApi.getVendureConfig();
                 }
 
-                const adminUiPlugin = vendureConfig.plugins?.find(plugin => plugin.name === 'AdminUiPlugin');
+                const adminUiPlugin = vendureConfig.plugins?.find(
+                    plugin => (plugin as Type<any>).name === 'AdminUiPlugin',
+                );
 
                 if (!adminUiPlugin) {
                     throw new Error('AdminUiPlugin not found');
                 }
 
-                const adminUiOptions = adminUiPlugin.options as AdminUiPluginOptions;
+                const adminUiOptions = (adminUiPlugin as any).options as AdminUiPluginOptions;
 
                 return `
                     export const uiConfig = ${JSON.stringify(adminUiOptions.adminUiConfig)}