Преглед на файлове

feat(dashboard): Implement asset detail view

Michael Bromley преди 9 месеца
родител
ревизия
0bbcdb4f8c

+ 26 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts

@@ -0,0 +1,26 @@
+import { assetFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+
+export const assetDetailDocument = graphql(
+    `
+        query AssetDetail($id: ID!) {
+            asset(id: $id) {
+                ...Asset
+                tags {
+                    id
+                    value
+                }
+                customFields
+            }
+        }
+    `,
+    [assetFragment],
+);
+
+export const assetUpdateDocument = graphql(`
+    mutation AssetUpdate($input: UpdateAssetInput!) {
+        updateAsset(input: $input) {
+            id
+        }
+    }
+`);

+ 2 - 2
packages/dashboard/src/app/routes/_authenticated/_assets/assets.tsx

@@ -1,4 +1,4 @@
-import { AssetGallery } from '@/components/shared/asset-gallery.js';
+import { AssetGallery } from '@/components/shared/asset/asset-gallery.js';
 import { Page, PageTitle, PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute } from '@tanstack/react-router';
@@ -13,7 +13,7 @@ function RouteComponent() {
             <PageTitle>
                 <Trans>Assets</Trans>
             </PageTitle>
-            <AssetGallery selectable={false} />
+            <AssetGallery selectable={true} multiSelect='manual' />
         </Page>
     );
 }

+ 149 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/assets_.$id.tsx

