@kahitsan/ksui 0.3.0 → 0.4.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/README.md +109 -26
- package/host-ui.d.ts +4 -4
- package/package.json +7 -3
- package/src/components/{AccountAvatar.tsx → base/AccountAvatar.tsx} +10 -10
- package/src/components/base/ChartLegend.tsx +34 -0
- package/src/components/base/CopyButton.tsx +53 -0
- package/src/components/base/DetailRow.tsx +17 -0
- package/src/components/{ExistingAttachmentTile.tsx → base/ExistingAttachmentTile.tsx} +2 -2
- package/src/components/base/FormErrorBanner.tsx +27 -0
- package/src/components/base/FormField.tsx +19 -0
- package/src/components/base/ImageCropper.tsx +275 -0
- package/src/components/base/KpiCard.tsx +125 -0
- package/src/components/base/ProgressBar.tsx +328 -0
- package/src/components/base/RadioCardGroup.tsx +146 -0
- package/src/components/base/SegmentedFilter.tsx +50 -0
- package/src/components/base/StatusPill.tsx +90 -0
- package/src/components/base/TagPill.tsx +24 -0
- package/src/components/base/Tooltip.tsx +64 -0
- package/src/components/{ClientPicker.tsx → composite/ClientPicker.tsx} +1 -1
- package/src/components/composite/FormActions.tsx +79 -0
- package/src/components/composite/LiveTimer.tsx +434 -0
- package/src/components/{MarkdownNotes.tsx → composite/MarkdownNotes.tsx} +1 -1
- package/src/components/{PaymentAccountPicker.tsx → composite/PaymentAccountPicker.tsx} +2 -2
- package/src/components/composite/SecretReveal.tsx +63 -0
- package/src/components/{VoucherPicker.tsx → composite/VoucherPicker.tsx} +1 -1
- package/src/index.ts +84 -27
- package/src/utils/INPUT_CLASS.ts +7 -0
- package/src/{lib → utils}/account-logo-url.ts +1 -1
- package/src/{lib → utils}/accounts-index.tsx +2 -2
- package/src/{lib → utils}/attachments.ts +3 -3
- package/src/utils/formatFullDate.ts +14 -0
- package/src/utils/formatPHP.ts +13 -0
- package/src/utils/formatShortDate.ts +17 -0
- /package/src/components/{AddAttachmentTile.tsx → base/AddAttachmentTile.tsx} +0 -0
- /package/src/components/{CameraCapture.tsx → base/CameraCapture.tsx} +0 -0
- /package/src/components/{MentionTextarea.tsx → composite/MentionTextarea.tsx} +0 -0
- /package/src/{lib → utils}/account-icons.ts +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ImageCropper presents a square (1:1) crop tool over an uploaded image file.
|
|
2
|
+
//
|
|
3
|
+
// The user drags inside the box to reposition the selection and drags a
|
|
4
|
+
// corner to resize it. On Apply, the selected region is rendered to a canvas
|
|
5
|
+
// at the requested output size and handed back as a WebP Blob.
|
|
6
|
+
//
|
|
7
|
+
// The widget is dependency-free beyond the host kit: it uses the host's
|
|
8
|
+
// Modal + Button (provided by the consumer via "@kserp/host-ui"), solid-js,
|
|
9
|
+
// and a single lucide icon.
|
|
10
|
+
|
|
11
|
+
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
|
12
|
+
import { Modal, Button } from "@kserp/host-ui";
|
|
13
|
+
import X from "lucide-solid/icons/x";
|
|
14
|
+
|
|
15
|
+
interface ImageCropperProps {
|
|
16
|
+
file: File;
|
|
17
|
+
outputSize?: number;
|
|
18
|
+
onCancel: () => void;
|
|
19
|
+
onApply: (blob: Blob) => void;
|
|
20
|
+
busy?: boolean;
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_DISPLAY_W = 480;
|
|
25
|
+
const MAX_DISPLAY_H = 360;
|
|
26
|
+
const MIN_SEL = 48;
|
|
27
|
+
|
|
28
|
+
type DragMode = "move" | "tl" | "tr" | "bl" | "br";
|
|
29
|
+
|
|
30
|
+
interface ImageState {
|
|
31
|
+
el: HTMLImageElement;
|
|
32
|
+
naturalWidth: number;
|
|
33
|
+
naturalHeight: number;
|
|
34
|
+
displayedWidth: number;
|
|
35
|
+
displayedHeight: number;
|
|
36
|
+
scale: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function ImageCropper(props: ImageCropperProps) {
|
|
40
|
+
const outputSize = () => props.outputSize ?? 512;
|
|
41
|
+
const [imageUrl, setImageUrl] = createSignal<string>("");
|
|
42
|
+
const [image, setImage] = createSignal<ImageState | null>(null);
|
|
43
|
+
const [sel, setSel] = createSignal({ x: 0, y: 0, size: 0 });
|
|
44
|
+
const [loadError, setLoadError] = createSignal("");
|
|
45
|
+
|
|
46
|
+
let dragMode: DragMode | null = null;
|
|
47
|
+
let pointerStartX = 0;
|
|
48
|
+
let pointerStartY = 0;
|
|
49
|
+
let selStart = { x: 0, y: 0, size: 0 };
|
|
50
|
+
|
|
51
|
+
onMount(() => {
|
|
52
|
+
const url = URL.createObjectURL(props.file);
|
|
53
|
+
setImageUrl(url);
|
|
54
|
+
|
|
55
|
+
const el = new Image();
|
|
56
|
+
el.onload = () => {
|
|
57
|
+
const scale = Math.min(MAX_DISPLAY_W / el.naturalWidth, MAX_DISPLAY_H / el.naturalHeight, 1);
|
|
58
|
+
const displayedWidth = Math.round(el.naturalWidth * scale);
|
|
59
|
+
const displayedHeight = Math.round(el.naturalHeight * scale);
|
|
60
|
+
const maxSize = Math.min(displayedWidth, displayedHeight);
|
|
61
|
+
const initialSize = Math.round(maxSize * 0.9);
|
|
62
|
+
setImage({ el, naturalWidth: el.naturalWidth, naturalHeight: el.naturalHeight, displayedWidth, displayedHeight, scale });
|
|
63
|
+
setSel({
|
|
64
|
+
x: Math.round((displayedWidth - initialSize) / 2),
|
|
65
|
+
y: Math.round((displayedHeight - initialSize) / 2),
|
|
66
|
+
size: initialSize,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
el.onerror = () => setLoadError("Could not load this image. Try a different file.");
|
|
70
|
+
el.src = url;
|
|
71
|
+
onCleanup(() => URL.revokeObjectURL(url));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function clampMove(nextX: number, nextY: number) {
|
|
75
|
+
const img = image();
|
|
76
|
+
if (!img) return { x: 0, y: 0 };
|
|
77
|
+
const s = sel().size;
|
|
78
|
+
return {
|
|
79
|
+
x: Math.max(0, Math.min(img.displayedWidth - s, nextX)),
|
|
80
|
+
y: Math.max(0, Math.min(img.displayedHeight - s, nextY)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function handlePointerDown(mode: DragMode) {
|
|
85
|
+
return (e: PointerEvent) => {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
dragMode = mode;
|
|
89
|
+
pointerStartX = e.clientX;
|
|
90
|
+
pointerStartY = e.clientY;
|
|
91
|
+
selStart = { ...sel() };
|
|
92
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handlePointerMove(e: PointerEvent) {
|
|
97
|
+
if (!dragMode) return;
|
|
98
|
+
const img = image();
|
|
99
|
+
if (!img) return;
|
|
100
|
+
const dx = e.clientX - pointerStartX;
|
|
101
|
+
const dy = e.clientY - pointerStartY;
|
|
102
|
+
|
|
103
|
+
if (dragMode === "move") {
|
|
104
|
+
const next = clampMove(selStart.x + dx, selStart.y + dy);
|
|
105
|
+
setSel({ x: next.x, y: next.y, size: selStart.size });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let newSize = selStart.size;
|
|
110
|
+
let newX = selStart.x;
|
|
111
|
+
let newY = selStart.y;
|
|
112
|
+
|
|
113
|
+
if (dragMode === "br") {
|
|
114
|
+
const delta = Math.max(dx, dy);
|
|
115
|
+
const limit = Math.min(img.displayedWidth - selStart.x, img.displayedHeight - selStart.y);
|
|
116
|
+
newSize = Math.max(MIN_SEL, Math.min(limit, selStart.size + delta));
|
|
117
|
+
} else if (dragMode === "bl") {
|
|
118
|
+
const delta = Math.max(-dx, dy);
|
|
119
|
+
const anchorX = selStart.x + selStart.size;
|
|
120
|
+
const limit = Math.min(anchorX, img.displayedHeight - selStart.y);
|
|
121
|
+
newSize = Math.max(MIN_SEL, Math.min(limit, selStart.size + delta));
|
|
122
|
+
newX = anchorX - newSize;
|
|
123
|
+
} else if (dragMode === "tr") {
|
|
124
|
+
const delta = Math.max(dx, -dy);
|
|
125
|
+
const anchorY = selStart.y + selStart.size;
|
|
126
|
+
const limit = Math.min(img.displayedWidth - selStart.x, anchorY);
|
|
127
|
+
newSize = Math.max(MIN_SEL, Math.min(limit, selStart.size + delta));
|
|
128
|
+
newY = anchorY - newSize;
|
|
129
|
+
} else if (dragMode === "tl") {
|
|
130
|
+
const delta = Math.max(-dx, -dy);
|
|
131
|
+
const anchorX = selStart.x + selStart.size;
|
|
132
|
+
const anchorY = selStart.y + selStart.size;
|
|
133
|
+
const limit = Math.min(anchorX, anchorY);
|
|
134
|
+
newSize = Math.max(MIN_SEL, Math.min(limit, selStart.size + delta));
|
|
135
|
+
newX = anchorX - newSize;
|
|
136
|
+
newY = anchorY - newSize;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setSel({ x: newX, y: newY, size: newSize });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handlePointerUp(e: PointerEvent) {
|
|
143
|
+
if (!dragMode) return;
|
|
144
|
+
dragMode = null;
|
|
145
|
+
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function recenter() {
|
|
149
|
+
const img = image();
|
|
150
|
+
if (!img) return;
|
|
151
|
+
const size = Math.round(Math.min(img.displayedWidth, img.displayedHeight) * 0.9);
|
|
152
|
+
setSel({
|
|
153
|
+
x: Math.round((img.displayedWidth - size) / 2),
|
|
154
|
+
y: Math.round((img.displayedHeight - size) / 2),
|
|
155
|
+
size,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function handleApply() {
|
|
160
|
+
const img = image();
|
|
161
|
+
if (!img) return;
|
|
162
|
+
const sx = sel().x / img.scale;
|
|
163
|
+
const sy = sel().y / img.scale;
|
|
164
|
+
const ssize = sel().size / img.scale;
|
|
165
|
+
const out = outputSize();
|
|
166
|
+
const canvas = document.createElement("canvas");
|
|
167
|
+
canvas.width = out;
|
|
168
|
+
canvas.height = out;
|
|
169
|
+
const ctx = canvas.getContext("2d");
|
|
170
|
+
if (!ctx) { setLoadError("Your browser couldn't open a 2D canvas to render the crop."); return; }
|
|
171
|
+
ctx.imageSmoothingEnabled = true;
|
|
172
|
+
ctx.imageSmoothingQuality = "high";
|
|
173
|
+
ctx.drawImage(img.el, sx, sy, ssize, ssize, 0, 0, out, out);
|
|
174
|
+
const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, "image/webp", 0.9));
|
|
175
|
+
if (!blob) {
|
|
176
|
+
setLoadError("Couldn't encode the cropped image. Try a smaller source image or a different file.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
props.onApply(blob);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const maskTop = () => ({ top: "0px", left: "0px", width: `${image()?.displayedWidth ?? 0}px`, height: `${sel().y}px` });
|
|
183
|
+
const maskBottom = () => ({ top: `${sel().y + sel().size}px`, left: "0px", width: `${image()?.displayedWidth ?? 0}px`, height: `${(image()?.displayedHeight ?? 0) - sel().y - sel().size}px` });
|
|
184
|
+
const maskLeft = () => ({ top: `${sel().y}px`, left: "0px", width: `${sel().x}px`, height: `${sel().size}px` });
|
|
185
|
+
const maskRight = () => ({ top: `${sel().y}px`, left: `${sel().x + sel().size}px`, width: `${(image()?.displayedWidth ?? 0) - sel().x - sel().size}px`, height: `${sel().size}px` });
|
|
186
|
+
const selectionStyle = () => ({ left: `${sel().x}px`, top: `${sel().y}px`, width: `${sel().size}px`, height: `${sel().size}px` });
|
|
187
|
+
const sourceCropSizePx = () => {
|
|
188
|
+
const img = image();
|
|
189
|
+
if (!img) return 0;
|
|
190
|
+
return Math.round(sel().size / img.scale);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<Modal onClose={props.onCancel} size="md">
|
|
195
|
+
<div class="flex items-center justify-between mb-4">
|
|
196
|
+
<h2 class="text-lg font-semibold text-zinc-100">{props.title ?? "Crop logo"}</h2>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={props.onCancel}
|
|
200
|
+
class="text-zinc-500 hover:text-zinc-300 cursor-pointer"
|
|
201
|
+
disabled={props.busy}
|
|
202
|
+
aria-label="Close cropper"
|
|
203
|
+
>
|
|
204
|
+
<X size={20} />
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<Show when={loadError()}>
|
|
209
|
+
<div class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400 mb-3">
|
|
210
|
+
{loadError()}
|
|
211
|
+
</div>
|
|
212
|
+
</Show>
|
|
213
|
+
|
|
214
|
+
<p class="text-xs text-zinc-500 mb-3">
|
|
215
|
+
Drag inside the box to reposition. Drag a corner to resize. The result is saved as a 1:1 square.
|
|
216
|
+
</p>
|
|
217
|
+
|
|
218
|
+
<div class="flex justify-center mb-4">
|
|
219
|
+
<Show when={image()}>
|
|
220
|
+
{(img) => (
|
|
221
|
+
<div
|
|
222
|
+
class="relative select-none touch-none bg-zinc-900 rounded-lg overflow-hidden"
|
|
223
|
+
style={{ width: `${img().displayedWidth}px`, height: `${img().displayedHeight}px` }}
|
|
224
|
+
onPointerMove={handlePointerMove}
|
|
225
|
+
onPointerUp={handlePointerUp}
|
|
226
|
+
onPointerCancel={handlePointerUp}
|
|
227
|
+
>
|
|
228
|
+
<img src={imageUrl()} alt="" draggable={false} class="absolute inset-0 w-full h-full pointer-events-none" />
|
|
229
|
+
<div class="absolute bg-black/60 pointer-events-none" style={maskTop()} />
|
|
230
|
+
<div class="absolute bg-black/60 pointer-events-none" style={maskBottom()} />
|
|
231
|
+
<div class="absolute bg-black/60 pointer-events-none" style={maskLeft()} />
|
|
232
|
+
<div class="absolute bg-black/60 pointer-events-none" style={maskRight()} />
|
|
233
|
+
<div class="absolute border-2 border-amber-400 cursor-move" style={selectionStyle()} onPointerDown={handlePointerDown("move")}>
|
|
234
|
+
<span class="absolute inset-0 ring-1 ring-amber-400/30 pointer-events-none" />
|
|
235
|
+
<span class="absolute -top-1.5 -left-1.5 w-3 h-3 bg-amber-400 rounded-sm cursor-nwse-resize" onPointerDown={handlePointerDown("tl")} />
|
|
236
|
+
<span class="absolute -top-1.5 -right-1.5 w-3 h-3 bg-amber-400 rounded-sm cursor-nesw-resize" onPointerDown={handlePointerDown("tr")} />
|
|
237
|
+
<span class="absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-amber-400 rounded-sm cursor-nesw-resize" onPointerDown={handlePointerDown("bl")} />
|
|
238
|
+
<span class="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-amber-400 rounded-sm cursor-nwse-resize" onPointerDown={handlePointerDown("br")} />
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
</Show>
|
|
243
|
+
<Show when={!image() && !loadError()}>
|
|
244
|
+
<div class="w-[320px] h-[240px] flex items-center justify-center text-xs text-zinc-500 border border-zinc-700 rounded-lg">
|
|
245
|
+
Loading…
|
|
246
|
+
</div>
|
|
247
|
+
</Show>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div class="flex items-center justify-between text-xs text-zinc-500 mb-4">
|
|
251
|
+
<span>
|
|
252
|
+
Source crop: <span class="tabular-nums text-zinc-300">{sourceCropSizePx()}</span> px ·
|
|
253
|
+
output {outputSize()}×{outputSize()}
|
|
254
|
+
</span>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
onClick={recenter}
|
|
258
|
+
class="text-amber-400 hover:text-amber-300 transition-colors cursor-pointer disabled:opacity-50"
|
|
259
|
+
disabled={!image() || props.busy}
|
|
260
|
+
>
|
|
261
|
+
Reset selection
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="flex justify-end gap-2">
|
|
266
|
+
<Button type="button" intent="secondary" variant="ghost" onClick={props.onCancel} disabled={props.busy}>
|
|
267
|
+
Cancel
|
|
268
|
+
</Button>
|
|
269
|
+
<Button type="button" intent="primary" variant="clip1" onClick={handleApply} disabled={!image() || props.busy}>
|
|
270
|
+
{props.busy ? "Saving…" : "Apply"}
|
|
271
|
+
</Button>
|
|
272
|
+
</div>
|
|
273
|
+
</Modal>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createMemo, Show, type JSX } from "solid-js";
|
|
2
|
+
import { Dynamic } from "solid-js/web";
|
|
3
|
+
|
|
4
|
+
type IconComponent = (props: { size?: number; class?: string }) => JSX.Element;
|
|
5
|
+
|
|
6
|
+
/** Public tone vocabulary, shared with StatusPill for consistency. */
|
|
7
|
+
export type KpiTone = "success" | "danger" | "info" | "warning";
|
|
8
|
+
|
|
9
|
+
// Domain-free tone record. Each tone supplies a Tailwind text class for the
|
|
10
|
+
// label icon + value and a literal SVG stroke color for the sparkline.
|
|
11
|
+
const TONE: Record<KpiTone, { text: string; stroke: string }> = {
|
|
12
|
+
success: { text: "text-emerald-400", stroke: "#34d399" },
|
|
13
|
+
danger: { text: "text-red-400", stroke: "#f87171" },
|
|
14
|
+
info: { text: "text-blue-400", stroke: "#60a5fa" },
|
|
15
|
+
warning: { text: "text-amber-400", stroke: "#fbbf24" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Module-private counter so each card's sparkline gradient gets a unique id,
|
|
19
|
+
// even when many cards render on one page.
|
|
20
|
+
let _kpiIdCounter = 0;
|
|
21
|
+
function nextKpiId(): number {
|
|
22
|
+
return ++_kpiIdCounter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KpiCardProps {
|
|
26
|
+
/** Short uppercase caption above the value. */
|
|
27
|
+
label: string;
|
|
28
|
+
/** Pre-formatted value string. The caller formats currency/numbers; the
|
|
29
|
+
* card never formats. */
|
|
30
|
+
value: string;
|
|
31
|
+
/** Color intent for the icon, value, and sparkline. */
|
|
32
|
+
tone: KpiTone;
|
|
33
|
+
/** Lucide (or any) icon component rendered top-right. */
|
|
34
|
+
icon: IconComponent;
|
|
35
|
+
/** Optional small caption under the value. */
|
|
36
|
+
hint?: string;
|
|
37
|
+
/** Optional numeric series rendered as an inline-SVG sparkline. Needs at
|
|
38
|
+
* least two points to draw. */
|
|
39
|
+
sparkline?: number[];
|
|
40
|
+
/** Optional clip/shape utility class. Defaults to none, so the tile renders
|
|
41
|
+
* with a plain rounded border and no host-specific CSS required. */
|
|
42
|
+
clipClass?: string;
|
|
43
|
+
/** Extra classes on the outer tile. */
|
|
44
|
+
class?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A compact KPI tile: caption, big tone-colored value, optional hint, and an
|
|
48
|
+
// optional sparkline. Presentational only — no data fetching, no formatting.
|
|
49
|
+
export default function KpiCard(props: KpiCardProps): JSX.Element {
|
|
50
|
+
const gradId = `spark-${nextKpiId()}`;
|
|
51
|
+
const t = () => TONE[props.tone];
|
|
52
|
+
const sparkPath = createMemo(() => {
|
|
53
|
+
const data = props.sparkline;
|
|
54
|
+
if (!data || data.length < 2) return null;
|
|
55
|
+
const w = 100;
|
|
56
|
+
const h = 24;
|
|
57
|
+
const min = Math.min(...data);
|
|
58
|
+
const max = Math.max(...data);
|
|
59
|
+
const range = max - min || 1;
|
|
60
|
+
const pts = data.map((v, i) => {
|
|
61
|
+
const x = (i / (data.length - 1)) * w;
|
|
62
|
+
const y = h - ((v - min) / range) * h;
|
|
63
|
+
return [x, y] as const;
|
|
64
|
+
});
|
|
65
|
+
const d = pts
|
|
66
|
+
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + "," + p[1].toFixed(1))
|
|
67
|
+
.join(" ");
|
|
68
|
+
const area = d + ` L${w},${h} L0,${h} Z`;
|
|
69
|
+
return { line: d, area };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
class={`rounded-lg border border-zinc-800/50 bg-zinc-900/50 p-4 relative overflow-hidden ${
|
|
75
|
+
props.clipClass ?? ""
|
|
76
|
+
} ${props.class ?? ""}`}
|
|
77
|
+
>
|
|
78
|
+
<div class="flex items-start justify-between mb-3">
|
|
79
|
+
<span class="text-[10px] text-zinc-500 uppercase tracking-[0.25em] font-semibold">
|
|
80
|
+
{props.label}
|
|
81
|
+
</span>
|
|
82
|
+
<div class={`flex items-center justify-center ${t().text}`}>
|
|
83
|
+
<Dynamic component={props.icon} size={16} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="flex items-end justify-between gap-2">
|
|
87
|
+
<div class="min-w-0 flex-1">
|
|
88
|
+
<div
|
|
89
|
+
class={`text-base sm:text-lg lg:text-xl font-bold leading-tight tabular-nums ${t().text}`}
|
|
90
|
+
>
|
|
91
|
+
{props.value}
|
|
92
|
+
</div>
|
|
93
|
+
<Show when={props.hint}>
|
|
94
|
+
<div class="text-[11px] text-zinc-600 mt-1">{props.hint}</div>
|
|
95
|
+
</Show>
|
|
96
|
+
</div>
|
|
97
|
+
<Show when={sparkPath()}>
|
|
98
|
+
{(p) => (
|
|
99
|
+
<svg
|
|
100
|
+
viewBox="0 0 100 24"
|
|
101
|
+
preserveAspectRatio="none"
|
|
102
|
+
class="w-[56px] h-[22px] shrink-0 opacity-90"
|
|
103
|
+
>
|
|
104
|
+
<defs>
|
|
105
|
+
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
|
106
|
+
<stop offset="0%" stop-color={t().stroke} stop-opacity="0.3" />
|
|
107
|
+
<stop offset="100%" stop-color={t().stroke} stop-opacity="0" />
|
|
108
|
+
</linearGradient>
|
|
109
|
+
</defs>
|
|
110
|
+
<path d={p().area} fill={`url(#${gradId})`} />
|
|
111
|
+
<path
|
|
112
|
+
d={p().line}
|
|
113
|
+
fill="none"
|
|
114
|
+
stroke={t().stroke}
|
|
115
|
+
stroke-width="1.5"
|
|
116
|
+
stroke-linecap="round"
|
|
117
|
+
stroke-linejoin="round"
|
|
118
|
+
/>
|
|
119
|
+
</svg>
|
|
120
|
+
)}
|
|
121
|
+
</Show>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|