@vendure/dashboard 3.4.3-master-202509190229 → 3.4.3-master-202509200226

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/vite/vite-plugin-config.js +1 -0
  2. package/package.json +4 -4
  3. package/src/app/routes/_authenticated/_administrators/administrators.tsx +1 -2
  4. package/src/app/routes/_authenticated/_assets/assets.graphql.ts +39 -0
  5. package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +18 -7
  6. package/src/app/routes/_authenticated/_assets/components/asset-tag-filter.tsx +206 -0
  7. package/src/app/routes/_authenticated/_assets/components/asset-tags-editor.tsx +226 -0
  8. package/src/app/routes/_authenticated/_assets/components/manage-tags-dialog.tsx +217 -0
  9. package/src/app/routes/_authenticated/_channels/channels.tsx +1 -2
  10. package/src/app/routes/_authenticated/_collections/collections.tsx +2 -16
  11. package/src/app/routes/_authenticated/_countries/countries.tsx +1 -2
  12. package/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx +1 -2
  13. package/src/app/routes/_authenticated/_customers/customers.tsx +1 -2
  14. package/src/app/routes/_authenticated/_facets/facets.tsx +0 -1
  15. package/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx +1 -2
  16. package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +1 -2
  17. package/src/app/routes/_authenticated/_products/products.tsx +1 -2
  18. package/src/app/routes/_authenticated/_promotions/promotions.tsx +1 -2
  19. package/src/app/routes/_authenticated/_roles/roles.tsx +1 -2
  20. package/src/app/routes/_authenticated/_sellers/sellers.tsx +1 -2
  21. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +1 -2
  22. package/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx +1 -2
  23. package/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx +1 -2
  24. package/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx +1 -2
  25. package/src/app/routes/_authenticated/_zones/zones.tsx +1 -2
  26. package/src/lib/components/data-table/data-table-bulk-actions.tsx +5 -14
  27. package/src/lib/components/data-table/use-all-bulk-actions.ts +19 -0
  28. package/src/lib/components/data-table/use-generated-columns.tsx +12 -3
  29. package/src/lib/components/layout/nav-main.tsx +50 -25
  30. package/src/lib/components/shared/asset/asset-focal-point-editor.tsx +1 -1
  31. package/src/lib/components/shared/asset/asset-gallery.tsx +83 -50
  32. package/src/lib/components/shared/paginated-list-data-table.tsx +1 -0
  33. package/src/lib/components/shared/vendure-image.tsx +9 -1
  34. package/src/lib/framework/defaults.ts +24 -0
  35. package/src/lib/framework/extension-api/types/navigation.ts +8 -0
  36. package/src/lib/framework/nav-menu/nav-menu-extensions.ts +26 -0
  37. package/src/lib/framework/page/list-page.tsx +7 -0
  38. package/src/lib/hooks/use-custom-field-config.ts +19 -2
  39. package/src/lib/index.ts +0 -1
  40. package/src/lib/providers/channel-provider.tsx +22 -6
  41. package/src/lib/providers/server-config.tsx +1 -0
  42. package/src/app/routes/_authenticated/_collections/components/move-single-collection.tsx +0 -33
  43. package/src/lib/components/shared/asset/focal-point-control.tsx +0 -57
@@ -47,6 +47,7 @@ export function viteConfigPlugin({ packageRoot }) {
47
47
  ...(((_e = config.optimizeDeps) === null || _e === void 0 ? void 0 : _e.include) || []),
48
48
  '@/components > recharts',
49
49
  '@/components > react-dropzone',
50
+ '@/components > @tiptap/react', // https://github.com/fawmi/vue-google-maps/issues/148#issuecomment-1235143844
50
51
  '@vendure/common/lib/generated-types',
51
52
  ] });
52
53
  return config;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.4.3-master-202509190229",
4
+ "version": "3.4.3-master-202509200226",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -104,8 +104,8 @@
104
104
  "@types/react": "^19.0.10",
105
105
  "@types/react-dom": "^19.0.4",
106
106
  "@uidotdev/usehooks": "^2.4.1",
107
- "@vendure/common": "^3.4.3-master-202509190229",
108
- "@vendure/core": "^3.4.3-master-202509190229",
107
+ "@vendure/common": "^3.4.3-master-202509200226",
108
+ "@vendure/core": "^3.4.3-master-202509200226",
109
109
  "@vitejs/plugin-react": "^4.3.4",
110
110
  "acorn": "^8.11.3",