@@ -0,0 +1,149 @@
+import { AssetPreview } from '@/components/shared/asset/asset-preview.js'
+import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader.js';
+import { createFileRoute } from '@tanstack/react-router'
+import { assetDetailDocument, assetUpdateDocument } from './assets.graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { toast } from 'sonner';
+import { Page, PageTitle, PageActionBar, PageActionBarRight, PageBlock, PageLayout } from '@/framework/layout-engine/page-layout.js'
+import { useDetailPage } from '@/framework/page/use-detail-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { useState, useRef } from 'react';
+import { PreviewPreset } from '@/components/shared/asset/asset-preview.js';
+import { AssetPreviewSelector } from '@/components/shared/asset/asset-preview-selector.js';
+import { AssetProperties } from '@/components/shared/asset/asset-properties.js';
+import { AssetFocalPointEditor } from '@/components/shared/asset/asset-focal-point-editor.js';
+import { FocusIcon } from 'lucide-react';
+import { Point } from '@/components/shared/asset/focal-point-control.js';
+import { Label } from '@/components/ui/label.js';
+export const Route = createFileRoute('/_authenticated/_assets/assets_/$id')({
+    component: AssetDetailPage,
+    loader: detailPageRouteLoader({
+        queryDocument: assetDetailDocument,
+        breadcrumb(isNew, entity) {
+            return [
+                { path: '/assets', label: 'Assets' },
+                isNew ? <Trans>New asset</Trans> : entity?.name ?? '',
+            ];
+        },
+    }),
+    errorComponent: ({ error }) => <ErrorPage message={error.message} />,
+});
+
+function AssetDetailPage() {
+    const params = Route.useParams();
+    const { i18n } = useLingui();
+
+    const imageRef = useRef<HTMLImageElement>(null);
+    const [size, setSize] = useState<PreviewPreset>('medium');
+    const [width, setWidth] = useState(0);
+    const [height, setHeight] = useState(0);
+    const [focalPoint, setFocalPoint] = useState<Point | undefined>(undefined);
+    const [settingFocalPoint, setSettingFocalPoint] = useState(false);
+    const { form, submitHandler, entity, isPending } = useDetailPage({
+        queryDocument: assetDetailDocument,
+        updateDocument: assetUpdateDocument,
+        setValuesForUpdate: entity => {
+            return {
+                id: entity.id,
+                focalPoint: entity.focalPoint,
+                name: entity.name,
+                tags: entity.tags?.map(tag => tag.value) ?? [],
+                // customFields: entity.customFields,
+            };
+        },
+        params: { id: params.id },
+        onSuccess: async () => {
+            toast(i18n.t('Successfully updated asset'));
+            form.reset(form.getValues());
+        },
+        onError: err => {
+            toast(i18n.t('Failed to update asset'), {
+                description: err instanceof Error ? err.message : 'Unknown error',
+            });
+        },
+    });
+
+    const updateDimensions = () => {
+        if (!imageRef.current) return;
+        const img = imageRef.current;
+        const imgWidth = img.naturalWidth;
+        const imgHeight = img.naturalHeight;
+        setWidth(imgWidth);
+        setHeight(imgHeight);
+    };
+
+    if (!entity) {
+        return null;
+    }
+    return (
+        <Page pageId="asset-detail" form={form} submitHandler={submitHandler}>
+            <PageTitle>
+                <Trans>Edit asset</Trans>
+            </PageTitle>
+            <PageActionBar>
+                <PageActionBarRight>
+                    <PermissionGuard requires={['UpdateChannel']}>
+                        <Button
+                            type="submit"
+                            disabled={!form.formState.isDirty || isPending}
+                        >
+                            <Trans>Update</Trans>
+                        </Button>
+                    </PermissionGuard>
+                </PageActionBarRight>
+            </PageActionBar>
+            <PageLayout>
+                <PageBlock column="main" blockId="asset-preview">
+                    <div className="relative flex items-center justify-center bg-muted/30 rounded-lg min-h-[300px] overflow-auto">
+                        <AssetFocalPointEditor
+                            width={width}
+                            height={height}
+                            settingFocalPoint={settingFocalPoint}
+                            focalPoint={form.getValues().focalPoint ?? { x: 0.5, y: 0.5 }}
+                            onFocalPointChange={(point) => {
+                                form.setValue('focalPoint.x', point.x, { shouldDirty: true });
+                                form.setValue('focalPoint.y', point.y, { shouldDirty: true });
+                                setSettingFocalPoint(false);
+                            }}
+                            onCancel={() => {
+                                setSettingFocalPoint(false);
+                            }}
+                        >
+                            <VendureImage
+                                ref={imageRef}
+                                asset={entity}
+                                preset={size || undefined}
+                                mode="resize"
+                                useFocalPoint={true}
+                                onLoad={updateDimensions}
+                                className="max-w-full max-h-full object-contain"
+                            />
+                        </AssetFocalPointEditor>
+                    </div>
+                </PageBlock>
+                <PageBlock column="side" blockId="asset-properties">
+                    <AssetProperties asset={entity} />
+                </PageBlock>
+                <PageBlock column="side" blockId="asset-size">
+                    <div className="flex flex-col gap-2">
+                        <AssetPreviewSelector size={size} setSize={setSize} width={width} height={height} />
+                        <div className="flex items-center gap-2">
+                            <Button type='button' variant="outline" size="icon" onClick={() => setSettingFocalPoint(true)}>
+                                <FocusIcon className="h-4 w-4" />
+                            </Button>
+                            <div className="text-sm text-muted-foreground">
+                                <Label><Trans>Focal Point</Trans></Label>
+                                <div className="text-sm text-muted-foreground">
+                                    {form.getValues().focalPoint?.x && form.getValues().focalPoint?.y ? `${form.getValues().focalPoint?.x.toFixed(2)}, ${form.getValues().focalPoint?.y.toFixed(2)}` : <Trans>Not set</Trans>}
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </PageBlock>
+            </PageLayout>
+        </Page>
+    )
+}

+ 0 - 345
packages/dashboard/src/lib/components/shared/asset-preview.tsx

