@happyvertical/smrt-content 0.51.39 → 0.53.0

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 (72) hide show
  1. package/AGENTS.md +6 -0
  2. package/README.md +86 -0
  3. package/dist/body-format.d.ts +104 -0
  4. package/dist/body-format.d.ts.map +1 -1
  5. package/dist/body-format.js +439 -48
  6. package/dist/body-format.js.map +1 -1
  7. package/dist/chunks/content-query-C6RcW8V_.js +4131 -0
  8. package/dist/chunks/content-query-C6RcW8V_.js.map +1 -0
  9. package/dist/chunks/{content-versions-CvOoDLSK.js → content-versions-HUeMI1TV.js} +2 -2
  10. package/dist/chunks/{content-versions-CvOoDLSK.js.map → content-versions-HUeMI1TV.js.map} +1 -1
  11. package/dist/chunks/{contents-BB7RIBx2.js → contents-CHa5soKR.js} +4 -6
  12. package/dist/chunks/contents-CHa5soKR.js.map +1 -0
  13. package/dist/content-query.d.ts.map +1 -1
  14. package/dist/content.d.ts +13 -1
  15. package/dist/content.d.ts.map +1 -1
  16. package/dist/contents.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +6 -6
  20. package/dist/manifest.json +113 -23
  21. package/dist/server/content-list-data-surface.d.ts +1 -1
  22. package/dist/server/content-list-data-surface.d.ts.map +1 -1
  23. package/dist/server.js +3 -3
  24. package/dist/server.js.map +1 -1
  25. package/dist/smrt-knowledge.json +92 -10
  26. package/dist/svelte/components/ContentBodyEditor.svelte +898 -38
  27. package/dist/svelte/components/ContentBodyEditor.svelte.d.ts +67 -0
  28. package/dist/svelte/components/ContentBodyEditor.svelte.d.ts.map +1 -1
  29. package/dist/svelte/components/ContentBodyRenderer.svelte +36 -11
  30. package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts +2 -0
  31. package/dist/svelte/components/ContentBodyRenderer.svelte.d.ts.map +1 -1
  32. package/dist/svelte/components/ContentClaimAuditTool.svelte +45 -2
  33. package/dist/svelte/components/ContentCorrectionsTool.svelte +24 -2
  34. package/dist/svelte/components/ContentEditor.svelte +67 -17
  35. package/dist/svelte/components/ContentEditor.svelte.d.ts +12 -0
  36. package/dist/svelte/components/ContentEditor.svelte.d.ts.map +1 -1
  37. package/dist/svelte/components/ContentGovernancePanel.svelte +4 -2
  38. package/dist/svelte/components/ContentMetadataFields.svelte +141 -51
  39. package/dist/svelte/components/ContentMetadataFields.svelte.d.ts +20 -0
  40. package/dist/svelte/components/ContentMetadataFields.svelte.d.ts.map +1 -1
  41. package/dist/svelte/components/ContentPictureDrawer.svelte +611 -0
  42. package/dist/svelte/components/ContentPictureDrawer.svelte.d.ts +68 -0
  43. package/dist/svelte/components/ContentPictureDrawer.svelte.d.ts.map +1 -0
  44. package/dist/svelte/components/ContentReviewStatusTray.svelte +2 -2
  45. package/dist/svelte/components/ContentStatusFields.svelte +85 -38
  46. package/dist/svelte/components/ContentStatusFields.svelte.d.ts +10 -0
  47. package/dist/svelte/components/ContentStatusFields.svelte.d.ts.map +1 -1
  48. package/dist/svelte/components/ContentTitleField.svelte +109 -12
  49. package/dist/svelte/components/ContentTitleField.svelte.d.ts +12 -0
  50. package/dist/svelte/components/ContentTitleField.svelte.d.ts.map +1 -1
  51. package/dist/svelte/components/ContentTransparencyReport.svelte +3 -1
  52. package/dist/svelte/components/ContentTransparencyTool.svelte +18 -3
  53. package/dist/svelte/components/ContentVersionsTool.svelte +26 -2
  54. package/dist/svelte/components/content-field-mode.d.ts +20 -0
  55. package/dist/svelte/components/content-field-mode.d.ts.map +1 -0
  56. package/dist/svelte/components/content-field-mode.js +26 -0
  57. package/dist/svelte/content-editor-media.d.ts +14 -0
  58. package/dist/svelte/content-editor-media.d.ts.map +1 -1
  59. package/dist/svelte/content-editor-media.js +41 -0
  60. package/dist/svelte/content-editor-state.svelte.d.ts +17 -0
  61. package/dist/svelte/content-editor-state.svelte.d.ts.map +1 -1
  62. package/dist/svelte/content-editor-state.svelte.js +46 -0
  63. package/dist/svelte/i18n.editor.d.ts +49 -0
  64. package/dist/svelte/i18n.editor.d.ts.map +1 -1
  65. package/dist/svelte/i18n.editor.js +53 -1
  66. package/dist/svelte/index.d.ts +10 -5
  67. package/dist/svelte/index.d.ts.map +1 -1
  68. package/dist/svelte/index.js +5 -3
  69. package/package.json +27 -25
  70. package/dist/chunks/content-query-DPJCcD1V.js +0 -4092
  71. package/dist/chunks/content-query-DPJCcD1V.js.map +0 -1
  72. package/dist/chunks/contents-BB7RIBx2.js.map +0 -1
