@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.
- package/dist/vite/vite-plugin-config.js +1 -0
- package/package.json +11 -7
- package/src/app/common/duplicate-bulk-action.tsx +37 -23
- package/src/app/common/duplicate-entity-dialog.tsx +117 -0
- package/src/app/routes/_authenticated/_administrators/administrators.tsx +1 -2
- package/src/app/routes/_authenticated/_assets/assets.graphql.ts +39 -0
- package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +18 -7
- package/src/app/routes/_authenticated/_assets/components/asset-tag-filter.tsx +206 -0
- package/src/app/routes/_authenticated/_assets/components/asset-tags-editor.tsx +226 -0
- package/src/app/routes/_authenticated/_assets/components/manage-tags-dialog.tsx +217 -0
- package/src/app/routes/_authenticated/_channels/channels.tsx +1 -2
- package/src/app/routes/_authenticated/_collections/collections.tsx +2 -16
- package/src/app/routes/_authenticated/_countries/countries.tsx +1 -2
- package/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx +1 -2
- package/src/app/routes/_authenticated/_customers/customers.tsx +1 -2
- package/src/app/routes/_authenticated/_facets/facets.tsx +0 -1
- package/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx +1 -2
- package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +1 -2
- package/src/app/routes/_authenticated/_products/products.tsx +1 -2
- package/src/app/routes/_authenticated/_promotions/promotions.tsx +1 -2
- package/src/app/routes/_authenticated/_roles/roles.tsx +1 -2
- package/src/app/routes/_authenticated/_sellers/sellers.tsx +1 -2
- package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +1 -2
- package/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx +1 -2
- package/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx +1 -2
- package/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx +1 -2
- package/src/app/routes/_authenticated/_zones/zones.tsx +1 -2
- package/src/lib/components/data-input/rich-text-input.tsx +2 -115
- package/src/lib/components/data-table/data-table-bulk-actions.tsx +5 -14
- package/src/lib/components/data-table/use-all-bulk-actions.ts +19 -0
- package/src/lib/components/data-table/use-generated-columns.tsx +12 -3
- package/src/lib/components/layout/nav-main.tsx +50 -25
- package/src/lib/components/shared/asset/asset-focal-point-editor.tsx +1 -1
- package/src/lib/components/shared/asset/asset-gallery.tsx +83 -50
- package/src/lib/components/shared/paginated-list-data-table.tsx +1 -0
- package/src/lib/components/shared/rich-text-editor/image-dialog.tsx +223 -0
- package/src/lib/components/shared/rich-text-editor/link-dialog.tsx +151 -0
- package/src/lib/components/shared/rich-text-editor/responsive-toolbar.tsx +439 -0
- package/src/lib/components/shared/rich-text-editor/rich-text-editor.tsx +338 -0
- package/src/lib/components/shared/rich-text-editor/table-delete-menu.tsx +104 -0
- package/src/lib/components/shared/rich-text-editor/table-edit-icons.tsx +225 -0
- package/src/lib/components/shared/vendure-image.tsx +9 -1
- package/src/lib/framework/defaults.ts +24 -0
- package/src/lib/framework/extension-api/types/navigation.ts +8 -0
- package/src/lib/framework/nav-menu/nav-menu-extensions.ts +26 -0
- package/src/lib/framework/page/list-page.tsx +7 -0
- package/src/lib/graphql/common-operations.ts +19 -0
- package/src/lib/graphql/fragments.ts +23 -13
- package/src/lib/hooks/use-custom-field-config.ts +19 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/providers/channel-provider.tsx +22 -6
- package/src/lib/providers/server-config.tsx +1 -0
- package/src/app/routes/_authenticated/_collections/components/move-single-collection.tsx +0 -33
- 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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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="
|
|
298
|
-
<div className="
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
|
@@ -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
|
+
}
|