@viibestack/ui 0.7.3 → 0.7.6
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 +2 -2
- package/src/auth.ts +23 -3
- package/src/badge.tsx +21 -7
- package/src/components.tsx +11 -3
- package/src/dashboard.tsx +444 -0
- package/src/data-table.tsx +224 -0
- package/src/data.ts +153 -2
- package/src/icons.tsx +70 -0
- package/src/index.ts +2 -0
- package/src/nav.tsx +64 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viibestack/ui",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), gamification primitives (PointsDisplay, StreakCounter, Achievements, Leaderboard, fireReward), a real per-app data client (listRows/upsertRow/deleteRow/captureClientError), a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError), and a self-gating PoweredByBadge component -- backed by ViibeStack's own platform-managed data store.",
|
|
3
|
+
"version": "0.7.6",
|
|
4
|
+
"description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), gamification primitives (PointsDisplay, StreakCounter, Achievements, Leaderboard, fireReward), a real per-app data client for browser code (listRows/listRowsPage/getRow/upsertRow/deleteRow/captureClientError) plus server-safe variants for Route Handlers/Server Components (listRowsServer/listRowsPageServer/getRowServer/upsertRowServer/deleteRowServer/resolveAppIdByHostname), a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError), and a self-gating PoweredByBadge component -- backed by ViibeStack's own platform-managed data store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
package/src/auth.ts
CHANGED
|
@@ -20,12 +20,24 @@ export type ActionResult = { ok: true } | { ok: false; error: string };
|
|
|
20
20
|
|
|
21
21
|
let appIdPromise: Promise<string | null> | null = null;
|
|
22
22
|
|
|
23
|
+
// Caches only a successful resolution -- a failed one (network blip, timing
|
|
24
|
+
// on very first page load) clears appIdPromise back to null so the NEXT
|
|
25
|
+
// signUp()/logIn()/etc. call on this same page load retries instead of
|
|
26
|
+
// being permanently stuck on one transient failure.
|
|
23
27
|
function resolveAppId(): Promise<string | null> {
|
|
24
28
|
if (!appIdPromise) {
|
|
25
29
|
appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
|
|
26
30
|
.then((res) => (res.ok ? res.json() : null))
|
|
27
|
-
.
|
|
28
|
-
|
|
31
|
+
// Response.json() resolves to `unknown` under newer TS DOM lib
|
|
32
|
+
// versions (not `any`), so a typed callback param above rejects it --
|
|
33
|
+
// cast in the body instead, same as every other res.json() call site
|
|
34
|
+
// in this file.
|
|
35
|
+
.then((data) => (data as { app_id?: string } | null)?.app_id ?? null)
|
|
36
|
+
.catch(() => null)
|
|
37
|
+
.then((appId) => {
|
|
38
|
+
if (appId === null) appIdPromise = null;
|
|
39
|
+
return appId;
|
|
40
|
+
});
|
|
29
41
|
}
|
|
30
42
|
return appIdPromise;
|
|
31
43
|
}
|
|
@@ -309,7 +321,15 @@ export async function createMonetizationCheckout(interval: "monthly" | "annual",
|
|
|
309
321
|
const res = await fetch(`${window.location.origin}/api/apps/${appId}/monetization/checkout`, {
|
|
310
322
|
method: "POST",
|
|
311
323
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${stored.jwt}` },
|
|
312
|
-
|
|
324
|
+
// return_origin tells the server which origin to send Stripe back to --
|
|
325
|
+
// this app-auth session lives in localStorage (origin-scoped), so if
|
|
326
|
+
// the checkout's success_url ever pointed somewhere else (e.g. a BYOD
|
|
327
|
+
// custom domain's server-side deployed_url falling back to the
|
|
328
|
+
// platform's *.apps.sideblend.com dispatch hostname) the post-payment
|
|
329
|
+
// redirect would land the browser on a different origin than the one
|
|
330
|
+
// holding this session, and getMonetizationStatus() would report
|
|
331
|
+
// "not signed in" forever -- indistinguishable from "no subscription".
|
|
332
|
+
body: JSON.stringify({ billing_interval: interval, seats, return_origin: window.location.origin }),
|
|
313
333
|
});
|
|
314
334
|
const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
|
|
315
335
|
if (!res.ok || !body.url) return { ok: false, error: body.error ?? "failed to start checkout" };
|
package/src/badge.tsx
CHANGED
|
@@ -18,11 +18,16 @@ function resolveShowBadge(): Promise<boolean> {
|
|
|
18
18
|
if (!showBadgePromise) {
|
|
19
19
|
showBadgePromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
|
|
20
20
|
.then((res) => (res.ok ? res.json() : null))
|
|
21
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Response.json() resolves to `unknown` under newer TS DOM lib
|
|
22
|
+
// versions (not `any`), so a typed callback param above rejects it --
|
|
23
|
+
// cast in the body instead, same as every other res.json() call site
|
|
24
|
+
// in this package.
|
|
25
|
+
.then((data) => {
|
|
26
|
+
const appId = (data as { app_id?: string } | null)?.app_id;
|
|
27
|
+
if (!appId) return true;
|
|
28
|
+
return fetch(`${window.location.origin}/api/apps/${appId}/badge-status`)
|
|
24
29
|
.then((res) => (res.ok ? res.json() : null))
|
|
25
|
-
.then((status
|
|
30
|
+
.then((status) => (status as { show_badge?: boolean } | null)?.show_badge ?? true);
|
|
26
31
|
})
|
|
27
32
|
.catch(() => true);
|
|
28
33
|
}
|
|
@@ -32,7 +37,14 @@ function resolveShowBadge(): Promise<boolean> {
|
|
|
32
37
|
const DISMISS_KEY = "viibestack_badge_dismissed";
|
|
33
38
|
|
|
34
39
|
export function PoweredByBadge() {
|
|
35
|
-
|
|
40
|
+
// null = badge-status still resolving. The badge (and its backlink to
|
|
41
|
+
// viibestack.ai) is in the DOM from first render but visibility:hidden
|
|
42
|
+
// until resolution -- previously the whole element only existed after a
|
|
43
|
+
// two-fetch chain settled, so a crawler snapshotting the rendered page
|
|
44
|
+
// before then never saw the platform's one organic backlink from every
|
|
45
|
+
// free-plan app. Hidden-not-absent keeps that link parseable the whole
|
|
46
|
+
// time without ever flashing the badge at a plan-waived tenant.
|
|
47
|
+
const [show, setShow] = useState<boolean | null>(null);
|
|
36
48
|
const [dismissed, setDismissed] = useState(false);
|
|
37
49
|
|
|
38
50
|
useEffect(() => {
|
|
@@ -50,14 +62,16 @@ export function PoweredByBadge() {
|
|
|
50
62
|
};
|
|
51
63
|
}, []);
|
|
52
64
|
|
|
53
|
-
if (
|
|
65
|
+
if (show === false || dismissed) return null;
|
|
54
66
|
|
|
55
67
|
return (
|
|
56
68
|
<span
|
|
57
69
|
style={{
|
|
70
|
+
visibility: show === null ? "hidden" : "visible",
|
|
58
71
|
position: "fixed",
|
|
59
72
|
bottom: "1rem",
|
|
60
|
-
|
|
73
|
+
left: "50%",
|
|
74
|
+
transform: "translateX(-50%)",
|
|
61
75
|
zIndex: 9999,
|
|
62
76
|
display: "flex",
|
|
63
77
|
alignItems: "center",
|
package/src/components.tsx
CHANGED
|
@@ -447,12 +447,20 @@ export function Tbody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
|
447
447
|
return <tbody className="divide-y divide-gray-200 dark:divide-gray-800" {...props} />;
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
export function Tr(
|
|
451
|
-
return <tr {...
|
|
450
|
+
export function Tr({ className, ...rest }: HTMLAttributes<HTMLTableRowElement>) {
|
|
451
|
+
return <tr className={cx("transition-colors hover:bg-gray-50 dark:hover:bg-gray-900/50", className)} {...rest} />;
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
455
|
-
return
|
|
455
|
+
return (
|
|
456
|
+
<th
|
|
457
|
+
className={cx(
|
|
458
|
+
"px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
|
|
459
|
+
className,
|
|
460
|
+
)}
|
|
461
|
+
{...rest}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
456
464
|
}
|
|
457
465
|
|
|
458
466
|
export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Admin-dashboard/AI-console building blocks -- StatCard, ProgressBar,
|
|
4
|
+
// Breadcrumb, Pagination, Tooltip, Timeline, PricingTable, ChatBubble/
|
|
5
|
+
// ChatPanel. Same conventions as components.tsx (plain Tailwind utility
|
|
6
|
+
// classes, bg-primary/text-primary picking up this app's --primary CSS
|
|
7
|
+
// custom property automatically, dark: variants throughout) -- these are
|
|
8
|
+
// a separate file rather than additions to components.tsx because they're
|
|
9
|
+
// a distinct, later-added vocabulary (dashboards, AI chat UIs, pricing
|
|
10
|
+
// pages) rather than generic form/layout primitives. Visual patterns
|
|
11
|
+
// (card shape, chat bubble layout, stat delta styling) are informed by
|
|
12
|
+
// packages/design-library's catalog -- see that package's README for the
|
|
13
|
+
// design source and licensing provenance.
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import type { ReactNode } from "react";
|
|
16
|
+
import { Avatar, Badge } from "./components";
|
|
17
|
+
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, SendIcon, TrendDownIcon, TrendUpIcon } from "./icons";
|
|
18
|
+
|
|
19
|
+
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
20
|
+
return parts.filter(Boolean).join(" ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── StatCard ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface StatCardDelta {
|
|
26
|
+
value: string;
|
|
27
|
+
direction: "up" | "down" | "flat";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StatCardProps {
|
|
31
|
+
label: ReactNode;
|
|
32
|
+
value: ReactNode;
|
|
33
|
+
icon?: ReactNode;
|
|
34
|
+
delta?: StatCardDelta;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DELTA_COLOR: Record<StatCardDelta["direction"], string> = {
|
|
39
|
+
up: "text-green-600 dark:text-green-400",
|
|
40
|
+
down: "text-red-600 dark:text-red-400",
|
|
41
|
+
flat: "text-gray-500 dark:text-gray-400",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function StatCard({ label, value, icon, delta, className }: StatCardProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={cx("rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900", className)}>
|
|
47
|
+
<div className="flex items-start justify-between gap-3">
|
|
48
|
+
<div>
|
|
49
|
+
<div className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</div>
|
|
50
|
+
<div className="mt-1.5 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
|
51
|
+
</div>
|
|
52
|
+
{icon && <div className="shrink-0 rounded-lg bg-primary/10 p-2 text-primary">{icon}</div>}
|
|
53
|
+
</div>
|
|
54
|
+
{delta && (
|
|
55
|
+
<div className={cx("mt-3 inline-flex items-center gap-1 text-xs font-medium", DELTA_COLOR[delta.direction])}>
|
|
56
|
+
{delta.direction === "up" && <TrendUpIcon size={14} />}
|
|
57
|
+
{delta.direction === "down" && <TrendDownIcon size={14} />}
|
|
58
|
+
<span>{delta.value}</span>
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── ProgressBar ─────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export type ProgressVariant = "primary" | "success" | "warning" | "danger";
|
|
68
|
+
|
|
69
|
+
const PROGRESS_VARIANTS: Record<ProgressVariant, string> = {
|
|
70
|
+
primary: "bg-primary",
|
|
71
|
+
success: "bg-green-500",
|
|
72
|
+
warning: "bg-amber-500",
|
|
73
|
+
danger: "bg-red-500",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface ProgressBarProps {
|
|
77
|
+
/** 0-100, clamped. */
|
|
78
|
+
value: number;
|
|
79
|
+
variant?: ProgressVariant;
|
|
80
|
+
label?: ReactNode;
|
|
81
|
+
showValue?: boolean;
|
|
82
|
+
className?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function ProgressBar({ value, variant = "primary", label, showValue, className }: ProgressBarProps) {
|
|
86
|
+
const pct = Math.max(0, Math.min(100, value));
|
|
87
|
+
return (
|
|
88
|
+
<div className={className}>
|
|
89
|
+
{(label || showValue) && (
|
|
90
|
+
<div className="mb-1.5 flex items-center justify-between text-sm">
|
|
91
|
+
{label && <span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>}
|
|
92
|
+
{showValue && <span className="text-gray-500 dark:text-gray-400">{Math.round(pct)}%</span>}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
|
96
|
+
<div className={cx("h-full rounded-full transition-all", PROGRESS_VARIANTS[variant])} style={{ width: `${pct}%` }} />
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Breadcrumb ──────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export interface BreadcrumbItem {
|
|
105
|
+
label: ReactNode;
|
|
106
|
+
href?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface BreadcrumbProps {
|
|
110
|
+
items: BreadcrumbItem[];
|
|
111
|
+
/** Override how a non-final linked item renders, e.g. to use next/link. */
|
|
112
|
+
renderLink?: (item: BreadcrumbItem) => ReactNode;
|
|
113
|
+
className?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function Breadcrumb({ items, renderLink, className }: BreadcrumbProps) {
|
|
117
|
+
return (
|
|
118
|
+
<nav aria-label="Breadcrumb" className={cx("flex flex-wrap items-center gap-1.5 text-sm", className)}>
|
|
119
|
+
{items.map((item, i) => {
|
|
120
|
+
const last = i === items.length - 1;
|
|
121
|
+
return (
|
|
122
|
+
<span key={i} className="flex items-center gap-1.5">
|
|
123
|
+
{item.href && !last ? (
|
|
124
|
+
renderLink ? (
|
|
125
|
+
renderLink(item)
|
|
126
|
+
) : (
|
|
127
|
+
<a href={item.href} className="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
|
|
128
|
+
{item.label}
|
|
129
|
+
</a>
|
|
130
|
+
)
|
|
131
|
+
) : (
|
|
132
|
+
<span className={last ? "font-medium text-gray-900 dark:text-gray-100" : "text-gray-500 dark:text-gray-400"}>
|
|
133
|
+
{item.label}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
{!last && <ChevronRightIcon size={14} className="text-gray-300 dark:text-gray-700" />}
|
|
137
|
+
</span>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</nav>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Pagination ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export interface PaginationProps {
|
|
147
|
+
/** 1-based current page. */
|
|
148
|
+
page: number;
|
|
149
|
+
totalPages: number;
|
|
150
|
+
onChange: (page: number) => void;
|
|
151
|
+
/** Page buttons kept on each side of the current page before collapsing to an ellipsis. */
|
|
152
|
+
siblingCount?: number;
|
|
153
|
+
className?: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function paginationRange(page: number, totalPages: number, siblingCount: number): (number | "ellipsis")[] {
|
|
157
|
+
const totalNumbers = siblingCount * 2 + 5;
|
|
158
|
+
if (totalPages <= totalNumbers) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
159
|
+
|
|
160
|
+
const left = Math.max(page - siblingCount, 1);
|
|
161
|
+
const right = Math.min(page + siblingCount, totalPages);
|
|
162
|
+
const range: (number | "ellipsis")[] = [1];
|
|
163
|
+
if (left > 2) range.push("ellipsis");
|
|
164
|
+
for (let i = Math.max(left, 2); i <= Math.min(right, totalPages - 1); i++) range.push(i);
|
|
165
|
+
if (right < totalPages - 1) range.push("ellipsis");
|
|
166
|
+
if (totalPages > 1) range.push(totalPages);
|
|
167
|
+
return range;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const PAGE_BTN =
|
|
171
|
+
"inline-flex h-8 min-w-8 items-center justify-center rounded-lg px-2 text-sm font-medium transition-colors " +
|
|
172
|
+
"disabled:cursor-not-allowed disabled:opacity-40";
|
|
173
|
+
|
|
174
|
+
export function Pagination({ page, totalPages, onChange, siblingCount = 1, className }: PaginationProps) {
|
|
175
|
+
if (totalPages <= 1) return null;
|
|
176
|
+
const range = paginationRange(page, totalPages, siblingCount);
|
|
177
|
+
return (
|
|
178
|
+
<nav aria-label="Pagination" className={cx("flex items-center gap-1", className)}>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
onClick={() => onChange(page - 1)}
|
|
182
|
+
disabled={page <= 1}
|
|
183
|
+
aria-label="Previous page"
|
|
184
|
+
className={cx(PAGE_BTN, "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800")}
|
|
185
|
+
>
|
|
186
|
+
<ChevronLeftIcon size={16} />
|
|
187
|
+
</button>
|
|
188
|
+
{range.map((p, i) =>
|
|
189
|
+
p === "ellipsis" ? (
|
|
190
|
+
<span key={`e${i}`} className="px-1 text-gray-400 dark:text-gray-600">
|
|
191
|
+
…
|
|
192
|
+
</span>
|
|
193
|
+
) : (
|
|
194
|
+
<button
|
|
195
|
+
key={p}
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => onChange(p)}
|
|
198
|
+
aria-current={p === page ? "page" : undefined}
|
|
199
|
+
className={cx(
|
|
200
|
+
PAGE_BTN,
|
|
201
|
+
p === page ? "bg-primary text-white" : "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800",
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
{p}
|
|
205
|
+
</button>
|
|
206
|
+
),
|
|
207
|
+
)}
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={() => onChange(page + 1)}
|
|
211
|
+
disabled={page >= totalPages}
|
|
212
|
+
aria-label="Next page"
|
|
213
|
+
className={cx(PAGE_BTN, "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800")}
|
|
214
|
+
>
|
|
215
|
+
<ChevronRightIcon size={16} />
|
|
216
|
+
</button>
|
|
217
|
+
</nav>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Tooltip ─────────────────────────────────────────────────────────────────
|
|
222
|
+
// CSS-only (group-hover/group-focus-within) -- no floating-ui/positioning
|
|
223
|
+
// dependency, consistent with this package's zero-runtime-dependency goal.
|
|
224
|
+
// Fine for short label-style tooltips; not meant for large rich content.
|
|
225
|
+
|
|
226
|
+
export type TooltipSide = "top" | "bottom" | "left" | "right";
|
|
227
|
+
|
|
228
|
+
const TOOLTIP_SIDE_CLASS: Record<TooltipSide, string> = {
|
|
229
|
+
top: "bottom-full left-1/2 -translate-x-1/2 mb-1.5",
|
|
230
|
+
bottom: "top-full left-1/2 -translate-x-1/2 mt-1.5",
|
|
231
|
+
left: "right-full top-1/2 -translate-y-1/2 mr-1.5",
|
|
232
|
+
right: "left-full top-1/2 -translate-y-1/2 ml-1.5",
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export interface TooltipProps {
|
|
236
|
+
content: ReactNode;
|
|
237
|
+
children: ReactNode;
|
|
238
|
+
side?: TooltipSide;
|
|
239
|
+
className?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function Tooltip({ content, children, side = "top", className }: TooltipProps) {
|
|
243
|
+
return (
|
|
244
|
+
<span className={cx("group relative inline-flex", className)}>
|
|
245
|
+
{children}
|
|
246
|
+
<span
|
|
247
|
+
role="tooltip"
|
|
248
|
+
className={cx(
|
|
249
|
+
"pointer-events-none absolute z-50 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-xs font-medium text-white opacity-0 " +
|
|
250
|
+
"shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 dark:bg-gray-100 dark:text-gray-900",
|
|
251
|
+
TOOLTIP_SIDE_CLASS[side],
|
|
252
|
+
)}
|
|
253
|
+
>
|
|
254
|
+
{content}
|
|
255
|
+
</span>
|
|
256
|
+
</span>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Timeline ────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export interface TimelineItem {
|
|
263
|
+
title: ReactNode;
|
|
264
|
+
description?: ReactNode;
|
|
265
|
+
timestamp?: ReactNode;
|
|
266
|
+
icon?: ReactNode;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface TimelineProps {
|
|
270
|
+
items: TimelineItem[];
|
|
271
|
+
className?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function Timeline({ items, className }: TimelineProps) {
|
|
275
|
+
return (
|
|
276
|
+
<ol className={cx("relative flex flex-col gap-6 border-l border-gray-200 pl-6 dark:border-gray-800", className)}>
|
|
277
|
+
{items.map((item, i) => (
|
|
278
|
+
<li key={i} className="relative">
|
|
279
|
+
<span className="absolute -left-[1.6rem] flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-white dark:ring-gray-950">
|
|
280
|
+
{item.icon ?? <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
|
|
281
|
+
</span>
|
|
282
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
283
|
+
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.title}</div>
|
|
284
|
+
{item.timestamp && <div className="shrink-0 text-xs text-gray-400 dark:text-gray-500">{item.timestamp}</div>}
|
|
285
|
+
</div>
|
|
286
|
+
{item.description && <div className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{item.description}</div>}
|
|
287
|
+
</li>
|
|
288
|
+
))}
|
|
289
|
+
</ol>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── PricingTable ────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export interface PricingPlan {
|
|
296
|
+
name: ReactNode;
|
|
297
|
+
price: ReactNode;
|
|
298
|
+
interval?: ReactNode;
|
|
299
|
+
description?: ReactNode;
|
|
300
|
+
features: string[];
|
|
301
|
+
highlighted?: boolean;
|
|
302
|
+
/** Rendered under the feature list -- typically a Button. */
|
|
303
|
+
cta?: ReactNode;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface PricingTableProps {
|
|
307
|
+
plans: PricingPlan[];
|
|
308
|
+
className?: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function PricingTable({ plans, className }: PricingTableProps) {
|
|
312
|
+
return (
|
|
313
|
+
<div className={cx("grid gap-6 sm:grid-cols-2 lg:grid-cols-3", className)}>
|
|
314
|
+
{plans.map((plan, i) => (
|
|
315
|
+
<div
|
|
316
|
+
key={i}
|
|
317
|
+
className={cx(
|
|
318
|
+
"flex flex-col rounded-xl border p-6",
|
|
319
|
+
plan.highlighted
|
|
320
|
+
? "border-primary bg-primary/5 shadow-lg"
|
|
321
|
+
: "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900",
|
|
322
|
+
)}
|
|
323
|
+
>
|
|
324
|
+
{plan.highlighted && (
|
|
325
|
+
<Badge variant="info" className="mb-3 w-fit">
|
|
326
|
+
Most popular
|
|
327
|
+
</Badge>
|
|
328
|
+
)}
|
|
329
|
+
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{plan.name}</div>
|
|
330
|
+
{plan.description && <div className="mt-1 text-sm text-gray-500 dark:text-gray-400">{plan.description}</div>}
|
|
331
|
+
<div className="mt-4 flex items-baseline gap-1">
|
|
332
|
+
<span className="text-3xl font-bold text-gray-900 dark:text-gray-100">{plan.price}</span>
|
|
333
|
+
{plan.interval && <span className="text-sm text-gray-500 dark:text-gray-400">{plan.interval}</span>}
|
|
334
|
+
</div>
|
|
335
|
+
<ul className="mt-5 flex flex-1 flex-col gap-2.5">
|
|
336
|
+
{plan.features.map((f, fi) => (
|
|
337
|
+
<li key={fi} className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
338
|
+
<CheckIcon size={16} className="mt-0.5 shrink-0 text-primary" />
|
|
339
|
+
{f}
|
|
340
|
+
</li>
|
|
341
|
+
))}
|
|
342
|
+
</ul>
|
|
343
|
+
{plan.cta && <div className="mt-6">{plan.cta}</div>}
|
|
344
|
+
</div>
|
|
345
|
+
))}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── ChatBubble / ChatPanel ──────────────────────────────────────────────────
|
|
351
|
+
// An AI chat surface is a first-class pattern on this platform (not a
|
|
352
|
+
// generic "messaging app" widget) -- most apps built here either embed the
|
|
353
|
+
// AI add-on directly or build a support/assistant panel around it.
|
|
354
|
+
|
|
355
|
+
export interface ChatMessage {
|
|
356
|
+
id: string | number;
|
|
357
|
+
role: "user" | "assistant";
|
|
358
|
+
content: ReactNode;
|
|
359
|
+
timestamp?: ReactNode;
|
|
360
|
+
name?: string;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export interface ChatBubbleProps {
|
|
364
|
+
message: ChatMessage;
|
|
365
|
+
className?: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function ChatBubble({ message, className }: ChatBubbleProps) {
|
|
369
|
+
const isUser = message.role === "user";
|
|
370
|
+
return (
|
|
371
|
+
<div className={cx("flex items-start gap-2.5", isUser && "flex-row-reverse", className)}>
|
|
372
|
+
<Avatar name={message.name ?? (isUser ? "You" : "AI")} size={28} className={isUser ? undefined : "bg-gray-700"} />
|
|
373
|
+
<div className={cx("flex max-w-[75%] flex-col gap-1", isUser && "items-end")}>
|
|
374
|
+
<div
|
|
375
|
+
className={cx(
|
|
376
|
+
"rounded-2xl px-3.5 py-2 text-sm",
|
|
377
|
+
isUser
|
|
378
|
+
? "rounded-tr-sm bg-primary text-white"
|
|
379
|
+
: "rounded-tl-sm bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100",
|
|
380
|
+
)}
|
|
381
|
+
>
|
|
382
|
+
{message.content}
|
|
383
|
+
</div>
|
|
384
|
+
{message.timestamp && <div className="px-1 text-xs text-gray-400 dark:text-gray-500">{message.timestamp}</div>}
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface ChatPanelProps {
|
|
391
|
+
messages: ChatMessage[];
|
|
392
|
+
onSend: (text: string) => void;
|
|
393
|
+
placeholder?: string;
|
|
394
|
+
disabled?: boolean;
|
|
395
|
+
className?: string;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Uncontrolled input (owns its own draft-text state) -- the caller only
|
|
399
|
+
// ever sees a finished message via onSend, same division of responsibility
|
|
400
|
+
// as Modal owning its own open/close focus handling.
|
|
401
|
+
export function ChatPanel({ messages, onSend, placeholder = "Type a message...", disabled, className }: ChatPanelProps) {
|
|
402
|
+
const [value, setValue] = useState("");
|
|
403
|
+
|
|
404
|
+
function submit() {
|
|
405
|
+
const text = value.trim();
|
|
406
|
+
if (!text || disabled) return;
|
|
407
|
+
onSend(text);
|
|
408
|
+
setValue("");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<div className={cx("flex flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900", className)}>
|
|
413
|
+
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
|
414
|
+
{messages.map((m) => (
|
|
415
|
+
<ChatBubble key={m.id} message={m} />
|
|
416
|
+
))}
|
|
417
|
+
</div>
|
|
418
|
+
<div className="flex items-center gap-2 border-t border-gray-200 p-3 dark:border-gray-800">
|
|
419
|
+
<input
|
|
420
|
+
value={value}
|
|
421
|
+
onChange={(e) => setValue(e.target.value)}
|
|
422
|
+
onKeyDown={(e) => {
|
|
423
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
submit();
|
|
426
|
+
}
|
|
427
|
+
}}
|
|
428
|
+
placeholder={placeholder}
|
|
429
|
+
disabled={disabled}
|
|
430
|
+
className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
|
431
|
+
/>
|
|
432
|
+
<button
|
|
433
|
+
type="button"
|
|
434
|
+
onClick={submit}
|
|
435
|
+
disabled={disabled || !value.trim()}
|
|
436
|
+
aria-label="Send message"
|
|
437
|
+
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-white transition-colors hover:brightness-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
438
|
+
>
|
|
439
|
+
<SendIcon size={16} />
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Admio-styled data table -- search, sortable columns, pagination, and
|
|
4
|
+
// optional row selection, composed from the existing Table primitives and
|
|
5
|
+
// Checkbox/Pagination/Spinner/EmptyState components rather than reinventing
|
|
6
|
+
// any of them. Use this instead of hand-rolling search/sort/pagination
|
|
7
|
+
// logic around the bare Table/Thead/Tbody/Tr/Th/Td primitives in
|
|
8
|
+
// components.tsx -- those stay as the lightweight option for simple,
|
|
9
|
+
// fully client-rendered data with no interactive chrome.
|
|
10
|
+
import { useMemo, useState } from "react";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { Checkbox, EmptyState, Spinner, Tbody, Td, Th, Thead, Tr } from "./components";
|
|
13
|
+
import { Pagination } from "./dashboard";
|
|
14
|
+
import { ChevronDownIcon, SearchIcon } from "./icons";
|
|
15
|
+
|
|
16
|
+
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
17
|
+
return parts.filter(Boolean).join(" ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DataTableColumn<T> {
|
|
21
|
+
key: string;
|
|
22
|
+
header: ReactNode;
|
|
23
|
+
render: (row: T) => ReactNode;
|
|
24
|
+
/** Enables click-to-sort on this column's header. Requires sortValue. */
|
|
25
|
+
sortable?: boolean;
|
|
26
|
+
/** Value to compare when sorting -- render() output isn't assumed to be sortable (e.g. a Badge). */
|
|
27
|
+
sortValue?: (row: T) => string | number;
|
|
28
|
+
className?: string;
|
|
29
|
+
headerClassName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DataTableProps<T> {
|
|
33
|
+
columns: DataTableColumn<T>[];
|
|
34
|
+
data: T[];
|
|
35
|
+
getRowKey: (row: T) => string;
|
|
36
|
+
/** Optional card-header title, shown above the search box. */
|
|
37
|
+
title?: ReactNode;
|
|
38
|
+
searchable?: boolean;
|
|
39
|
+
searchPlaceholder?: string;
|
|
40
|
+
/** What to match search text against; defaults to every column's rendered text via a JSON.stringify(row) fallback. */
|
|
41
|
+
searchValue?: (row: T) => string;
|
|
42
|
+
selectable?: boolean;
|
|
43
|
+
selectedKeys?: string[];
|
|
44
|
+
onSelectionChange?: (keys: string[]) => void;
|
|
45
|
+
/** Row count per page. Omit or 0 to disable pagination. */
|
|
46
|
+
pageSize?: number;
|
|
47
|
+
onRowClick?: (row: T) => void;
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
emptyState?: ReactNode;
|
|
50
|
+
className?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function DataTable<T>({
|
|
54
|
+
columns,
|
|
55
|
+
data,
|
|
56
|
+
getRowKey,
|
|
57
|
+
title,
|
|
58
|
+
searchable,
|
|
59
|
+
searchPlaceholder = "Search...",
|
|
60
|
+
searchValue,
|
|
61
|
+
selectable,
|
|
62
|
+
selectedKeys,
|
|
63
|
+
onSelectionChange,
|
|
64
|
+
pageSize = 0,
|
|
65
|
+
onRowClick,
|
|
66
|
+
loading,
|
|
67
|
+
emptyState,
|
|
68
|
+
className,
|
|
69
|
+
}: DataTableProps<T>) {
|
|
70
|
+
const [search, setSearch] = useState("");
|
|
71
|
+
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
|
72
|
+
const [page, setPage] = useState(1);
|
|
73
|
+
// Uncontrolled selection fallback -- same pattern as Tabs's activeKey/internalKey split.
|
|
74
|
+
const [internalSelected, setInternalSelected] = useState<string[]>([]);
|
|
75
|
+
const selected = selectedKeys ?? internalSelected;
|
|
76
|
+
|
|
77
|
+
function setSelected(keys: string[]) {
|
|
78
|
+
if (selectedKeys === undefined) setInternalSelected(keys);
|
|
79
|
+
onSelectionChange?.(keys);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const filtered = useMemo(() => {
|
|
83
|
+
if (!searchable || !search.trim()) return data;
|
|
84
|
+
const q = search.trim().toLowerCase();
|
|
85
|
+
return data.filter((row) => {
|
|
86
|
+
const haystack = searchValue ? searchValue(row) : JSON.stringify(row);
|
|
87
|
+
return haystack.toLowerCase().includes(q);
|
|
88
|
+
});
|
|
89
|
+
}, [data, search, searchable, searchValue]);
|
|
90
|
+
|
|
91
|
+
const sorted = useMemo(() => {
|
|
92
|
+
if (!sort) return filtered;
|
|
93
|
+
const col = columns.find((c) => c.key === sort.key);
|
|
94
|
+
if (!col?.sortValue) return filtered;
|
|
95
|
+
const withValues = filtered.map((row) => ({ row, v: col.sortValue!(row) }));
|
|
96
|
+
withValues.sort((a, b) => (a.v < b.v ? -1 : a.v > b.v ? 1 : 0));
|
|
97
|
+
if (sort.dir === "desc") withValues.reverse();
|
|
98
|
+
return withValues.map((w) => w.row);
|
|
99
|
+
}, [filtered, sort, columns]);
|
|
100
|
+
|
|
101
|
+
const totalPages = pageSize > 0 ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
|
|
102
|
+
const pageSafe = Math.min(page, totalPages);
|
|
103
|
+
const pageRows = pageSize > 0 ? sorted.slice((pageSafe - 1) * pageSize, pageSafe * pageSize) : sorted;
|
|
104
|
+
|
|
105
|
+
function toggleSort(key: string) {
|
|
106
|
+
setSort((prev) => {
|
|
107
|
+
if (prev?.key !== key) return { key, dir: "asc" };
|
|
108
|
+
if (prev.dir === "asc") return { key, dir: "desc" };
|
|
109
|
+
return null;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function toggleRow(key: string) {
|
|
114
|
+
setSelected(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const allOnPageSelected = pageRows.length > 0 && pageRows.every((row) => selected.includes(getRowKey(row)));
|
|
118
|
+
|
|
119
|
+
function toggleAllOnPage() {
|
|
120
|
+
const pageKeys = pageRows.map(getRowKey);
|
|
121
|
+
if (allOnPageSelected) setSelected(selected.filter((k) => !pageKeys.includes(k)));
|
|
122
|
+
else setSelected([...new Set([...selected, ...pageKeys])]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className={cx("overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900", className)}>
|
|
127
|
+
{(title || searchable) && (
|
|
128
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-800">
|
|
129
|
+
{title && <div className="text-base font-semibold text-gray-900 dark:text-gray-100">{title}</div>}
|
|
130
|
+
{searchable && (
|
|
131
|
+
<div className="relative">
|
|
132
|
+
<SearchIcon size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
133
|
+
<input
|
|
134
|
+
type="text"
|
|
135
|
+
value={search}
|
|
136
|
+
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
137
|
+
placeholder={searchPlaceholder}
|
|
138
|
+
className="w-full max-w-[220px] rounded-lg border border-gray-300 bg-white py-1.5 pl-9 pr-3 text-sm text-gray-900 outline-none focus:border-primary focus:ring-1 focus:ring-primary dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{loading ? (
|
|
146
|
+
<div className="flex items-center justify-center gap-2 p-10 text-gray-400">
|
|
147
|
+
<Spinner size={20} />
|
|
148
|
+
</div>
|
|
149
|
+
) : pageRows.length === 0 ? (
|
|
150
|
+
<div className="p-6">
|
|
151
|
+
{emptyState ?? <EmptyState title="No results" description={search ? "Try a different search." : "Nothing here yet."} />}
|
|
152
|
+
</div>
|
|
153
|
+
) : (
|
|
154
|
+
<div className="overflow-x-auto">
|
|
155
|
+
<table className="w-full text-left text-sm">
|
|
156
|
+
<Thead>
|
|
157
|
+
<Tr>
|
|
158
|
+
{selectable && (
|
|
159
|
+
<Th className="w-10">
|
|
160
|
+
<Checkbox checked={allOnPageSelected} onChange={toggleAllOnPage} aria-label="Select all rows on this page" />
|
|
161
|
+
</Th>
|
|
162
|
+
)}
|
|
163
|
+
{columns.map((col) => (
|
|
164
|
+
<Th key={col.key} className={col.headerClassName}>
|
|
165
|
+
{col.sortable && col.sortValue ? (
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={() => toggleSort(col.key)}
|
|
169
|
+
className="inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
|
|
170
|
+
>
|
|
171
|
+
{col.header}
|
|
172
|
+
<ChevronDownIcon
|
|
173
|
+
size={12}
|
|
174
|
+
className={cx(
|
|
175
|
+
"transition-transform",
|
|
176
|
+
sort?.key === col.key ? (sort.dir === "asc" ? "rotate-180" : "") : "opacity-30",
|
|
177
|
+
)}
|
|
178
|
+
/>
|
|
179
|
+
</button>
|
|
180
|
+
) : (
|
|
181
|
+
col.header
|
|
182
|
+
)}
|
|
183
|
+
</Th>
|
|
184
|
+
))}
|
|
185
|
+
</Tr>
|
|
186
|
+
</Thead>
|
|
187
|
+
<Tbody>
|
|
188
|
+
{pageRows.map((row) => {
|
|
189
|
+
const key = getRowKey(row);
|
|
190
|
+
return (
|
|
191
|
+
<Tr
|
|
192
|
+
key={key}
|
|
193
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
194
|
+
className={onRowClick ? "cursor-pointer" : undefined}
|
|
195
|
+
>
|
|
196
|
+
{selectable && (
|
|
197
|
+
<Td onClick={(e) => e.stopPropagation()}>
|
|
198
|
+
<Checkbox checked={selected.includes(key)} onChange={() => toggleRow(key)} aria-label="Select row" />
|
|
199
|
+
</Td>
|
|
200
|
+
)}
|
|
201
|
+
{columns.map((col) => (
|
|
202
|
+
<Td key={col.key} className={col.className}>
|
|
203
|
+
{col.render(row)}
|
|
204
|
+
</Td>
|
|
205
|
+
))}
|
|
206
|
+
</Tr>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</Tbody>
|
|
210
|
+
</table>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{pageSize > 0 && !loading && sorted.length > 0 && (
|
|
215
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-5 py-3 text-xs text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
|
216
|
+
<span>
|
|
217
|
+
Showing {(pageSafe - 1) * pageSize + 1}-{Math.min(pageSafe * pageSize, sorted.length)} of {sorted.length}
|
|
218
|
+
</span>
|
|
219
|
+
<Pagination page={pageSafe} totalPages={totalPages} onChange={setPage} />
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
package/src/data.ts
CHANGED
|
@@ -21,14 +21,40 @@ function authHeaders(): Record<string, string> {
|
|
|
21
21
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Fixed platform origin for the *Server variants below (same value as the
|
|
25
|
+
// generation prompt's own ${base}, i.e. env.PORTAL_REDIRECT_URL) -- NOT
|
|
26
|
+
// window.location.origin. Two confirmed platform-side gaps forced this
|
|
27
|
+
// (agent reports 25d63fe8/ed34d032/5d149c3f): (1) window doesn't exist in a
|
|
28
|
+
// Next.js Route Handler/Server Component, so the browser-only functions
|
|
29
|
+
// below throw ReferenceError there; (2) even a same-origin fetch from
|
|
30
|
+
// inside a Worker back to its OWN public hostname (e.g. via
|
|
31
|
+
// req.nextUrl.origin, reasoning "that's this app's origin too") reliably
|
|
32
|
+
// hangs ~20s and returns Cloudflare 522 -- a same-zone Worker can't
|
|
33
|
+
// self-fetch the hostname it's currently serving. Hitting this fixed
|
|
34
|
+
// platform origin directly sidesteps both: no window read, and it's never
|
|
35
|
+
// the same zone/Worker as the app's own deployed domain.
|
|
36
|
+
const PLATFORM_ORIGIN = "https://viibestack.ai";
|
|
37
|
+
|
|
24
38
|
let appIdPromise: Promise<string | null> | null = null;
|
|
25
39
|
|
|
40
|
+
// Caches only a successful resolution -- a failed one (network blip, timing
|
|
41
|
+
// on very first page load) clears appIdPromise back to null so the NEXT
|
|
42
|
+
// listRows/upsertRow/etc. call on this same page load retries instead of
|
|
43
|
+
// being permanently stuck on one transient failure.
|
|
26
44
|
function resolveAppId(): Promise<string | null> {
|
|
27
45
|
if (!appIdPromise) {
|
|
28
46
|
appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
|
|
29
47
|
.then((res) => (res.ok ? res.json() : null))
|
|
30
|
-
.
|
|
31
|
-
|
|
48
|
+
// Response.json() resolves to `unknown` under newer TS DOM lib
|
|
49
|
+
// versions (not `any`), so a typed callback param above rejects it --
|
|
50
|
+
// cast in the body instead, same as every other res.json() call site
|
|
51
|
+
// in this file.
|
|
52
|
+
.then((data) => (data as { app_id?: string } | null)?.app_id ?? null)
|
|
53
|
+
.catch(() => null)
|
|
54
|
+
.then((appId) => {
|
|
55
|
+
if (appId === null) appIdPromise = null;
|
|
56
|
+
return appId;
|
|
57
|
+
});
|
|
32
58
|
}
|
|
33
59
|
return appIdPromise;
|
|
34
60
|
}
|
|
@@ -94,6 +120,25 @@ export async function upsertRow<T extends { id: string }>(table: string, row: T)
|
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
123
|
+
// Single-row fetch by id -- for a detail page or anything keyed by a URL
|
|
124
|
+
// param, this beats paging through listRows()/listRowsPage() to find one
|
|
125
|
+
// record (impractical past a few hundred rows). Returns null on a 404 or
|
|
126
|
+
// any network failure, same "fail soft, let the caller show empty state"
|
|
127
|
+
// convention as every other helper in this file.
|
|
128
|
+
export async function getRow<T extends { id: string }>(table: string, id: string): Promise<T | null> {
|
|
129
|
+
const appId = await resolveAppId();
|
|
130
|
+
if (!appId) return null;
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(`${window.location.origin}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
|
|
133
|
+
headers: authHeaders(),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) return null;
|
|
136
|
+
return (await res.json()) as T;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
97
142
|
export async function deleteRow(table: string, id: string): Promise<void> {
|
|
98
143
|
const appId = await resolveAppId();
|
|
99
144
|
if (!appId) return;
|
|
@@ -131,3 +176,109 @@ export async function captureClientError(message: string, stack?: string): Promi
|
|
|
131
176
|
// best-effort
|
|
132
177
|
}
|
|
133
178
|
}
|
|
179
|
+
|
|
180
|
+
// ── Server-safe variants ────────────────────────────────────────────────
|
|
181
|
+
// Everything above resolves this app's own id via window.location and
|
|
182
|
+
// fetches window.location.origin -- correct for browser code, but a Next.js
|
|
183
|
+
// Route Handler or Server Component has no window, and (see PLATFORM_ORIGIN
|
|
184
|
+
// above) fetching the incoming request's own origin from inside a Worker
|
|
185
|
+
// hangs and 522s even when window IS polyfilled. Use these instead from any
|
|
186
|
+
// server-side code (Route Handlers, Server Components, server actions); the
|
|
187
|
+
// browser functions above remain correct for client components.
|
|
188
|
+
//
|
|
189
|
+
// No app-id caching here (each function takes it explicitly) -- a Worker
|
|
190
|
+
// request handler shouldn't assume module-level state survives or is safe
|
|
191
|
+
// to share across concurrent requests the way a browser tab's single JS
|
|
192
|
+
// context can.
|
|
193
|
+
|
|
194
|
+
// Resolve this app's own id from its public hostname (e.g.
|
|
195
|
+
// new URL(request.url).hostname inside a Route Handler, or the incoming
|
|
196
|
+
// Host header) -- the server-side equivalent of the browser resolveAppId()
|
|
197
|
+
// above. No auth required, same as the client version.
|
|
198
|
+
export async function resolveAppIdByHostname(hostname: string): Promise<string | null> {
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(`${PLATFORM_ORIGIN}/api/apps/resolve?hostname=${encodeURIComponent(hostname)}`);
|
|
201
|
+
if (!res.ok) return null;
|
|
202
|
+
const data = (await res.json()) as { app_id?: string } | null;
|
|
203
|
+
return data?.app_id ?? null;
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function serverAuthHeaders(authToken?: string): Record<string, string> {
|
|
210
|
+
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// authToken is optional and passed explicitly -- getStoredAuthToken() reads
|
|
214
|
+
// browser localStorage, which doesn't exist server-side. For a real_accounts
|
|
215
|
+
// app, read the caller's own session token from the incoming request
|
|
216
|
+
// (cookie/header) and pass it through here.
|
|
217
|
+
export async function listRowsPageServer<T extends { id: string }>(
|
|
218
|
+
appId: string, table: string, opts?: { limit?: number; cursor?: string; authToken?: string },
|
|
219
|
+
): Promise<RowsPage<T>> {
|
|
220
|
+
try {
|
|
221
|
+
const params = new URLSearchParams();
|
|
222
|
+
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
223
|
+
if (opts?.cursor) params.set("cursor", opts.cursor);
|
|
224
|
+
const qs = params.toString();
|
|
225
|
+
const res = await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data-public/${table}${qs ? `?${qs}` : ""}`, {
|
|
226
|
+
headers: serverAuthHeaders(opts?.authToken),
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) return { rows: [], nextCursor: null };
|
|
229
|
+
const data = (await res.json()) as { rows: T[]; next_cursor: string | null };
|
|
230
|
+
return { rows: data.rows ?? [], nextCursor: data.next_cursor ?? null };
|
|
231
|
+
} catch {
|
|
232
|
+
return { rows: [], nextCursor: null };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Same MAX_AUTO_PAGES auto-pagination as listRows() above -- see its own
|
|
237
|
+
// comment for when to use listRowsPageServer() instead for a table headed
|
|
238
|
+
// into the tens of thousands of rows.
|
|
239
|
+
export async function listRowsServer<T extends { id: string }>(appId: string, table: string, authToken?: string): Promise<T[]> {
|
|
240
|
+
const all: T[] = [];
|
|
241
|
+
let cursor: string | undefined;
|
|
242
|
+
for (let page = 0; page < MAX_AUTO_PAGES; page++) {
|
|
243
|
+
const { rows, nextCursor } = await listRowsPageServer<T>(appId, table, { limit: 1000, cursor, authToken });
|
|
244
|
+
all.push(...rows);
|
|
245
|
+
if (!nextCursor) break;
|
|
246
|
+
cursor = nextCursor;
|
|
247
|
+
}
|
|
248
|
+
return all;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function upsertRowServer<T extends { id: string }>(appId: string, table: string, row: T, authToken?: string): Promise<void> {
|
|
252
|
+
try {
|
|
253
|
+
await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data/${table}`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "Content-Type": "application/json", ...serverAuthHeaders(authToken) },
|
|
256
|
+
body: JSON.stringify({ rows: [row] }),
|
|
257
|
+
});
|
|
258
|
+
} catch {
|
|
259
|
+
// best-effort -- a write failure shouldn't crash the handler calling this
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function getRowServer<T extends { id: string }>(appId: string, table: string, id: string, authToken?: string): Promise<T | null> {
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
|
|
266
|
+
headers: serverAuthHeaders(authToken),
|
|
267
|
+
});
|
|
268
|
+
if (!res.ok) return null;
|
|
269
|
+
return (await res.json()) as T;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function deleteRowServer(appId: string, table: string, id: string, authToken?: string): Promise<void> {
|
|
276
|
+
try {
|
|
277
|
+
await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
|
|
278
|
+
method: "DELETE",
|
|
279
|
+
headers: serverAuthHeaders(authToken),
|
|
280
|
+
});
|
|
281
|
+
} catch {
|
|
282
|
+
// best-effort
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/icons.tsx
CHANGED
|
@@ -333,3 +333,73 @@ export function StarIcon(props: IconProps = {}) {
|
|
|
333
333
|
</svg>
|
|
334
334
|
);
|
|
335
335
|
}
|
|
336
|
+
|
|
337
|
+
export function TrendUpIcon(props: IconProps = {}) {
|
|
338
|
+
return (
|
|
339
|
+
<svg {...svgProps(props)}>
|
|
340
|
+
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
|
341
|
+
<polyline points="17 6 23 6 23 12" />
|
|
342
|
+
</svg>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function TrendDownIcon(props: IconProps = {}) {
|
|
347
|
+
return (
|
|
348
|
+
<svg {...svgProps(props)}>
|
|
349
|
+
<polyline points="23 18 13.5 8.5 8.5 13.5 1 6" />
|
|
350
|
+
<polyline points="17 18 23 18 23 12" />
|
|
351
|
+
</svg>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function ChevronRightIcon(props: IconProps = {}) {
|
|
356
|
+
return (
|
|
357
|
+
<svg {...svgProps(props)}>
|
|
358
|
+
<polyline points="9 6 15 12 9 18" />
|
|
359
|
+
</svg>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function ChevronLeftIcon(props: IconProps = {}) {
|
|
364
|
+
return (
|
|
365
|
+
<svg {...svgProps(props)}>
|
|
366
|
+
<polyline points="15 6 9 12 15 18" />
|
|
367
|
+
</svg>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function SendIcon(props: IconProps = {}) {
|
|
372
|
+
return (
|
|
373
|
+
<svg {...svgProps(props)}>
|
|
374
|
+
<line x1="22" y1="2" x2="11" y2="13" />
|
|
375
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
|
376
|
+
</svg>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function EyeIcon(props: IconProps = {}) {
|
|
381
|
+
return (
|
|
382
|
+
<svg {...svgProps(props)}>
|
|
383
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
384
|
+
<circle cx="12" cy="12" r="3" />
|
|
385
|
+
</svg>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function CopyIcon(props: IconProps = {}) {
|
|
390
|
+
return (
|
|
391
|
+
<svg {...svgProps(props)}>
|
|
392
|
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
|
393
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
394
|
+
</svg>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function BanIcon(props: IconProps = {}) {
|
|
399
|
+
return (
|
|
400
|
+
<svg {...svgProps(props)}>
|
|
401
|
+
<circle cx="12" cy="12" r="10" />
|
|
402
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
403
|
+
</svg>
|
|
404
|
+
);
|
|
405
|
+
}
|
package/src/index.ts
CHANGED
package/src/nav.tsx
CHANGED
|
@@ -23,7 +23,56 @@
|
|
|
23
23
|
// here by never using flex-wrap for this layout at all.
|
|
24
24
|
import { createPortal } from "react-dom";
|
|
25
25
|
import { useEffect, useState, type ReactNode } from "react";
|
|
26
|
-
import { MenuIcon, CloseIcon, ChevronDownIcon } from "./icons";
|
|
26
|
+
import { MenuIcon, CloseIcon, ChevronDownIcon, SunIcon, MoonIcon } from "./icons";
|
|
27
|
+
|
|
28
|
+
// Same data-theme/localStorage convention the platform's injected theme
|
|
29
|
+
// scripts use (services/deploy/src/template-patches.ts) -- reading through
|
|
30
|
+
// that shared contract, not a separate one, keeps this in sync with
|
|
31
|
+
// whichever mechanism (the ?theme= query param bridge, the standalone
|
|
32
|
+
// floating fallback button) touches the same page.
|
|
33
|
+
function isDark(): boolean {
|
|
34
|
+
const attr = document.documentElement.getAttribute("data-theme");
|
|
35
|
+
if (attr === "dark") return true;
|
|
36
|
+
if (attr === "light") return false;
|
|
37
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setTheme(next: "dark" | "light") {
|
|
41
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem("vs_theme", next);
|
|
44
|
+
} catch {
|
|
45
|
+
// localStorage unavailable (private mode, etc.) -- the attribute change alone still repaints this tab
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Deliberately marked with the SAME data-vs="viibestack-theme-toggle"
|
|
50
|
+
// attribute the platform's standalone fallback script looks for (see that
|
|
51
|
+
// script's own comment in template-patches.ts) -- an app using AppShell
|
|
52
|
+
// gets a real toggle integrated into its own nav by default, and the
|
|
53
|
+
// fallback's MutationObserver detects this one and removes itself instead
|
|
54
|
+
// of adding a second, floating, duplicate button.
|
|
55
|
+
function ThemeToggleItem() {
|
|
56
|
+
const [dark, setDark] = useState(false);
|
|
57
|
+
|
|
58
|
+
useEffect(() => setDark(isDark()), []);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
data-vs="viibestack-theme-toggle"
|
|
64
|
+
onClick={() => {
|
|
65
|
+
const next = dark ? "light" : "dark";
|
|
66
|
+
setTheme(next);
|
|
67
|
+
setDark(!dark);
|
|
68
|
+
}}
|
|
69
|
+
className={LINK_CLASS}
|
|
70
|
+
>
|
|
71
|
+
{dark ? <SunIcon /> : <MoonIcon />}
|
|
72
|
+
{dark ? "Light mode" : "Dark mode"}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
27
76
|
|
|
28
77
|
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
29
78
|
return parts.filter(Boolean).join(" ");
|
|
@@ -65,10 +114,21 @@ export interface AppShellProps {
|
|
|
65
114
|
renderLink?: (item: NavLinkItem, active: boolean) => ReactNode;
|
|
66
115
|
/** Rendered at the bottom of the sidebar/drawer -- e.g. a user menu, sign-out button. */
|
|
67
116
|
footer?: ReactNode;
|
|
117
|
+
/**
|
|
118
|
+
* Set true to omit the built-in dark/light toggle row from the nav.
|
|
119
|
+
* Shown by default -- a real nav-integrated toggle beats every app
|
|
120
|
+
* reinventing one, and it doubles as the platform's recommended way to
|
|
121
|
+
* suppress the floating fallback toggle every app otherwise gets
|
|
122
|
+
* injected at build time (see template-patches.ts's THEME_TOGGLE_SCRIPT):
|
|
123
|
+
* both share the same data-vs="viibestack-theme-toggle" marker, so the
|
|
124
|
+
* fallback detects this one and backs off instead of adding a second,
|
|
125
|
+
* floating, duplicate button.
|
|
126
|
+
*/
|
|
127
|
+
hideThemeToggle?: boolean;
|
|
68
128
|
children: ReactNode;
|
|
69
129
|
}
|
|
70
130
|
|
|
71
|
-
export function AppShell({ brand, nav, activeHref, renderLink, footer, children }: AppShellProps) {
|
|
131
|
+
export function AppShell({ brand, nav, activeHref, renderLink, footer, hideThemeToggle, children }: AppShellProps) {
|
|
72
132
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
73
133
|
const [mounted, setMounted] = useState(false);
|
|
74
134
|
|
|
@@ -136,6 +196,7 @@ export function AppShell({ brand, nav, activeHref, renderLink, footer, children
|
|
|
136
196
|
<div className="px-4 py-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
|
|
137
197
|
<nav className="flex flex-col gap-1 overflow-y-auto px-3 pb-3">
|
|
138
198
|
{nav.map((item, i) => renderItem(item, i))}
|
|
199
|
+
{!hideThemeToggle && mounted && <ThemeToggleItem />}
|
|
139
200
|
</nav>
|
|
140
201
|
{footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
|
|
141
202
|
</aside>
|
|
@@ -180,6 +241,7 @@ export function AppShell({ brand, nav, activeHref, renderLink, footer, children
|
|
|
180
241
|
</div>
|
|
181
242
|
<nav className="flex flex-col gap-1 px-3 pb-3">
|
|
182
243
|
{nav.map((item, i) => renderItem(item, i, () => setMobileOpen(false)))}
|
|
244
|
+
{!hideThemeToggle && <ThemeToggleItem />}
|
|
183
245
|
</nav>
|
|
184
246
|
{footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
|
|
185
247
|
</div>
|