includio-cms 0.14.4 → 0.14.6

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 (48) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/DOCS.md +1 -1
  3. package/ROADMAP.md +11 -0
  4. package/dist/admin/client/account/profile-section.svelte +2 -2
  5. package/dist/admin/client/admin/admin-after-login-layout-content.svelte +12 -1
  6. package/dist/admin/client/collection/collection-entries.svelte +48 -4
  7. package/dist/admin/client/login/login-page.svelte +4 -0
  8. package/dist/admin/client/login/reset-password-page.svelte +4 -0
  9. package/dist/admin/client/maintenance/maintenance-page.svelte +12 -0
  10. package/dist/admin/client/users/accept-invite-page.svelte +4 -0
  11. package/dist/admin/client/users/users-page.svelte +10 -0
  12. package/dist/admin/components/fields/media-field.svelte +203 -253
  13. package/dist/admin/components/fields/relation-field.svelte +4 -9
  14. package/dist/admin/components/fields/url-field.svelte +42 -18
  15. package/dist/admin/components/layout/nav-footer.svelte +12 -9
  16. package/dist/admin/components/media/file/file-details.svelte +115 -18
  17. package/dist/admin/components/media/file/file-miniature.svelte +2 -2
  18. package/dist/admin/components/media/media-selector.svelte +9 -8
  19. package/dist/admin/remote/media.remote.d.ts +6 -4
  20. package/dist/admin/remote/media.remote.js +17 -1
  21. package/dist/admin/utils/debounce.d.ts +1 -0
  22. package/dist/admin/utils/debounce.js +8 -0
  23. package/dist/admin/utils/entryThumbnail.d.ts +6 -1
  24. package/dist/admin/utils/entryThumbnail.js +22 -13
  25. package/dist/admin/utils/pageTitle.d.ts +8 -0
  26. package/dist/admin/utils/pageTitle.js +15 -0
  27. package/dist/cms/runtime/types.d.ts +4 -4
  28. package/dist/core/cms.d.ts +1 -0
  29. package/dist/core/cms.js +2 -0
  30. package/dist/core/server/media/operations/batchRegenerateVideoPosters.d.ts +12 -0
  31. package/dist/core/server/media/operations/batchRegenerateVideoPosters.js +46 -42
  32. package/dist/core/server/media/operations/regenerateVideoPoster.d.ts +5 -0
  33. package/dist/core/server/media/operations/regenerateVideoPoster.js +17 -0
  34. package/dist/core/server/media/operations/replaceFile.js +8 -4
  35. package/dist/core/server/media/operations/uploadFile.js +7 -3
  36. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +11 -0
  37. package/dist/core/server/media/utils/generateAdminThumbnail.d.ts +3 -0
  38. package/dist/core/server/media/utils/generateAdminThumbnail.js +33 -0
  39. package/dist/db-postgres/schema/imageStyle.js +5 -4
  40. package/dist/db-postgres/schema/videoStyle.js +2 -4
  41. package/dist/files-local/video.js +3 -18
  42. package/dist/types/cms.d.ts +1 -0
  43. package/dist/updates/0.14.5/index.d.ts +2 -0
  44. package/dist/updates/0.14.5/index.js +11 -0
  45. package/dist/updates/0.14.6/index.d.ts +2 -0
  46. package/dist/updates/0.14.6/index.js +21 -0
  47. package/dist/updates/index.js +3 -1
  48. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import * as Dialog from '../../../components/ui/dialog/index.js';
3
+ import * as Tooltip from '../../../components/ui/tooltip/index.js';
3
4
  import { onMount } from 'svelte';
4
5
  import Button from '../../../components/ui/button/button.svelte';
5
6
  import type { MediaField } from '../../../types/fields.js';
@@ -9,9 +10,9 @@
9
10
  import X from '@tabler/icons-svelte/icons/x';
10
11
  import ChevronLeft from '@tabler/icons-svelte/icons/chevron-left';
11
12
  import ChevronRight from '@tabler/icons-svelte/icons/chevron-right';
12
- import Upload from '@tabler/icons-svelte/icons/upload';
13
- import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
14
13
  import AlertTriangle from '@tabler/icons-svelte/icons/alert-triangle';
14
+ import Video from '@tabler/icons-svelte/icons/video';
15
+ import Photo from '@tabler/icons-svelte/icons/photo';
15
16
  import FileMiniature from '../media/file/file-miniature.svelte';
