@marianmeres/stuic 3.133.0 → 3.134.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.
@@ -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 | 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
- | `prevNextBottom` | `boolean` | `false` | Render prev/next arrows at bottom |
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
- forceDownload(
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;
@@ -255,6 +262,7 @@
255
262
  }
256
263
  },
257
264
  serializeValue = JSON.stringify,
265
+ onDownload,
258
266
  classWrap = "",
259
267
  // ...rest
260
268
  }: Props = $props();
@@ -718,4 +726,7 @@
718
726
  onDelete={(_, index) => {
719
727
  remove_by_idx(index);
720
728
  }}
729
+ onDownload={onDownload
730
+ ? (_previewAsset, idx) => onDownload(assets[idx], idx)
731
+ : undefined}
721
732
  />
@@ -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;
@@ -497,6 +497,42 @@ announcements. The chosen order is serialized to `value`.
497
497
  />
498
498
  ```
499
499
 
500
+ ### Lazy / auth-gated download (`onDownload`)
501
+
502
+ By default the preview's **Download** button fetches `asset.url.original` with a plain
503
+ unauthenticated request. When the bytes live behind an authenticated route (a Bearer
504
+ token, a signed request, …), or you'd rather fetch them **only on download-click** instead
505
+ of pre-resolving an object URL for every asset up front, pass `onDownload`. It receives the
506
+ full `FieldAsset` (including `_raw`, so you can recover your own model id) plus its index,
507
+ and replaces the default download entirely. It may be async — the button shows a busy state
508
+ until it settles, and a rejection is caught so a failed download never breaks the preview.
509
+
510
+ ```svelte
511
+ <FieldAssets
512
+ label="Attachments"
513
+ name="attachments"
514
+ bind:value
515
+ {processAssets}
516
+ onDownload={async (asset) => {
517
+ const id = (asset._raw as { model_id: string }).model_id;
518
+ const res = await fetch(`/todo/attachment/${id}`, {
519
+ headers: { Authorization: `Bearer ${token}` },
520
+ });
521
+ const objUrl = URL.createObjectURL(await res.blob());
522
+ const a = document.createElement("a");
523
+ a.href = objUrl;
524
+ a.download = asset.name || "download";
525
+ document.body.appendChild(a);
526
+ a.click();
527
+ a.remove();
528
+ setTimeout(() => URL.revokeObjectURL(objUrl), 10_000);
529
+ }}
530
+ />
531
+ ```
532
+
533
+ > With this, non-image attachments need **no** pre-fetched object URL at all — they render
534
+ > as a file icon and only fetch bytes when the user actually clicks Download.
535
+
500
536
  ---
501
537
 
502
538
  ## 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
- | string
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.133.0",
3
+ "version": "3.134.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",