Przeglądaj źródła

feat(dashboard): Add support for Asset tags (#3822)

Michael Bromley 3 miesięcy temu
rodzic
commit
5c6967fb27

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

@@ -35,3 +35,42 @@ export const deleteAssetsDocument = graphql(`
         }
     }
 `);
+
+export const tagListDocument = graphql(`
+    query TagList($options: TagListOptions) {
+        tags(options: $options) {
+            items {
+                id
+                value
+            }
+            totalItems
+        }
+    }
+`);
+
+export const createTagDocument = graphql(`
+    mutation CreateTag($input: CreateTagInput!) {
+        createTag(input: $input) {
+            id
+            value
+        }
+    }
+`);
+
+export const updateTagDocument = graphql(`
+    mutation UpdateTag($input: UpdateTagInput!) {
+        updateTag(input: $input) {
+            id
+            value
+        }
+    }
+`);
+
+export const deleteTagDocument = graphql(`
+    mutation DeleteTag($id: ID!) {
+        deleteTag(id: $id) {
+            result
+            message
+        }
+    }
+`);

+ 15 - 1
packages/dashboard/src/app/routes/_authenticated/_assets/assets_.$id.tsx

@@ -24,6 +24,7 @@ import { FocusIcon } from 'lucide-react';
 import { useRef, useState } from 'react';
 import { toast } from 'sonner';
 import { assetDetailDocument, assetUpdateDocument } from './assets.graphql.js';
+import { AssetTagsEditor } from './components/asset-tags-editor.js';
 
 const pageId = 'asset-detail';
 
@@ -51,7 +52,7 @@ function AssetDetailPage() {
     const [width, setWidth] = useState(0);
     const [height, setHeight] = useState(0);
     const [settingFocalPoint, setSettingFocalPoint] = useState(false);
-    const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
+    const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
         pageId,
         queryDocument: assetDetailDocument,
         updateDocument: assetUpdateDocument,
@@ -161,6 +162,19 @@ function AssetDetailPage() {
                         </div>
                     </div>
                 </PageBlock>
+                <PageBlock column="side" blockId="asset-tags">
+                    <AssetTagsEditor
+                        selectedTags={form.watch('tags') || []}
+                        onTagsChange={tags => {
+                            form.setValue('tags', tags, { shouldDirty: true });
+                        }}
+                        onTagsUpdated={() => {
+                            // Refresh the asset entity to get updated tag values
+                            refreshEntity();
+                        }}
+                        disabled={isPending}
+                    />
+                </PageBlock>
             </PageLayout>
         </Page>
     );

+ 206 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/components/asset-tag-filter.tsx

@@ -0,0 +1,206 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+    CommandList,
+} from '@/vdb/components/ui/command.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { cn } from '@/vdb/lib/utils.js';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import { Check, Filter, Loader2, X } from 'lucide-react';
+import { useState } from 'react';
+import { tagListDocument } from '../assets.graphql.js';
+
+interface AssetTagFilterProps {
+    selectedTags: string[];
+    onTagsChange: (tags: string[]) => void;
+}
+
+export function AssetTagFilter({ selectedTags, onTagsChange }: Readonly<AssetTagFilterProps>) {
+    const [open, setOpen] = useState(false);
+    const [searchValue, setSearchValue] = useState('');
+
+    const debouncedSearch = useDebounce(searchValue, 300);
+    const pageSize = 25;
+
+    // Fetch available tags with infinite query
+    const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
+        queryKey: ['tags', debouncedSearch],
+        queryFn: async ({ pageParam = 0 }) => {
+            const options: any = {
+                skip: pageParam * pageSize,
+                take: pageSize,
+                sort: { value: 'ASC' },
+            };
+
+            if (debouncedSearch.trim()) {
+                options.filter = {
+                    value: { contains: debouncedSearch.trim() },
+                };
+            }
+
+            const response = await api.query(tagListDocument, { options });
+            return response.tags;
+        },
+        getNextPageParam: (lastPage, allPages) => {
+            if (!lastPage) return undefined;
+            const totalFetched = allPages.length * pageSize;
+            return totalFetched < lastPage.totalItems ? allPages.length : undefined;
+        },
+        initialPageParam: 0,
+        staleTime: 1000 * 60 * 5,
+    });
+
+    const availableTags = data?.pages.flatMap(page => page?.items ?? []) ?? [];
+    const totalTags = data?.pages[0]?.totalItems ?? 0;
+
+    // Tags are already filtered server-side, so use them directly
+    const filteredTags = availableTags;
+
+    const handleSelectTag = (tagValue: string) => {
+        if (!selectedTags.includes(tagValue)) {
+            onTagsChange([...selectedTags, tagValue]);
+        }
+        setSearchValue('');
+    };
+
+    const handleRemoveTag = (tagToRemove: string) => {
+        onTagsChange(selectedTags.filter(tag => tag !== tagToRemove));
+    };
+
+    const handleClearAll = () => {
+        onTagsChange([]);
+        setOpen(false);
+    };
+
+    const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
+        const target = e.currentTarget;
+        const scrolledToBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 1;
+
+        if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
+            fetchNextPage();
+        }
+    };
+
+    return (
+        <div className="flex items-center gap-2">
+            <Popover open={open} onOpenChange={setOpen}>
+                <PopoverTrigger asChild>
+                    <Button
+                        variant="outline"
+                        size="sm"
+                        role="combobox"
+                        aria-expanded={open}
+                        className="justify-start"
+                    >
+                        <Filter className="h-4 w-4 mr-2" />
+                        <Trans>Filter by tags</Trans>
+                        {selectedTags.length > 0 && (
+                            <Badge variant="secondary" className="ml-2">
+                                {selectedTags.length}
+                            </Badge>
+                        )}
+                    </Button>
+                </PopoverTrigger>
+                <PopoverContent className="w-80 p-0" align="start">
+                    <Command shouldFilter={false}>
+                        <CommandInput
+                            placeholder="Search tags..."
+                            value={searchValue}
+                            onValueChange={setSearchValue}
+                        />
+                        <CommandList className="max-h-[300px] overflow-y-auto" onScroll={handleScroll}>
+                            <CommandEmpty>
+                                {isLoading ? (
+                                    <div className="flex items-center justify-center py-6">
+                                        <Loader2 className="h-4 w-4 animate-spin mr-2" />
+                                        <Trans>Loading...</Trans>
+                                    </div>
+                                ) : (
+                                    <div className="p-2 text-sm">
+                                        <Trans>No tags found</Trans>
+                                    </div>
+                                )}
+                            </CommandEmpty>
+                            <CommandGroup>
+                                {filteredTags.map(tag => {
+                                    const isSelected = selectedTags.includes(tag.value);
+                                    return (
+                                        <CommandItem
+                                            key={tag.id}
+                                            onSelect={() => {
+                                                if (isSelected) {
+                                                    handleRemoveTag(tag.value);
+                                                } else {
+                                                    handleSelectTag(tag.value);
+                                                }
+                                            }}
+                                        >
+                                            <Check
+                                                className={cn(
+                                                    'mr-2 h-4 w-4',
+                                                    isSelected ? 'opacity-100' : 'opacity-0',
+                                                )}
+                                            />
+                                            {tag.value}
+                                        </CommandItem>
+                                    );
+                                })}
+
+                                {(isFetchingNextPage || isLoading) && (
+                                    <div className="flex items-center justify-center py-2">
+                                        <Loader2 className="h-4 w-4 animate-spin" />
+                                    </div>
+                                )}
+
+                                {!hasNextPage &&
+                                    filteredTags.length > 0 &&
+                                    totalTags > filteredTags.length && (
+                                        <div className="text-center py-2 text-xs text-muted-foreground">
+                                            <Trans>Showing all {filteredTags.length} results</Trans>
+                                        </div>
+                                    )}
+                            </CommandGroup>
+                        </CommandList>
+                        {selectedTags.length > 0 && (
+                            <div className="border-t p-2">
+                                <Button
+                                    variant="ghost"
+                                    size="sm"
+                                    className="w-full justify-start"
+                                    onClick={handleClearAll}
+                                >
+                                    <Trans>Clear all</Trans>
+                                </Button>
+                            </div>
+                        )}
+                    </Command>
+                </PopoverContent>
+            </Popover>
+
+            {/* Display selected tags */}
+            {selectedTags.length > 0 && (
+                <div className="flex flex-wrap gap-1">
+                    {selectedTags.map(tag => (
+                        <Badge key={tag} variant="secondary" className="text-xs">
+                            {tag}
+                            <button
+                                onClick={() => handleRemoveTag(tag)}
+                                className="ml-1 hover:bg-destructive/20 rounded-full p-0.5 transition-colors"
+                            >
+                                <X className="h-3 w-3" />
+                            </button>
+                        </Badge>
+                    ))}
+                </div>
+            )}
+        </div>
+    );
+}

