@kahitsan/ksui 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis Edward M. Miranda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @kahitsan/ksui
2
+
3
+ The single canonical copy of the shared SolidJS UI components that KahitSan/Hilinga
4
+ plugins consume — pickers, the mention textarea, markdown notes, the camera +
5
+ attachment tiles, and the data-driven account avatar — plus the `@kserp/host-ui`
6
+ ambient type contract for the host UI kit.
7
+
8
+ Extracted from `kserp/packages/plugin-ui` (`@kahitsan/plugin-ui`) into its own repo
9
+ so the UI package versions and publishes independently of the kernel.
10
+
11
+ ## Install
12
+
13
+ Published to the **public npm registry** (npmjs.com) as `@kahitsan/ksui` — no
14
+ registry config or auth needed:
15
+
16
+ ```
17
+ npm install @kahitsan/ksui
18
+ ```
19
+
20
+ ## How it ships
21
+
22
+ This is a SolidJS component library shipped as **source** under a `solid` export
23
+ condition (see `package.json`). The consumer's `vite-plugin-solid` compiles the
24
+ components, while `solid-js` and `@kserp/host-ui` stay **externalized** to the host
25
+ runtime globals — so the component source is bundled into the plugin IIFE exactly
26
+ as a local copy would be: one Solid instance, the host UI kit reused. `lucide-solid`
27
+ is bundled from the consumer's own deps.
28
+
29
+ Consumers must keep `solid-js` + `@kserp/host-ui` externalized in their
30
+ `vite.remote.config.ts`, and (until a `.d.ts` bundle ships) reference the host kit
31
+ types via the `./host-ui` export:
32
+
33
+ ```ts
34
+ /// <reference types="@kahitsan/ksui/host-ui" />
35
+ ```
36
+
37
+ ## Type-checking
38
+
39
+ `npm run typecheck` runs `tsc --noEmit` standalone (`jsxImportSource: solid-js`, the
40
+ shipped `host-ui.d.ts` in scope). The authoritative gate remains each consuming
41
+ plugin's own `tsc`, where the plugin's `lucide-solid` and host runtime are present.
42
+
43
+ ## Publishing
44
+
45
+ Push a `v*` tag (e.g. `v0.3.0`); the release workflow publishes to GitHub Packages.
package/host-ui.d.ts ADDED
@@ -0,0 +1,145 @@
1
+ // CANONICAL SDK type defs for the host UI kit (window.__KSERP_UI__, externalized
2
+ // as "@kserp/host-ui"). This ships in @kahitsan/plugin-ui and is the single
3
+ // source of truth — every plugin (ours, and any third-party with no kernel
4
+ // source) gets it from the installed package via
5
+ // `/// <reference types="@kahitsan/plugin-ui/host-ui" />`; there are no more
6
+ // per-plugin copies to drift. The host owns the runtime: its remote loader
7
+ // (kserp src/lib/remote-loader.ts) populates the global from the host's kit
8
+ // barrel (kserp src/lib/host-ui.tsx) before loading any remote. Keep this in sync
9
+ // with that barrel — every member here must be exported there, and vice-versa.
10
+ declare module "@kserp/host-ui" {
11
+ import type { JSX, Accessor } from "solid-js";
12
+
13
+ // --- Shared table types (mirror src/components/ui/DataTable/DataTable.tsx) ---
14
+ export interface DataTableRow {
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ [key: string]: any;
17
+ }
18
+ export interface DataTableColumn<T extends DataTableRow> {
19
+ data: (keyof T & string) | null;
20
+ title?: string;
21
+ render?: (
22
+ data: T[keyof T] | null,
23
+ type: "display",
24
+ row: T,
25
+ meta: { row: number; col: number; search: string },
26
+ ) => JSX.Element | string;
27
+ orderable?: boolean;
28
+ className?: string;
29
+ }
30
+ export interface FetchResult<T> {
31
+ data: T[];
32
+ total: number;
33
+ }
34
+ export interface FetchParams {
35
+ page: number;
36
+ limit: number;
37
+ search: string;
38
+ sortBy: string | null;
39
+ sortDir: "asc" | "desc";
40
+ dateFilter: string | null;
41
+ dateFrom?: string | null;
42
+ dateTo?: string | null;
43
+ }
44
+
45
+ export interface DataTableProps<T extends DataTableRow> {
46
+ columns?: DataTableColumn<T>[];
47
+ /**
48
+ * Generic data fetcher. When provided, the table runs in server-side mode.
49
+ * Optional: omit it and pass `data` for client-side mode.
50
+ */
51
+ fetchFn?: (params: FetchParams) => Promise<FetchResult<T>>;
52
+ /** Static data (client-side mode). Ignored when `fetchFn` is set. */
53
+ data?: T[];
54
+ /**
55
+ * Expose the table's refetch handle to the parent. The callback receives
56
+ * `{ refetch, resetAndRefetch }`: `refetch()` re-fetches with the current
57
+ * state; `resetAndRefetch()` resets pagination to page 1 (clearing loadMore
58
+ * accumulators) before fetching.
59
+ */
60
+ onRefetch?: (api: { refetch: () => void; resetAndRefetch: () => void }) => void;
61
+ // Remaining props are passed through; kept permissive so plugins can use the
62
+ // full surface of the host component without re-declaring it here.
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ [key: string]: any;
65
+ }
66
+ export function DataTable<T extends DataTableRow>(props: DataTableProps<T>): JSX.Element;
67
+
68
+ // --- DatePicker ---
69
+ export interface DateRangeValue {
70
+ start: string | null;
71
+ end: string | null;
72
+ }
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ export function DatePicker(props: any): JSX.Element;
75
+
76
+ // --- Modal ---
77
+ // The Modal is mounted/unmounted by the caller (wrap it in <Show when={open}>);
78
+ // there is no `open` prop. onClose fires on Escape / backdrop / dismissal.
79
+ export type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "5xl" | "7xl";
80
+ export type ModalTone = "default" | "danger";
81
+ export interface ModalProps {
82
+ onClose: () => void;
83
+ dismissable?: boolean;
84
+ variant?: "default" | "sheet";
85
+ size?: ModalSize;
86
+ tone?: ModalTone;
87
+ ariaLabel?: string;
88
+ children: JSX.Element;
89
+ }
90
+ export function Modal(props: ModalProps): JSX.Element;
91
+
92
+ // --- SearchableSelect ---
93
+ export interface SearchableOption {
94
+ value: string;
95
+ label: string;
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ [key: string]: any;
98
+ }
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ export function SearchableSelect(props: any): JSX.Element;
101
+
102
+ // --- Other components (permissive: full prop surface lives in the host) ---
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ export const Button: (props: any) => JSX.Element;
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ export const PageShell: (props: any) => JSX.Element;
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ export const PageTitle: (props: any) => JSX.Element;
109
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
110
+ export const PageShareButton: (props: any) => JSX.Element;
111
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
112
+ export const Avatar: (props: any) => JSX.Element;
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
+ export const PluginPageLoader: (props: any) => JSX.Element;
115
+
116
+ // --- Confirm ---
117
+ export function confirm(opts: {
118
+ title?: string;
119
+ message?: string;
120
+ confirmLabel?: string;
121
+ cancelLabel?: string;
122
+ danger?: boolean;
123
+ }): Promise<boolean>;
124
+
125
+ // --- Host hooks (run on the host's Solid runtime + context providers) ---
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ export function useActiveWorkspace(): any;
128
+ export function useCan(code: string): Accessor<boolean>;
129
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
+ export function usePermissions(): any;
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ export function PermissionGate(props: any): JSX.Element;
133
+
134
+ // --- Helpers ---
135
+ export function highlightMatch(text: string, query: string, markClass?: string): JSX.Element;
136
+ export function HighlightedText(props: {
137
+ text: string;
138
+ query: string;
139
+ markClass?: string;
140
+ }): JSX.Element;
141
+ export function matchesQuery(text: string | null | undefined, query: string): boolean;
142
+ export function matchesAny(query: string, ...fields: (string | null | undefined)[]): boolean;
143
+ export function useFocusTrap(el: HTMLElement | undefined): () => void;
144
+ export function autoFocusOnMount(el: HTMLElement | undefined): void;
145
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@kahitsan/ksui",
3
+ "version": "0.3.0",
4
+ "description": "ksui — shared SolidJS UI components + the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to GitHub Packages and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "solid": "./src/index.ts",
11
+ "types": "./src/index.ts",
12
+ "development": "./src/index.ts",
13
+ "import": "./src/index.ts"
14
+ },
15
+ "./host-ui": {
16
+ "types": "./host-ui.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "src",
21
+ "host-ui.d.ts"
22
+ ],
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "lucide-solid": "^1.7.0"
28
+ },
29
+ "peerDependencies": {
30
+ "solid-js": "^1.9.0"
31
+ },
32
+ "devDependencies": {
33
+ "solid-js": "^1.9.0",
34
+ "typescript": "^5.6.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/KahitSan/ksui.git"
42
+ },
43
+ "license": "MIT"
44
+ }
@@ -0,0 +1,169 @@
1
+ // AccountAvatar — renders a small visual chip for an account or a user.
2
+ //
3
+ // Two distinct semantics share this chip, and the chip picks by intent:
4
+ //
5
+ // • ACCOUNT (a financial account — the default). Shows the account's
6
+ // uploaded logo, else its chosen icon glyph (the slug picked in the
7
+ // financial-accounts create/edit modal, or the type default). NEVER an
8
+ // initials circle, and the container is a ROUNDED SQUARE (rounded-md).
9
+ // This matches the /financial-accounts page exactly — a financial
10
+ // account is not a person, so it must not render as an initials avatar.
11
+ //
12
+ // • USER (a person badge — calendar entries, mention lists, timesheet
13
+ // attribution). Shows the profile photo, else an initial-on-color
14
+ // CIRCLE, in a circular (rounded-full) container. Callers opt in with
15
+ // `{ id: 0, type: 'user', name: 'Myra Abilay', image }` (the `type:
16
+ // 'user'` is the signal) or by passing `variant="user"` explicitly.
17
+ //
18
+ // The variant is inferred from `account.type === 'user'` and can be forced
19
+ // with the `variant` prop. Both paths render their image through the same
20
+ // <img> so chip sizing is identical regardless of source.
21
+
22
+ import { Show } from "solid-js";
23
+ import { Dynamic } from "solid-js/web";
24
+ import { getAccountIcon } from "../lib/account-icons";
25
+ import { buildLogoSrc } from "../lib/account-logo-url";
26
+
27
+ // Shared 16-color palette and initials algorithm — keep in lockstep with
28
+ // the kserp's ~/lib/avatar.ts so the host runtime and the plugin fleet
29
+ // render the same chip for the same user. Exported so any future widget
30
+ // (or a caller's inline use) can derive a user's color/initials without
31
+ // rendering the chip itself.
32
+ const AVATAR_PALETTE = [
33
+ "#e11d48", "#db2777", "#c026d3", "#9333ea", "#7c3aed",
34
+ "#4f46e5", "#2563eb", "#0284c7", "#0891b2", "#0d9488",
35
+ "#059669", "#65a30d", "#ca8a04", "#ea580c", "#dc2626",
36
+ ];
37
+ export function getInitials(name: string): string {
38
+ const trimmed = name?.trim();
39
+ if (!trimmed) return "?";
40
+ const parts = trimmed.split(/\s+/);
41
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
42
+ return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
43
+ }
44
+ export function getAvatarColor(name: string): string {
45
+ if (!name) return AVATAR_PALETTE[0];
46
+ let h = 0;
47
+ for (let i = 0; i < name.length; i++) {
48
+ h = (h * 31 + name.charCodeAt(i)) | 0;
49
+ }
50
+ return AVATAR_PALETTE[Math.abs(h) % AVATAR_PALETTE.length];
51
+ }
52
+
53
+ /** Build a base64 SVG data URL for the initial-on-color circle. Because it
54
+ * renders as an `<img>`, the chip scales uniformly with every other source
55
+ * (photo, s3 logo) — no per-plugin/inline text-size drift. The viewBox is
56
+ * 100×100 and the font-size is calculated to keep 2-character initials
57
+ * comfortably inside the circle. */
58
+ export function buildInitialsSvg(name: string): string {
59
+ const initials = getInitials(name);
60
+ const color = getAvatarColor(name);
61
+ const fontSize = initials.length > 1 ? 38 : 44;
62
+ const svg = [
63
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
64
+ `<circle cx="50" cy="50" r="50" fill="${color}"/>`,
65
+ `<text x="50" y="50" text-anchor="middle" dominant-baseline="central" ` +
66
+ `fill="white" font-size="${fontSize}" font-weight="bold" ` +
67
+ `font-family="system-ui,-apple-system,sans-serif">${initials}</text>`,
68
+ "</svg>",
69
+ ].join("");
70
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
71
+ }
72
+
73
+ export interface AvatarAccount {
74
+ id: number | string;
75
+ s3_link?: string | null;
76
+ icon?: string | null;
77
+ color?: string | null;
78
+ type: string;
79
+ /** Display name. Only used by the USER variant: when no photo is set the
80
+ * chip falls through to an initial-on-color circle. Ignored by the
81
+ * account variant, which never renders initials. */
82
+ name?: string;
83
+ /** User profile photo URL (e.g. Google identity provider). USER variant
84
+ * only — higher priority than the initials fallback. */
85
+ image?: string | null;
86
+ }
87
+
88
+ interface AccountAvatarProps {
89
+ account: AvatarAccount;
90
+ size?: number;
91
+ class?: string;
92
+ iconClass?: string;
93
+ alt?: string;
94
+ /** Force the rendering intent. Defaults to "user" when
95
+ * `account.type === 'user'`, otherwise "account". */
96
+ variant?: "account" | "user";
97
+ }
98
+
99
+ export default function AccountAvatar(props: AccountAvatarProps) {
100
+ const size = () => props.size ?? 28;
101
+ const iconSize = () => Math.max(12, Math.round(size() * 0.6));
102
+ const iconStyle = () => (props.account.color ? { color: props.account.color } : undefined);
103
+
104
+ const isUser = () =>
105
+ props.variant === "user" ||
106
+ (props.variant == null && props.account.type === "user");
107
+
108
+ // USER source: profile photo, else the initial-on-color circle.
109
+ const userImgSrc = () => {
110
+ if (props.account.s3_link) return buildLogoSrc(props.account.s3_link);
111
+ if (props.account.image) return props.account.image;
112
+ if (props.account.name) return buildInitialsSvg(props.account.name);
113
+ return null;
114
+ };
115
+
116
+ const iconGlyph = () => (
117
+ <Dynamic
118
+ component={getAccountIcon(props.account)}
119
+ size={iconSize()}
120
+ class={props.iconClass ?? "text-zinc-300"}
121
+ style={iconStyle()}
122
+ />
123
+ );
124
+
125
+ return (
126
+ <span
127
+ data-testid={`account-avatar-${props.account.id}`}
128
+ class={`inline-flex items-center justify-center shrink-0 ${props.class ?? ""}`}
129
+ style={{
130
+ width: `${size()}px`,
131
+ height: `${size()}px`,
132
+ }}
133
+ title={props.account.name}
134
+ >
135
+ <Show
136
+ when={isUser()}
137
+ fallback={
138
+ // ACCOUNT: uploaded logo (rounded square), else the chosen icon
139
+ // glyph. No initials — a financial account is not a person.
140
+ <Show when={props.account.s3_link} fallback={iconGlyph()}>
141
+ <img
142
+ src={buildLogoSrc(props.account.s3_link)}
143
+ alt={props.alt ?? ""}
144
+ class="w-full h-full rounded-md object-cover"
145
+ />
146
+ </Show>
147
+ }
148
+ >
149
+ {/* USER: profile photo or initial-on-color circle. */}
150
+ <Show when={userImgSrc()} fallback={iconGlyph()}>
151
+ <img
152
+ src={userImgSrc()!}
153
+ alt={props.alt ?? (props.account.name ?? "")}
154
+ class="w-full h-full rounded-full object-cover"
155
+ onError={(e) => {
156
+ // A profile photo / s3 logo that expired or was deleted
157
+ // degrades to the initial-on-color SVG instead of the
158
+ // broken-image icon.
159
+ if (props.account.name && !props.account.s3_link) {
160
+ (e.currentTarget as HTMLImageElement).src =
161
+ buildInitialsSvg(props.account.name);
162
+ }
163
+ }}
164
+ />
165
+ </Show>
166
+ </Show>
167
+ </span>
168
+ );
169
+ }
@@ -0,0 +1,96 @@
1
+ // Source: KahitSan/kserp src/components/AddAttachmentTile.tsx (vendored into the plugin remote).
2
+ // Dashed "Add" tile with a small Camera / Image-or-file popover menu.
3
+
4
+ import { createSignal, onCleanup, onMount, Show } from "solid-js";
5
+ import { Portal } from "solid-js/web";
6
+ import Plus from "lucide-solid/icons/plus";
7
+ import Camera from "lucide-solid/icons/camera";
8
+ import FileIcon from "lucide-solid/icons/file";
9
+
10
+ interface Props {
11
+ uploading: boolean;
12
+ onPickFile: () => void;
13
+ onPickCamera: () => void;
14
+ }
15
+
16
+ export default function AddAttachmentTile(props: Props) {
17
+ const [open, setOpen] = createSignal(false);
18
+ const [pos, setPos] = createSignal({ top: 0, left: 0 });
19
+ let btn: HTMLButtonElement | undefined;
20
+ let menu: HTMLDivElement | undefined;
21
+
22
+ const place = () => {
23
+ if (!btn) return;
24
+ const r = btn.getBoundingClientRect();
25
+ setPos({ top: r.bottom + 8, left: r.left });
26
+ };
27
+
28
+ onMount(() => {
29
+ const handler = (e: MouseEvent) => {
30
+ if (btn && !btn.contains(e.target as Node) && menu && !menu.contains(e.target as Node)) {
31
+ setOpen(false);
32
+ }
33
+ };
34
+ document.addEventListener("click", handler);
35
+ window.addEventListener("scroll", place, true);
36
+ window.addEventListener("resize", place);
37
+ onCleanup(() => {
38
+ document.removeEventListener("click", handler);
39
+ window.removeEventListener("scroll", place, true);
40
+ window.removeEventListener("resize", place);
41
+ });
42
+ });
43
+
44
+ return (
45
+ <div class="shrink-0">
46
+ <button
47
+ ref={btn}
48
+ type="button"
49
+ onClick={(e) => {
50
+ e.stopPropagation();
51
+ if (props.uploading) return;
52
+ place();
53
+ setOpen(!open());
54
+ }}
55
+ disabled={props.uploading}
56
+ class="w-24 h-24 flex flex-col items-center justify-center gap-1 border border-dashed border-zinc-700 bg-zinc-800/30 text-zinc-400 hover:bg-zinc-800/60 hover:border-amber-500/40 hover:text-amber-400 active:bg-zinc-800/80 transition-colors ks-hud-clip-top-left-bottom-right cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
57
+ aria-label="Add attachment"
58
+ >
59
+ <Plus size={22} />
60
+ <span class="text-[10px] uppercase tracking-wider">{props.uploading ? "Uploading" : "Add"}</span>
61
+ </button>
62
+ <Show when={open()}>
63
+ <Portal>
64
+ <div
65
+ ref={menu}
66
+ style={{ top: `${pos().top}px`, left: `${pos().left}px` }}
67
+ class="fixed z-[60] min-w-[160px] rounded-lg border border-zinc-600 bg-zinc-800 shadow-2xl p-1 ks-hud-clip-top-left-bottom-right"
68
+ >
69
+ <button
70
+ type="button"
71
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800 rounded cursor-pointer"
72
+ onClick={() => {
73
+ setOpen(false);
74
+ props.onPickCamera();
75
+ }}
76
+ >
77
+ <Camera size={16} />
78
+ <span>Camera</span>
79
+ </button>
80
+ <button
81
+ type="button"
82
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-200 hover:bg-zinc-800 rounded cursor-pointer"
83
+ onClick={() => {
84
+ setOpen(false);
85
+ props.onPickFile();
86
+ }}
87
+ >
88
+ <FileIcon size={16} />
89
+ <span>Image or file</span>
90
+ </button>
91
+ </div>
92
+ </Portal>
93
+ </Show>
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,144 @@
1
+ // Source: KahitSan/kserp src/components/CameraCapture.tsx (vendored into the plugin remote).
2
+ // getUserMedia camera capture modal. Button comes from the host UI kit.
3
+
4
+ import { createSignal, onCleanup, onMount, Show } from "solid-js";
5
+ import { Button } from "@kserp/host-ui";
6
+ import Camera from "lucide-solid/icons/camera";
7
+ import X from "lucide-solid/icons/x";
8
+
9
+ interface Props {
10
+ onCapture: (file: File) => void;
11
+ onClose: () => void;
12
+ }
13
+
14
+ export default function CameraCapture(props: Props) {
15
+ let videoRef: HTMLVideoElement | undefined;
16
+ let canvasRef: HTMLCanvasElement | undefined;
17
+ const [stream, setStream] = createSignal<MediaStream | null>(null);
18
+ const [error, setError] = createSignal("");
19
+ const [captured, setCaptured] = createSignal<string | null>(null);
20
+
21
+ onMount(async () => {
22
+ stream()
23
+ ?.getTracks()
24
+ .forEach((t) => t.stop());
25
+ try {
26
+ const s = await navigator.mediaDevices.getUserMedia({
27
+ video: { facingMode: "environment", width: { ideal: 1920 }, height: { ideal: 1080 } },
28
+ audio: false,
29
+ });
30
+ setStream(s);
31
+ if (videoRef) {
32
+ videoRef.srcObject = s;
33
+ videoRef.play();
34
+ }
35
+ } catch {
36
+ setError("Could not access camera. Check permissions or try the Browse button instead.");
37
+ }
38
+ });
39
+
40
+ onCleanup(() => {
41
+ stream()
42
+ ?.getTracks()
43
+ .forEach((t) => t.stop());
44
+ });
45
+
46
+ function takePhoto() {
47
+ if (!videoRef || !canvasRef) return;
48
+ canvasRef.width = videoRef.videoWidth;
49
+ canvasRef.height = videoRef.videoHeight;
50
+ const ctx = canvasRef.getContext("2d");
51
+ if (!ctx) return;
52
+ ctx.drawImage(videoRef, 0, 0);
53
+ setCaptured(canvasRef.toDataURL("image/jpeg", 0.9));
54
+ }
55
+
56
+ function confirmCapture() {
57
+ if (!canvasRef) return;
58
+ const onCapture = props.onCapture;
59
+ canvasRef.toBlob(
60
+ (blob) => {
61
+ if (blob) {
62
+ const file = new File([blob], `capture-${Date.now()}.jpg`, { type: "image/jpeg" });
63
+ onCapture(file);
64
+ }
65
+ },
66
+ "image/jpeg",
67
+ 0.9,
68
+ );
69
+ }
70
+
71
+ function retake() {
72
+ setCaptured(null);
73
+ }
74
+
75
+ return (
76
+ <div
77
+ data-testid="camera-capture-modal"
78
+ class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
79
+ >
80
+ <div class="card-bg rounded-xl border border-amber-500/30 overflow-hidden max-w-lg w-full shadow-2xl">
81
+ <div class="flex items-center justify-between p-3 border-b border-zinc-800/50">
82
+ <span
83
+ class="text-sm text-zinc-200 font-medium flex items-center gap-2"
84
+ data-testid="camera-capture-title"
85
+ >
86
+ <Camera size={16} /> Camera
87
+ </span>
88
+ <button
89
+ onClick={() => props.onClose()}
90
+ class="text-zinc-500 hover:text-zinc-300 cursor-pointer"
91
+ aria-label="Close camera"
92
+ >
93
+ <X size={18} />
94
+ </button>
95
+ </div>
96
+
97
+ <Show when={error()}>
98
+ <div class="p-6 text-center">
99
+ <p class="text-sm text-red-400 mb-3">{error()}</p>
100
+ <Button intent="secondary" variant="ghost" onClick={() => props.onClose()}>
101
+ Close
102
+ </Button>
103
+ </div>
104
+ </Show>
105
+
106
+ <Show when={!error()}>
107
+ <div class="relative bg-black aspect-video">
108
+ <Show when={!captured()}>
109
+ <video ref={videoRef} autoplay playsinline muted class="w-full h-full object-cover" />
110
+ </Show>
111
+ <Show when={captured()}>
112
+ <img src={captured()!} alt="Captured" class="w-full h-full object-cover" />
113
+ </Show>
114
+ <canvas ref={canvasRef} class="hidden" />
115
+ </div>
116
+
117
+ <div class="flex items-center justify-center gap-3 p-3">
118
+ <Show
119
+ when={!captured()}
120
+ fallback={
121
+ <>
122
+ <Button intent="secondary" variant="ghost" onClick={retake}>
123
+ Retake
124
+ </Button>
125
+ <Button intent="primary" variant="clip1" onClick={confirmCapture}>
126
+ Use Photo
127
+ </Button>
128
+ </>
129
+ }
130
+ >
131
+ <button
132
+ type="button"
133
+ onClick={takePhoto}
134
+ class="w-14 h-14 rounded-full border-4 border-zinc-400 bg-zinc-200 hover:bg-white active:scale-95 transition-all cursor-pointer"
135
+ title="Take photo"
136
+ aria-label="Take photo"
137
+ />
138
+ </Show>
139
+ </div>
140
+ </Show>
141
+ </div>
142
+ </div>
143
+ );
144
+ }