@kahitsan/ksui 0.26.0 → 0.28.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
// Renders one already-uploaded attachment as a 24×24 tile: an image preview or
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Renders one already-uploaded attachment as a 24×24 tile: an image preview or a
|
|
2
|
+
// paperclip/file fallback, an "Unavailable" placeholder when the source can't be
|
|
3
|
+
// resolved, and an optional remove button. confirm is ksui's own self-contained
|
|
4
|
+
// dialog. The third of the attachment widget set alongside AddAttachmentTile +
|
|
5
|
+
// CameraCapture.
|
|
6
|
+
//
|
|
7
|
+
// Two source modes:
|
|
8
|
+
// • default — resolve the PUBLIC s3_link via attachmentUrl() (legacy public bucket).
|
|
9
|
+
// • `rawHref` set — stream the PRIVATE object's bytes from that authed same-origin
|
|
10
|
+
// route and render the resulting blob: (the proxy/blob pattern). s3_link is then
|
|
11
|
+
// ignored for rendering; a spinner shows while the bytes stream.
|
|
6
12
|
|
|
7
13
|
import { Show, type Component } from "solid-js";
|
|
8
14
|
import Paperclip from "lucide-solid/icons/paperclip";
|
|
9
15
|
import X from "lucide-solid/icons/x";
|
|
10
16
|
import TriangleAlert from "lucide-solid/icons/triangle-alert";
|
|
17
|
+
import Loader2 from "lucide-solid/icons/loader-2";
|
|
11
18
|
import { confirm } from "../../utils/confirm";
|
|
12
19
|
import { attachmentUrl, isResolvableAttachment } from "../../utils/attachments";
|
|
20
|
+
import { createObjectUrlResource } from "../../utils/object-url-resource";
|
|
13
21
|
|
|
14
22
|
export interface ExistingAttachment {
|
|
15
23
|
id: number;
|
|
@@ -23,10 +31,26 @@ interface Props {
|
|
|
23
31
|
testId: string;
|
|
24
32
|
onDelete?: (attachmentId: number) => Promise<void> | void;
|
|
25
33
|
fallbackIcon?: Component<{ size?: number }>;
|
|
34
|
+
// When set, stream the private object's bytes from this authed same-origin route
|
|
35
|
+
// and render a blob: — the proxy/blob mode. When absent, fall back to the public
|
|
36
|
+
// s3_link. The fetch carries credentials; pass extra headers via `rawInit`.
|
|
37
|
+
rawHref?: string;
|
|
38
|
+
rawInit?: RequestInit;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export default function ExistingAttachmentTile(props: Props) {
|
|
29
|
-
|
|
42
|
+
// Always call the hook (Solid rule); a null href no-ops when not in blob mode.
|
|
43
|
+
const blob = createObjectUrlResource(
|
|
44
|
+
() => props.rawHref ?? null,
|
|
45
|
+
{ init: props.rawInit },
|
|
46
|
+
);
|
|
47
|
+
const isBlobMode = () => props.rawHref != null;
|
|
48
|
+
const url = (): string | undefined =>
|
|
49
|
+
isBlobMode() ? (blob() ?? undefined) : attachmentUrl(props.attachment.s3_link);
|
|
50
|
+
const resolvable = () =>
|
|
51
|
+
isBlobMode() ? blob() != null : isResolvableAttachment(props.attachment.s3_link);
|
|
52
|
+
const loading = () => (isBlobMode() ? blob.loading : false);
|
|
53
|
+
|
|
30
54
|
const FallbackIcon = () => {
|
|
31
55
|
const Icon = props.fallbackIcon ?? Paperclip;
|
|
32
56
|
return <Icon size={20} />;
|
|
@@ -35,15 +59,26 @@ export default function ExistingAttachmentTile(props: Props) {
|
|
|
35
59
|
return (
|
|
36
60
|
<div class="relative group shrink-0" data-testid={props.testId}>
|
|
37
61
|
<Show
|
|
38
|
-
when={
|
|
62
|
+
when={resolvable()}
|
|
39
63
|
fallback={
|
|
40
64
|
<div
|
|
41
65
|
class="flex w-24 h-24 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-zinc-700 bg-zinc-900/40 px-2 text-center text-zinc-500"
|
|
42
|
-
title={
|
|
66
|
+
title={
|
|
67
|
+
loading()
|
|
68
|
+
? `${props.attachment.file_name} (loading)`
|
|
69
|
+
: `${props.attachment.file_name} (file is no longer available)`
|
|
70
|
+
}
|
|
43
71
|
>
|
|
44
|
-
<
|
|
72
|
+
<Show
|
|
73
|
+
when={loading()}
|
|
74
|
+
fallback={<TriangleAlert size={18} class="text-amber-500/70" />}
|
|
75
|
+
>
|
|
76
|
+
<Loader2 size={18} class="animate-spin text-zinc-500" />
|
|
77
|
+
</Show>
|
|
45
78
|
<span class="truncate max-w-full text-[10px]">{props.attachment.file_name}</span>
|
|
46
|
-
<span class="text-[9px] uppercase tracking-wider">
|
|
79
|
+
<span class="text-[9px] uppercase tracking-wider">
|
|
80
|
+
{loading() ? "Loading" : "Unavailable"}
|
|
81
|
+
</span>
|
|
47
82
|
</div>
|
|
48
83
|
}
|
|
49
84
|
>
|
package/src/index.ts
CHANGED
|
@@ -194,6 +194,9 @@ export { buildLogoSrc } from "./utils/account-logo-url";
|
|
|
194
194
|
|
|
195
195
|
export { attachmentUrl, isResolvableAttachment } from "./utils/attachments";
|
|
196
196
|
|
|
197
|
+
export { createObjectUrlResource } from "./utils/object-url-resource";
|
|
198
|
+
export type { ObjectUrlOptions } from "./utils/object-url-resource";
|
|
199
|
+
|
|
197
200
|
export { useAccountsIndex, resolveAccount, resolveAccountName } from "./utils/accounts-index";
|
|
198
201
|
|
|
199
202
|
export { INPUT_CLASS } from "./utils/INPUT_CLASS";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createResource, createEffect, onCleanup, type Resource } from "solid-js";
|
|
2
|
+
|
|
3
|
+
export interface ObjectUrlOptions {
|
|
4
|
+
/** Extra fetch init, merged after `credentials: "include"` (e.g. tenant headers). */
|
|
5
|
+
init?: RequestInit;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fetch a (typically authed, same-origin) resource and expose it as an object
|
|
10
|
+
* URL (`blob:`) for an `<img src>` / `<a href>`. This is the proxy/blob pattern
|
|
11
|
+
* for a privately-stored asset: the bytes come back through an app route that
|
|
12
|
+
* enforces auth/ownership, never a public or signed third-party URL — so the
|
|
13
|
+
* rendered src is a clean same-origin `blob:`, the storage origin is never
|
|
14
|
+
* exposed, and there is no leakable bearer link.
|
|
15
|
+
*
|
|
16
|
+
* The href accessor is the resource source: when it changes, the new blob is
|
|
17
|
+
* fetched and the previous object URL is revoked; the final one is revoked on
|
|
18
|
+
* cleanup (a created object URL leaks until revoked). `url()` is null while
|
|
19
|
+
* loading or on any failure — the consumer gates its own render — and
|
|
20
|
+
* `url.loading` distinguishes the two.
|
|
21
|
+
*
|
|
22
|
+
* Domain-free: the consumer supplies the href and any init (headers/credentials);
|
|
23
|
+
* this primitive assumes nothing about auth, tenancy, or endpoints.
|
|
24
|
+
*/
|
|
25
|
+
export function createObjectUrlResource(
|
|
26
|
+
href: () => string | null | undefined,
|
|
27
|
+
options: ObjectUrlOptions = {},
|
|
28
|
+
): Resource<string | null> {
|
|
29
|
+
const [url] = createResource(
|
|
30
|
+
() => href() || null,
|
|
31
|
+
async (src) => {
|
|
32
|
+
const res = await fetch(src, { credentials: "include", ...options.init });
|
|
33
|
+
if (!res.ok) return null;
|
|
34
|
+
const blob = await res.blob();
|
|
35
|
+
return URL.createObjectURL(blob);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Revoke the previous object URL when the resolved value changes, and the
|
|
40
|
+
// final one on unmount. During a refetch the resource holds its prior value
|
|
41
|
+
// (cur === prev), so an in-flight reload doesn't revoke a URL still on screen.
|
|
42
|
+
let prev: string | null = null;
|
|
43
|
+
createEffect(() => {
|
|
44
|
+
const cur = url() ?? null;
|
|
45
|
+
if (prev && prev !== cur) URL.revokeObjectURL(prev);
|
|
46
|
+
prev = cur;
|
|
47
|
+
});
|
|
48
|
+
onCleanup(() => {
|
|
49
|
+
if (prev) URL.revokeObjectURL(prev);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return url;
|
|
53
|
+
}
|