+ 226 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/components/asset-tags-editor.tsx

@@ -0,0 +1,226 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem,
+} from '@/vdb/components/ui/command.js';
+import { Label } from '@/vdb/components/ui/label.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { cn } from '@/vdb/lib/utils.js';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Check, ChevronsUpDown, Settings2, X } from 'lucide-react';
+import { useCallback, useState } from 'react';
+import { toast } from 'sonner';
+import { createTagDocument, tagListDocument } from '../assets.graphql.js';
+import { ManageTagsDialog } from './manage-tags-dialog.js';
+
+interface AssetTagsEditorProps {
+    selectedTags: string[];
+    onTagsChange: (tags: string[]) => void;
+    disabled?: boolean;
+    onTagsUpdated?: () => void;
+}
+
+export function AssetTagsEditor({
+    selectedTags,
+    onTagsChange,
+    disabled = false,
+    onTagsUpdated,
+}: Readonly<AssetTagsEditorProps>) {
+    const [open, setOpen] = useState(false);
+    const [searchValue, setSearchValue] = useState('');
+    const [manageDialogOpen, setManageDialogOpen] = useState(false);
+    const queryClient = useQueryClient();
+
+    // Fetch available tags
+    const { data: tagsData } = useQuery({
+        queryKey: ['tags'],
+        queryFn: () => api.query(tagListDocument, { options: { take: 100 } }),
+        staleTime: 1000 * 60 * 5, // 5 minutes
+    });
+
+    // Create new tag mutation
+    const createTagMutation = useMutation({
+        mutationFn: (tagValue: string) => api.mutate(createTagDocument, { input: { value: tagValue } }),
+        onSuccess: data => {
+            const newTag = data.createTag.value;
+            onTagsChange([...selectedTags, newTag]);
+            toast.success(`Created tag "${newTag}"`);
+            setSearchValue('');
+            // Invalidate and refetch tags list
+            queryClient.invalidateQueries({ queryKey: ['tags'] });
+        },
+        onError: error => {
+            toast.error('Failed to create tag', {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        },
+    });
+
+    const availableTags = tagsData?.tags.items || [];
+
+    // Filter tags based on search value
+    const filteredTags = availableTags.filter(tag =>
+        tag.value.toLowerCase().includes(searchValue.toLowerCase()),
+    );
+
+    // Check if search value would create a new tag
+    const isNewTag =
+        searchValue.trim() &&
+        !availableTags.some(tag => tag.value.toLowerCase() === searchValue.toLowerCase());
+
+    const handleSelectTag = useCallback(
+        (tagValue: string) => {
+            if (!selectedTags.includes(tagValue)) {
+                onTagsChange([...selectedTags, tagValue]);
+            }
+            setSearchValue('');
+            setOpen(false);
+        },
+        [selectedTags, onTagsChange],
+    );
+
+    const handleRemoveTag = useCallback(
+        (tagToRemove: string) => {
+            onTagsChange(selectedTags.filter(tag => tag !== tagToRemove));
+        },
+        [selectedTags, onTagsChange],
+    );
+
+    const handleCreateTag = useCallback(() => {
+        if (isNewTag) {
+            createTagMutation.mutate(searchValue.trim());
+        }
+    }, [isNewTag, searchValue, createTagMutation]);
+
+    return (
+        <div className="space-y-3">
+            <Label>
+                <Trans>Tags</Trans>
+            </Label>
+
+            {/* Selected tags display */}
+            <div className="flex flex-wrap gap-2 min-h-[32px]">
+                {selectedTags.length === 0 ? (
+                    <span className="text-sm text-muted-foreground">
+                        <Trans>No tags selected</Trans>
+                    </span>
+                ) : (
+                    selectedTags.map(tag => (
+                        <Badge key={tag} variant="secondary" className="flex items-center gap-1">
+                            {tag}
+                            {!disabled && (
+                                <button
+                                    type="button"
+                                    onClick={() => handleRemoveTag(tag)}
+                                    className="ml-1 hover:bg-destructive/20 rounded-full p-0.5 transition-colors"
+                                >
+                                    <X className="h-3 w-3" />
+                                </button>
+                            )}
+                        </Badge>
+                    ))
+                )}
+            </div>
+
+            {/* Tag selector */}
+            {!disabled && (
+                <Popover open={open} onOpenChange={setOpen}>
+                    <PopoverTrigger asChild>
+                        <Button
+                            variant="outline"
+                            role="combobox"
+                            aria-expanded={open}
+                            className="w-full justify-between"
+                        >
+                            <Trans>Add tags...</Trans>
+                            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                        </Button>
+                    </PopoverTrigger>
+                    <PopoverContent className="w-full p-0" align="start">
+                        <Command>
+                            <CommandInput
+                                placeholder="Search tags..."
+                                value={searchValue}
+                                onValueChange={setSearchValue}
+                            />
+                            <CommandEmpty>
+                                {searchValue.trim() ? (
+                                    <div className="">
+                                        <Button
+                                            variant="ghost"
+                                            className="w-full justify-start"
+                                            onClick={handleCreateTag}
+                                            disabled={createTagMutation.isPending}
+                                        >
+                                            <Trans>Create "{searchValue.trim()}"</Trans>
+                                        </Button>
+                                    </div>
+                                ) : (
+                                    <div className="p-2 text-sm">
+                                        <Trans>No tags found</Trans>
+                                    </div>
+                                )}
+                            </CommandEmpty>
+                            <CommandGroup>
+                                {/* Show option to create new tag if search doesn't match exactly */}
+                                {isNewTag && (
+                                    <CommandItem
+                                        onSelect={handleCreateTag}
+                                        disabled={createTagMutation.isPending}
+                                        className="font-medium"
+                                    >
+                                        <Trans>Create "{searchValue.trim()}"</Trans>
+                                    </CommandItem>
+                                )}
+
+                                {/* Show existing tags */}
+                                {filteredTags.map(tag => {
+                                    const isSelected = selectedTags.includes(tag.value);
+                                    return (
+                                        <CommandItem
+                                            key={tag.id}
+                                            onSelect={() => handleSelectTag(tag.value)}
+                                            disabled={isSelected}
+                                        >
+                                            <Check
+                                                className={cn(
+                                                    'mr-2 h-4 w-4',
+                                                    isSelected ? 'opacity-100' : 'opacity-0',
+                                                )}
+                                            />
+                                            {tag.value}
+                                        </CommandItem>
+                                    );
+                                })}
+                            </CommandGroup>
+                        </Command>
+                    </PopoverContent>
+                </Popover>
+            )}
+
+            {!disabled && (
+                <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => setManageDialogOpen(true)}
+                    className="w-full justify-start"
+                >
+                    <Settings2 className="h-4 w-4 mr-2" />
+                    <Trans>Manage tags</Trans>
+                </Button>
+            )}
+            {/* Manage Tags Dialog */}
+            <ManageTagsDialog
+                open={manageDialogOpen}
+                onOpenChange={setManageDialogOpen}
+                onTagsUpdated={onTagsUpdated}
+            />
+        </div>
+    );
+}

+ 217 - 0
packages/dashboard/src/app/routes/_authenticated/_assets/components/manage-tags-dialog.tsx

@@ -0,0 +1,217 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    Dialog,
+    DialogContent,
+    DialogDescription,
+    DialogFooter,
+    DialogHeader,
+    DialogTitle,
+} from '@/vdb/components/ui/dialog.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { api } from '@/vdb/graphql/api.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { cn } from '@/vdb/lib/utils.js';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Trash2 } from 'lucide-react';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { deleteTagDocument, tagListDocument, updateTagDocument } from '../assets.graphql.js';
+
+interface ManageTagsDialogProps {
+    open: boolean;
+    onOpenChange: (open: boolean) => void;
+    onTagsUpdated?: () => void;
+}
+
+export function ManageTagsDialog({ open, onOpenChange, onTagsUpdated }: Readonly<ManageTagsDialogProps>) {
+    const queryClient = useQueryClient();
+    const [toDelete, setToDelete] = useState<string[]>([]);
+    const [toUpdate, setToUpdate] = useState<Array<{ id: string; value: string }>>([]);
+    const [isSaving, setIsSaving] = useState(false);
+
+    // Fetch all tags
+    const { data: tagsData, isLoading } = useQuery({
+        queryKey: ['tags'],
+        queryFn: () => api.query(tagListDocument, { options: { take: 100 } }),
+        staleTime: 1000 * 60 * 5,
+    });
+
+    // Update tag mutation
+    const updateTagMutation = useMutation({
+        mutationFn: ({ id, value }: { id: string; value: string }) =>
+            api.mutate(updateTagDocument, { input: { id, value } }),
+    });
+
+    // Delete tag mutation
+    const deleteTagMutation = useMutation({
+        mutationFn: (id: string) => api.mutate(deleteTagDocument, { id }),
+    });
+
+    const allTags = tagsData?.tags.items || [];
+
+    const toggleDelete = (id: string) => {
+        if (toDelete.includes(id)) {
+            setToDelete(toDelete.filter(_id => _id !== id));
+        } else {
+            setToDelete([...toDelete, id]);
+        }
+    };
+
+    const markedAsDeleted = (id: string) => {
+        return toDelete.includes(id);
+    };
+
+    const updateTagValue = (id: string, value: string) => {
+        const exists = toUpdate.find(i => i.id === id);
+        if (exists) {
+            if (value === allTags.find(tag => tag.id === id)?.value) {
+                // If value is reverted to original, remove from update list
+                setToUpdate(toUpdate.filter(i => i.id !== id));
+            } else {
+                exists.value = value;
+                setToUpdate([...toUpdate]);
+            }
+        } else {
+            setToUpdate([...toUpdate, { id, value }]);
+        }
+    };
+
+    const getDisplayValue = (id: string) => {
+        const updateItem = toUpdate.find(i => i.id === id);
+        if (updateItem) {
+            return updateItem.value;
+        }
+        return allTags.find(tag => tag.id === id)?.value || '';
+    };
+
+    const renderTagsList = () => {
+        if (isLoading) {
+            return (
+                <div className="text-sm text-muted-foreground">
+                    <Trans>Loading tags...</Trans>
+                </div>
+            );
+        }
+
+        if (allTags.length === 0) {
+            return (
+                <div className="text-sm text-muted-foreground">
+                    <Trans>No tags found</Trans>
+                </div>
+            );
+        }
+
+        return allTags.map(tag => {
+            const isDeleted = markedAsDeleted(tag.id);
+            const isModified = toUpdate.some(i => i.id === tag.id);
+
+            return (
+                <div
+                    key={tag.id}
+                    className={cn(
+                        'flex items-center gap-2 p-2 rounded-md',
+                        isDeleted && 'opacity-50',
+                    )}
+                >
+                    <Input
+                        value={getDisplayValue(tag.id)}
+                        onChange={e => updateTagValue(tag.id, e.target.value)}
+                        disabled={isDeleted || isSaving}
+                        className={cn('flex-1', isModified && !isDeleted && 'border-primary')}
+                    />
+                    <Button
+                        variant={isDeleted ? 'default' : 'ghost'}
+                        size="icon"
+                        onClick={() => toggleDelete(tag.id)}
+                        disabled={isSaving}
+                        className={cn(isDeleted && 'bg-destructive hover:bg-destructive/90')}
+                    >
+                        <Trash2 className="h-4 w-4" />
+                    </Button>
+                </div>
+            );
+        });
+    };
+
+    const hasChanges = toDelete.length > 0 || toUpdate.length > 0;
+
+    const handleCancel = () => {
+        setToDelete([]);
+        setToUpdate([]);
+        onOpenChange(false);
+    };
+
+    const handleSave = async () => {
+        setIsSaving(true);
+
+        try {
+            const operations = [];
+
+            // Delete operations
+            for (const id of toDelete) {
+                operations.push(deleteTagMutation.mutateAsync(id));
+            }
+
+            // Update operations (skip if marked for deletion)
+            for (const item of toUpdate) {
+                if (!toDelete.includes(item.id)) {
+                    operations.push(updateTagMutation.mutateAsync(item));
+                }
+            }
+
+            await Promise.all(operations);
+
+            // Invalidate tags query to refresh the list
+            await queryClient.invalidateQueries({ queryKey: ['tags'] });
+
+            // Also invalidate asset queries to refresh any assets using these tags
+            await queryClient.invalidateQueries({ queryKey: ['asset'] });
+
+            toast.success('Tags updated successfully');
+
+            // Call callback to notify parent component
+            if (onTagsUpdated) {
+                onTagsUpdated();
+            }
+
+            // Reset state
+            setToDelete([]);
+            setToUpdate([]);
+            onOpenChange(false);
+        } catch (error) {
+            toast.error('Failed to update tags', {
+                description: error instanceof Error ? error.message : 'Unknown error',
+            });
+        } finally {
+            setIsSaving(false);
+        }
+    };
+
+    return (
+        <Dialog open={open} onOpenChange={onOpenChange}>
+            <DialogContent className="max-w-md">
+                <DialogHeader>
+                    <DialogTitle>
+                        <Trans>Manage Tags</Trans>
+                    </DialogTitle>
+                    <DialogDescription>
+                        <Trans>Edit or delete existing tags</Trans>
+                    </DialogDescription>
+                </DialogHeader>
+
+                <div className="max-h-[400px] overflow-y-auto space-y-2 py-4">
+                    {renderTagsList()}
+                </div>
+
+                <DialogFooter>
+                    <Button variant="outline" onClick={handleCancel} disabled={isSaving}>
+                        <Trans>Cancel</Trans>
+                    </Button>
+                    <Button onClick={handleSave} disabled={!hasChanges || isSaving}>
+                        {isSaving ? <Trans>Saving...</Trans> : <Trans>Save Changes</Trans>}
+                    </Button>
+                </DialogFooter>
+            </DialogContent>
+        </Dialog>
+    );
+}