@@ -1,345 +0,0 @@
-import { Button } from '@/components/ui/button.js';
-import { Card, CardContent, CardHeader } from '@/components/ui/card.js';
-import { FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form.js';
-import { Input } from '@/components/ui/input.js';
-import { Label } from '@/components/ui/label.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
-import { VendureImage } from '@/components/shared/vendure-image.js';
-import { AssetFragment } from '@/graphql/fragments.js';
-import { cn, formatFileSize } from '@/lib/utils.js';
-import { ChevronLeft, ChevronRight, Crosshair, Edit, ExternalLink, X } from 'lucide-react';
-import { useEffect, useRef, useState } from 'react';
-import { useForm } from 'react-hook-form';
-import { FocalPointControl } from './focal-point-control.js';
-
-export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
-
-interface Point {
-    x: number;
-    y: number;
-}
-
-export type AssetWithTags = AssetFragment & { tags?: { value: string }[] };
-
-interface AssetPreviewProps {
-    asset: AssetWithTags;
-    assets?: AssetWithTags[];
-    editable?: boolean;
-    customFields?: any[];
-    onAssetChange?: (asset: Partial<AssetWithTags>) => 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>
-    );
-}

+ 93 - 0
packages/dashboard/src/lib/components/shared/asset/asset-focal-point-editor.tsx

@@ -0,0 +1,93 @@
+import { Button } from "@/components/ui/button.js";
+import { cn } from "@/lib/utils.js";
+import { Crosshair, X } from "lucide-react";
+import { useRef, useState } from "react";
+import { DndContext, useDraggable } from "@dnd-kit/core";
+import { restrictToParentElement } from "@dnd-kit/modifiers";
+import { CSS } from "@dnd-kit/utilities";
+import { Trans } from "@/lib/trans.js";
+
+export interface AssetFocalPointEditorProps {
+    settingFocalPoint: boolean;
+    focalPoint: Point | undefined;
+    width: number;
+    height: number;
+    onFocalPointChange: (point: Point) => void;
+    onCancel: () => void;
+    children?: React.ReactNode;
+}
+
+interface Point {
+    x: number;
+    y: number;
+}
+
+function DraggableFocalPoint({ point }: { point: Point }) {
+    const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
+        id: "focal-point",
+    });
+    const style = {
+        left: `${point.x * 100}%`,
+        top: `${point.y * 100}%`,
+        transform: isDragging ? `translate(-50%, -50%) ${CSS.Translate.toString(transform)}` : `translate(-50%, -50%)`,
+    };
+
+    return (
+        <div
+            ref={setNodeRef}
+            style={style}
+            {...listeners}
+            {...attributes}
+            className="absolute w-8 h-8 rounded-full border-4 border-white bg-brand/20 shadow-lg cursor-move"
+        />
+    );
+}
+
+export function AssetFocalPointEditor({ settingFocalPoint, focalPoint, width, height, onFocalPointChange, onCancel, children }: AssetFocalPointEditorProps) {
+
+    const [focalPointCurrent, setFocalPointCurrent] = useState<Point>(focalPoint ?? { x: 0.5, y: 0.5 });
+
+    const handleDragEnd = (event: any) => {
+        const { delta } = event;
+        const newX = Math.max(0, Math.min(1, focalPointCurrent.x + delta.x / width));
+        const newY = Math.max(0, Math.min(1, focalPointCurrent.y + delta.y / height));
+        const newPoint = { x: newX, y: newY };
+        setFocalPointCurrent(newPoint);
+    };
+
+    return (
+        <div
+            className={cn(
+                'relative',
+                'flex items-center justify-center',
+                settingFocalPoint && 'cursor-crosshair',
+            )}
+        >
+            <div className="relative" style={{ width: `${width}px`, height: `${height}px` }}>
+                {children}
+                {settingFocalPoint && (
+                    <DndContext onDragEnd={handleDragEnd} modifiers={[restrictToParentElement]}>
+                        <DraggableFocalPoint
+                            point={focalPointCurrent}
+                        />
+                    </DndContext>
+                )}
+            </div>
+
+            {settingFocalPoint && (
+                <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
+                    <Button type="button" variant="secondary" onClick={onCancel}>
+                        <X className="mr-2 h-4 w-4" />
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button type="button" onClick={() => {
+                        onFocalPointChange(focalPointCurrent);
+                    }}>
+                        <Crosshair className="mr-2 h-4 w-4" />
+                        <Trans>Set Focal Point</Trans>
+                    </Button>
+                </div>
+            )}
+        </div>
+    );
+}

