@vendure/dashboard 3.4.3-master-202509180227 → 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 (54) hide show
  1. package/dist/vite/vite-plugin-config.js +1 -0
  2. package/package.json +11 -7
  3. package/src/app/common/duplicate-bulk-action.tsx +37 -23
  4. package/src/app/common/duplicate-entity-dialog.tsx +117 -0
  5. package/src/app/routes/_authenticated/_administrators/administrators.tsx +1 -2
  6. package/src/app/routes/_authenticated/_assets/assets.graphql.ts +39 -0
  7. package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +18 -7
  8. package/src/app/routes/_authenticated/_assets/components/asset-tag-filter.tsx +206 -0
  9. package/src/app/routes/_authenticated/_assets/components/asset-tags-editor.tsx +226 -0
  10. package/src/app/routes/_authenticated/_assets/components/manage-tags-dialog.tsx +217 -0
  11. package/src/app/routes/_authenticated/_channels/channels.tsx +1 -2
  12. package/src/app/routes/_authenticated/_collections/collections.tsx +2 -16
  13. package/src/app/routes/_authenticated/_countries/countries.tsx +1 -2
  14. package/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx +1 -2
  15. package/src/app/routes/_authenticated/_customers/customers.tsx +1 -2
  16. package/src/app/routes/_authenticated/_facets/facets.tsx +0 -1
  17. package/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx +1 -2
  18. package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +1 -2
  19. package/src/app/routes/_authenticated/_products/products.tsx +1 -2
  20. package/src/app/routes/_authenticated/_promotions/promotions.tsx +1 -2
  21. package/src/app/routes/_authenticated/_roles/roles.tsx +1 -2
  22. package/src/app/routes/_authenticated/_sellers/sellers.tsx +1 -2
  23. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +1 -2
  24. package/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx +1 -2
  25. package/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx +1 -2
  26. package/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx +1 -2
  27. package/src/app/routes/_authenticated/_zones/zones.tsx +1 -2
  28. package/src/lib/components/data-input/rich-text-input.tsx +2 -115
  29. package/src/lib/components/data-table/data-table-bulk-actions.tsx +5 -14
  30. package/src/lib/components/data-table/use-all-bulk-actions.ts +19 -0
  31. package/src/lib/components/data-table/use-generated-columns.tsx +12 -3
  32. package/src/lib/components/layout/nav-main.tsx +50 -25
  33. package/src/lib/components/shared/asset/asset-focal-point-editor.tsx +1 -1
  34. package/src/lib/components/shared/asset/asset-gallery.tsx +83 -50
  35. package/src/lib/components/shared/paginated-list-data-table.tsx +1 -0
  36. package/src/lib/components/shared/rich-text-editor/image-dialog.tsx +223 -0
  37. package/src/lib/components/shared/rich-text-editor/link-dialog.tsx +151 -0
  38. package/src/lib/components/shared/rich-text-editor/responsive-toolbar.tsx +439 -0
  39. package/src/lib/components/shared/rich-text-editor/rich-text-editor.tsx +338 -0
  40. package/src/lib/components/shared/rich-text-editor/table-delete-menu.tsx +104 -0
  41. package/src/lib/components/shared/rich-text-editor/table-edit-icons.tsx +225 -0
  42. package/src/lib/components/shared/vendure-image.tsx +9 -1
  43. package/src/lib/framework/defaults.ts +24 -0
  44. package/src/lib/framework/extension-api/types/navigation.ts +8 -0
  45. package/src/lib/framework/nav-menu/nav-menu-extensions.ts +26 -0
  46. package/src/lib/framework/page/list-page.tsx +7 -0
  47. package/src/lib/graphql/common-operations.ts +19 -0
  48. package/src/lib/graphql/fragments.ts +23 -13
  49. package/src/lib/hooks/use-custom-field-config.ts +19 -2
  50. package/src/lib/index.ts +0 -1
  51. package/src/lib/providers/channel-provider.tsx +22 -6
  52. package/src/lib/providers/server-config.tsx +1 -0
  53. package/src/app/routes/_authenticated/_collections/components/move-single-collection.tsx +0 -33
  54. package/src/lib/components/shared/asset/focal-point-control.tsx +0 -57
@@ -23,6 +23,8 @@ import { useDebounce } from '@uidotdev/usehooks';
23
23
  import { Loader2, Search, Upload, X } from 'lucide-react';