111
111
  "acorn-walk": "^8.3.2",
@@ -156,5 +156,5 @@
156
156
  "lightningcss-linux-arm64-musl": "^1.29.3",
157
157
  "lightningcss-linux-x64-musl": "^1.29.1"
158
158
  },
159
- "gitHead": "1a94626a2e07d33d881a751ff394f8351c3521b4"
159
+ "gitHead": "e2f1befe89f08f48faf285a6a929ca2a4042c903"
160
160
  }
@@ -8,7 +8,7 @@ import { ListPage } from '@/vdb/framework/page/list-page.js';
8
8
  import { Trans } from '@/vdb/lib/trans.js';
9
9
  import { createFileRoute, Link } from '@tanstack/react-router';
10
10
  import { PlusIcon } from 'lucide-react';
11
- import { administratorListDocument, deleteAdministratorDocument } from './administrators.graphql.js';
11
+ import { administratorListDocument } from './administrators.graphql.js';
12
12
  import { DeleteAdministratorsBulkAction } from './components/administrator-bulk-actions.js';
13
13
 
14
14
  export const Route = createFileRoute('/_authenticated/_administrators/administrators')({
@@ -22,7 +22,6 @@ function AdministratorListPage() {
22
22
  pageId="administrator-list"
23
23
  title="Administrators"
24
24
  listQuery={administratorListDocument}
25
- deleteMutation={deleteAdministratorDocument}
26
25
  route={Route}
27
26
  onSearchTermChange={searchTerm => {
28
27
  return {
@@ -35,3 +35,42 @@ export const deleteAssetsDocument = graphql(`
35
35
  }
36
36
  }
37
37
  `);
38
+
39
+ export const tagListDocument = graphql(`
40
+ query TagList($options: TagListOptions) {
41
+ tags(options: $options) {
42
+ items {
43
+ id
44
+ value
45
+ }
46
+ totalItems
47
+ }
48
+ }
49
+ `);
50
+
51
+ export const createTagDocument = graphql(`
52
+ mutation CreateTag($input: CreateTagInput!) {
53
+ createTag(input: $input) {
54
+ id
55
+ value
56
+ }
57
+ }
58
+ `);
59
+
60
+ export const updateTagDocument = graphql(`
61
+ mutation UpdateTag($input: UpdateTagInput!) {
62
+ updateTag(input: $input) {
63
+ id
64
+ value
65
+ }
66
+ }
67
+ `);
68
+
69
+ export const deleteTagDocument = graphql(`
70
+ mutation DeleteTag($id: ID!) {
71
+ deleteTag(id: $id) {
72
+ result
73
+ message
74
+ }
75
+ }
76
+ `);
@@ -2,7 +2,6 @@ import { AssetFocalPointEditor } from '@/vdb/components/shared/asset/asset-focal
2
2
  import { AssetPreviewSelector } from '@/vdb/components/shared/asset/asset-preview-selector.js';
3
3
  import { PreviewPreset } from '@/vdb/components/shared/asset/asset-preview.js';
4
4
  import { AssetProperties } from '@/vdb/components/shared/asset/asset-properties.js';
5
- import { Point } from '@/vdb/components/shared/asset/focal-point-control.js';
6
5
  import { ErrorPage } from '@/vdb/components/shared/error-page.js';
7
6
  import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
8
7
  import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
@@ -25,6 +24,7 @@ import { FocusIcon } from 'lucide-react';
25
24
  import { useRef, useState } from 'react';
26
25
  import { toast } from 'sonner';
27
26
  import { assetDetailDocument, assetUpdateDocument } from './assets.graphql.js';
27
+ import { AssetTagsEditor } from './components/asset-tags-editor.js';
28
28
 
29
29
  const pageId = 'asset-detail';
30
30
 
@@ -51,9 +51,8 @@ function AssetDetailPage() {
51
51
  const [size, setSize] = useState<PreviewPreset>('medium');
52
52
  const [width, setWidth] = useState(0);
53
53
  const [height, setHeight] = useState(0);
54
- const [focalPoint, setFocalPoint] = useState<Point | undefined>(undefined);
55
54
  const [settingFocalPoint, setSettingFocalPoint] = useState(false);
56
- const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
55
+ const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
57
56
  pageId,
58
57
  queryDocument: assetDetailDocument,
59
58
  updateDocument: assetUpdateDocument,
@@ -106,12 +105,12 @@ function AssetDetailPage() {
106
105
  </PageActionBar>
107
106
  <PageLayout>
108
107
  <PageBlock column="main" blockId="asset-preview">
109
- <div className="relative flex items-center justify-center bg-muted/30 rounded-lg min-h-[300px] overflow-auto">
108
+ <div className="relative flex items-center justify-center bg-muted/30 rounded-lg min-h-[300px] overflow-auto resize-y">
110
109
  <AssetFocalPointEditor
111
110
  width={width}
112
111
  height={height}
113
112
  settingFocalPoint={settingFocalPoint}
114
- focalPoint={form.getValues().focalPoint ?? { x: 0.5, y: 0.5 }}
113
+ focalPoint={entity.focalPoint ?? { x: 0.5, y: 0.5 }}
115
114
  onFocalPointChange={point => {
116
115
  form.setValue('focalPoint.x', point.x, { shouldDirty: true });
117
116
  form.setValue('focalPoint.y', point.y, { shouldDirty: true });
@@ -125,10 +124,9 @@ function AssetDetailPage() {
125
124
  ref={imageRef}
126
125
  asset={entity}
127
126
  preset={size || undefined}
128
- mode="resize"
129
127
  useFocalPoint={true}
130
128
  onLoad={updateDimensions}
131
- className="max-w-full max-h-full object-contain"
129
+ className="max-w-full object-contain"
132
130
  />
133
131
  </AssetFocalPointEditor>
134
132
  </div>
@@ -164,6 +162,19 @@ function AssetDetailPage() {
164
162
  </div>
165
163
  </div>
166
164
  </PageBlock>
165
+ <PageBlock column="side" blockId="asset-tags">
166
+ <AssetTagsEditor
167
+ selectedTags={form.watch('tags') || []}
168
+ onTagsChange={tags => {
169
+ form.setValue('tags', tags, { shouldDirty: true });
170
+ }}
171
+ onTagsUpdated={() => {
172
+ // Refresh the asset entity to get updated tag values
173
+ refreshEntity();
174
+ }}
175
+ disabled={isPending}
176
+ />
177
+ </PageBlock>
167
178
  </PageLayout>
168
179
  </Page>
169
180
  );
@@ -0,0 +1,206 @@
1
+ import { Badge } from '@/vdb/components/ui/badge.js';
2
+ import { Button } from '@/vdb/components/ui/button.js';
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ CommandList,
10
+ } from '@/vdb/components/ui/command.js';
11
+ import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
12
+ import { api } from '@/vdb/graphql/api.js';
13
+ import { Trans } from '@/vdb/lib/trans.js';
14
+ import { cn } from '@/vdb/lib/utils.js';
15
+ import { useInfiniteQuery } from '@tanstack/react-query';
16
+ import { useDebounce } from '@uidotdev/usehooks';
17
+ import { Check, Filter, Loader2, X } from 'lucide-react';
18
+ import { useState } from 'react';
19
+ import { tagListDocument } from '../assets.graphql.js';
20
+
21
+ interface AssetTagFilterProps {
22
+ selectedTags: string[];
23
+ onTagsChange: (tags: string[]) => void;
24
+ }
25
+
26
+ export function AssetTagFilter({ selectedTags, onTagsChange }: Readonly<AssetTagFilterProps>) {
27
+ const [open, setOpen] = useState(false);
28
+ const [searchValue, setSearchValue] = useState('');
29
+
30
+ const debouncedSearch = useDebounce(searchValue, 300);
31
+ const pageSize = 25;
32
+
33
+ // Fetch available tags with infinite query
34
+ const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
35
+ queryKey: ['tags', debouncedSearch],
36
+ queryFn: async ({ pageParam = 0 }) => {
37
+ const options: any = {
38
+ skip: pageParam * pageSize,
39
+ take: pageSize,
40
+ sort: { value: 'ASC' },
41
+ };
42
+
43
+ if (debouncedSearch.trim()) {
44
+ options.filter = {
45
+ value: { contains: debouncedSearch.trim() },
46
+ };
47
+ }
48
+
49
+ const response = await api.query(tagListDocument, { options });
50
+ return response.tags;
51
+ },
52
+ getNextPageParam: (lastPage, allPages) => {
53
+ if (!lastPage) return undefined;
54
+ const totalFetched = allPages.length * pageSize;
55
+ return totalFetched < lastPage.totalItems ? allPages.length : undefined;
56
+ },
57
+ initialPageParam: 0,
58
+ staleTime: 1000 * 60 * 5,
59
+ });
60
+
61
+ const availableTags = data?.pages.flatMap(page => page?.items ?? []) ?? [];
62
+ const totalTags = data?.pages[0]?.totalItems ?? 0;
63
+
64
+ // Tags are already filtered server-side, so use them directly
65
+ const filteredTags = availableTags;
66
+
67
+ const handleSelectTag = (tagValue: string) => {
68
+ if (!selectedTags.includes(tagValue)) {
69
+ onTagsChange([...selectedTags, tagValue]);
70
+ }
71
+ setSearchValue('');
72
+ };
73
+
74
+ const handleRemoveTag = (tagToRemove: string) => {
75
+ onTagsChange(selectedTags.filter(tag => tag !== tagToRemove));
76
+ };
77
+
78
+ const handleClearAll = () => {
79
+ onTagsChange([]);
80
+ setOpen(false);
81
+ };
82
+
83
+ const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
84
+ const target = e.currentTarget;
85
+ const scrolledToBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 1;
86
+
87
+ if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
88
+ fetchNextPage();
89
+ }
90
+ };
91
+
92
+ return (
93
+ <div className="flex items-center gap-2">
94
+ <Popover open={open} onOpenChange={setOpen}>
95
+ <PopoverTrigger asChild>
96
+ <Button
97
+ variant="outline"
98
+ size="sm"
99
+ role="combobox"
100
+ aria-expanded={open}
101
+ className="justify-start"
102
+ >
103
+ <Filter className="h-4 w-4 mr-2" />
104
+ <Trans>Filter by tags</Trans>
105
+ {selectedTags.length > 0 && (
106
+ <Badge variant="secondary" className="ml-2">
107
+ {selectedTags.length}
108
+ </Badge>
109
+ )}
110
+ </Button>
111
+ </PopoverTrigger>
112
+ <PopoverContent className="w-80 p-0" align="start">
113
+ <Command shouldFilter={false}>
114
+ <CommandInput
115
+ placeholder="Search tags..."
116
+ value={searchValue}
117
+ onValueChange={setSearchValue}
118
+ />
119
+ <CommandList className="max-h-[300px] overflow-y-auto" onScroll={handleScroll}>
120
+ <CommandEmpty>
121
+ {isLoading ? (
122
+ <div className="flex items-center justify-center py-6">
123
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
124
+ <Trans>Loading...</Trans>
125
+ </div>
126
+ ) : (
127
+ <div className="p-2 text-sm">
128
+ <Trans>No tags found</Trans>
129
+ </div>
130
+ )}
131
+ </CommandEmpty>
132
+ <CommandGroup>
133
+ {filteredTags.map(tag => {
134
+ const isSelected = selectedTags.includes(tag.value);
135
+ return (
136
+ <CommandItem
137
+ key={tag.id}
138
+ onSelect={() => {
139
+ if (isSelected) {
140
+ handleRemoveTag(tag.value);
141
+ } else {
142
+ handleSelectTag(tag.value);
143
+ }
144
+ }}
145
+ >
146
+ <Check
147
+ className={cn(
148
+ 'mr-2 h-4 w-4',
149
+ isSelected ? 'opacity-100' : 'opacity-0',
150
+ )}
151
+ />
152
+ {tag.value}
153
+ </CommandItem>
154
+ );
155
+ })}
156
+
157
+ {(isFetchingNextPage || isLoading) && (
158
+ <div className="flex items-center justify-center py-2">
159
+ <Loader2 className="h-4 w-4 animate-spin" />
160
+ </div>
161
+ )}
162
+
163
+ {!hasNextPage &&
164
+ filteredTags.length > 0 &&
165
+ totalTags > filteredTags.length && (
166
+ <div className="text-center py-2 text-xs text-muted-foreground">
167
+ <Trans>Showing all {filteredTags.length} results</Trans>
168
+ </div>
169
+ )}
170
+ </CommandGroup>
171
+ </CommandList>
172
+ {selectedTags.length > 0 && (
173
+ <div className="border-t p-2">
174
+ <Button
175
+ variant="ghost"
176
+ size="sm"
177
+ className="w-full justify-start"
178
+ onClick={handleClearAll}
179
+ >
180
+ <Trans>Clear all</Trans>
181
+ </Button>
182
+ </div>
183
+ )}
184
+ </Command>
185
+ </PopoverContent>
186
+ </Popover>
187
+
188
+ {/* Display selected tags */}
189
+ {selectedTags.length > 0 && (
190
+ <div className="flex flex-wrap gap-1">
191
+ {selectedTags.map(tag => (
192
+ <Badge key={tag} variant="secondary" className="text-xs">
193
+ {tag}
194
+ <button
195
+ onClick={() => handleRemoveTag(tag)}
196
+ className="ml-1 hover:bg-destructive/20 rounded-full p-0.5 transition-colors"
197
+ >
198
+ <X className="h-3 w-3" />
199
+ </button>
200
+ </Badge>
201
+ ))}
202
+ </div>
203
+ )}
204
+ </div>
205
+ );
206
+ }
@@ -0,0 +1,226 @@
1
+ import { Badge } from '@/vdb/components/ui/badge.js';
2
+ import { Button } from '@/vdb/components/ui/button.js';
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ } from '@/vdb/components/ui/command.js';
10
+ import { Label } from '@/vdb/components/ui/label.js';
11
+ import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
12
+ import { api } from '@/vdb/graphql/api.js';
13
+ import { Trans } from '@/vdb/lib/trans.js';
14
+ import { cn } from '@/vdb/lib/utils.js';
15
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
16
+ import { Check, ChevronsUpDown, Settings2, X } from 'lucide-react';
17
+ import { useCallback, useState } from 'react';
18
+ import { toast } from 'sonner';
19
+ import { createTagDocument, tagListDocument } from '../assets.graphql.js';
20
+ import { ManageTagsDialog } from './manage-tags-dialog.js';
21
+
22
+ interface AssetTagsEditorProps {
23
+ selectedTags: string[];
24
+ onTagsChange: (tags: string[]) => void;
25
+ disabled?: boolean;
26
+ onTagsUpdated?: () => void;
27
+ }
28
+
29
+ export function AssetTagsEditor({
30
+ selectedTags,
31
+ onTagsChange,
32
+ disabled = false,
33
+ onTagsUpdated,
34
+ }: Readonly<AssetTagsEditorProps>) {
35
+ const [open, setOpen] = useState(false);
36
+ const [searchValue, setSearchValue] = useState('');
37
+ const [manageDialogOpen, setManageDialogOpen] = useState(false);
38
+ const queryClient = useQueryClient();
39
+
40
+ // Fetch available tags
41
+ const { data: tagsData } = useQuery({
42
+ queryKey: ['tags'],
43
+ queryFn: () => api.query(tagListDocument, { options: { take: 100 } }),
44
+ staleTime: 1000 * 60 * 5, // 5 minutes
45
+ });
46
+
47
+ // Create new tag mutation
48
+ const createTagMutation = useMutation({
49
+ mutationFn: (tagValue: string) => api.mutate(createTagDocument, { input: { value: tagValue } }),
50
+ onSuccess: data => {
51
+ const newTag = data.createTag.value;
52
+ onTagsChange([...selectedTags, newTag]);
53
+ toast.success(`Created tag "${newTag}"`);
54
+ setSearchValue('');
55
+ // Invalidate and refetch tags list
56
+ queryClient.invalidateQueries({ queryKey: ['tags'] });
57
+ },
58
+ onError: error => {
59
+ toast.error('Failed to create tag', {
60
+ description: error instanceof Error ? error.message : 'Unknown error',
61
+ });
62
+ },
63
+ });
64
+
65
+ const availableTags = tagsData?.tags.items || [];
66
+
67
+ // Filter tags based on search value
68
+ const filteredTags = availableTags.filter(tag =>
69
+ tag.value.toLowerCase().includes(searchValue.toLowerCase()),
70
+ );
71
+
72
+ // Check if search value would create a new tag
73
+ const isNewTag =
74
+ searchValue.trim() &&
75
+ !availableTags.some(tag => tag.value.toLowerCase() === searchValue.toLowerCase());
76
+
77
+ const handleSelectTag = useCallback(
78
+ (tagValue: string) => {
79
+ if (!selectedTags.includes(tagValue)) {
80
+ onTagsChange([...selectedTags, tagValue]);
81
+ }
82
+ setSearchValue('');
83
+ setOpen(false);
84
+ },
85
+ [selectedTags, onTagsChange],
86
+ );
87
+
88
+ const handleRemoveTag = useCallback(
89
+ (tagToRemove: string) => {
90
+ onTagsChange(selectedTags.filter(tag => tag !== tagToRemove));
91
+ },
92
+ [selectedTags, onTagsChange],
93
+ );
94
+
95
+ const handleCreateTag = useCallback(() => {
96
+ if (isNewTag) {
97
+ createTagMutation.mutate(searchValue.trim());
98
+ }
99
+ }, [isNewTag, searchValue, createTagMutation]);
100
+
101
+ return (
102
+ <div className="space-y-3">
103
+ <Label>
104
+ <Trans>Tags</Trans>
105
+ </Label>
106
+
107
+ {/* Selected tags display */}
108
+ <div className="flex flex-wrap gap-2 min-h-[32px]">
109
+ {selectedTags.length === 0 ? (
110
+ <span className="text-sm text-muted-foreground">
111
+ <Trans>No tags selected</Trans>
112
+ </span>
113
+ ) : (
114
+ selectedTags.map(tag => (
115
+ <Badge key={tag} variant="secondary" className="flex items-center gap-1">
116
+ {tag}
117
+ {!disabled && (
118
+ <button
119
+ type="button"
120
+ onClick={() => handleRemoveTag(tag)}
121
+ className="ml-1 hover:bg-destructive/20 rounded-full p-0.5 transition-colors"
122
+ >
123
+ <X className="h-3 w-3" />
124
+ </button>
125
+ )}
126
+ </Badge>
127
+ ))
128
+ )}
129
+ </div>
130
+
131
+ {/* Tag selector */}
132
+ {!disabled && (
133
+ <Popover open={open} onOpenChange={setOpen}>
134
+ <PopoverTrigger asChild>
135
+ <Button
136
+ variant="outline"
137
+ role="combobox"
138
+ aria-expanded={open}
139
+ className="w-full justify-between"
140
+ >
141
+ <Trans>Add tags...</Trans>
142
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
143
+ </Button>
144
+ </PopoverTrigger>
145
+ <PopoverContent className="w-full p-0" align="start">
146
+ <Command>
147
+ <CommandInput
148
+ placeholder="Search tags..."
149
+ value={searchValue}
150
+ onValueChange={setSearchValue}
151
+ />
152
+ <CommandEmpty>
153
+ {searchValue.trim() ? (
154
+ <div className="">
155
+ <Button
156
+ variant="ghost"
157
+ className="w-full justify-start"
158
+ onClick={handleCreateTag}
159
+ disabled={createTagMutation.isPending}
160
+ >
161
+ <Trans>Create "{searchValue.trim()}"</Trans>
162
+ </Button>
163
+ </div>
164
+ ) : (
165
+ <div className="p-2 text-sm">
166
+ <Trans>No tags found</Trans>
167
+ </div>
168
+ )}
169
+ </CommandEmpty>
170
+ <CommandGroup>
171
+ {/* Show option to create new tag if search doesn't match exactly */}
172
+ {isNewTag && (
173
+ <CommandItem
174
+ onSelect={handleCreateTag}
175
+ disabled={createTagMutation.isPending}
176
+ className="font-medium"
177
+ >
178
+ <Trans>Create "{searchValue.trim()}"</Trans>
179
+ </CommandItem>
180
+ )}
181
+
182
+ {/* Show existing tags */}
183
+ {filteredTags.map(tag => {
184
+ const isSelected = selectedTags.includes(tag.value);
185
+ return (
186
+ <CommandItem
187
+ key={tag.id}
188
+ onSelect={() => handleSelectTag(tag.value)}
189
+ disabled={isSelected}
190
+ >
191
+ <Check
192
+ className={cn(
193
+ 'mr-2 h-4 w-4',
194
+ isSelected ? 'opacity-100' : 'opacity-0',
195
+ )}
196
+ />
197
+ {tag.value}
198
+ </CommandItem>
199
+ );
200
+ })}
201
+ </CommandGroup>
202
+ </Command>
203
+ </PopoverContent>
204
+ </Popover>
205
+ )}
206
+
207
+ {!disabled && (
208
+ <Button
209
+ variant="ghost"
210
+ size="sm"
211
+ onClick={() => setManageDialogOpen(true)}
212
+ className="w-full justify-start"
213
+ >
214
+ <Settings2 className="h-4 w-4 mr-2" />
215
+ <Trans>Manage tags</Trans>
216
+ </Button>
217
+ )}
218
+ {/* Manage Tags Dialog */}
219
+ <ManageTagsDialog
220
+ open={manageDialogOpen}
221
+ onOpenChange={setManageDialogOpen}
222
+ onTagsUpdated={onTagsUpdated}
223
+ />
224
+ </div>
225
+ );
226
+ }