+ 51 - 20
packages/dashboard/src/lib/components/shared/asset-gallery.tsx → packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx

@@ -23,6 +23,7 @@ import { Loader2, Search, Upload, X } from 'lucide-react';
 import { useCallback, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
 import { useDebounce } from '@uidotdev/usehooks';
+import { DetailPageButton } from '../detail-page-button.js';
 
 const getAssetListDocument = graphql(
     `
@@ -72,7 +73,14 @@ export type Asset = AssetFragment;
 export interface AssetGalleryProps {
     onSelect?: (assets: Asset[]) => void;
     selectable?: boolean;
-    multiSelect?: boolean;
+    /**
+     * @description
+     * Defines whether multiple assets can be selected.
+     * 
+     * If set to 'auto', the asset selection will be toggled when the user clicks on an asset.
+     * If set to 'manual', multiple selection will occur only if the user holds down the control/cmd key.
+     */
+    multiSelect?: 'auto' | 'manual';
     initialSelectedAssets?: Asset[];
     pageSize?: number;
     fixedHeight?: boolean;
@@ -84,7 +92,7 @@ export interface AssetGalleryProps {
 export function AssetGallery({
     onSelect,
     selectable = true,
-    multiSelect = false,
+    multiSelect = undefined,
     initialSelectedAssets = [],
     pageSize = 24,
     fixedHeight = false,
@@ -149,24 +157,44 @@ export function AssetGallery({
     const totalPages = Math.ceil(totalItems / pageSize);
 
     // Handle selection
-    const handleSelect = (asset: Asset) => {
-        if (!multiSelect) {
-            setSelected([asset]);
-            onSelect?.([asset]);
+    const handleSelect = (asset: Asset, event: React.MouseEvent) => {
+        if (multiSelect === 'auto') {
+            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);
             return;
         }
 
-        const isSelected = selected.some(a => a.id === asset.id);
-        let newSelected: Asset[];
 
-        if (isSelected) {
-            newSelected = selected.filter(a => a.id !== asset.id);
+        // Manual mode - check for modifier key
+        const isModifierKeyPressed = event.metaKey || event.ctrlKey;
+
+        if (multiSelect === 'manual' && isModifierKeyPressed) {
+            // Toggle selection
+            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);
         } else {
-            newSelected = [...selected, asset];
+            // No modifier key - single select
+            setSelected([asset]);
+            onSelect?.([asset]);
         }
-
-        setSelected(newSelected);
-        onSelect?.(newSelected);
     };
 
     // Check if an asset is selected
@@ -272,7 +300,7 @@ export function AssetGallery({
                                     ${isSelected(asset as Asset) ? 'ring-2 ring-primary' : ''}
                                     flex flex-col min-w-[120px]
                                 `}
-                                onClick={() => handleSelect(asset as Asset)}
+                                onClick={(e) => handleSelect(asset as Asset, e)}
                             >
                                 <div
                                     className="relative w-full bg-muted/30"
@@ -296,11 +324,14 @@ export function AssetGallery({
                                     <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>
-                                    )}
+                                    <div className='flex justify-between items-center'>
+                                        {asset.fileSize && (
+                                            <p className="text-xs text-muted-foreground mt-1">
+                                                {formatFileSize(asset.fileSize)}
+                                            </p>
+                                        )}
+                                        <DetailPageButton id={asset.id} label={<Trans>Edit</Trans>} />
+                                    </div>
                                 </CardContent>
                             </Card>
                         ))

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