16
17
  import { getRemotes } from '../../../sveltekit/index.js';
17
18
  import MediaSelector from '../media/media-selector.svelte';
@@ -24,11 +25,11 @@
24
25
  remove: string;
25
26
  change: string;
26
27
  of: string;
27
- transcript: string;
28
- audioDescription: string;
29
- addTranscript: string;
30
- addAudioDescription: string;
31
- removeFile: string;
28
+ a11yBadge: string;
29
+ a11yMissingTranscript: string;
30
+ a11yMissingAudioDesc: string;
31
+ a11yMissingBoth: string;
32
+ a11yHint: string;
32
33
  }
33
34
  > = {
34
35
  pl: {
@@ -36,22 +37,22 @@
36
37
  remove: 'Usuń',
37
38
  change: 'Zmień',
38
39
  of: 'z',
39
- transcript: 'Transkrypcja',
40
- audioDescription: 'Audiodeskrypcja',
41
- addTranscript: 'Dodaj transkrypcję',
42
- addAudioDescription: 'Dodaj audiodeskrypcję',
43
- removeFile: 'Usuń'
40
+ a11yBadge: 'Dostępność',
41
+ a11yMissingTranscript: 'Brakuje transkrypcji',
42
+ a11yMissingAudioDesc: 'Brakuje audiodeskrypcji',
43
+ a11yMissingBoth: 'Brakuje transkrypcji i audiodeskrypcji',
44
+ a11yHint: 'Uzupełnij w bibliotece mediów'
44
45
  },
45
46
  en: {
46
47
  selectMedia: 'Select media',
47
48
  remove: 'Remove',
48
49
  change: 'Change',
49
50
  of: 'of',
50
- transcript: 'Transcript',
51
- audioDescription: 'Audio description',
52
- addTranscript: 'Add transcript',
53
- addAudioDescription: 'Add audio description',
54
- removeFile: 'Remove'
51
+ a11yBadge: 'Accessibility',
52
+ a11yMissingTranscript: 'Transcript missing',
53
+ a11yMissingAudioDesc: 'Audio description missing',
54
+ a11yMissingBoth: 'Transcript and audio description missing',
55
+ a11yHint: 'Add it in the media library'
55
56
  }
56
57
  };
57
58
 
@@ -61,49 +62,74 @@
61
62
  let currentIndex = $state(0);
62
63
  let lightboxOpen = $state(false);
63
64
  let lightboxFile = $state<MediaFile | null>(null);
64
-
65
- // Accessibility dialogs
66
- let transcriptDialogOpen = $state(false);
67
- let audioDescDialogOpen = $state(false);
68
- let transcriptSelected = $state<string>('');
69
- let audioDescSelected = $state<string>('');
70
- let currentVideoFile = $state<MediaFile | null>(null);
65
+ let dialogOpen = $state(false);
71
66
 
72
67
  function openLightbox(file: MediaFile) {
73
68
  lightboxFile = file;
74
69
  lightboxOpen = true;
75
70
  }
76
71
 
77
- $effect(() => {
78
- if (transcriptSelected && currentVideoFile) {
79
- remotes.updateMediaAccessibility({
80
- fileId: currentVideoFile.id,
81
- transcriptFileId: transcriptSelected
82
- });
83
- currentVideoFile.transcriptFileId = transcriptSelected;
84
- transcriptDialogOpen = false;
85
- transcriptSelected = '';
86
- }
87
- });
72
+ function openPicker() {
73
+ dialogOpen = true;
74
+ }
88
75
 
89
- $effect(() => {
90
- if (audioDescSelected && currentVideoFile) {
91
- remotes.updateMediaAccessibility({
92
- fileId: currentVideoFile.id,
93
- audioDescriptionFileId: audioDescSelected
94
- });
95
- currentVideoFile.audioDescriptionFileId = audioDescSelected;
96
- audioDescDialogOpen = false;
97
- audioDescSelected = '';
98
- }
99
- });
76
+ function a11yMissingText(file: MediaFile): string {
77
+ const t = lang[interfaceLanguage.current];
78
+ const noTranscript = !file.transcriptFileId;
79
+ const noAudioDesc = !file.audioDescriptionFileId;
80
+ if (noTranscript && noAudioDesc) return t.a11yMissingBoth;
81
+ if (noTranscript) return t.a11yMissingTranscript;
82
+ return t.a11yMissingAudioDesc;
83
+ }
100
84
 