@@ -0,0 +1,611 @@
1
+ <script lang="ts" module>
2
+ /** One picture the drawer offers (from the host's picture library). */
3
+ export interface ContentPicture {
4
+ /** Stable id; passed back to the host on insert/drag/main. */
5
+ id: string;
6
+ /** Plain name shown under the picture and used by search. */
7
+ title: string;
8
+ /**
9
+ * Same-origin preview URL for the `<img>`. Never a signed/tokened
10
+ * provider URL: the host serves previews through its own route.
11
+ */
12
+ previewUrl: string | null;
13
+ width?: number | null;
14
+ height?: number | null;
15
+ /** Extra words search matches (alt text, tags). */
16
+ keywords?: string;
17
+ }
18
+
19
+ /** The MIME type a dragged picture (or a JSON array of them) travels as. */
20
+ export const CONTENT_PICTURE_DRAG_TYPE = 'application/x-smrt-image';
21
+
22
+ /** The drag payload ContentBodyEditor turns into inserted pictures. */
23
+ export function pictureDragPayload(pictures: ContentPicture[]): string {
24
+ return JSON.stringify(
25
+ pictures.map((picture) => ({
26
+ id: picture.id,
27
+ name: picture.title,
28
+ sourceUri: picture.previewUrl,
29
+ width: picture.width ?? null,
30
+ height: picture.height ?? null,
31
+ })),
32
+ );
33
+ }
34
+
35
+ /** Pictures whose title or keywords contain every word of `query`. */
36
+ export function filterPictures(
37
+ pictures: ContentPicture[],
38
+ query: string,
39
+ ): ContentPicture[] {
40
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
41
+ if (words.length === 0) return pictures;
42
+ return pictures.filter((picture) => {
43
+ const haystack = `${picture.title} ${picture.keywords ?? ''}`.toLowerCase();
44
+ return words.every((word) => haystack.includes(word));
45
+ });
46
+ }
47
+ </script>
48
+
49
+ <script lang="ts">
50
+ /**
51
+ * ContentPictureDrawer — the one "Add pictures" drawer for a content editor.
52
+ *
53
+ * Shows the host's picture library as a plain grid with one search box and an
54
+ * upload button. A picture is dragged into the story (drop position), or
55
+ * tapped to pick it (several at once) and inserted at the cursor with Insert —
56
+ * the keyboard and phone path. It also shows and changes the main picture:
57
+ * the first picture in the story, unless the person chose one.
58
+ *
59
+ * It holds no data of its own: the host loads pictures, uploads files, and
60
+ * resolves an inserted picture into its stored asset.
61
+ */
62
+ import { Input } from '@happyvertical/smrt-ui/forms';
63
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
64
+ import { Button } from '@happyvertical/smrt-ui/ui';
65
+ import type { ContentMainPictureMode } from '../../body-format';
66
+ import { M } from '../i18n.editor.js';
67
+
68
+ const { t } = useI18n();
69
+
70
+ export interface Props {
71
+ /** The library's pictures (loaded by the host). */
72
+ pictures?: ContentPicture[];
73
+ /** Pictures are loading. */
74
+ loading?: boolean;
75
+ /** A plain-language load or upload problem, or null. */
76
+ error?: string | null;
77
+ /**
78
+ * More pictures can be loaded with `onLoadMore`: the drawer asks for them
79
+ * as the list scrolls near its end, or as keyboard focus nears the end.
80
+ */
81
+ hasMore?: boolean;
82
+ /** An upload is in progress. */
83
+ uploading?: boolean;
84
+ /** Asset ids of pictures already in the story. */
85
+ inStoryIds?: string[];
86
+ /** Id of the current main picture. */
87
+ mainPictureId?: string | null;
88
+ /** How the main picture was decided (see `resolveBodyMainPicture`). */
89
+ mainPictureMode?: ContentMainPictureMode;
90
+ /** The search text (when the host searches; see `onSearch`). */
91
+ query?: string;
92
+ /**
93
+ * The host searches its library for this text (it should wait for typing
94
+ * to pause and start its paging again). Without it the drawer filters the
95
+ * pictures it was given.
96
+ */
97
+ onSearch?: (query: string) => void;
98
+ /** Insert these pictures at the cursor, in order. */
99
+ onInsert?: (pictures: ContentPicture[]) => void;
100
+ /** Upload these files (then insert them). */
101
+ onUpload?: (files: File[]) => void;
102
+ /** Load the next page of pictures into the gallery. */
103
+ onLoadMore?: () => void;
104
+ /** Make this picture the main picture. */
105
+ onUseAsMain?: (picture: ContentPicture) => void;
106
+ /** Clear the choice: the first picture in the story is the main picture. */
107
+ onClearMainChoice?: () => void;
108
+ /** Close the drawer. */
109
+ onClose?: () => void;
110
+ }
111
+
112
+ let {
113
+ pictures = [],
114
+ loading = false,
115
+ error = null,
116
+ hasMore = false,
117
+ uploading = false,
118
+ inStoryIds = [],
119
+ mainPictureId = null,
120
+ mainPictureMode = 'none',
121
+ query: hostQuery = '',
122
+ onSearch,
123
+ onInsert,
124
+ onUpload,
125
+ onLoadMore,
126
+ onUseAsMain,
127
+ onClearMainChoice,
128
+ onClose,
129
+ }: Props = $props();
130
+
131
+ // svelte-ignore state_referenced_locally
132
+ let query = $state(hostQuery);
133
+ let selectedIds = $state<string[]>([]);
134
+ let fileInput = $state<HTMLInputElement | null>(null);
135
+ let brokenPreviews = $state<Record<string, true>>({});
136
+ let gridElement = $state<HTMLElement | null>(null);
137
+ let moreSentinel = $state<HTMLElement | null>(null);
138
+ /** Screen-reader news about loading more ("12 more pictures"). */
139
+ let moreStatus = $state('');
140
+ let countBeforeMore: number | null = null;
141
+ let moreRequestedAt = 0;
142
+ /** A host that never answered a request may be asked again after this long. */
143
+ const MORE_RETRY_MS = 1500;
144
+
145
+ /** How close to the end (in tiles) keyboard focus asks for more. */
146
+ const MORE_FOCUS_TILES = 6;
147
+ /** How far below the visible list (px) scrolling asks for more. */
148
+ const MORE_SCROLL_MARGIN = 240;
149
+
150
+ function requestMore() {
151
+ // One request at a time: wait for the host to answer (a new page, or
152
+ // its loading flag going back off) before asking again.
153
+ if (!hasMore || loading || !onLoadMore) return;
154
+ if (countBeforeMore !== null && Date.now() - moreRequestedAt < MORE_RETRY_MS) {
155
+ return;
156
+ }
157
+ countBeforeMore = pictures.length;
158
+ moreRequestedAt = Date.now();
159
+ moreStatus = t(M['content.content_picture_drawer.loading_more']);
160
+ onLoadMore();
161
+ }
162
+
163
+ function sentinelNearView(): boolean {
164
+ if (!gridElement || !moreSentinel) return false;
165
+ const grid = gridElement.getBoundingClientRect();
166
+ const sentinel = moreSentinel.getBoundingClientRect();
167
+ return sentinel.top <= grid.bottom + MORE_SCROLL_MARGIN;
168
+ }
169
+
170
+ // Scrolling near the end of the list loads more.
171
+ $effect(() => {
172
+ const root = gridElement;
173
+ const target = moreSentinel;
174
+ if (!root || !target || typeof IntersectionObserver === 'undefined') return;
175
+ const observer = new IntersectionObserver(
176
+ (entries) => {
177
+ if (entries.some((entry) => entry.isIntersecting)) requestMore();
178
+ },
179
+ { root, rootMargin: `0px 0px ${MORE_SCROLL_MARGIN}px 0px` },
180
+ );
181
+ observer.observe(target);
182
+ return () => observer.disconnect();
183
+ });
184
+
185
+ // After a page arrives: say how many came, and keep going while the list
186
+ // is still too short to scroll (the observer only fires on a change).
187
+ let wasLoading = false;
188
+ $effect(() => {
189
+ const count = pictures.length;
190
+ if (loading) {
191
+ wasLoading = true;
192
+ return;
193
+ }
194
+ const answered = wasLoading || count !== countBeforeMore;
195
+ wasLoading = false;
196
+ if (countBeforeMore !== null && answered) {
197
+ const added = count - countBeforeMore;
198
+ countBeforeMore = null;
199
+ moreStatus =
200
+ added > 0
201
+ ? t(M['content.content_picture_drawer.more_loaded'], { count: added })
202
+ : '';
203
+ }
204
+ if (hasMore && sentinelNearView()) queueMicrotask(requestMore);
205
+ });
206
+
207
+ // Keyboard and screen-reader users: focus near the end loads more.
208
+ function handleGridFocus(event: FocusEvent) {
209
+ const tile = (event.target as Element | null)?.closest('.drawer-picture');
210
+ if (!tile || !gridElement) return;
211
+ const tiles = Array.from(gridElement.querySelectorAll('.drawer-picture'));
212
+ if (tiles.indexOf(tile) >= tiles.length - MORE_FOCUS_TILES) requestMore();
213
+ }
214
+
215
+ // A searching host already sent only the matches. A picture whose preview
216
+ // could not be loaded (its file or row is gone) is left out, not shown as a
217
+ // broken tile.
218
+ const visible = $derived(
219
+ (onSearch ? pictures : filterPictures(pictures, query)).filter(
220
+ (picture) => !brokenPreviews[picture.id],
221
+ ),
222
+ );
223
+
224
+ function hideBrokenPicture(picture: ContentPicture) {
225
+ if (brokenPreviews[picture.id]) return;
226
+ console.warn(
227
+ '[ContentPictureDrawer] Leaving out a picture whose preview could not be loaded',
228
+ picture.id,
229
+ );
230
+ brokenPreviews = { ...brokenPreviews, [picture.id]: true };
231
+ selectedIds = selectedIds.filter((id) => id !== picture.id);
232
+ }
233
+ const inStory = $derived(new Set(inStoryIds));
234
+ const selected = $derived(
235
+ selectedIds
236
+ .map((id) => pictures.find((picture) => picture.id === id))
237
+ .filter((picture): picture is ContentPicture => Boolean(picture)),
238
+ );
239
+
240
+ function toggle(picture: ContentPicture) {
241
+ selectedIds = selectedIds.includes(picture.id)
242
+ ? selectedIds.filter((id) => id !== picture.id)
243
+ : [...selectedIds, picture.id];
244
+ }
245
+
246
+ function insertSelected() {
247
+ if (selected.length === 0) return;
248
+ onInsert?.(selected);
249
+ selectedIds = [];
250
+ }
251
+
252
+ function useSelectedAsMain() {
253
+ const [picture] = selected;
254
+ if (!picture) return;
255
+ onUseAsMain?.(picture);
256
+ selectedIds = [];
257
+ }
258
+
259
+ function handleDragStart(event: DragEvent, picture: ContentPicture) {
260
+ if (!event.dataTransfer) return;
261
+ // Dragging a picked picture drags every picked one, in pick order.
262
+ const dragged = selectedIds.includes(picture.id) ? selected : [picture];
263
+ event.dataTransfer.effectAllowed = 'copy';
264
+ event.dataTransfer.setData(
265
+ CONTENT_PICTURE_DRAG_TYPE,
266
+ pictureDragPayload(dragged),
267
+ );
268
+ }
269
+
270
+ function handleFiles(event: Event & { currentTarget: HTMLInputElement }) {
271
+ const files = Array.from(event.currentTarget.files ?? []).filter((file) =>
272
+ file.type.startsWith('image/'),
273
+ );
274
+ event.currentTarget.value = '';
275
+ if (files.length > 0) onUpload?.(files);
276
+ }
277
+ </script>
278
+
279
+ <div class="content-picture-drawer">
280
+ <header class="drawer-header">
281
+ <h2>{t(M['content.content_picture_drawer.title'])}</h2>
282
+ {#if onClose}
283
+ <Button type="button" variant="secondary" size="sm" class="drawer-done" onclick={onClose}>
284
+ {t(M['content.content_picture_drawer.close'])}
285
+ </Button>
286
+ {/if}
287
+ </header>
288
+
289
+ <p class="drawer-hint">{t(M['content.content_picture_drawer.hint'])}</p>
290
+
291
+ {#if mainPictureMode === 'chosen'}
292
+ <p class="drawer-main">
293
+ <span>{t(M['content.content_picture_drawer.main_chosen'])}</span>
294
+ {#if onClearMainChoice}
295
+ <Button type="button" variant="ghost" size="sm" onclick={onClearMainChoice}>
296
+ {t(M['content.content_picture_drawer.main_reset'])}
297
+ </Button>
298
+ {/if}
299
+ </p>
300
+ {:else if mainPictureMode === 'automatic'}
301
+ <p class="drawer-main">{t(M['content.content_picture_drawer.main_automatic'])}</p>
302
+ {/if}
303
+
304
+ <div class="drawer-tools">
305
+ <Input
306
+ type="search"
307
+ bind:value={query}
308
+ oninput={() => onSearch?.(query)}
309
+ placeholder={t(M['content.content_picture_drawer.search'])}
310
+ aria-label={t(M['content.content_picture_drawer.search'])}
311
+ />
312
+ {#if onUpload}
313
+ <Button
314
+ type="button"
315
+ variant="secondary"
316
+ disabled={uploading}
317
+ onclick={() => fileInput?.click()}
318
+ >
319
+ {uploading
320
+ ? t(M['content.content_picture_drawer.uploading'])
321
+ : t(M['content.content_picture_drawer.upload'])}
322
+ </Button>
323
+ <!-- raw-primitive-allow: hidden file input opened by the Upload button (needs the DOM element for .click()) -->
324
+ <input
325
+ bind:this={fileInput}
326
+ class="drawer-file-input"
327
+ type="file"
328
+ accept="image/*"
329
+ multiple
330
+ tabindex="-1"
331
+ aria-hidden="true"
332
+ onchange={handleFiles}
333
+ />
334
+ {/if}
335
+ </div>
336
+
337
+ {#if error}
338
+ <p class="drawer-error" role="alert">{error}</p>
339
+ {/if}
340
+
341
+ {#if visible.length > 0}
342
+ <ul class="drawer-grid" bind:this={gridElement} onfocusin={handleGridFocus}>
343
+ {#each visible as picture (picture.id)}
344
+ {@const isSelected = selectedIds.includes(picture.id)}
345
+ {@const isMain = mainPictureId === picture.id}
346
+ <li
347
+ class="drawer-picture"
348
+ class:selected={isSelected}
349
+ class:main={isMain}
350
+ draggable="true"
351
+ ondragstart={(event) => handleDragStart(event, picture)}
352
+ >
353
+ <!-- raw-primitive-allow: a large picture tile that toggles selection (aria-pressed) and wraps an image; Button would impose its own padding and label layout -->
354
+ <button
355
+ type="button"
356
+ class="drawer-picture-pick"
357
+ aria-pressed={isSelected}
358
+ aria-label={t(M['content.content_picture_drawer.select'], { name: picture.title })}
359
+ onclick={() => toggle(picture)}
360
+ >
361
+ {#if picture.previewUrl}
362
+ <img
363
+ src={picture.previewUrl}
364
+ alt=""
365
+ loading="lazy"
366
+ decoding="async"
367
+ draggable="false"
368
+ onerror={() => hideBrokenPicture(picture)}
369
+ />
370
+ {:else}
371
+ <span class="drawer-picture-missing">
372
+ {t(M['content.content_picture_drawer.no_preview'])}
373
+ </span>
374
+ {/if}
375
+ {#if isSelected}
376
+ <span class="drawer-picture-check" aria-hidden="true">
377
+ {selectedIds.indexOf(picture.id) + 1}
378
+ </span>
379
+ {/if}
380
+ </button>
381
+ <span class="drawer-picture-title">{picture.title}</span>
382
+ <span class="drawer-picture-tags">
383
+ {#if isMain}
384
+ <span class="tag tag-main">{t(M['content.content_picture_drawer.main'])}</span>
385
+ {:else if inStory.has(picture.id)}
386
+ <span class="tag">{t(M['content.content_picture_drawer.in_story'])}</span>
387
+ {/if}
388
+ </span>
389
+ </li>
390
+ {/each}
391
+ {#if hasMore && onLoadMore}
392
+ <li class="drawer-more" aria-hidden="true" bind:this={moreSentinel}>
393
+ {#if loading}{t(M['content.content_picture_drawer.loading_more'])}{/if}
394
+ </li>
395
+ {/if}
396
+ </ul>
397
+ {:else if loading}
398
+ <p class="drawer-hint">{t(M['content.content_picture_drawer.loading'])}</p>
399
+ {:else if pictures.some((picture) => !brokenPreviews[picture.id]) || (onSearch && query.trim())}
400
+ <p class="drawer-hint">{t(M['content.content_picture_drawer.no_match'])}</p>
401
+ {:else}
402
+ <p class="drawer-hint">{t(M['content.content_picture_drawer.empty'])}</p>
403
+ {/if}
404
+
405
+ <p class="drawer-status" role="status" aria-live="polite">{moreStatus}</p>
406
+
407
+ {#if selected.length > 0 && (onInsert || onUseAsMain)}
408
+ <div class="drawer-actions">
409
+ {#if onInsert}
410
+ <Button type="button" variant="primary" onclick={insertSelected}>
411
+ {selected.length === 1
412
+ ? t(M['content.content_picture_drawer.insert'])
413
+ : t(M['content.content_picture_drawer.insert_count'], { count: selected.length })}
414
+ </Button>
415
+ {/if}
416
+ {#if selected.length === 1 && onUseAsMain && selected[0].id !== mainPictureId}
417
+ <Button type="button" variant="secondary" onclick={useSelectedAsMain}>
418
+ {t(M['content.content_picture_drawer.use_as_main'])}
419
+ </Button>
420
+ {/if}
421
+ <Button type="button" variant="ghost" onclick={() => (selectedIds = [])}>
422
+ {t(M['content.content_picture_drawer.clear_selection'])}
423
+ </Button>
424
+ </div>
425
+ {/if}
426
+ </div>
427
+
428
+ <style>
429
+ .content-picture-drawer {
430
+ display: grid;
431
+ gap: 0.75rem;
432
+ }
433
+
434
+ .drawer-header {
435
+ display: flex;
436
+ align-items: center;
437
+ justify-content: space-between;
438
+ gap: 0.75rem;
439
+ }
440
+
441
+ .drawer-header h2 {
442
+ margin: 0;
443
+ font-size: var(--smrt-typography-body-large-size, 1rem);
444
+ color: var(--smrt-color-on-surface);
445
+ }
446
+
447
+ .content-picture-drawer :global(.drawer-done) {
448
+ min-height: 2.75rem;
449
+ }
450
+
451
+ .drawer-hint,
452
+ .drawer-main {
453
+ margin: 0;
454
+ color: var(--smrt-color-on-surface-variant);
455
+ font-size: var(--smrt-typography-body-medium-size, 0.85rem);
456
+ }
457
+
458
+ .drawer-main {
459
+ display: flex;
460
+ flex-wrap: wrap;
461
+ align-items: center;
462
+ gap: 0.5rem;
463
+ }
464
+
465
+ .drawer-tools {
466
+ display: flex;
467
+ flex-wrap: wrap;
468
+ gap: 0.5rem;
469
+ }
470
+
471
+ .drawer-tools :global(input) {
472
+ flex: 1 1 12rem;
473
+ min-height: 2.75rem;
474
+ }
475
+
476
+ .drawer-file-input {
477
+ display: none;
478
+ }
479
+
480
+ .drawer-error {
481
+ margin: 0;
482
+ color: var(--smrt-color-error);
483
+ font-size: var(--smrt-typography-body-medium-size, 0.85rem);
484
+ }
485
+
486
+ .drawer-grid {
487
+ /* Scrolls on its own so the story stays in reach for dragging. */
488
+ max-height: min(22rem, 45vh);
489
+ overflow-y: auto;
490
+ display: grid;
491
+ grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
492
+ gap: 0.6rem;
493
+ margin: 0;
494
+ padding: 0;
495
+ list-style: none;
496
+ }
497
+
498
+ .drawer-more {
499
+ grid-column: 1 / -1;
500
+ min-height: 1.5rem;
501
+ color: var(--smrt-color-on-surface-variant);
502
+ font-size: var(--smrt-typography-body-small-size, 0.8rem);
503
+ text-align: center;
504
+ }
505
+
506
+ .drawer-status {
507
+ position: absolute;
508
+ width: 1px;
509
+ height: 1px;
510
+ overflow: hidden;
511
+ clip: rect(0 0 0 0);
512
+ white-space: nowrap;
513
+ }
514
+
515
+ .drawer-picture {
516
+ display: grid;
517
+ gap: 0.25rem;
518
+ align-content: start;
519
+ min-width: 0;
520
+ cursor: grab;
521
+ }
522
+
523
+ .drawer-picture-pick {
524
+ position: relative;
525
+ display: grid;
526
+ place-items: center;
527
+ aspect-ratio: 4 / 3;
528
+ width: 100%;
529
+ padding: 0;
530
+ overflow: hidden;
531
+ border: 2px solid var(--smrt-color-outline-variant);
532
+ border-radius: 0.5rem;
533
+ background: var(--smrt-color-surface-container-low);
534
+ color: var(--smrt-color-on-surface-variant);
535
+ cursor: pointer;
536
+ }
537
+
538
+ .drawer-picture.selected .drawer-picture-pick {
539
+ border-color: var(--smrt-color-primary);
540
+ }
541
+
542
+ .drawer-picture.main .drawer-picture-pick {
543
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--smrt-color-primary) 30%, transparent);
544
+ }
545
+
546
+ .drawer-picture-pick img {
547
+ width: 100%;
548
+ height: 100%;
549
+ object-fit: cover;
550
+ pointer-events: none;
551
+ }
552
+
553
+ .drawer-picture-check {
554
+ position: absolute;
555
+ top: 0.35rem;
556
+ right: 0.35rem;
557
+ display: grid;
558
+ place-items: center;
559
+ min-width: 1.5rem;
560
+ height: 1.5rem;
561
+ border-radius: var(--smrt-radius-full, 999px);
562
+ background: var(--smrt-color-primary);
563
+ color: var(--smrt-color-on-primary);
564
+ font-size: var(--smrt-typography-body-small-size, 0.8rem);
565
+ font-weight: var(--smrt-typography-weight-bold, 700);
566
+ }
567
+
568
+ .drawer-picture-missing {
569
+ padding: 0.25rem;
570
+ font-size: var(--smrt-typography-body-small-size, 0.75rem);
571
+ text-align: center;
572
+ }
573
+
574
+ .drawer-picture-title {
575
+ overflow: hidden;
576
+ color: var(--smrt-color-on-surface);
577
+ font-size: var(--smrt-typography-body-small-size, 0.8rem);
578
+ text-overflow: ellipsis;
579
+ white-space: nowrap;
580
+ }
581
+
582
+ .drawer-picture-tags {
583
+ min-height: 0;
584
+ }
585
+
586
+ .tag {
587
+ display: inline-block;
588
+ padding: 0.05rem 0.4rem;
589
+ border-radius: var(--smrt-radius-full, 999px);
590
+ background: var(--smrt-color-surface-container-high);
591
+ color: var(--smrt-color-on-surface-variant);
592
+ font-size: var(--smrt-typography-body-small-size, 0.72rem);
593
+ }
594
+
595
+ .tag-main {
596
+ background: var(--smrt-color-primary);
597
+ color: var(--smrt-color-on-primary);
598
+ }
599
+
600
+ .drawer-actions {
601
+ display: flex;
602
+ flex-wrap: wrap;
603
+ gap: 0.5rem;
604
+ padding: 0.5rem 0;
605
+ background: var(--smrt-color-surface);
606
+ }
607
+
608
+ .drawer-actions :global(button) {
609
+ min-height: 2.75rem;
610
+ }
611
+ </style>
@@ -0,0 +1,68 @@
1
+ /** One picture the drawer offers (from the host's picture library). */
2
+ export interface ContentPicture {
3
+ /** Stable id; passed back to the host on insert/drag/main. */
4
+ id: string;
5
+ /** Plain name shown under the picture and used by search. */
6
+ title: string;
7
+ /**
8
+ * Same-origin preview URL for the `<img>`. Never a signed/tokened
9
+ * provider URL: the host serves previews through its own route.
10
+ */
11
+ previewUrl: string | null;
12
+ width?: number | null;
13
+ height?: number | null;
14
+ /** Extra words search matches (alt text, tags). */
15
+ keywords?: string;
16
+ }
17
+ /** The MIME type a dragged picture (or a JSON array of them) travels as. */
18
+ export declare const CONTENT_PICTURE_DRAG_TYPE = "application/x-smrt-image";
19
+ /** The drag payload ContentBodyEditor turns into inserted pictures. */
20
+ export declare function pictureDragPayload(pictures: ContentPicture[]): string;
21
+ /** Pictures whose title or keywords contain every word of `query`. */
22
+ export declare function filterPictures(pictures: ContentPicture[], query: string): ContentPicture[];
23
+ import type { ContentMainPictureMode } from '../../body-format';
24
+ export interface Props {
25
+ /** The library's pictures (loaded by the host). */
26
+ pictures?: ContentPicture[];
27
+ /** Pictures are loading. */
28
+ loading?: boolean;
29
+ /** A plain-language load or upload problem, or null. */
30
+ error?: string | null;
31
+ /**
32
+ * More pictures can be loaded with `onLoadMore`: the drawer asks for them
33
+ * as the list scrolls near its end, or as keyboard focus nears the end.
34
+ */
35
+ hasMore?: boolean;
36
+ /** An upload is in progress. */
37
+ uploading?: boolean;
38
+ /** Asset ids of pictures already in the story. */
39
+ inStoryIds?: string[];
40
+ /** Id of the current main picture. */
41
+ mainPictureId?: string | null;
42
+ /** How the main picture was decided (see `resolveBodyMainPicture`). */
43
+ mainPictureMode?: ContentMainPictureMode;
44
+ /** The search text (when the host searches; see `onSearch`). */
45
+ query?: string;
46
+ /**
47
+ * The host searches its library for this text (it should wait for typing
48
+ * to pause and start its paging again). Without it the drawer filters the
49
+ * pictures it was given.
50
+ */
51
+ onSearch?: (query: string) => void;
52
+ /** Insert these pictures at the cursor, in order. */
53
+ onInsert?: (pictures: ContentPicture[]) => void;
54
+ /** Upload these files (then insert them). */
55
+ onUpload?: (files: File[]) => void;
56
+ /** Load the next page of pictures into the gallery. */
57
+ onLoadMore?: () => void;
58
+ /** Make this picture the main picture. */
59
+ onUseAsMain?: (picture: ContentPicture) => void;
60
+ /** Clear the choice: the first picture in the story is the main picture. */
61
+ onClearMainChoice?: () => void;
62
+ /** Close the drawer. */
63
+ onClose?: () => void;
64
+ }
65
+ declare const ContentPictureDrawer: import("svelte").Component<Props, {}, "">;
66
+ type ContentPictureDrawer = ReturnType<typeof ContentPictureDrawer>;
67
+ export default ContentPictureDrawer;
68
+ //# sourceMappingURL=ContentPictureDrawer.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ContentPictureDrawer.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/ContentPictureDrawer.svelte.ts"],"names":[],"mappings":"AAGA,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,4EAA4E;AAC5E,eAAO,MAAM,yBAAyB,6BAA6B,CAAC;AAEpE,uEAAuE;AACvE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,MAAM,CAUrE;AAED,sEAAsE;AACtE,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,cAAc,EAAE,EAC1B,KAAK,EAAE,MAAM,GACZ,cAAc,EAAE,CAOlB;AAkBD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAIhE,MAAM,WAAW,KAAK;IACpB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;IAC5B,4BAA4B;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gCAAgC;IAChC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,kDAAkD;IAClD,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,sCAAsC;IACtC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,uEAAuE;IACvE,eAAe,CAAC,EAAE,sBAAsB,CAAC;IACzC,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,qDAAqD;IACrD,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,EAAE,KAAK,IAAI,CAAC;IAChD,6CAA6C;IAC7C,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,IAAI,CAAC;IACnC,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,IAAI,CAAC;IACxB,0CAA0C;IAC1C,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,IAAI,CAAC;IAChD,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC/B,wBAAwB;IACxB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACtB;AAuSD,QAAA,MAAM,oBAAoB,2CAAwC,CAAC;AACnE,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;AACpE,eAAe,oBAAoB,CAAC"}
@@ -101,8 +101,8 @@ let {
101
101
  position: relative;
102
102
  display: inline-grid;
103
103
  place-items: center;
104
- width: 2.5rem;
105
- height: 2.5rem;
104
+ width: 2.75rem;
105
+ height: 2.75rem;
106
106
  border: 1px solid var(--smrt-color-outline-variant);
107
107
  border-radius: 0.55rem;
108
108
  background: var(--smrt-color-surface-container-low);