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.
- package/CHANGELOG.md +30 -0
- package/DOCS.md +1 -1
- package/ROADMAP.md +11 -0
- package/dist/admin/client/account/profile-section.svelte +2 -2
- package/dist/admin/client/admin/admin-after-login-layout-content.svelte +12 -1
- package/dist/admin/client/collection/collection-entries.svelte +48 -4
- package/dist/admin/client/login/login-page.svelte +4 -0
- package/dist/admin/client/login/reset-password-page.svelte +4 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +12 -0
- package/dist/admin/client/users/accept-invite-page.svelte +4 -0
- package/dist/admin/client/users/users-page.svelte +10 -0
- package/dist/admin/components/fields/media-field.svelte +203 -253
- package/dist/admin/components/fields/relation-field.svelte +4 -9
- package/dist/admin/components/fields/url-field.svelte +42 -18
- package/dist/admin/components/layout/nav-footer.svelte +12 -9
- package/dist/admin/components/media/file/file-details.svelte +115 -18
- package/dist/admin/components/media/file/file-miniature.svelte +2 -2
- package/dist/admin/components/media/media-selector.svelte +9 -8
- package/dist/admin/remote/media.remote.d.ts +6 -4
- package/dist/admin/remote/media.remote.js +17 -1
- package/dist/admin/utils/debounce.d.ts +1 -0
- package/dist/admin/utils/debounce.js +8 -0
- package/dist/admin/utils/entryThumbnail.d.ts +6 -1
- package/dist/admin/utils/entryThumbnail.js +22 -13
- package/dist/admin/utils/pageTitle.d.ts +8 -0
- package/dist/admin/utils/pageTitle.js +15 -0
- package/dist/cms/runtime/types.d.ts +4 -4
- package/dist/core/cms.d.ts +1 -0
- package/dist/core/cms.js +2 -0
- package/dist/core/server/media/operations/batchRegenerateVideoPosters.d.ts +12 -0
- package/dist/core/server/media/operations/batchRegenerateVideoPosters.js +46 -42
- package/dist/core/server/media/operations/regenerateVideoPoster.d.ts +5 -0
- package/dist/core/server/media/operations/regenerateVideoPoster.js +17 -0
- package/dist/core/server/media/operations/replaceFile.js +8 -4
- package/dist/core/server/media/operations/uploadFile.js +7 -3
- package/dist/core/server/media/styles/operations/batchGenerateStyles.js +11 -0
- package/dist/core/server/media/utils/generateAdminThumbnail.d.ts +3 -0
- package/dist/core/server/media/utils/generateAdminThumbnail.js +33 -0
- package/dist/db-postgres/schema/imageStyle.js +5 -4
- package/dist/db-postgres/schema/videoStyle.js +2 -4
- package/dist/files-local/video.js +3 -18
- package/dist/types/cms.d.ts +1 -0
- package/dist/updates/0.14.5/index.d.ts +2 -0
- package/dist/updates/0.14.5/index.js +11 -0
- package/dist/updates/0.14.6/index.d.ts +2 -0
- package/dist/updates/0.14.6/index.js +21 -0
- package/dist/updates/index.js +3 -1
- 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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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()
|
|
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
|
|
117
|
-
|
|
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
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
194
|
-
<
|
|
195
|
-
{#
|
|
196
|
-
{#if
|
|
197
|
-
<div class="
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
316
|
-
|
|
264
|
+
{@render mediaActions(() => { value = field.multiple ? [] : ''; })}
|
|
265
|
+
{/if}
|
|
317
266
|
{/if}
|
|
318
|
-
{/
|
|
319
|
-
</
|
|
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
|
|
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
|
-
|
|
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
|
|