101
85
  type Props = {
102
86
  field: MediaField;
103
87
  value: string | string[] | undefined;
104
88
  };
105
89
 
106
- let { field, value = $bindable(), ...props }: Props = $props();
90
+ let { field, value = $bindable() }: Props = $props();
91
+
92
+ // Single-file state: fetch MediaFile via a plain fetch inside an $effect instead of
93
+ // `$derived(remotes.getFileById(value))`. Reason: wrapping the remote query in a
94
+ // $derived couples its lifecycle to the derivation's refcounting — rapid toggling
95
+ // of value ('' → id) around dialog unmount triggered "query instance is no longer
96
+ // active" errors. A plain fetch bypasses the SvelteKit remote query cache
97
+ // refcounting entirely.
98
+ let singleFile = $state<MediaFile | null>(null);
99
+ let singleFileReady = $state(false);
100
+ $effect(() => {
101
+ const v = value;
102
+ if (typeof v !== 'string' || !v) {
103
+ singleFile = null;
104
+ singleFileReady = true;
105
+ return;
106
+ }
107
+ singleFileReady = false;
108
+ let cancelled = false;
109
+ (async () => {
110
+ try {
111
+ const data = await remotes.getFileById(v);
112
+ if (!cancelled) {
113
+ singleFile = data as MediaFile;
114
+ singleFileReady = true;
115
+ }
116
+ } catch {
117
+ if (!cancelled) {
118
+ singleFile = null;
119
+ singleFileReady = true;
120
+ }
121
+ }
122
+ })();
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ });
127
+
128
+ const mFilesQuery = $derived(
129
+ Array.isArray(value) && value.length > 0
130
+ ? remotes.getMediaFiles({ data: { ids: value as string[] } })
131
+ : null
132
+ );
107
133
 
