@marianmeres/stuic 3.133.0 → 3.135.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.
- package/dist/components/AssetsPreview/AssetsPreview.svelte +9 -0
- package/dist/components/AssetsPreview/AssetsPreview.svelte.d.ts +7 -0
- package/dist/components/AssetsPreview/AssetsPreviewInline.svelte +9 -0
- package/dist/components/AssetsPreview/AssetsPreviewInline.svelte.d.ts +7 -0
- package/dist/components/AssetsPreview/README.md +48 -7
- package/dist/components/AssetsPreview/_internal/AssetsPreviewContent.svelte +37 -4
- package/dist/components/AssetsPreview/_internal/AssetsPreviewContent.svelte.d.ts +7 -0
- package/dist/components/Avatar/Avatar.svelte +1 -4
- package/dist/components/Input/FieldAssets.svelte +190 -96
- package/dist/components/Input/FieldAssets.svelte.d.ts +16 -0
- package/dist/components/Input/README.md +59 -3
- package/dist/components/Nav/README.md +1 -3
- package/dist/components/Thc/Thc.svelte +1 -6
- package/package.json +1 -1
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
close: () => void;
|
|
38
38
|
}
|
|
39
39
|
) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Optional consumer-supplied download handler. When provided, the Download button
|
|
42
|
+
* calls this instead of `forceDownload(asset.url.original)` — for auth-gated bytes
|
|
43
|
+
* or lazy fetching. Receives the current asset + its index. May be async; the
|
|
44
|
+
* button shows a busy/disabled state while the returned promise settles.
|
|
45
|
+
*/
|
|
46
|
+
onDownload?: (asset: AssetPreview, index: number) => void | Promise<void>;
|
|
40
47
|
/** optional "do not display file name" switch flag */
|
|
41
48
|
noName?: boolean;
|
|
42
49
|
/** When true (default), panning is clamped to keep image within bounds */
|
|
@@ -74,6 +81,7 @@
|
|
|
74
81
|
t = t_default,
|
|
75
82
|
classControls = "",
|
|
76
83
|
onDelete,
|
|
84
|
+
onDownload,
|
|
77
85
|
onAreaClick,
|
|
78
86
|
noName,
|
|
79
87
|
clampPan = false,
|
|
@@ -185,6 +193,7 @@
|
|
|
185
193
|
{classControls}
|
|
186
194
|
{t}
|
|
187
195
|
{onDelete}
|
|
196
|
+
{onDownload}
|
|
188
197
|
{noName}
|
|
189
198
|
{clampPan}
|
|
190
199
|
{noDownload}
|
|
@@ -16,6 +16,13 @@ export interface Props {
|
|
|
16
16
|
onDelete?: (asset: AssetPreview, index: number, controls: {
|
|
17
17
|
close: () => void;
|
|
18
18
|
}) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Optional consumer-supplied download handler. When provided, the Download button
|
|
21
|
+
* calls this instead of `forceDownload(asset.url.original)` — for auth-gated bytes
|
|
22
|
+
* or lazy fetching. Receives the current asset + its index. May be async; the
|
|
23
|
+
* button shows a busy/disabled state while the returned promise settles.
|
|
24
|
+
*/
|
|
25
|
+
onDownload?: (asset: AssetPreview, index: number) => void | Promise<void>;
|
|
19
26
|
/** optional "do not display file name" switch flag */
|
|
20
27
|
noName?: boolean;
|
|
21
28
|
/** When true (default), panning is clamped to keep image within bounds */
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
close: () => void;
|
|
23
23
|
}
|
|
24
24
|
) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Optional consumer-supplied download handler. When provided, the Download button
|
|
27
|
+
* calls this instead of `forceDownload(asset.url.original)` — for auth-gated bytes
|
|
28
|
+
* or lazy fetching. Receives the current asset + its index. May be async; the
|
|
29
|
+
* button shows a busy/disabled state while the returned promise settles.
|
|
30
|
+
*/
|
|
31
|
+
onDownload?: (asset: AssetPreview, index: number) => void | Promise<void>;
|
|
25
32
|
/** optional "do not display file name" switch flag */
|
|
26
33
|
noName?: boolean;
|
|
27
34
|
/** When true (default), panning is clamped to keep image within bounds */
|
|
@@ -65,6 +72,7 @@
|
|
|
65
72
|
class: classProp = "",
|
|
66
73
|
classControls = "",
|
|
67
74
|
onDelete,
|
|
75
|
+
onDownload,
|
|
68
76
|
onAreaClick,
|
|
69
77
|
noName,
|
|
70
78
|
clampPan = false,
|
|
@@ -154,6 +162,7 @@
|
|
|
154
162
|
{classControls}
|
|
155
163
|
{t}
|
|
156
164
|
{onDelete}
|
|
165
|
+
{onDownload}
|
|
157
166
|
{noName}
|
|
158
167
|
{clampPan}
|
|
159
168
|
{noDownload}
|
|
@@ -12,6 +12,13 @@ export interface Props {
|
|
|
12
12
|
onDelete?: (asset: AssetPreview, index: number, controls: {
|
|
13
13
|
close: () => void;
|
|
14
14
|
}) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Optional consumer-supplied download handler. When provided, the Download button
|
|
17
|
+
* calls this instead of `forceDownload(asset.url.original)` — for auth-gated bytes
|
|
18
|
+
* or lazy fetching. Receives the current asset + its index. May be async; the
|
|
19
|
+
* button shows a busy/disabled state while the returned promise settles.
|
|
20
|
+
*/
|
|
21
|
+
onDownload?: (asset: AssetPreview, index: number) => void | Promise<void>;
|
|
15
22
|
/** optional "do not display file name" switch flag */
|
|
16
23
|
noName?: boolean;
|
|
17
24
|
/** When true (default), panning is clamped to keep image within bounds */
|
|
@@ -4,13 +4,14 @@ A modal-based asset preview component for displaying images and files. Supports
|
|
|
4
4
|
|
|
5
5
|
## Props
|
|
6
6
|
|
|
7
|
-
| Prop | Type
|
|
8
|
-
| ---------------- |
|
|
9
|
-
| `assets` | `string[] \| AssetPreview[]`
|
|
10
|
-
| `classControls` | `string`
|
|
11
|
-
| `t` | `TranslateFn`
|
|
12
|
-
| `onDelete` | `(asset, index) => void`
|
|
13
|
-
| `
|
|
7
|
+
| Prop | Type | Default | Description |
|
|
8
|
+
| ---------------- | ----------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| `assets` | `string[] \| AssetPreview[]` | - | Array of assets to preview |
|
|
10
|
+
| `classControls` | `string` | - | CSS for control buttons |
|
|
11
|
+
| `t` | `TranslateFn` | built-in | Translation function for i18n |
|
|
12
|
+
| `onDelete` | `(asset, index) => void` | - | Optional delete handler |
|
|
13
|
+
| `onDownload` | `(asset, index) => void \| Promise<void>` | - | Optional download handler. Replaces the default `forceDownload(url.original)` — for auth-gated or lazily-fetched bytes |
|
|
14
|
+
| `prevNextBottom` | `boolean` | `false` | Render prev/next arrows at bottom |
|
|
14
15
|
|
|
15
16
|
## Types
|
|
16
17
|
|
|
@@ -140,6 +141,46 @@ interface AssetPreview {
|
|
|
140
141
|
<AssetsPreview bind:this={preview} {assets} onDelete={handleDelete} />
|
|
141
142
|
```
|
|
142
143
|
|
|
144
|
+
### With a Custom Download Handler
|
|
145
|
+
|
|
146
|
+
By default the Download button calls the built-in `forceDownload(asset.url.original)`,
|
|
147
|
+
which does a plain unauthenticated `fetch`. Supply `onDownload` to take over the action —
|
|
148
|
+
e.g. to fetch **auth-gated** bytes (a `Authorization: Bearer …` route), or to fetch
|
|
149
|
+
**lazily** only when the user actually clicks Download instead of pre-resolving an object
|
|
150
|
+
URL for every asset up front. When provided, the default path is skipped entirely.
|
|
151
|
+
|
|
152
|
+
The handler may be async; while the returned promise is pending the button shows a busy
|
|
153
|
+
spinner and is disabled (so rapid clicks can't fire duplicate fetches). Rejections are
|
|
154
|
+
caught and logged, so a failed download never breaks the preview.
|
|
155
|
+
|
|
156
|
+
```svelte
|
|
157
|
+
<script lang="ts">
|
|
158
|
+
import { AssetsPreview, type AssetPreview } from "stuic";
|
|
159
|
+
|
|
160
|
+
let preview: AssetsPreview;
|
|
161
|
+
|
|
162
|
+
async function handleDownload(asset: AssetPreview) {
|
|
163
|
+
// Fetch the bytes yourself (e.g. with an auth header), then save them.
|
|
164
|
+
const res = await fetch(String(asset.url.original), {
|
|
165
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
166
|
+
});
|
|
167
|
+
const objUrl = URL.createObjectURL(await res.blob());
|
|
168
|
+
const a = document.createElement("a");
|
|
169
|
+
a.href = objUrl;
|
|
170
|
+
a.download = asset.name || "download";
|
|
171
|
+
document.body.appendChild(a);
|
|
172
|
+
a.click();
|
|
173
|
+
a.remove();
|
|
174
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 10_000);
|
|
175
|
+
}
|
|
176
|
+
</script>
|
|
177
|
+
|
|
178
|
+
<AssetsPreview bind:this={preview} {assets} onDownload={handleDownload} />
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
> Visibility of the Download button is still controlled solely by `noDownload` —
|
|
182
|
+
> `onDownload` only changes _what_ the button does, not _whether_ it shows.
|
|
183
|
+
|
|
143
184
|
### With Custom Translations
|
|
144
185
|
|
|
145
186
|
```svelte
|
|
@@ -55,6 +55,13 @@
|
|
|
55
55
|
close: () => void;
|
|
56
56
|
}
|
|
57
57
|
) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Optional consumer-supplied download handler. When provided, the Download button
|
|
60
|
+
* calls this instead of `forceDownload(asset.url.original)` — for auth-gated bytes
|
|
61
|
+
* or lazy fetching. Receives the current preview asset + its index. May be async;
|
|
62
|
+
* the button shows a busy/disabled state while the returned promise settles.
|
|
63
|
+
*/
|
|
64
|
+
onDownload?: (asset: AssetPreview, index: number) => void | Promise<void>;
|
|
58
65
|
/** Callback when a clickable area on an image is clicked */
|
|
59
66
|
onAreaClick?: (data: { area: AssetArea; asset: AssetPreviewNormalized }) => void;
|
|
60
67
|
onClose?: () => void;
|
|
@@ -81,11 +88,37 @@
|
|
|
81
88
|
classControls = "",
|
|
82
89
|
t = t_default,
|
|
83
90
|
onDelete,
|
|
91
|
+
onDownload,
|
|
84
92
|
onAreaClick,
|
|
85
93
|
onClose,
|
|
86
94
|
slideDuration = 300,
|
|
87
95
|
}: Props = $props();
|
|
88
96
|
|
|
97
|
+
// Busy state for an async `onDownload`; disables the button + shows a spinner so
|
|
98
|
+
// rapid clicks can't fire duplicate (possibly authed) fetches. Only the custom
|
|
99
|
+
// path drives this — the default `forceDownload` path is left exactly as-is.
|
|
100
|
+
let downloading = $state(false);
|
|
101
|
+
|
|
102
|
+
async function handleDownload() {
|
|
103
|
+
if (typeof onDownload === "function") {
|
|
104
|
+
try {
|
|
105
|
+
downloading = true;
|
|
106
|
+
await onDownload(previewAsset, previewIdx);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// A failed/late download must never reject up and tear down the preview.
|
|
109
|
+
clog.error("onDownload failed", e);
|
|
110
|
+
} finally {
|
|
111
|
+
downloading = false;
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Default (unchanged): fetch the original bytes and force a browser download.
|
|
116
|
+
forceDownload(
|
|
117
|
+
resolveUrl(String(previewAsset.url.original), baseUrl),
|
|
118
|
+
previewAsset?.name || ""
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
89
122
|
let dotTooltip: string | undefined = $state();
|
|
90
123
|
|
|
91
124
|
// Zoom state
|
|
@@ -747,12 +780,12 @@
|
|
|
747
780
|
<Button
|
|
748
781
|
class={twMerge(BUTTON_CLS, classControls)}
|
|
749
782
|
type="button"
|
|
783
|
+
disabled={downloading}
|
|
784
|
+
spinner={downloading}
|
|
785
|
+
spinnerOnly
|
|
750
786
|
onclick={(e) => {
|
|
751
787
|
e.preventDefault();
|
|
752
|
-
|
|
753
|
-
resolveUrl(String(previewAsset.url.original), baseUrl),
|
|
754
|
-
previewAsset?.name || ""
|
|
755
|
-
);
|
|
788
|
+
handleDownload();
|
|
756
789
|
}}
|
|
757
790
|
aria-label={t("download")}
|
|
758
791
|
tooltip={t("download")}
|
|
@@ -26,6 +26,13 @@ interface Props {
|
|
|
26
26
|
onDelete?: (asset: AssetPreview, index: number, controls: {
|
|
27
27
|
close: () => void;
|
|
28
28
|
}) => void;
|
|
29
|
+
/**
|
|
30
|
+
* Optional consumer-supplied download handler. When provided, the Download button
|
|
31
|
+
* calls this instead of `forceDownload(asset.url.original)` — for auth-gated bytes
|
|
32
|
+
* or lazy fetching. Receives the current preview asset + its index. May be async;
|
|
33
|
+
* the button shows a busy/disabled state while the returned promise settles.
|
|
34
|
+
*/
|
|
35
|
+
onDownload?: (asset: AssetPreview, index: number) => void | Promise<void>;
|
|
29
36
|
/** Callback when a clickable area on an image is clicked */
|
|
30
37
|
onAreaClick?: (data: {
|
|
31
38
|
area: AssetArea;
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
export type IconFn = (opts?: { size?: number; class?: string }) => string;
|
|
3
3
|
export type AvatarFallback =
|
|
4
|
-
| "icon
|
|
5
|
-
| "initials"
|
|
6
|
-
| { icon: IconFn }
|
|
7
|
-
| { initials: string };
|
|
4
|
+
"icon" | "initials" | { icon: IconFn } | { initials: string };
|
|
8
5
|
|
|
9
6
|
export interface Props {
|
|
10
7
|
/** Photo URL - when provided, renders in photo mode */
|
|
@@ -163,6 +163,13 @@
|
|
|
163
163
|
t?: TranslateFn;
|
|
164
164
|
parseValue?: (strigifiedModels: string) => FieldAsset[];
|
|
165
165
|
serializeValue?: (assets: FieldAsset[]) => string;
|
|
166
|
+
/**
|
|
167
|
+
* See AssetsPreview.onDownload. When provided, the preview's Download button calls
|
|
168
|
+
* this instead of `forceDownload(asset.url.original)` — for auth-gated bytes or
|
|
169
|
+
* lazy fetching. Receives the full `FieldAsset` (incl. `_raw`) for the clicked
|
|
170
|
+
* asset + its index. May be async; the button shows a busy state while it settles.
|
|
171
|
+
*/
|
|
172
|
+
onDownload?: (asset: FieldAsset, index: number) => void | Promise<void>;
|
|
166
173
|
notifications?: NotificationsStack;
|
|
167
174
|
cardinality?: number;
|
|
168
175
|
accept?: string;
|
|
@@ -180,6 +187,15 @@
|
|
|
180
187
|
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
181
188
|
*/
|
|
182
189
|
ordered?: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
|
|
192
|
+
* the field. Focus-scoped — the paste is consumed only while the field (or a
|
|
193
|
+
* control inside it) holds focus; clicking anywhere in the field focuses it,
|
|
194
|
+
* so no Tab is needed. Routed through the same validation + upload path as
|
|
195
|
+
* drag and the file picker, so `accept`, `cardinality` and `processAssets`
|
|
196
|
+
* all apply. No-op unless `processAssets` is provided. Default `false`.
|
|
197
|
+
*/
|
|
198
|
+
pasteable?: boolean;
|
|
183
199
|
//
|
|
184
200
|
classWrap?: string;
|
|
185
201
|
}
|
|
@@ -245,6 +261,7 @@
|
|
|
245
261
|
classControls = "",
|
|
246
262
|
isLoading = false,
|
|
247
263
|
ordered = false,
|
|
264
|
+
pasteable = false,
|
|
248
265
|
parseValue = (strigifiedModels: string) => {
|
|
249
266
|
const val = strigifiedModels ?? "[]";
|
|
250
267
|
try {
|
|
@@ -255,6 +272,7 @@
|
|
|
255
272
|
}
|
|
256
273
|
},
|
|
257
274
|
serializeValue = JSON.stringify,
|
|
275
|
+
onDownload,
|
|
258
276
|
classWrap = "",
|
|
259
277
|
// ...rest
|
|
260
278
|
}: Props = $props();
|
|
@@ -272,6 +290,10 @@
|
|
|
272
290
|
let inputEl = $state<HTMLInputElement>()!;
|
|
273
291
|
// Outer wrapper for scrollIntoView and focus targeting.
|
|
274
292
|
let wrapEl: HTMLDivElement | undefined = $state();
|
|
293
|
+
// The asset grid container, which lives INSIDE InputWrap's `.input-wrap` box.
|
|
294
|
+
// `focusForPaste` focuses this (not `wrapEl`, an ancestor) so the `:focus-within`
|
|
295
|
+
// ring on `.input-wrap` actually lights up on click.
|
|
296
|
+
let gridEl: HTMLDivElement | undefined = $state();
|
|
275
297
|
let hiddenInputEl: HTMLInputElement | undefined = $state();
|
|
276
298
|
let assetsPreview: AssetsPreview = $state()!;
|
|
277
299
|
|
|
@@ -404,6 +426,160 @@
|
|
|
404
426
|
focusMovedButton(to, to < from ? "prev" : "next");
|
|
405
427
|
}
|
|
406
428
|
|
|
429
|
+
// Funnels every file source (drop, picker, paste) through one path so they all
|
|
430
|
+
// share the same accept/cardinality checks, optimistic tiles and processAssets
|
|
431
|
+
// upload. Accepts a plain File[] too (paste builds one); the spread normalizes
|
|
432
|
+
// both it and a FileList.
|
|
433
|
+
function handleIncomingFiles(files: FileList | File[] | null, wasDrop = false) {
|
|
434
|
+
clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
|
|
435
|
+
|
|
436
|
+
// Copy the File objects into a stable array, then IMMEDIATELY release the
|
|
437
|
+
// hidden <input type="file">'s retained FileList. A native file input
|
|
438
|
+
// keeps its FileList after a selection until it is reset (form reset or
|
|
439
|
+
// `value = ""`), and its `change` event (wired by the `fileDropzone`
|
|
440
|
+
// action) calls back into this handler. Without this reset, any later
|
|
441
|
+
// stray `change` re-runs this with the SAME file still in `inputEl.files`,
|
|
442
|
+
// pushing a duplicate optimistic asset and firing a real re-upload.
|
|
443
|
+
// `onSubmitValidityCheck` used to dispatch exactly such a synthetic
|
|
444
|
+
// `change` on submit (now fixed there too — belt and braces). Clearing
|
|
445
|
+
// also restores the ability to re-select the same file twice in a row (an
|
|
446
|
+
// unchanged value emits no `change`). The blob URLs we create below are
|
|
447
|
+
// independent of the input, so clearing here is safe.
|
|
448
|
+
const incoming = [...(files ?? [])];
|
|
449
|
+
if (inputEl) inputEl.value = "";
|
|
450
|
+
|
|
451
|
+
// Nothing to consume — a cancelled picker, or a stray/synthetic `change`
|
|
452
|
+
// on an already-cleared input. Bail before touching state or calling
|
|
453
|
+
// processAssets (which would otherwise run with an empty batch).
|
|
454
|
+
if (!incoming.length) return;
|
|
455
|
+
|
|
456
|
+
if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
|
|
457
|
+
const msg = t("invalid_type", { accept });
|
|
458
|
+
if (notifications) notifications.error(msg);
|
|
459
|
+
else alert(msg);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const cardErrMsg = t("cardinality_reached", { max: cardinality });
|
|
464
|
+
// `>=` (not `>`): refuse the moment the field already holds `cardinality`
|
|
465
|
+
// assets, instead of optimistically adding one past the limit and relying
|
|
466
|
+
// on the validator to reject it afterwards (the off-by-one that made the
|
|
467
|
+
// single-cardinality symptom loud).
|
|
468
|
+
if (assets.length >= cardinality) {
|
|
469
|
+
if (notifications) notifications.error(cardErrMsg);
|
|
470
|
+
else alert(cardErrMsg);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const toBeProcessed: FieldAsset[] = [];
|
|
475
|
+
|
|
476
|
+
for (const file of incoming) {
|
|
477
|
+
if (assets.length >= cardinality) {
|
|
478
|
+
notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// this create a unique blob url, which we'll use as id as well
|
|
483
|
+
const url = URL.createObjectURL(file);
|
|
484
|
+
const asset: FieldAsset = {
|
|
485
|
+
id: url,
|
|
486
|
+
url: { thumb: url, full: url, original: url },
|
|
487
|
+
type: file.type,
|
|
488
|
+
name: file.name,
|
|
489
|
+
meta: { isUploading: true },
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// ASAP optimistic UI update
|
|
493
|
+
assets.push(asset);
|
|
494
|
+
|
|
495
|
+
// prepare data for server upload
|
|
496
|
+
toBeProcessed.push(asset);
|
|
497
|
+
}
|
|
498
|
+
value = serializeValue(assets);
|
|
499
|
+
|
|
500
|
+
if (typeof processAssets === "function") {
|
|
501
|
+
isUploading = true;
|
|
502
|
+
processAssets(toBeProcessed, onProgress)
|
|
503
|
+
.then((uploaded: FieldAssetWithBlobUrl[]) => {
|
|
504
|
+
// clog("uploaded", uploaded);
|
|
505
|
+
for (const ass of uploaded ?? []) {
|
|
506
|
+
ass.meta ??= {};
|
|
507
|
+
ass.meta.isUploading = false;
|
|
508
|
+
if (ass.blobUrl) {
|
|
509
|
+
const idx = assets.findIndex((a) => a.id === ass.blobUrl);
|
|
510
|
+
if (idx > -1) {
|
|
511
|
+
ass.meta ??= {};
|
|
512
|
+
ass.meta.isUploading = false;
|
|
513
|
+
assets[idx] = ass;
|
|
514
|
+
} else {
|
|
515
|
+
clog.error(`Asset idx ${idx} not found?!?`, ass);
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
clog.warn(`Missing blobUrl in`, ass);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
value = serializeValue(assets);
|
|
522
|
+
})
|
|
523
|
+
.catch((e) => notifications?.error(`${e}`))
|
|
524
|
+
.finally(() => (isUploading = false));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- Clipboard paste (opt-in via `pasteable`) -------------------------------
|
|
529
|
+
// Focus-scoped: the paste listener lives on the field wrapper, so it only fires
|
|
530
|
+
// while the field (or a control inside it) holds focus. `focusForPaste` focuses
|
|
531
|
+
// the wrapper on any click within it, so a click + Ctrl/Cmd-V works with no Tab.
|
|
532
|
+
// Pasted files go through `handleIncomingFiles`, inheriting accept/cardinality
|
|
533
|
+
// validation and the processAssets upload — no synthetic `change` event.
|
|
534
|
+
function extractClipboardFiles(e: ClipboardEvent): File[] {
|
|
535
|
+
const dt = e.clipboardData;
|
|
536
|
+
if (!dt) return [];
|
|
537
|
+
const out: File[] = [];
|
|
538
|
+
// Prefer items (lets us keep only file-kind entries, e.g. a pasted
|
|
539
|
+
// screenshot); fall back to `.files` for browsers that only populate that.
|
|
540
|
+
for (let i = 0; i < (dt.items?.length ?? 0); i++) {
|
|
541
|
+
const it = dt.items[i];
|
|
542
|
+
if (it?.kind === "file") {
|
|
543
|
+
const f = it.getAsFile();
|
|
544
|
+
if (f) out.push(f);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!out.length && dt.files?.length) out.push(...dt.files);
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function handlePaste(e: ClipboardEvent) {
|
|
552
|
+
if (!pasteable || typeof processAssets !== "function") return;
|
|
553
|
+
const files = extractClipboardFiles(e);
|
|
554
|
+
if (!files.length) return; // let plain-text pastes etc. pass through
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
handleIncomingFiles(files);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Focus the wrapper on any click inside it so a following paste lands here.
|
|
560
|
+
// Capture phase: fires even though the inner thumbnail/control buttons
|
|
561
|
+
// stopPropagation, and even in browsers that don't focus <button> on click
|
|
562
|
+
// (Safari/Firefox on macOS). Skipped when focus is already inside the field —
|
|
563
|
+
// paste bubbles from any focused descendant anyway.
|
|
564
|
+
function focusForPaste() {
|
|
565
|
+
if (!pasteable || !wrapEl) return;
|
|
566
|
+
if (wrapEl.contains(document.activeElement)) return;
|
|
567
|
+
// Focus a node INSIDE `.input-wrap` (the grid) so its `:focus-within` ring
|
|
568
|
+
// shows; fall back to the wrapper when the grid isn't mounted (loading).
|
|
569
|
+
(gridEl ?? wrapEl).focus({ preventScroll: true });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
$effect(() => {
|
|
573
|
+
if (!pasteable || !wrapEl) return;
|
|
574
|
+
const el = wrapEl;
|
|
575
|
+
el.addEventListener("paste", handlePaste);
|
|
576
|
+
el.addEventListener("click", focusForPaste, true);
|
|
577
|
+
return () => {
|
|
578
|
+
el.removeEventListener("paste", handlePaste);
|
|
579
|
+
el.removeEventListener("click", focusForPaste, true);
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
|
|
407
583
|
onDestroy(() => {
|
|
408
584
|
try {
|
|
409
585
|
assets.forEach((a) => {
|
|
@@ -429,7 +605,11 @@
|
|
|
429
605
|
<!-- screen-reader announcements for reorder actions -->
|
|
430
606
|
<div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
|
|
431
607
|
{/if}
|
|
432
|
-
<div
|
|
608
|
+
<div
|
|
609
|
+
class="p-2 flex items-center gap-0.5 flex-wrap w-full focus:outline-none"
|
|
610
|
+
bind:this={gridEl}
|
|
611
|
+
tabindex="-1"
|
|
612
|
+
>
|
|
433
613
|
{#each assets as asset, idx (asset.id)}
|
|
434
614
|
{@const { thumb, full, original } = asset_urls(asset)}
|
|
435
615
|
{@const _is_img = isImage(asset.type ?? thumb)}
|
|
@@ -563,100 +743,7 @@
|
|
|
563
743
|
use:fileDropzone={() => ({
|
|
564
744
|
enabled: typeof processAssets === "function",
|
|
565
745
|
inputEl,
|
|
566
|
-
processFiles
|
|
567
|
-
clog.debug(`processFiles`, wasDrop ? "[DROPPED]" : "", files);
|
|
568
|
-
|
|
569
|
-
// Copy the File objects into a stable array, then IMMEDIATELY release the
|
|
570
|
-
// hidden <input type="file">'s retained FileList. A native file input
|
|
571
|
-
// keeps its FileList after a selection until it is reset (form reset or
|
|
572
|
-
// `value = ""`), and its `change` event (wired by the `fileDropzone`
|
|
573
|
-
// action) calls back into this handler. Without this reset, any later
|
|
574
|
-
// stray `change` re-runs processFiles with the SAME file still in
|
|
575
|
-
// `inputEl.files`, pushing a duplicate optimistic asset and firing a real
|
|
576
|
-
// re-upload. `onSubmitValidityCheck` used to dispatch exactly such a
|
|
577
|
-
// synthetic `change` on submit (now fixed there too — belt and braces).
|
|
578
|
-
// Clearing also restores the ability to re-select the same file twice in
|
|
579
|
-
// a row (an unchanged value emits no `change`). The blob URLs we create
|
|
580
|
-
// below are independent of the input, so clearing here is safe.
|
|
581
|
-
const incoming = [...(files ?? [])];
|
|
582
|
-
if (inputEl) inputEl.value = "";
|
|
583
|
-
|
|
584
|
-
// Nothing to consume — a cancelled picker, or a stray/synthetic `change`
|
|
585
|
-
// on an already-cleared input. Bail before touching state or calling
|
|
586
|
-
// processAssets (which would otherwise run with an empty batch).
|
|
587
|
-
if (!incoming.length) return;
|
|
588
|
-
|
|
589
|
-
if (accept && incoming.some((f) => !is_accepted_type(accept, f.type))) {
|
|
590
|
-
const msg = t("invalid_type", { accept });
|
|
591
|
-
if (notifications) notifications.error(msg);
|
|
592
|
-
else alert(msg);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const cardErrMsg = t("cardinality_reached", { max: cardinality });
|
|
597
|
-
// `>=` (not `>`): refuse the moment the field already holds `cardinality`
|
|
598
|
-
// assets, instead of optimistically adding one past the limit and relying
|
|
599
|
-
// on the validator to reject it afterwards (the off-by-one that made the
|
|
600
|
-
// single-cardinality symptom loud).
|
|
601
|
-
if (assets.length >= cardinality) {
|
|
602
|
-
if (notifications) notifications.error(cardErrMsg);
|
|
603
|
-
else alert(cardErrMsg);
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const toBeProcessed: FieldAsset[] = [];
|
|
608
|
-
|
|
609
|
-
for (const file of incoming) {
|
|
610
|
-
if (assets.length >= cardinality) {
|
|
611
|
-
notifications ? notifications.error(cardErrMsg) : alert(cardErrMsg);
|
|
612
|
-
break;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// this create a unique blob url, which we'll use as id as well
|
|
616
|
-
const url = URL.createObjectURL(file);
|
|
617
|
-
const asset: FieldAsset = {
|
|
618
|
-
id: url,
|
|
619
|
-
url: { thumb: url, full: url, original: url },
|
|
620
|
-
type: file.type,
|
|
621
|
-
name: file.name,
|
|
622
|
-
meta: { isUploading: true },
|
|
623
|
-
};
|
|
624
|
-
|
|
625
|
-
// ASAP optimistic UI update
|
|
626
|
-
assets.push(asset);
|
|
627
|
-
|
|
628
|
-
// prepare data for server upload
|
|
629
|
-
toBeProcessed.push(asset);
|
|
630
|
-
}
|
|
631
|
-
value = serializeValue(assets);
|
|
632
|
-
|
|
633
|
-
if (typeof processAssets === "function") {
|
|
634
|
-
isUploading = true;
|
|
635
|
-
processAssets(toBeProcessed, onProgress)
|
|
636
|
-
.then((uploaded: FieldAssetWithBlobUrl[]) => {
|
|
637
|
-
// clog("uploaded", uploaded);
|
|
638
|
-
for (const ass of uploaded ?? []) {
|
|
639
|
-
ass.meta ??= {};
|
|
640
|
-
ass.meta.isUploading = false;
|
|
641
|
-
if (ass.blobUrl) {
|
|
642
|
-
const idx = assets.findIndex((a) => a.id === ass.blobUrl);
|
|
643
|
-
if (idx > -1) {
|
|
644
|
-
ass.meta ??= {};
|
|
645
|
-
ass.meta.isUploading = false;
|
|
646
|
-
assets[idx] = ass;
|
|
647
|
-
} else {
|
|
648
|
-
clog.error(`Asset idx ${idx} not found?!?`, ass);
|
|
649
|
-
}
|
|
650
|
-
} else {
|
|
651
|
-
clog.warn(`Missing blobUrl in`, ass);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
value = serializeValue(assets);
|
|
655
|
-
})
|
|
656
|
-
.catch((e) => notifications?.error(`${e}`))
|
|
657
|
-
.finally(() => (isUploading = false));
|
|
658
|
-
}
|
|
659
|
-
},
|
|
746
|
+
processFiles: handleIncomingFiles,
|
|
660
747
|
allowClick: !assets?.length,
|
|
661
748
|
})}
|
|
662
749
|
>
|
|
@@ -676,7 +763,11 @@
|
|
|
676
763
|
{classLabel}
|
|
677
764
|
{classLabelBox}
|
|
678
765
|
{classInputBox}
|
|
679
|
-
{
|
|
766
|
+
classInputBoxWrap={twMerge(
|
|
767
|
+
pasteable &&
|
|
768
|
+
"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-(--stuic-color-ring)",
|
|
769
|
+
classInputBoxWrap
|
|
770
|
+
)}
|
|
680
771
|
{classInputBoxWrapInvalid}
|
|
681
772
|
{classDescBox}
|
|
682
773
|
{classDescBoxToggle}
|
|
@@ -718,4 +809,7 @@
|
|
|
718
809
|
onDelete={(_, index) => {
|
|
719
810
|
remove_by_idx(index);
|
|
720
811
|
}}
|
|
812
|
+
onDownload={onDownload
|
|
813
|
+
? (_previewAsset, idx) => onDownload(assets[idx], idx)
|
|
814
|
+
: undefined}
|
|
721
815
|
/>
|
|
@@ -54,6 +54,13 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
54
54
|
t?: TranslateFn;
|
|
55
55
|
parseValue?: (strigifiedModels: string) => FieldAsset[];
|
|
56
56
|
serializeValue?: (assets: FieldAsset[]) => string;
|
|
57
|
+
/**
|
|
58
|
+
* See AssetsPreview.onDownload. When provided, the preview's Download button calls
|
|
59
|
+
* this instead of `forceDownload(asset.url.original)` — for auth-gated bytes or
|
|
60
|
+
* lazy fetching. Receives the full `FieldAsset` (incl. `_raw`) for the clicked
|
|
61
|
+
* asset + its index. May be async; the button shows a busy state while it settles.
|
|
62
|
+
*/
|
|
63
|
+
onDownload?: (asset: FieldAsset, index: number) => void | Promise<void>;
|
|
57
64
|
notifications?: NotificationsStack;
|
|
58
65
|
cardinality?: number;
|
|
59
66
|
accept?: string;
|
|
@@ -68,6 +75,15 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
68
75
|
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
69
76
|
*/
|
|
70
77
|
ordered?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Opt-in: allow pasting image/file data from the clipboard (Ctrl/Cmd-V) into
|
|
80
|
+
* the field. Focus-scoped — the paste is consumed only while the field (or a
|
|
81
|
+
* control inside it) holds focus; clicking anywhere in the field focuses it,
|
|
82
|
+
* so no Tab is needed. Routed through the same validation + upload path as
|
|
83
|
+
* drag and the file picker, so `accept`, `cardinality` and `processAssets`
|
|
84
|
+
* all apply. No-op unless `processAssets` is provided. Default `false`.
|
|
85
|
+
*/
|
|
86
|
+
pasteable?: boolean;
|
|
71
87
|
classWrap?: string;
|
|
72
88
|
}
|
|
73
89
|
declare const FieldAssets: import("svelte").Component<Props, {
|
|
@@ -475,9 +475,29 @@ on submit (and round-trips on reopen). Single-select fields ignore the prop.
|
|
|
475
475
|
## FieldAssets
|
|
476
476
|
|
|
477
477
|
Asset/image upload field with an inline thumbnail grid and a built-in lightbox preview
|
|
478
|
-
(`AssetsPreview`). Files are added via the picker button
|
|
479
|
-
|
|
480
|
-
processing).
|
|
478
|
+
(`AssetsPreview`). Files are added via the picker button, by dragging them onto the field,
|
|
479
|
+
or (opt-in) by pasting from the clipboard; each shows as a thumbnail with its filename (and
|
|
480
|
+
an upload progress indicator while processing).
|
|
481
|
+
|
|
482
|
+
### Paste from the clipboard (`pasteable`)
|
|
483
|
+
|
|
484
|
+
Opt in with `pasteable` to let users paste image/file data from the clipboard (Ctrl/Cmd-V) —
|
|
485
|
+
a screenshot, a copied image, etc. It is **focus-scoped**: a paste is consumed only while the
|
|
486
|
+
field (or a control inside it) holds focus. Clicking anywhere in the field focuses it, so no
|
|
487
|
+
Tab is needed, and a `:focus-within` ring on the input box signals the paste-ready state.
|
|
488
|
+
Pasted files go through the same path as the picker and drag-and-drop, so `accept`,
|
|
489
|
+
`cardinality` and `processAssets` all apply. No-op unless `processAssets` is provided.
|
|
490
|
+
|
|
491
|
+
```svelte
|
|
492
|
+
<FieldAssets
|
|
493
|
+
label="Screenshots"
|
|
494
|
+
name="screenshots"
|
|
495
|
+
bind:value
|
|
496
|
+
{processAssets}
|
|
497
|
+
accept="image/*"
|
|
498
|
+
pasteable
|
|
499
|
+
/>
|
|
500
|
+
```
|
|
481
501
|
|
|
482
502
|
### Ordering the assets (`ordered`)
|
|
483
503
|
|
|
@@ -497,6 +517,42 @@ announcements. The chosen order is serialized to `value`.
|
|
|
497
517
|
/>
|
|
498
518
|
```
|
|
499
519
|
|
|
520
|
+
### Lazy / auth-gated download (`onDownload`)
|
|
521
|
+
|
|
522
|
+
By default the preview's **Download** button fetches `asset.url.original` with a plain
|
|
523
|
+
unauthenticated request. When the bytes live behind an authenticated route (a Bearer
|
|
524
|
+
token, a signed request, …), or you'd rather fetch them **only on download-click** instead
|
|
525
|
+
of pre-resolving an object URL for every asset up front, pass `onDownload`. It receives the
|
|
526
|
+
full `FieldAsset` (including `_raw`, so you can recover your own model id) plus its index,
|
|
527
|
+
and replaces the default download entirely. It may be async — the button shows a busy state
|
|
528
|
+
until it settles, and a rejection is caught so a failed download never breaks the preview.
|
|
529
|
+
|
|
530
|
+
```svelte
|
|
531
|
+
<FieldAssets
|
|
532
|
+
label="Attachments"
|
|
533
|
+
name="attachments"
|
|
534
|
+
bind:value
|
|
535
|
+
{processAssets}
|
|
536
|
+
onDownload={async (asset) => {
|
|
537
|
+
const id = (asset._raw as { model_id: string }).model_id;
|
|
538
|
+
const res = await fetch(`/todo/attachment/${id}`, {
|
|
539
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
540
|
+
});
|
|
541
|
+
const objUrl = URL.createObjectURL(await res.blob());
|
|
542
|
+
const a = document.createElement("a");
|
|
543
|
+
a.href = objUrl;
|
|
544
|
+
a.download = asset.name || "download";
|
|
545
|
+
document.body.appendChild(a);
|
|
546
|
+
a.click();
|
|
547
|
+
a.remove();
|
|
548
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 10_000);
|
|
549
|
+
}}
|
|
550
|
+
/>
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
> With this, non-image attachments need **no** pre-fetched object URL at all — they render
|
|
554
|
+
> as a file icon and only fetch bytes when the user actually clicks Download.
|
|
555
|
+
|
|
500
556
|
---
|
|
501
557
|
|
|
502
558
|
## Honeypot & TimeTrap (anti-bot primitives)
|
|
@@ -282,9 +282,7 @@ Note: in icon-only sidebar mode (`isCollapsed`), the section title is visually h
|
|
|
282
282
|
import { Nav } from "stuic";
|
|
283
283
|
import { page } from "$app/stores";
|
|
284
284
|
|
|
285
|
-
const groups = [
|
|
286
|
-
/* ... */
|
|
287
|
-
];
|
|
285
|
+
const groups = [/* ... */];
|
|
288
286
|
</script>
|
|
289
287
|
|
|
290
288
|
<Nav
|
|
@@ -57,12 +57,7 @@
|
|
|
57
57
|
* ```
|
|
58
58
|
*/
|
|
59
59
|
export type THC =
|
|
60
|
-
|
|
|
61
|
-
| WithText
|
|
62
|
-
| WithHtml
|
|
63
|
-
| WithComponent
|
|
64
|
-
| WithSnippet
|
|
65
|
-
| AsSnippet;
|
|
60
|
+
string | WithText | WithHtml | WithComponent | WithSnippet | AsSnippet;
|
|
66
61
|
|
|
67
62
|
export interface Props extends Record<string, any> {
|
|
68
63
|
thc: THC;
|