@@ -47,7 +47,7 @@ export function AssetPickerDialog({
         <div className="flex-grow py-4">
           <AssetGallery 
             onSelect={handleAssetSelect}
-            multiSelect={multiSelect}
+            multiSelect='manual'
             initialSelectedAssets={initialSelectedAssets}
             fixedHeight={true}
           />

+ 1 - 7
packages/dashboard/src/lib/components/shared/asset-preview-dialog.tsx → packages/dashboard/src/lib/components/shared/asset/asset-preview-dialog.tsx

@@ -12,9 +12,7 @@ interface AssetPreviewDialogProps {
     onOpenChange: (open: boolean) => void;
     asset: AssetWithTags;
     assets?: AssetWithTags[];
-    editable?: boolean;
     customFields?: any[];
-    onAssetChange?: (asset: Partial<AssetWithTags>) => void;
 }
 
 export function AssetPreviewDialog({
@@ -22,24 +20,20 @@ export function AssetPreviewDialog({
     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>
+                    <DialogDescription>Preview of {asset.name}</DialogDescription>
                 </DialogHeader>
                 <div className="h-full p-6">
                     <AssetPreview
                         asset={asset}
                         assets={assets}
-                        editable={editable}
                         customFields={customFields}
-                        onAssetChange={onAssetChange}
                     />
                 </div>
             </DialogContent>

+ 34 - 0
packages/dashboard/src/lib/components/shared/asset/asset-preview-selector.tsx

@@ -0,0 +1,34 @@
+
+
+import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select.js";
+import { PreviewPreset } from "./asset-preview.js";
+
+export interface AssetPreviewSelectorProps {
+    size: PreviewPreset;
+    setSize: (size: PreviewPreset) => void;
+    width: number;
+    height: number;
+}
+
+export function AssetPreviewSelector({ size, setSize, width, height }: AssetPreviewSelectorProps) {
+    return (
+        <div className="flex items-center gap-2">
+            <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>
+        </div>
+    );
+}

+ 128 - 0
packages/dashboard/src/lib/components/shared/asset/asset-preview.tsx

@@ -0,0 +1,128 @@
+import { VendureImage } from '@/components/shared/vendure-image.js';
+import { Button } from '@/components/ui/button.js';
+import { Card, CardContent } from '@/components/ui/card.js';
+import { AssetFragment } from '@/graphql/fragments.js';
+import { cn } from '@/lib/utils.js';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { AssetPreviewSelector } from './asset-preview-selector.js';
+import { AssetProperties } from './asset-properties.js';
+
+export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
+
+
+export type AssetWithTags = AssetFragment & { tags?: { value: string }[] };
+
+interface AssetPreviewProps {
+    asset: AssetWithTags;
+    assets?: AssetWithTags[];
+    customFields?: any[];
+}
+
+export function AssetPreview({
+    asset,
+    assets,
+    customFields = [],
+}: AssetPreviewProps) {
+    const [size, setSize] = useState<PreviewPreset>('medium');
+    const [width, setWidth] = useState(0);
+    const [height, setHeight] = useState(0);
+    const [centered, setCentered] = useState(true);
+    const [assetIndex, setAssetIndex] = useState(assets?.indexOf(asset) || 0);
+
+    const imageRef = useRef<HTMLImageElement>(null);
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    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;
+        setWidth(imgWidth);
+        setHeight(imgHeight);
+        setCentered(imgWidth <= containerWidth && imgHeight <= containerHeight);
+    };
+
+    return (
+        <div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4 h-full">
+            <div className="space-y-4">
+                <Card>
+                    <CardContent className="pt-6 space-y-4">
+                        <AssetProperties asset={activeAsset} />
+                        <AssetPreviewSelector size={size} setSize={setSize} width={width} height={height} />
+                    </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',
+                    )}
+                >
+                    <VendureImage
+                        ref={imageRef}
+                        asset={activeAsset}
+                        preset={size || undefined}
+                        mode="resize"
+                        onLoad={updateDimensions}
+                        className="max-w-full max-h-full object-contain"
+                    />
+                </div>
+            </div>
+        </div>
+    );
+}

+ 46 - 0
packages/dashboard/src/lib/components/shared/asset/asset-properties.tsx

@@ -0,0 +1,46 @@
+import { formatFileSize } from "@/lib/utils.js";
+
+import { Label } from "@/components/ui/label.js";
+import { AssetFragment } from "@/graphql/fragments.js";
+import { ExternalLink } from "lucide-react";
+
+export interface AssetPropertiesProps {
+    asset: AssetFragment;
+}
+
+export function AssetProperties({ asset }: AssetPropertiesProps) {
+    return (
+        <div className="space-y-4">
+            <div>
+                <Label>Name</Label>
+                <p className="truncate text-sm text-muted-foreground">{asset.name}</p>
+            </div>
+            <div>
+                <Label>Source File</Label>
+                <a
+                    href={asset.source}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="text-sm text-primary hover:underline"
+                >
+                    {asset.source.split('/').pop()}
+                    <ExternalLink className="ml-1 h-3 w-3 inline" />
+                </a>
+            </div>
+
+            <div>
+                <Label>File Size</Label>
+                <p className="text-sm text-muted-foreground">
+                    {formatFileSize(asset.fileSize)}
+                </p>
+            </div>
+
+            <div>
+                <Label>Dimensions</Label>
+                <p className="text-sm text-muted-foreground">
+                    {asset.width} x {asset.height}
+                </p>
+            </div>
+        </div>
+    );
+}

+ 1 - 1
packages/dashboard/src/lib/components/shared/focal-point-control.tsx → packages/dashboard/src/lib/components/shared/asset/focal-point-control.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useState } from 'react';
 import { cn } from '@/lib/utils.js';
 
-interface Point {
+export interface Point {
     x: number;
     y: number;
 }

+ 3 - 3
packages/dashboard/src/lib/components/shared/entity-assets.tsx

@@ -24,9 +24,9 @@ import {
 } from '@dnd-kit/sortable';
 import { CSS } from '@dnd-kit/utilities';
 import { EllipsisIcon, ImageIcon, PaperclipIcon } from 'lucide-react';
-import React, { useCallback, useEffect, useState } from 'react';
-import { AssetPickerDialog } from './asset-picker-dialog.js';
-import { AssetPreviewDialog } from './asset-preview-dialog.js';
+import { useCallback, useEffect, useState } from 'react';
+import { AssetPickerDialog } from './asset/asset-picker-dialog.js';
+import { AssetPreviewDialog } from './asset/asset-preview-dialog.js';
 import { VendureImage } from './vendure-image.js';
 
 type Asset = AssetFragment;

+ 1 - 1
packages/dashboard/src/lib/components/shared/vendure-image.tsx

@@ -70,7 +70,7 @@ export function VendureImage({
     }
 
     // Apply focal point if available and requested
-    if (useFocalPoint && asset.focalPoint && mode === 'crop') {
+    if (useFocalPoint && asset.focalPoint) {
         url.searchParams.set('fpx', asset.focalPoint.x.toString());
         url.searchParams.set('fpy', asset.focalPoint.y.toString());
     }

+ 5 - 5
packages/dashboard/src/lib/index.ts

@@ -28,10 +28,10 @@ export * from './components/layout/nav-user.js';
 export * from './components/login/login-form.js';
 export * from './components/shared/alerts.js';
 export * from './components/shared/animated-number.js';
-export * from './components/shared/asset-gallery.js';
-export * from './components/shared/asset-picker-dialog.js';
-export * from './components/shared/asset-preview-dialog.js';
-export * from './components/shared/asset-preview.js';
+export * from './components/shared/asset/asset-gallery.js';
+export * from './components/shared/asset/asset-picker-dialog.js';
+export * from './components/shared/asset/asset-preview-dialog.js';
+export * from './components/shared/asset/asset-preview.js';
 export * from './components/shared/assigned-facet-values.js';
 export * from './components/shared/channel-code-label.js';
 export * from './components/shared/channel-selector.js';
@@ -50,7 +50,7 @@ export * from './components/shared/entity-assets.js';
 export * from './components/shared/error-page.js';
 export * from './components/shared/facet-value-chip.js';
 export * from './components/shared/facet-value-selector.js';
-export * from './components/shared/focal-point-control.js';
+export * from './components/shared/asset/focal-point-control.js';
 export * from './components/shared/form-field-wrapper.js';
 export * from './components/shared/history-timeline/history-entry.js';
 export * from './components/shared/history-timeline/history-note-checkbox.js';