108
134
  onMount(() => {
109
135
  if (value === undefined) {
@@ -112,213 +138,156 @@
112
138
  });
113
139
  </script>
114
140
 
141
+ {#snippet typeBadge(file: MediaFile)}
142
+ <div class="absolute top-1 right-1 rounded-sm bg-background/80 backdrop-blur-sm p-0.5 pointer-events-none">
143
+ {#if file.type === 'video'}
144
+ <Video class="text-muted-foreground" />
145
+ {:else if file.type === 'image'}
146
+ <Photo class="text-muted-foreground" />
147
+ {/if}
148
+ </div>
149
+ {/snippet}
150
+
151
+ {#snippet a11yBadge(file: MediaFile)}
152
+ {#if file.type === 'video' && (!file.transcriptFileId || !file.audioDescriptionFileId)}
153
+ <Tooltip.Provider>
154
+ <Tooltip.Root>
155
+ <Tooltip.Trigger
156
+ type="button"
157
+ onclick={(e) => { e.preventDefault(); e.stopPropagation(); }}
158
+ aria-label={`${lang[interfaceLanguage.current].a11yBadge}: ${a11yMissingText(file)}. ${lang[interfaceLanguage.current].a11yHint}`}
159
+ class="absolute top-1 left-1 inline-flex items-center gap-1 rounded-full bg-background/85 backdrop-blur-sm px-2 py-0.5 text-[11px] font-medium text-warning border border-warning/40 shadow-sm cursor-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-warning/40"
160
+ >
161
+ <AlertTriangle class="h-3 w-3" />
162
+ <span>{lang[interfaceLanguage.current].a11yBadge}</span>
163
+ </Tooltip.Trigger>
164
+ <Tooltip.Content class="max-w-[220px] text-xs">
165
+ <div class="font-medium">{a11yMissingText(file)}</div>
166
+ <div class="text-[11px] opacity-80 mt-0.5">{lang[interfaceLanguage.current].a11yHint}</div>
167
+ </Tooltip.Content>
168
+ </Tooltip.Root>
169
+ </Tooltip.Provider>
170
+ {/if}
171
+ {/snippet}
172
+
115
173
  {#snippet videoPreview(file: MediaFile)}
116
- <div class="w-full h-full">
117
- tt<!-- svelte-ignore a11y_media_has_caption -->
174
+ <div class="ml-checkered-bg relative aspect-square w-full overflow-hidden rounded-2xl border">
175
+ <!-- svelte-ignore a11y_media_has_caption -->
118
176
  <video
119
177
  controls
120
178
  poster={file.posterUrl || file.thumbnailUrl || undefined}
121
- class="w-full h-full object-contain bg-black rounded-xl"
179
+ class="w-full h-full object-contain"
122
180
  >
123
181
  <source src={file.url} type={file.mimeType || undefined} />
124
182
  </video>
183
+ {@render typeBadge(file)}
184
+ {@render a11yBadge(file)}
125
185
  </div>
126
186
  {/snippet}
127
187
 
128
- {#snippet accessibilityPanel(file: MediaFile)}
129
- {#if file.type === 'video'}
130
- <div class="mt-2 space-y-1.5">
131
- <!-- Transcript -->
132
- <div class="flex items-center justify-between gap-2 rounded-lg border p-1.5">
133
- <div class="flex items-center gap-1.5 text-xs">
134
- {#if file.transcriptFileId}
135
- <CircleCheck class="h-3.5 w-3.5 text-green-500" />
136
- {:else}
137
- <AlertTriangle class="h-3.5 w-3.5 text-yellow-500" />
138
- {/if}
139
- <span class={file.transcriptFileId ? '' : 'text-muted-foreground'}>{lang[interfaceLanguage.current].transcript}</span>
140
- </div>
141
- <div class="flex gap-1">
142
- {#if file.transcriptFileId}
143
- <Button size="sm" variant="ghost" class="h-5 text-[10px] px-1.5" onclick={() => {
144
- remotes.updateMediaAccessibility({ fileId: file.id, transcriptFileId: null });
145
- file.transcriptFileId = null;
146
- }}>
147
- {lang[interfaceLanguage.current].removeFile}
148
- </Button>
149
- {/if}
150
- <Button size="sm" variant="outline" class="h-5 text-[10px] px-1.5" onclick={() => {
151
- currentVideoFile = file;
152
- transcriptDialogOpen = true;
153
- }}>
154
- <Upload class="h-3 w-3 mr-0.5" />
155
- {lang[interfaceLanguage.current].addTranscript}
156
- </Button>
157
- </div>
158
- </div>
188
+ {#snippet imagePreview(file: MediaFile)}
189
+ <button
190
+ type="button"
191
+ class="relative aspect-square w-full overflow-hidden rounded-2xl border cursor-zoom-in"
192
+ onclick={() => openLightbox(file)}
193
+ >
194
+ <FileMiniature {file} />
195
+ </button>
196
+ {/snippet}
159
197
 
160
- <!-- Audio Description -->
161
- <div class="flex items-center justify-between gap-2 rounded-lg border p-1.5">
162
- <div class="flex items-center gap-1.5 text-xs">
163
- {#if file.audioDescriptionFileId}
164
- <CircleCheck class="h-3.5 w-3.5 text-green-500" />
165
- {:else}
166
- <AlertTriangle class="h-3.5 w-3.5 text-yellow-500" />
167
- {/if}
168
- <span class={file.audioDescriptionFileId ? '' : 'text-muted-foreground'}>{lang[interfaceLanguage.current].audioDescription}</span>
169
- </div>
170
- <div class="flex gap-1">
171
- {#if file.audioDescriptionFileId}
172
- <Button size="sm" variant="ghost" class="h-5 text-[10px] px-1.5" onclick={() => {
173
- remotes.updateMediaAccessibility({ fileId: file.id, audioDescriptionFileId: null });
174
- file.audioDescriptionFileId = null;
175
- }}>
176
- {lang[interfaceLanguage.current].removeFile}
177
- </Button>
178
- {/if}
179
- <Button size="sm" variant="outline" class="h-5 text-[10px] px-1.5" onclick={() => {
180
- currentVideoFile = file;
181
- audioDescDialogOpen = true;
182
- }}>
183
- <Upload class="h-3 w-3 mr-0.5" />
184
- {lang[interfaceLanguage.current].addAudioDescription}
185
- </Button>
186
- </div>
187
- </div>
188
- </div>
189
- {/if}
198
+ {#snippet mediaActions(onRemove: () => void)}
199
+ <div class="flex items-center justify-between gap-2 mt-1.5">
200
+ <Button size="sm" variant="secondary" class="h-8" onclick={openPicker}>
201
+ <Plus class="h-4 w-4" />
202
+ {lang[interfaceLanguage.current].change}
203
+ </Button>
204
+ {#if !field.required}
205
+ <Button variant="destructive" size="sm" class="h-8" onclick={onRemove}>
206
+ <X class="h-4 w-4" />
207
+ {lang[interfaceLanguage.current].remove}
208
+ </Button>
209
+ {/if}
210
+ </div>
190
211
  {/snippet}
191
212
 
192
213
  {#if value !== undefined}
193
- <Dialog.Root>
194
- <Dialog.Trigger>
195
- {#snippet child({ props })}
196
- {#if value && ((typeof value === 'string' && value !== '') || (Array.isArray(value) && value.length > 0))}
197
- <div class="max-w-96">
198
- {#if typeof value === 'string'}
199
- {@const fileQuery = remotes.getFileById(value)}
200
- {#if !fileQuery.ready}
201
- <div class="animate-pulse bg-muted w-full aspect-video rounded-xl"></div>
202
- {:else if fileQuery.current}
203
- {@const file = fileQuery.current}
204
- {#if file.type === 'video'}
205
- <div class="overflow-hidden rounded-2xl border">
206
- {@render videoPreview(file)}
207
- <div class="flex items-center justify-between gap-2 p-2 bg-gradient-to-t from-muted/50">
208
- <Button {...props} size="sm" variant="secondary" class="h-8">
209
- <Plus class="h-4 w-4" />
210
- {lang[interfaceLanguage.current].change}
211
- </Button>
212
- {#if !field.required}
213
- <Button variant="destructive" size="sm" class="h-8" onclick={(e) => { e.stopPropagation(); value = ''; }}>
214
- <X class="h-4 w-4" />
215
- {lang[interfaceLanguage.current].remove}
216
- </Button>
217
- {/if}
218
- </div>
219
- {@render accessibilityPanel(file)}
220
- </div>
221
- {:else}
222
- <div class="group/dialog-trigger relative aspect-square max-w-64 overflow-hidden rounded-2xl border">
223
- <button
224
- type="button"
225
- class="p-1 w-full h-full cursor-zoom-in"
226
- onclick={(e) => { e.stopPropagation(); e.preventDefault(); openLightbox(file); }}
227
- >
228
- <FileMiniature {file} />
229
- </button>
230
- <div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-plum-darker/50 to-transparent p-2 pt-8">
231
- <Button {...props} size="sm" variant="secondary" class="h-8">
232
- <Plus class="h-4 w-4" />
233
- {lang[interfaceLanguage.current].change}
234
- </Button>
235
- {#if !field.required}
236
- <Button variant="destructive" size="sm" class="h-8" onclick={(e) => { e.stopPropagation(); value = ''; }}>
237
- <X class="h-4 w-4" />
238
- {lang[interfaceLanguage.current].remove}
239
- </Button>
240
- {/if}
241
- </div>
242
- </div>
243
- {/if}
214
+ {#if value && ((typeof value === 'string' && value !== '') || (Array.isArray(value) && value.length > 0))}
215
+ <div class="max-w-64">
216
+ {#if typeof value === 'string'}
217
+ {#if !singleFileReady}
218
+ <div class="animate-pulse bg-muted w-full aspect-square rounded-2xl"></div>
219
+ {:else if singleFile}
220
+ {@const file = singleFile}
221
+ {#if file.type === 'video'}
222
+ {@render videoPreview(file)}
223
+ {:else}
224
+ {@render imagePreview(file)}
225
+ {/if}
226
+ {@render mediaActions(() => { value = ''; })}
227
+ {/if}
228
+ {:else if Array.isArray(value) && value.length > 0}
229
+ {@const valueArr = value}
230
+ {#if !mFilesQuery?.ready}
231
+ <div class="animate-pulse bg-muted w-full aspect-square rounded-2xl"></div>
232
+ {:else if mFilesQuery?.current}
233
+ {@const file = mFilesQuery.current[currentIndex]}
234
+ {#if file}
235
+ <div class="relative">
236
+ {#if file.type === 'video'}
237
+ {@render videoPreview(file)}
238
+ {:else}
239
+ {@render imagePreview(file)}
244
240
  {/if}
245
- {:else if Array.isArray(value) && value.length > 0}
246
- {@const valueArr = value}
247
- {@const mFilesQuery = remotes.getMediaFiles({ data: { ids: valueArr } })}
248
- {#if !mFilesQuery.ready}
249
- <div class="animate-pulse bg-muted w-full aspect-video rounded-xl"></div>
250
- {:else if mFilesQuery.current}
251
- {@const file = mFilesQuery.current[currentIndex]}
252
- {#if file}
253
- <div class="group/dialog-trigger relative overflow-hidden rounded-2xl border">
254
- {#if file.type === 'video'}
255
- {@render videoPreview(file)}
256
- {:else}
257
- <button
258
- type="button"
259
- class="p-1 w-full aspect-square cursor-zoom-in"
260
- onclick={(e) => { e.stopPropagation(); e.preventDefault(); openLightbox(file); }}
261
- >
262
- <FileMiniature {file} />
263
- </button>
264
- {/if}
265
-
266
- {#if valueArr.length > 1}
267
- <div class="absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-1 pointer-events-none">
268
- <button
269
- type="button"
270
- class="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full bg-white/80 backdrop-blur shadow-md transition hover:bg-white hover:scale-105 dark:bg-background/80 dark:hover:bg-background"
271
- onclick={(e) => { e.stopPropagation(); currentIndex = currentIndex > 0 ? currentIndex - 1 : valueArr.length - 1; }}
272
- >
273
- <ChevronLeft class="h-5 w-5" />
274
- </button>
275
- <button
276
- type="button"
277
- class="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full bg-white/80 backdrop-blur shadow-md transition hover:bg-white hover:scale-105 dark:bg-background/80 dark:hover:bg-background"
278
- onclick={(e) => { e.stopPropagation(); currentIndex = currentIndex < valueArr.length - 1 ? currentIndex + 1 : 0; }}
279
- >
280
- <ChevronRight class="h-5 w-5" />
281
- </button>
282
- </div>
283
- <div class="absolute top-2 right-2 rounded-full bg-plum-darker/60 px-2 py-0.5 text-xs font-medium text-white backdrop-blur">
284
- {currentIndex + 1} / {valueArr.length}
285
- </div>
286
- {/if}
287
241
 
288
- <div class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-plum-darker/50 to-transparent p-2 pt-8">
289
- <Button {...props} size="sm" variant="secondary" class="h-8">
290
- <Plus class="h-4 w-4" />
291
- {lang[interfaceLanguage.current].change}
292
- </Button>
293
- {#if !field.required}
294
- <Button variant="destructive" size="sm" class="h-8" onclick={(e) => { e.stopPropagation(); value = field.multiple ? [] : ''; }}>
295
- <X class="h-4 w-4" />
296
- {lang[interfaceLanguage.current].remove}
297
- </Button>
298
- {/if}
299
- </div>
300
- </div>
301
- {@render accessibilityPanel(file)}
302
- {/if}
242
+ {#if valueArr.length > 1}
243
+ <div class="absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-between px-1 pointer-events-none">
244
+ <button
245
+ type="button"
246
+ class="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full bg-white/80 backdrop-blur shadow-md transition hover:bg-white hover:scale-105 dark:bg-background/80 dark:hover:bg-background"
247
+ onclick={() => { currentIndex = currentIndex > 0 ? currentIndex - 1 : valueArr.length - 1; }}
248
+ >
249
+ <ChevronLeft class="h-5 w-5" />
250
+ </button>
251
+ <button
252
+ type="button"
253
+ class="pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full bg-white/80 backdrop-blur shadow-md transition hover:bg-white hover:scale-105 dark:bg-background/80 dark:hover:bg-background"
254
+ onclick={() => { currentIndex = currentIndex < valueArr.length - 1 ? currentIndex + 1 : 0; }}
255
+ >
256
+ <ChevronRight class="h-5 w-5" />
257
+ </button>
258
+ </div>
259
+ <div class="absolute top-2 right-2 rounded-full bg-plum-darker/60 px-2 py-0.5 text-xs font-medium text-white backdrop-blur">
260
+ {currentIndex + 1} / {valueArr.length}
261
+ </div>
303
262
  {/if}
304
- {/if}
305
- </div>
306
- {:else}
307
- <button
308
- {...props}
309
- type="button"
310
- class="flex aspect-video max-w-64 w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border bg-card/80 p-6 transition-colors hover:border-primary/50 hover:bg-card"
311
- >
312
- <div class="rounded-full bg-muted p-3">
313
- <Plus class="h-6 w-6 text-muted-foreground" />
314
263
  </div>
315
- <span class="text-sm text-muted-foreground">{lang[interfaceLanguage.current].selectMedia}</span>
316
- </button>
264
+ {@render mediaActions(() => { value = field.multiple ? [] : ''; })}
265
+ {/if}
317
266
  {/if}
318
- {/snippet}
319
- </Dialog.Trigger>
267
+ {/if}
268
+ </div>
269
+ {:else}
270
+ <button
271
+ type="button"
272
+ class="flex aspect-square max-w-64 w-full flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border bg-card/80 p-6 transition-colors hover:border-primary/50 hover:bg-card"
273
+ onclick={openPicker}
274
+ >
275
+ <div class="rounded-full bg-muted p-3">
276
+ <Plus class="h-6 w-6 text-muted-foreground" />
277
+ </div>
278
+ <span class="text-sm text-muted-foreground">{lang[interfaceLanguage.current].selectMedia}</span>
279
+ </button>
280
+ {/if}
281
+
282
+ <Dialog.Root bind:open={dialogOpen}>
320
283
  <Dialog.Content class="h-[85vh] w-full max-w-6xl! sm:max-w-6xl! overflow-hidden p-0 flex flex-col">
321
- <MediaSelector bind:selected={value} multiple={field.multiple} accept={field.accept} />
284
+ <MediaSelector
285
+ bind:selected={value}
286
+ multiple={field.multiple}
287
+ accept={field.accept}
288
+ onConfirm={() => (dialogOpen = false)}
289
+ onCancel={() => (dialogOpen = false)}
290
+ />
322
291
  </Dialog.Content>
323
292
  </Dialog.Root>
324
293
 
@@ -336,25 +305,6 @@ tt<!-- svelte-ignore a11y_media_has_caption -->
336
305
  {/if}
337
306
  </Dialog.Content>
338
307
  </Dialog.Root>
339
-
340
- <!-- Accessibility dialogs -->
341
- <Dialog.Root bind:open={transcriptDialogOpen}>
342
- <Dialog.Content class="max-w-5xl! sm:max-w-5xl!">
343
- <Dialog.Header>
344
- <Dialog.Title>{lang[interfaceLanguage.current].addTranscript}</Dialog.Title>
345
- </Dialog.Header>
346
- <MediaSelector bind:selected={transcriptSelected} />
347
- </Dialog.Content>
348
- </Dialog.Root>
349
-
350
- <Dialog.Root bind:open={audioDescDialogOpen}>
351
- <Dialog.Content class="max-w-5xl! sm:max-w-5xl!">
352
- <Dialog.Header>
353
- <Dialog.Title>{lang[interfaceLanguage.current].addAudioDescription}</Dialog.Title>
354
- </Dialog.Header>
355
- <MediaSelector bind:selected={audioDescSelected} accept="audio/*" />
356
- </Dialog.Content>
357
- </Dialog.Root>
358
308
  {/if}
359
309
 
360
310
  <style>
@@ -19,6 +19,7 @@
19
19
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
20
20
  import { droppable, draggable } from '@thisux/sveltednd';
21
21
  import { arrayMove } from '../../utils/arrayMove.js';
22
+ import { debounce } from '../../utils/debounce.js';
22
23
  import { flip } from 'svelte/animate';
23
24
  import { fade } from 'svelte/transition';
24
25
  import RelationPickerDialog from './relation-picker-dialog.svelte';
@@ -171,13 +172,7 @@
171
172
  // Auto-detect mode: <=30 published → inline popover, >30 → dialog
172
173
  let useDialog = $state(false);
173
174
 
174
- // Debounced search
175
- let searchTimeout: ReturnType<typeof setTimeout> | null = null;
176
-
177
- function debouncedSearch(query: string) {
178
- if (searchTimeout) clearTimeout(searchTimeout);
179
- searchTimeout = setTimeout(() => fetchOptions(query), 300);
180
- }
175
+ const debouncedSearch = debounce((query: string) => fetchOptions(query), 300);
181
176
 
182
177
  async function fetchOptions(search?: string) {
183
178
  pickerLoading = true;
@@ -475,7 +470,7 @@
475
470
  aria-haspopup="dialog"
476
471
  {...props}
477
472
  >
478
- {t.select} {singularLabel}
473
+ {`${t.select} ${singularLabel}`}
479
474
  <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
480
475
  </button>
481
476
 
@@ -508,7 +503,7 @@
508
503
  aria-expanded={popoverOpen}
509
504
  {...props}
510
505
  >
511
- {t.select} {singularLabel}
506
+ {`${t.select} ${singularLabel}`}
512
507
  <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
513
508
  </Popover.Trigger>
514
509