24
24
  import { useCallback, useState } from 'react';
25
25
  import { useDropzone } from 'react-dropzone';
26
+ import { tagListDocument } from '../../../../app/routes/_authenticated/_assets/assets.graphql.js';
27
+ import { AssetTagFilter } from '../../../../app/routes/_authenticated/_assets/components/asset-tag-filter.js';
26
28
  import { DetailPageButton } from '../detail-page-button.js';
27
29
  import { AssetBulkAction, AssetBulkActions } from './asset-bulk-actions.js';
28
30
 
@@ -74,7 +76,7 @@ export type Asset = AssetFragment;
74
76
  /**
75
77
  * @description
76
78
  * Props for the {@link AssetGallery} component.
77
- *
79
+ *
78
80
  * @docsCategory components
79
81
  * @docsPage AssetGallery
80
82
  */
@@ -134,16 +136,16 @@ export interface AssetGalleryProps {
134
136
  /**
135
137
  * @description
136
138
  * A component for displaying a gallery of assets.
137
- *
139
+ *
138
140
  * @example
139
141
  * ```tsx
140
142
  * <AssetGallery
141
- onSelect={handleAssetSelect}
142
- multiSelect="manual"
143
- initialSelectedAssets={initialSelectedAssets}
144
- fixedHeight={false}
145
- displayBulkActions={false}
146
- />
143
+ onSelect={handleAssetSelect}
144
+ multiSelect="manual"
145
+ initialSelectedAssets={initialSelectedAssets}
146
+ fixedHeight={false}
147
+ displayBulkActions={false}
148
+ />
147
149
  * ```
148
150
  *
149
151
  * @docsCategory components
@@ -169,9 +171,19 @@ export function AssetGallery({
169
171
  const debouncedSearch = useDebounce(search, 500);
170
172
  const [assetType, setAssetType] = useState<string>(AssetType.ALL);
171
173
  const [selected, setSelected] = useState<Asset[]>(initialSelectedAssets || []);
174
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
172
175
  const queryClient = useQueryClient();
173
176
 
174
- const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType];
177
+ const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType, selectedTags];
178
+
179
+ // Query for available tags to check if we should show the filter
180
+ const { data: tagsData } = useQuery({
181
+ queryKey: ['tags-check'],
182
+ queryFn: () => api.query(tagListDocument, { options: { take: 1 } }),
183
+ staleTime: 1000 * 60 * 5,
184
+ });
185
+
186
+ const hasTags = (tagsData?.tags.items?.length || 0) > 0;
175
187
 
176
188
  // Query for assets
177
189
  const { data, isLoading, refetch } = useQuery({
@@ -187,14 +199,20 @@ export function AssetGallery({
187
199
  filter.type = { eq: assetType };
188
200
  }
189
201
 
190
- return api.query(getAssetListDocument, {
191
- options: {
192
- skip: (page - 1) * pageSize,
193
- take: pageSize,
194
- filter: Object.keys(filter).length > 0 ? filter : undefined,
195
- sort: { createdAt: 'DESC' },
196
- },
197
- });
202
+ const options: any = {
203
+ skip: (page - 1) * pageSize,
204
+ take: pageSize,
205
+ filter: Object.keys(filter).length > 0 ? filter : undefined,
206
+ sort: { createdAt: 'DESC' },
207
+ };
208
+
209
+ // Add tag filtering if tags are provided
210
+ if (selectedTags && selectedTags.length > 0) {
211
+ options.tags = selectedTags;
212
+ options.tagsOperator = 'AND';
213
+ }
214
+
215
+ return api.query(getAssetListDocument, { options });
198
216
  },
199
217
  });
200
218
 
@@ -262,10 +280,17 @@ export function AssetGallery({
262
280
  // Check if an asset is selected
263
281
  const isSelected = (asset: Asset) => selected.some(a => a.id === asset.id);
264
282
 
283
+ // Handle tag changes
284
+ const handleTagsChange = (tags: string[]) => {
285
+ setSelectedTags(tags);
286
+ setPage(1); // Reset to page 1 when tags change
287
+ };
288
+
265
289
  // Clear filters
266
290
  const clearFilters = () => {
267
291
  setSearch('');
268
292
  setAssetType(AssetType.ALL);
293
+ setSelectedTags([]);
269
294
  setPage(1);
270
295
  };
271
296
 
@@ -294,40 +319,48 @@ export function AssetGallery({
294
319
  return (
295
320
  <div className={`relative flex flex-col w-full ${fixedHeight ? 'h-[600px]' : 'h-full'} ${className}`}>
296
321
  {showHeader && (
297
- <div className="flex flex-col md:flex-row gap-2 mb-4 flex-shrink-0">
298
- <div className="relative flex-grow flex items-center gap-2">
299
- <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
300
- <Input
301
- placeholder="Search assets..."
302
- value={search}
303
- onChange={e => setSearch(e.target.value)}
304
- className="pl-8"
305
- />
306
- {(search || assetType !== AssetType.ALL) && (
307
- <Button
308
- variant="ghost"
309
- size="sm"
310
- onClick={clearFilters}
311
- className="absolute right-0"
312
- >
313
- <X className="h-4 w-4 mr-1" /> Clear filters
314
- </Button>
315
- )}
322
+ <div className="space-y-4 mb-4 flex-shrink-0">
323
+ <div className="flex flex-col md:flex-row gap-2">
324
+ <div className="relative flex-grow flex items-center gap-2">
325
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
326
+ <Input
327
+ placeholder="Search assets..."
328
+ value={search}
329
+ onChange={e => setSearch(e.target.value)}
330
+ className="pl-8"
331
+ />
332
+ {(search || assetType !== AssetType.ALL || selectedTags.length > 0) && (
333
+ <Button
334
+ variant="ghost"
335
+ size="sm"
336
+ onClick={clearFilters}
337
+ className="absolute right-0"
338
+ >
339
+ <X className="h-4 w-4 mr-1" /> Clear filters
340
+ </Button>
341
+ )}
342
+ </div>
343
+ <Select value={assetType} onValueChange={setAssetType}>
344
+ <SelectTrigger className="w-full md:w-[180px]">
345
+ <SelectValue placeholder="Asset type" />
346
+ </SelectTrigger>
347
+ <SelectContent>
348
+ <SelectItem value={AssetType.ALL}>All types</SelectItem>
349
+ <SelectItem value={AssetType.IMAGE}>Images</SelectItem>
350
+ <SelectItem value={AssetType.VIDEO}>Video</SelectItem>
351
+ <SelectItem value={AssetType.BINARY}>Binary</SelectItem>
352
+ </SelectContent>
353
+ </Select>
354
+ <Button onClick={openFileDialog} className="whitespace-nowrap">
355
+ <Upload className="h-4 w-4 mr-2" /> <Trans>Upload</Trans>
356
+ </Button>
316
357
  </div>
317
- <Select value={assetType} onValueChange={setAssetType}>
318
- <SelectTrigger className="w-full md:w-[180px]">
319
- <SelectValue placeholder="Asset type" />
320
- </SelectTrigger>
321
- <SelectContent>
322
- <SelectItem value={AssetType.ALL}>All types</SelectItem>
323
- <SelectItem value={AssetType.IMAGE}>Images</SelectItem>
324
- <SelectItem value={AssetType.VIDEO}>Video</SelectItem>
325
- <SelectItem value={AssetType.BINARY}>Binary</SelectItem>
326
- </SelectContent>
327
- </Select>
328
- <Button onClick={openFileDialog} className="whitespace-nowrap">
329
- <Upload className="h-4 w-4 mr-2" /> <Trans>Upload</Trans>
330
- </Button>
358
+
359
+ {hasTags && (
360
+ <div className="flex items-center -mt-2">
361
+ <AssetTagFilter selectedTags={selectedTags} onTagsChange={handleTagsChange} />
362
+ </div>
363
+ )}
331
364
  </div>
332
365
  )}
333
366
 
@@ -437,6 +437,7 @@ export function PaginatedListDataTable<
437
437
  fields,
438
438
  customizeColumns,
439
439
  rowActions,
440
+ bulkActions,
440
441
  deleteMutation,
441
442
  additionalColumns,
442
443
  defaultColumnOrder,
@@ -0,0 +1,223 @@
1
+ import { Trans } from '@/vdb/lib/trans.js';
2
+ import { SetImageOptions } from '@tiptap/extension-image';
3
+ import { Editor } from '@tiptap/react';
4
+ import { ImageIcon, PaperclipIcon } from 'lucide-react';
5
+ import { useEffect, useState } from 'react';
6
+ import { Button } from '../../ui/button.js';
7
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog.js';
8
+ import { Input } from '../../ui/input.js';
9
+ import { Label } from '../../ui/label.js';
10
+ import { Asset } from '../asset/asset-gallery.js';
11
+ import { AssetPickerDialog } from '../asset/asset-picker-dialog.js';
12
+
13
+ export interface ImageDialogProps {
14
+ editor: Editor;
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ }
18
+
19
+ export function ImageDialog({ editor, isOpen, onClose }: Readonly<ImageDialogProps>) {
20
+ const [src, setSrc] = useState('');
21
+ const [alt, setAlt] = useState('');
22
+ const [title, setTitle] = useState('');
23
+ const [width, setWidth] = useState('');
24
+ const [height, setHeight] = useState('');
25
+ const [assetPickerOpen, setAssetPickerOpen] = useState(false);
26
+ const [previewUrl, setPreviewUrl] = useState('');
27
+
28
+ useEffect(() => {
29
+ if (isOpen) {
30
+ // Get current image attributes if editing existing image
31
+ const {
32
+ src: currentSrc,
33
+ alt: currentAlt,
34
+ title: currentTitle,
35
+ width: currentWidth,
36
+ height: currentHeight,
37
+ } = editor.getAttributes('image');
38
+
39
+ setSrc(currentSrc || '');
40
+ setAlt(currentAlt || '');
41
+ setTitle(currentTitle || '');
42
+ setWidth(currentWidth || '');
43
+ setHeight(currentHeight || '');
44
+ setPreviewUrl(currentSrc || '');
45
+ }
46
+ }, [isOpen, editor]);
47
+
48
+ const handleInsertImage = () => {
49
+ if (!src) {
50
+ return;
51
+ }
52
+
53
+ const attrs: SetImageOptions = {
54
+ src,
55
+ alt: alt || undefined,
56
+ title: title || undefined,
57
+ };
58
+
59
+ // Only add width/height if they are valid numbers
60
+ if (width && !isNaN(Number(width))) {
61
+ attrs.width = Number(width);
62
+ }
63
+ if (height && !isNaN(Number(height))) {
64
+ attrs.height = Number(height);
65
+ }
66
+
67
+ editor.chain().focus().setImage(attrs).run();
68
+
69
+ handleClose();
70
+ };
71
+
72
+ const handleClose = () => {
73
+ setSrc('');
74
+ setAlt('');
75
+ setTitle('');
76
+ setWidth('');
77
+ setHeight('');
78
+ setPreviewUrl('');
79
+ onClose();
80
+ };
81
+
82
+ const handleAssetSelect = (assets: Asset[]) => {
83
+ if (assets.length > 0) {
84
+ const asset = assets[0];
85
+ setSrc(asset.source);
86
+ setPreviewUrl(asset.preview);
87
+ // Set width and height from asset if available
88
+ if (asset.width) {
89
+ setWidth(asset.width.toString());
90
+ }
91
+ if (asset.height) {
92
+ setHeight(asset.height.toString());
93
+ }
94
+ }
95
+ setAssetPickerOpen(false);
96
+ };
97
+
98
+ const isEditing = editor.isActive('image');
99
+
100
+ return (
101
+ <>
102
+ <Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
103
+ <DialogContent className="sm:max-w-[525px]">
104
+ <DialogHeader>
105
+ <DialogTitle>
106
+ {isEditing ? <Trans>Edit image</Trans> : <Trans>Insert image</Trans>}
107
+ </DialogTitle>
108
+ </DialogHeader>
109
+ <div className="grid gap-4 py-4">
110
+ <div className="flex items-center justify-center">
111
+ {previewUrl ? (
112
+ <img
113
+ src={previewUrl}
114
+ alt={alt || 'Preview'}
115
+ className="max-w-[200px] max-h-[200px] object-contain border rounded"
116
+ />
117
+ ) : (
118
+ <div className="w-[200px] h-[200px] border rounded flex items-center justify-center bg-muted">
119
+ <ImageIcon className="w-16 h-16 text-muted-foreground" />
120
+ </div>
121
+ )}
122
+ </div>
123
+
124
+ <div className="flex items-center justify-center">
125
+ <Button
126
+ type="button"
127
+ variant="outline"
128
+ size="sm"
129
+ onClick={() => setAssetPickerOpen(true)}
130
+ >
131
+ <PaperclipIcon className="w-4 h-4 mr-2" />
132
+ <Trans>Add asset</Trans>
133
+ </Button>
134
+ </div>
135
+
136
+ <div className="grid gap-2">
137
+ <Label htmlFor="image-source">
138
+ <Trans>Source</Trans>
139
+ </Label>
140
+ <Input
141
+ id="image-source"
142
+ value={src}
143
+ onChange={e => {
144
+ setSrc(e.target.value);
145
+ setPreviewUrl(e.target.value);
146
+ }}
147
+ placeholder="https://example.com/image.jpg"
148
+ autoFocus
149
+ />
150
+ </div>
151
+
152
+ <div className="grid gap-2">
153
+ <Label htmlFor="image-title">
154
+ <Trans>Title</Trans>
155
+ </Label>
156
+ <Input
157
+ id="image-title"
158
+ value={title}
159
+ onChange={e => setTitle(e.target.value)}
160
+ placeholder=""
161
+ />
162
+ </div>
163
+
164
+ <div className="grid gap-2">
165
+ <Label htmlFor="image-alt">
166
+ <Trans>Description (alt)</Trans>
167
+ </Label>
168
+ <Input
169
+ id="image-alt"
170
+ value={alt}
171
+ onChange={e => setAlt(e.target.value)}
172
+ placeholder=""
173
+ />
174
+ </div>
175
+
176
+ <div className="grid grid-cols-2 gap-4">
177
+ <div className="grid gap-2">
178
+ <Label htmlFor="image-width">
179
+ <Trans>Width</Trans>
180
+ </Label>
181
+ <Input
182
+ id="image-width"
183
+ type="number"
184
+ value={width}
185
+ onChange={e => setWidth(e.target.value)}
186
+ placeholder="auto"
187
+ />
188
+ </div>
189
+ <div className="grid gap-2">
190
+ <Label htmlFor="image-height">
191
+ <Trans>Height</Trans>
192
+ </Label>
193
+ <Input
194
+ id="image-height"
195
+ type="number"
196
+ value={height}
197
+ onChange={e => setHeight(e.target.value)}
198
+ placeholder="auto"
199
+ />
200
+ </div>
201
+ </div>
202
+ </div>
203
+ <DialogFooter>
204
+ <Button type="button" variant="outline" onClick={handleClose}>
205
+ <Trans>Cancel</Trans>
206
+ </Button>
207
+ <Button type="button" onClick={handleInsertImage} disabled={!src}>
208
+ <Trans>Insert image</Trans>
209
+ </Button>
210
+ </DialogFooter>
211
+ </DialogContent>
212
+ </Dialog>
213
+
214
+ <AssetPickerDialog
215
+ open={assetPickerOpen}
216
+ onClose={() => setAssetPickerOpen(false)}
217
+ onSelect={handleAssetSelect}
218
+ multiSelect={false}
219
+ title="Select asset"
220
+ />
221
+ </>
222
+ );
223
+ }
@@ -0,0 +1,151 @@
1
+ import { Trans } from '@/vdb/lib/trans.js';
2
+ import { Editor } from '@tiptap/react';
3
+ import { useEffect, useState } from 'react';
4
+ import { Button } from '../../ui/button.js';
5
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog.js';
6
+ import { Input } from '../../ui/input.js';
7
+ import { Label } from '../../ui/label.js';
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select.js';
9
+
10
+ export interface LinkDialogProps {
11
+ editor: Editor;
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ }
15
+
16
+ export function LinkDialog({ editor, isOpen, onClose }: Readonly<LinkDialogProps>) {
17
+ const [href, setHref] = useState('');
18
+ const [title, setTitle] = useState('');
19
+ const [target, setTarget] = useState<'_self' | '_blank'>('_self');
20
+
21
+ useEffect(() => {
22
+ if (isOpen) {
23
+ // Get current link attributes if editing existing link
24
+ const {
25
+ href: currentHref,
26
+ target: currentTarget,
27
+ title: currentTitle,
28
+ } = editor.getAttributes('link');
29
+
30
+ setHref(currentHref || '');
31
+ setTitle(currentTitle || '');
32
+ setTarget(currentTarget === '_blank' ? '_blank' : '_self');
33
+
34
+ // If text is selected but no link, use selection as initial href if it looks like a URL
35
+ if (!currentHref) {
36
+ const { from, to } = editor.state.selection;
37
+ const selectedText = editor.state.doc.textBetween(from, to);
38
+ if (selectedText && (selectedText.startsWith('http') || selectedText.startsWith('www'))) {
39
+ setHref(selectedText);
40
+ }
41
+ }
42
+ }
43
+ }, [isOpen, editor]);
44
+
45
+ const handleSetLink = () => {
46
+ if (!href) {
47
+ // Remove link if href is empty
48
+ editor.chain().focus().unsetLink().run();
49
+ } else {
50
+ // Set link with all attributes
51
+ editor
52
+ .chain()
53
+ .focus()
54
+ .extendMarkRange('link')
55
+ .setLink({
56
+ href,
57
+ target,
58
+ rel: target === '_blank' ? 'noopener noreferrer' : undefined,
59
+ })
60
+ .run();
61
+ }
62
+ handleClose();
63
+ };
64
+
65
+ const handleClose = () => {
66
+ setHref('');
67
+ setTitle('');
68
+ setTarget('_self');
69
+ onClose();
70
+ };
71
+
72
+ const handleRemoveLink = () => {
73
+ editor.chain().focus().unsetLink().run();
74
+ handleClose();
75
+ };
76
+
77
+ const isEditing = editor.isActive('link');
78
+
79
+ return (
80
+ <Dialog open={isOpen} onOpenChange={open => !open && handleClose()}>
81
+ <DialogContent className="sm:max-w-[425px]">
82
+ <DialogHeader>
83
+ <DialogTitle>
84
+ {isEditing ? <Trans>Edit link</Trans> : <Trans>Insert link</Trans>}
85
+ </DialogTitle>
86
+ </DialogHeader>
87
+ <div className="grid gap-4 py-4">
88
+ <div className="grid grid-cols-4 items-center gap-4">
89
+ <Label htmlFor="link-href" className="text-right">
90
+ <Trans>Link href</Trans>
91
+ </Label>
92
+ <Input
93
+ id="link-href"
94
+ value={href}
95
+ onChange={e => setHref(e.target.value)}
96
+ placeholder="https://example.com"
97
+ className="col-span-3"
98
+ autoFocus
99
+ />
100
+ </div>
101
+ <div className="grid grid-cols-4 items-center gap-4">
102
+ <Label htmlFor="link-title" className="text-right">
103
+ <Trans>Link title</Trans>
104
+ </Label>
105
+ <Input
106
+ id="link-title"
107
+ value={title}
108
+ onChange={e => setTitle(e.target.value)}
109
+ placeholder=""
110
+ className="col-span-3"
111
+ />
112
+ </div>
113
+ <div className="grid grid-cols-4 items-center gap-4">
114
+ <Label htmlFor="link-target" className="text-right">
115
+ <Trans>Link target</Trans>
116
+ </Label>
117
+ <Select
118
+ value={target}
119
+ onValueChange={value => setTarget(value as '_self' | '_blank')}
120
+ >
121
+ <SelectTrigger className="col-span-3">
122
+ <SelectValue />
123
+ </SelectTrigger>
124
+ <SelectContent>
125
+ <SelectItem value="_self">
126
+ <Trans>Same window</Trans>
127
+ </SelectItem>
128
+ <SelectItem value="_blank">
129
+ <Trans>New window</Trans>
130
+ </SelectItem>
131
+ </SelectContent>
132
+ </Select>
133
+ </div>
134
+ </div>
135
+ <DialogFooter>
136
+ {isEditing && (
137
+ <Button type="button" variant="destructive" onClick={handleRemoveLink}>
138
+ <Trans>Remove link</Trans>
139
+ </Button>
140
+ )}
141
+ <Button type="button" variant="outline" onClick={handleClose}>
142
+ <Trans>Cancel</Trans>
143
+ </Button>
144
+ <Button type="button" onClick={handleSetLink}>
145
+ <Trans>Set link</Trans>
146
+ </Button>
147
+ </DialogFooter>
148
+ </DialogContent>
149
+ </Dialog>
150
+ );
151
+ }