+ 84 - 50
packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx

@@ -20,9 +20,12 @@ import { Trans } from '@/vdb/lib/trans.js';
 import { formatFileSize } from '@/vdb/lib/utils.js';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useDebounce } from '@uidotdev/usehooks';
+import { LogicalOperator } from '@vendure/common/lib/generated-types';
 import { Loader2, Search, Upload, X } from 'lucide-react';
 import { useCallback, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
+import { tagListDocument } from '../../../../app/routes/_authenticated/_assets/assets.graphql.js';
+import { AssetTagFilter } from '../../../../app/routes/_authenticated/_assets/components/asset-tag-filter.js';
 import { DetailPageButton } from '../detail-page-button.js';
 import { AssetBulkAction, AssetBulkActions } from './asset-bulk-actions.js';
 
@@ -74,7 +77,7 @@ export type Asset = AssetFragment;
 /**
  * @description
  * Props for the {@link AssetGallery} component.
- * 
+ *
  * @docsCategory components
  * @docsPage AssetGallery
  */
@@ -134,16 +137,16 @@ export interface AssetGalleryProps {
 /**
  * @description
  * A component for displaying a gallery of assets.
- * 
+ *
  * @example
  * ```tsx
  *  <AssetGallery
-        onSelect={handleAssetSelect}
-        multiSelect="manual"
-        initialSelectedAssets={initialSelectedAssets}
-        fixedHeight={false}
-        displayBulkActions={false}
-    />
+ onSelect={handleAssetSelect}
+ multiSelect="manual"
+ initialSelectedAssets={initialSelectedAssets}
+ fixedHeight={false}
+ displayBulkActions={false}
+ />
  * ```
  *
  * @docsCategory components
@@ -169,9 +172,19 @@ export function AssetGallery({
     const debouncedSearch = useDebounce(search, 500);
     const [assetType, setAssetType] = useState<string>(AssetType.ALL);
     const [selected, setSelected] = useState<Asset[]>(initialSelectedAssets || []);
+    const [selectedTags, setSelectedTags] = useState<string[]>([]);
     const queryClient = useQueryClient();
 
-    const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType];
+    const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType, selectedTags];
+
+    // Query for available tags to check if we should show the filter
+    const { data: tagsData } = useQuery({
+        queryKey: ['tags-check'],
+        queryFn: () => api.query(tagListDocument, { options: { take: 1 } }),
+        staleTime: 1000 * 60 * 5,
+    });
+
+    const hasTags = (tagsData?.tags.items?.length || 0) > 0;
 
     // Query for assets
     const { data, isLoading, refetch } = useQuery({
@@ -187,14 +200,20 @@ export function AssetGallery({
                 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' },
-                },
-            });
+            const options: any = {
+                skip: (page - 1) * pageSize,
+                take: pageSize,
+                filter: Object.keys(filter).length > 0 ? filter : undefined,
+                sort: { createdAt: 'DESC' },
+            };
+
+            // Add tag filtering if tags are provided
+            if (selectedTags && selectedTags.length > 0) {
+                options.tags = selectedTags;
+                options.tagsOperator = LogicalOperator.AND;
+            }
+
+            return api.query(getAssetListDocument, { options });
         },
     });
 
@@ -262,10 +281,17 @@ export function AssetGallery({
     // Check if an asset is selected
     const isSelected = (asset: Asset) => selected.some(a => a.id === asset.id);
 
+    // Handle tag changes
+    const handleTagsChange = (tags: string[]) => {
+        setSelectedTags(tags);
+        setPage(1); // Reset to page 1 when tags change
+    };
+
     // Clear filters
     const clearFilters = () => {
         setSearch('');
         setAssetType(AssetType.ALL);
+        setSelectedTags([]);
         setPage(1);
     };
 
@@ -294,40 +320,48 @@ export function AssetGallery({
     return (
         <div className={`relative flex flex-col w-full ${fixedHeight ? 'h-[600px]' : 'h-full'} ${className}`}>
             {showHeader && (
-                <div className="flex flex-col md:flex-row gap-2 mb-4 flex-shrink-0">
-                    <div className="relative flex-grow flex items-center gap-2">
-                        <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"
-                        />
-                        {(search || assetType !== AssetType.ALL) && (
-                            <Button
-                                variant="ghost"
-                                size="sm"
-                                onClick={clearFilters}
-                                className="absolute right-0"
-                            >
-                                <X className="h-4 w-4 mr-1" /> Clear filters
-                            </Button>
-                        )}
+                <div className="space-y-4 mb-4 flex-shrink-0">
+                    <div className="flex flex-col md:flex-row gap-2">
+                        <div className="relative flex-grow flex items-center gap-2">
+                            <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"
+                            />
+                            {(search || assetType !== AssetType.ALL || selectedTags.length > 0) && (
+                                <Button
+                                    variant="ghost"
+                                    size="sm"
+                                    onClick={clearFilters}
+                                    className="absolute right-0"
+                                >
+                                    <X className="h-4 w-4 mr-1" /> Clear filters
+                                </Button>
+                            )}
+                        </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>
+                        <Button onClick={openFileDialog} className="whitespace-nowrap">
+                            <Upload className="h-4 w-4 mr-2" /> <Trans>Upload</Trans>
+                        </Button>
                     </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>
-                    <Button onClick={openFileDialog} className="whitespace-nowrap">
-                        <Upload className="h-4 w-4 mr-2" /> <Trans>Upload</Trans>
-                    </Button>
+
+                    {hasTags && (
+                        <div className="flex items-center -mt-2">
+                            <AssetTagFilter selectedTags={selectedTags} onTagsChange={handleTagsChange} />
+                        </div>
+                    )}
                 </div>
             )}