@kahitsan/ksui 0.27.0 → 0.29.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.29.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,24 @@
|
|
|
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
|
-
import { Show, type Component } from "solid-js";
|
|
13
|
+
import { Show, createSignal, 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";
|
|
21
|
+
import ImageViewer from "./ImageViewer";
|
|
13
22
|
|
|
14
23
|
export interface ExistingAttachment {
|
|
15
24
|
id: number;
|
|
@@ -23,10 +32,27 @@ interface Props {
|
|
|
23
32
|
testId: string;
|
|
24
33
|
onDelete?: (attachmentId: number) => Promise<void> | void;
|
|
25
34
|
fallbackIcon?: Component<{ size?: number }>;
|
|
35
|
+
// When set, stream the private object's bytes from this authed same-origin route
|
|
36
|
+
// and render a blob: — the proxy/blob mode. When absent, fall back to the public
|
|
37
|
+
// s3_link. The fetch carries credentials; pass extra headers via `rawInit`.
|
|
38
|
+
rawHref?: string;
|
|
39
|
+
rawInit?: RequestInit;
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
export default function ExistingAttachmentTile(props: Props) {
|
|
29
|
-
|
|
43
|
+
// Always call the hook (Solid rule); a null href no-ops when not in blob mode.
|
|
44
|
+
const blob = createObjectUrlResource(
|
|
45
|
+
() => props.rawHref ?? null,
|
|
46
|
+
{ init: props.rawInit },
|
|
47
|
+
);
|
|
48
|
+
const isBlobMode = () => props.rawHref != null;
|
|
49
|
+
const url = (): string | undefined =>
|
|
50
|
+
isBlobMode() ? (blob() ?? undefined) : attachmentUrl(props.attachment.s3_link);
|
|
51
|
+
const resolvable = () =>
|
|
52
|
+
isBlobMode() ? blob() != null : isResolvableAttachment(props.attachment.s3_link);
|
|
53
|
+
const loading = () => (isBlobMode() ? blob.loading : false);
|
|
54
|
+
const [viewerOpen, setViewerOpen] = createSignal(false);
|
|
55
|
+
|
|
30
56
|
const FallbackIcon = () => {
|
|
31
57
|
const Icon = props.fallbackIcon ?? Paperclip;
|
|
32
58
|
return <Icon size={20} />;
|
|
@@ -35,15 +61,26 @@ export default function ExistingAttachmentTile(props: Props) {
|
|
|
35
61
|
return (
|
|
36
62
|
<div class="relative group shrink-0" data-testid={props.testId}>
|
|
37
63
|
<Show
|
|
38
|
-
when={
|
|
64
|
+
when={resolvable()}
|
|
39
65
|
fallback={
|
|
40
66
|
<div
|
|
41
67
|
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={
|
|
68
|
+
title={
|
|
69
|
+
loading()
|
|
70
|
+
? `${props.attachment.file_name} (loading)`
|
|
71
|
+
: `${props.attachment.file_name} (file is no longer available)`
|
|
72
|
+
}
|
|
43
73
|
>
|
|
44
|
-
<
|
|
74
|
+
<Show
|
|
75
|
+
when={loading()}
|
|
76
|
+
fallback={<TriangleAlert size={18} class="text-amber-500/70" />}
|
|
77
|
+
>
|
|
78
|
+
<Loader2 size={18} class="animate-spin text-zinc-500" />
|
|
79
|
+
</Show>
|
|
45
80
|
<span class="truncate max-w-full text-[10px]">{props.attachment.file_name}</span>
|
|
46
|
-
<span class="text-[9px] uppercase tracking-wider">
|
|
81
|
+
<span class="text-[9px] uppercase tracking-wider">
|
|
82
|
+
{loading() ? "Loading" : "Unavailable"}
|
|
83
|
+
</span>
|
|
47
84
|
</div>
|
|
48
85
|
}
|
|
49
86
|
>
|
|
@@ -52,8 +89,7 @@ export default function ExistingAttachmentTile(props: Props) {
|
|
|
52
89
|
fallback={
|
|
53
90
|
<a
|
|
54
91
|
href={url()}
|
|
55
|
-
|
|
56
|
-
rel="noopener"
|
|
92
|
+
download={props.attachment.file_name}
|
|
57
93
|
class="flex w-24 h-24 flex-col items-center justify-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/50 px-2 text-xs text-zinc-300 hover:border-amber-500/30"
|
|
58
94
|
>
|
|
59
95
|
<FallbackIcon />
|
|
@@ -61,14 +97,13 @@ export default function ExistingAttachmentTile(props: Props) {
|
|
|
61
97
|
</a>
|
|
62
98
|
}
|
|
63
99
|
>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class="block rounded-lg border border-zinc-700 overflow-hidden hover:border-amber-500/30"
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => setViewerOpen(true)}
|
|
103
|
+
class="block rounded-lg border border-zinc-700 overflow-hidden hover:border-amber-500/30 cursor-pointer"
|
|
69
104
|
>
|
|
70
105
|
<img src={url()} alt={props.attachment.file_name} class="w-24 h-24 object-cover" />
|
|
71
|
-
</
|
|
106
|
+
</button>
|
|
72
107
|
</Show>
|
|
73
108
|
</Show>
|
|
74
109
|
<Show when={props.onDelete}>
|
|
@@ -89,6 +124,13 @@ export default function ExistingAttachmentTile(props: Props) {
|
|
|
89
124
|
<X size={12} />
|
|
90
125
|
</button>
|
|
91
126
|
</Show>
|
|
127
|
+
<Show when={viewerOpen() && url()}>
|
|
128
|
+
<ImageViewer
|
|
129
|
+
src={url()!}
|
|
130
|
+
alt={props.attachment.file_name}
|
|
131
|
+
onClose={() => setViewerOpen(false)}
|
|
132
|
+
/>
|
|
133
|
+
</Show>
|
|
92
134
|
</div>
|
|
93
135
|
);
|
|
94
136
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Fullscreen image lightbox (Facebook-style): the image centered on a near-opaque
|
|
2
|
+
// backdrop, dismissed by the X button, a backdrop click, or Escape. Built on the
|
|
3
|
+
// native <dialog> (showModal) for top-layer rendering + native Escape + focus
|
|
4
|
+
// containment — the same approach as Modal — but full-bleed with no card chrome,
|
|
5
|
+
// so the image fills the viewport.
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: assets are now served as same-origin object URLs (blob:), which
|
|
8
|
+
// are revoked when their owning component unmounts — so opening one in a new tab
|
|
9
|
+
// (target="_blank") breaks the moment the source view closes. An in-page viewer
|
|
10
|
+
// renders the blob while it's still alive.
|
|
11
|
+
//
|
|
12
|
+
// Like the rest of ksui, no sidecar CSS: styles inject once via a runtime <style>
|
|
13
|
+
// with unscoped `ksui-imgviewer-*` class names. Mount === open, unmount === closed
|
|
14
|
+
// (wrap in `<Show when={…}>`).
|
|
15
|
+
|
|
16
|
+
import { onCleanup, onMount } from "solid-js";
|
|
17
|
+
import X from "lucide-solid/icons/x";
|
|
18
|
+
import { lockPullToRefresh, unlockPullToRefresh } from "../../utils/dom";
|
|
19
|
+
|
|
20
|
+
const STYLE_ID = "ksui-image-viewer-style";
|
|
21
|
+
|
|
22
|
+
function ensureStyle(): void {
|
|
23
|
+
if (typeof document === "undefined") return;
|
|
24
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
25
|
+
const style = document.createElement("style");
|
|
26
|
+
style.id = STYLE_ID;
|
|
27
|
+
style.textContent = `
|
|
28
|
+
.ksui-imgviewer{position:fixed;inset:0;z-index:60;background:transparent;padding:0;margin:0;max-width:none;max-height:none;width:100vw;height:100vh;border:0;}
|
|
29
|
+
.ksui-imgviewer[open]{display:flex;align-items:center;justify-content:center;}
|
|
30
|
+
.ksui-imgviewer::backdrop{background:rgba(0,0,0,0.92);backdrop-filter:blur(2px);}
|
|
31
|
+
.ksui-imgviewer-img{max-width:96vw;max-height:96vh;object-fit:contain;border-radius:0.25rem;box-shadow:0 25px 50px -12px rgba(0,0,0,0.8);}
|
|
32
|
+
.ksui-imgviewer-close{position:absolute;top:1rem;right:1rem;display:flex;width:2.5rem;height:2.5rem;align-items:center;justify-content:center;border-radius:9999px;background:rgba(255,255,255,0.12);color:#fff;border:0;cursor:pointer;}
|
|
33
|
+
.ksui-imgviewer-close:hover{background:rgba(255,255,255,0.22);}
|
|
34
|
+
`;
|
|
35
|
+
document.head.appendChild(style);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** Image URL to display (typically a blob: object URL or a public link). */
|
|
40
|
+
src: string;
|
|
41
|
+
/** Accessible name + img alt. */
|
|
42
|
+
alt?: string;
|
|
43
|
+
/** Fired on the X button, a backdrop click, or Escape. */
|
|
44
|
+
onClose: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function ImageViewer(props: Props) {
|
|
48
|
+
ensureStyle();
|
|
49
|
+
let dialogEl: HTMLDialogElement | undefined;
|
|
50
|
+
|
|
51
|
+
lockPullToRefresh();
|
|
52
|
+
onCleanup(unlockPullToRefresh);
|
|
53
|
+
|
|
54
|
+
onMount(() => {
|
|
55
|
+
if (dialogEl && !dialogEl.open) {
|
|
56
|
+
try {
|
|
57
|
+
dialogEl.showModal();
|
|
58
|
+
} catch {
|
|
59
|
+
// showModal throws if already open or detached; harmless to ignore.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
onCleanup(() => {
|
|
64
|
+
if (dialogEl?.open) dialogEl.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Escape fires the dialog's native `cancel`; intercept it to route through onClose.
|
|
68
|
+
const handleCancel = (e: Event) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
props.onClose();
|
|
71
|
+
};
|
|
72
|
+
// A click whose target IS the dialog landed on the backdrop (the image + button
|
|
73
|
+
// are inner elements and stop here), so close.
|
|
74
|
+
const handleClick = (e: MouseEvent) => {
|
|
75
|
+
if (e.target === dialogEl) props.onClose();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
|
80
|
+
<dialog
|
|
81
|
+
ref={dialogEl}
|
|
82
|
+
aria-modal="true"
|
|
83
|
+
aria-label={props.alt || "Image viewer"}
|
|
84
|
+
onCancel={handleCancel}
|
|
85
|
+
onClick={handleClick}
|
|
86
|
+
class="ksui-imgviewer"
|
|
87
|
+
>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
aria-label="Close"
|
|
91
|
+
onClick={props.onClose}
|
|
92
|
+
class="ksui-imgviewer-close"
|
|
93
|
+
>
|
|
94
|
+
<X size={22} />
|
|
95
|
+
</button>
|
|
96
|
+
<img src={props.src} alt={props.alt || ""} class="ksui-imgviewer-img" />
|
|
97
|
+
</dialog>
|
|
98
|
+
);
|
|
99
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export { default as FormField } from "./components/base/FormField";
|
|
|
52
52
|
export { default as DetailRow } from "./components/base/DetailRow";
|
|
53
53
|
export { default as Tooltip } from "./components/base/Tooltip";
|
|
54
54
|
export { default as ImageCropper } from "./components/base/ImageCropper";
|
|
55
|
+
export { default as ImageViewer } from "./components/base/ImageViewer";
|
|
55
56
|
export { default as ProgressBar, type ProgressBarProps } from "./components/base/ProgressBar";
|
|
56
57
|
export { default as ChartLegend } from "./components/base/ChartLegend";
|
|
57
58
|
|