@livo-build/kit 0.1.1 → 0.2.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/dist/auth/SignInWithEthereum.d.ts +8 -0
- package/dist/auth/SignInWithEthereum.js +18 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/useSiwe.d.ts +18 -0
- package/dist/auth/useSiwe.js +72 -0
- package/dist/data/DataTable.d.ts +26 -0
- package/dist/data/DataTable.js +15 -0
- package/dist/data/data.css +22 -0
- package/dist/data/index.d.ts +4 -0
- package/dist/data/index.js +4 -0
- package/dist/data/useCollection.d.ts +42 -0
- package/dist/data/useCollection.js +67 -0
- package/dist/data/useCollection.test.d.ts +1 -0
- package/dist/data/useCollection.test.js +27 -0
- package/dist/data/useEventSource.d.ts +15 -0
- package/dist/data/useEventSource.js +37 -0
- package/dist/data/useWebSocket.d.ts +17 -0
- package/dist/data/useWebSocket.js +61 -0
- package/dist/dataviz/Stat.d.ts +18 -0
- package/dist/dataviz/Stat.js +14 -0
- package/dist/dataviz/dataviz.css +15 -0
- package/dist/dataviz/index.d.ts +2 -0
- package/dist/dataviz/index.js +2 -0
- package/dist/dataviz/sparkline.d.ts +15 -0
- package/dist/dataviz/sparkline.js +31 -0
- package/dist/dataviz/sparkline.test.d.ts +1 -0
- package/dist/dataviz/sparkline.test.js +14 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +9 -0
- package/dist/hooks/index.d.ts +12 -0
- package/dist/hooks/index.js +30 -0
- package/dist/hyperliquid/PriceTicker.d.ts +11 -0
- package/dist/hyperliquid/PriceTicker.js +24 -0
- package/dist/hyperliquid/client.d.ts +9 -0
- package/dist/hyperliquid/client.js +17 -0
- package/dist/hyperliquid/hl.css +10 -0
- package/dist/hyperliquid/index.d.ts +3 -0
- package/dist/hyperliquid/index.js +3 -0
- package/dist/hyperliquid/useHyperliquid.d.ts +76 -0
- package/dist/hyperliquid/useHyperliquid.js +52 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +21 -7
- package/dist/nft/MintButton.d.ts +20 -0
- package/dist/nft/MintButton.js +9 -0
- package/dist/nft/NFTCard.d.ts +15 -0
- package/dist/nft/NFTCard.js +14 -0
- package/dist/nft/NFTMedia.d.ts +11 -0
- package/dist/nft/NFTMedia.js +16 -0
- package/dist/nft/index.d.ts +5 -0
- package/dist/nft/index.js +5 -0
- package/dist/nft/nft.css +16 -0
- package/dist/nft/resolveUri.d.ts +1 -0
- package/dist/nft/resolveUri.js +15 -0
- package/dist/nft/resolveUri.test.d.ts +1 -0
- package/dist/nft/resolveUri.test.js +13 -0
- package/dist/nft/useNFT.d.ts +34 -0
- package/dist/nft/useNFT.js +65 -0
- package/dist/polymarket/MarketCard.d.ts +11 -0
- package/dist/polymarket/MarketCard.js +16 -0
- package/dist/polymarket/MarketsList.d.ts +13 -0
- package/dist/polymarket/MarketsList.js +13 -0
- package/dist/polymarket/OddsBar.d.ts +9 -0
- package/dist/polymarket/OddsBar.js +13 -0
- package/dist/polymarket/OrderTicket.d.ts +12 -0
- package/dist/polymarket/OrderTicket.js +28 -0
- package/dist/polymarket/client.d.ts +73 -0
- package/dist/polymarket/client.js +31 -0
- package/dist/polymarket/index.d.ts +6 -0
- package/dist/polymarket/index.js +10 -0
- package/dist/polymarket/polymarket.css +49 -0
- package/dist/polymarket/usePolymarket.d.ts +59 -0
- package/dist/polymarket/usePolymarket.js +97 -0
- package/dist/telegram/index.d.ts +2 -0
- package/dist/telegram/index.js +2 -0
- package/dist/telegram/miniapp.d.ts +15 -0
- package/dist/telegram/miniapp.js +63 -0
- package/dist/telegram/useTelegramTheme.d.ts +2 -0
- package/dist/telegram/useTelegramTheme.js +20 -0
- package/dist/tx/TxProgress.d.ts +13 -0
- package/dist/tx/TxProgress.js +22 -0
- package/dist/tx/index.d.ts +1 -0
- package/dist/tx/index.js +1 -0
- package/dist/tx/tx.css +9 -0
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +1 -1
- package/dist/ui/ui.css +7 -0
- package/dist/ui/ui.d.ts +13 -0
- package/dist/ui/ui.js +24 -0
- package/dist/user/UserMenu.d.ts +10 -0
- package/dist/user/UserMenu.js +18 -0
- package/dist/user/index.d.ts +2 -0
- package/dist/user/index.js +2 -0
- package/dist/user/useUser.d.ts +32 -0
- package/dist/user/useUser.js +36 -0
- package/dist/user/user.css +15 -0
- package/dist/web3/TokenAmount.d.ts +16 -0
- package/dist/web3/TokenAmount.js +17 -0
- package/dist/web3/index.d.ts +1 -0
- package/dist/web3/index.js +1 -0
- package/dist/web3/web3.css +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import "../wallet/wallet.css";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { useSiwe, type UseSiweOptions } from "./useSiwe";
|
|
4
|
+
export interface SignInWithEthereumProps extends UseSiweOptions {
|
|
5
|
+
className?: string;
|
|
6
|
+
children?: (api: ReturnType<typeof useSiwe>) => ReactNode;
|
|
7
|
+
}
|
|
8
|
+
export declare function SignInWithEthereum({ className, children, ...opts }: SignInWithEthereumProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "../wallet/wallet.css";
|
|
3
|
+
import {} from "react";
|
|
4
|
+
import { useSiwe } from "./useSiwe";
|
|
5
|
+
import { shortAddress } from "../format";
|
|
6
|
+
import { cx } from "../util";
|
|
7
|
+
// A button that runs the Sign-In With Ethereum flow and reflects session state.
|
|
8
|
+
// Pass `children` for a custom render. Reuses the wallet-connect button styling.
|
|
9
|
+
export function SignInWithEthereum({ className, children, ...opts }) {
|
|
10
|
+
const siwe = useSiwe(opts);
|
|
11
|
+
if (children)
|
|
12
|
+
return _jsx(_Fragment, { children: children(siwe) });
|
|
13
|
+
if (siwe.status === "signed-in") {
|
|
14
|
+
return (_jsxs("button", { className: cx("wm-account", className), onClick: () => siwe.signOut(), children: [shortAddress(siwe.user?.address), " \u00B7 Sign out"] }));
|
|
15
|
+
}
|
|
16
|
+
const busy = siwe.status === "signing" || siwe.status === "loading";
|
|
17
|
+
return (_jsx("button", { className: cx("wm-cta", className), disabled: busy, onClick: () => siwe.signIn(), children: siwe.status === "signing" ? "Check your wallet…" : "Sign in with Ethereum" }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type SiweStatus = "loading" | "signed-out" | "signing" | "signed-in" | "error";
|
|
2
|
+
export interface SiweUser {
|
|
3
|
+
address: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface SiweState {
|
|
7
|
+
status: SiweStatus;
|
|
8
|
+
user?: SiweUser;
|
|
9
|
+
signIn: () => Promise<void>;
|
|
10
|
+
signOut: () => Promise<void>;
|
|
11
|
+
refresh: () => void;
|
|
12
|
+
error?: Error;
|
|
13
|
+
}
|
|
14
|
+
export interface UseSiweOptions {
|
|
15
|
+
/** Base path of the SIWE auth API (default "/api/auth"). */
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function useSiwe(options?: UseSiweOptions): SiweState;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { useConnection, useSignMessage } from "wagmi";
|
|
3
|
+
// Sign-In With Ethereum against a same-origin backend. Expects: GET {endpoint}/nonce
|
|
4
|
+
// → { nonce, message }, POST {endpoint}/verify { message, signature } → { user },
|
|
5
|
+
// GET {endpoint}/me → { user } | 401, POST {endpoint}/logout. The server builds the
|
|
6
|
+
// SIWE message (with createSiweMessage from @livo-build/runtime) and the session.
|
|
7
|
+
export function useSiwe(options = {}) {
|
|
8
|
+
const base = options.endpoint ?? "/api/auth";
|
|
9
|
+
const { address } = useConnection();
|
|
10
|
+
const { signMessageAsync } = useSignMessage();
|
|
11
|
+
const [status, setStatus] = useState("loading");
|
|
12
|
+
const [user, setUser] = useState();
|
|
13
|
+
const [error, setError] = useState();
|
|
14
|
+
const refresh = useCallback(async () => {
|
|
15
|
+
try {
|
|
16
|
+
const r = await fetch(`${base}/me`, { credentials: "include" });
|
|
17
|
+
if (r.ok) {
|
|
18
|
+
const j = (await r.json());
|
|
19
|
+
if (j.user) {
|
|
20
|
+
setUser(j.user);
|
|
21
|
+
setStatus("signed-in");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
setStatus("signed-out");
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
setError(e);
|
|
29
|
+
setStatus("error");
|
|
30
|
+
}
|
|
31
|
+
}, [base]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
void refresh();
|
|
34
|
+
}, [refresh]);
|
|
35
|
+
const signIn = useCallback(async () => {
|
|
36
|
+
if (!address)
|
|
37
|
+
return;
|
|
38
|
+
setStatus("signing");
|
|
39
|
+
setError(undefined);
|
|
40
|
+
try {
|
|
41
|
+
const nr = await fetch(`${base}/nonce?address=${address}`, { credentials: "include" });
|
|
42
|
+
const { message } = (await nr.json());
|
|
43
|
+
const signature = await signMessageAsync({ message });
|
|
44
|
+
const vr = await fetch(`${base}/verify`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
credentials: "include",
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify({ message, signature }),
|
|
49
|
+
});
|
|
50
|
+
const j = (await vr.json());
|
|
51
|
+
if (!vr.ok || !j.user)
|
|
52
|
+
throw new Error(j.error ?? "Sign-in failed");
|
|
53
|
+
setUser(j.user);
|
|
54
|
+
setStatus("signed-in");
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
setError(e);
|
|
58
|
+
setStatus("error");
|
|
59
|
+
}
|
|
60
|
+
}, [address, base, signMessageAsync]);
|
|
61
|
+
const signOut = useCallback(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await fetch(`${base}/logout`, { method: "POST", credentials: "include" });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* ignore */
|
|
67
|
+
}
|
|
68
|
+
setUser(undefined);
|
|
69
|
+
setStatus("signed-out");
|
|
70
|
+
}, [base]);
|
|
71
|
+
return { status, user, signIn, signOut, refresh: () => void refresh(), error };
|
|
72
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import "./data.css";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export interface Column<T> {
|
|
4
|
+
/** Field key (also the default sort key) or a synthetic id. */
|
|
5
|
+
key: keyof T | string;
|
|
6
|
+
header: ReactNode;
|
|
7
|
+
/** Custom cell render; defaults to String(item[key]). */
|
|
8
|
+
render?: (item: T, index: number) => ReactNode;
|
|
9
|
+
/** Allow clicking the header to sort by this column's key. Default false. */
|
|
10
|
+
sortable?: boolean;
|
|
11
|
+
align?: "left" | "right" | "center";
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DataTableProps<T> {
|
|
15
|
+
data: T[];
|
|
16
|
+
columns: Column<T>[];
|
|
17
|
+
pageSize?: number;
|
|
18
|
+
searchable?: boolean;
|
|
19
|
+
searchKeys?: (keyof T)[];
|
|
20
|
+
searchPlaceholder?: string;
|
|
21
|
+
empty?: ReactNode;
|
|
22
|
+
rowKey?: (item: T, index: number) => string | number;
|
|
23
|
+
onRowClick?: (item: T) => void;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function DataTable<T>({ data, columns, pageSize, searchable, searchKeys, searchPlaceholder, empty, rowKey, onRowClick, className, }: DataTableProps<T>): import("react").JSX.Element;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "./data.css";
|
|
3
|
+
import {} from "react";
|
|
4
|
+
import { useCollection } from "./useCollection";
|
|
5
|
+
import { cx } from "../util";
|
|
6
|
+
// A neutral, sortable, searchable, paginated table over an array (data wrangling
|
|
7
|
+
// done for you via useCollection). Style via .kit-table* — see STYLING.md.
|
|
8
|
+
export function DataTable({ data, columns, pageSize, searchable, searchKeys, searchPlaceholder = "Search…", empty, rowKey, onRowClick, className, }) {
|
|
9
|
+
const cfg = { pageSize, searchKeys };
|
|
10
|
+
const c = useCollection(data, cfg);
|
|
11
|
+
return (_jsxs("div", { className: cx("kit-table-wrap", className), children: [searchable && (_jsx("input", { className: "kit-table-search", value: c.query, placeholder: searchPlaceholder, onChange: (e) => c.setQuery(e.target.value) })), _jsxs("table", { className: "kit-table", children: [_jsx("thead", { children: _jsx("tr", { children: columns.map((col) => {
|
|
12
|
+
const active = c.sort?.key === col.key;
|
|
13
|
+
return (_jsxs("th", { "data-align": col.align, "data-sortable": col.sortable || undefined, "data-active": active || undefined, className: col.className, onClick: col.sortable ? () => c.toggleSort(col.key) : undefined, children: [col.header, active ? _jsx("span", { className: "kit-table-caret", children: c.sort?.dir === "asc" ? " ▲" : " ▼" }) : null] }, String(col.key)));
|
|
14
|
+
}) }) }), _jsx("tbody", { children: c.isEmpty ? (_jsx("tr", { className: "kit-table-empty", children: _jsx("td", { colSpan: columns.length, children: empty ?? "No results" }) })) : (c.items.map((item, i) => (_jsx("tr", { "data-clickable": onRowClick ? true : undefined, onClick: onRowClick ? () => onRowClick(item) : undefined, children: columns.map((col) => (_jsx("td", { "data-align": col.align, className: col.className, children: col.render ? col.render(item, i) : String(item[col.key] ?? "") }, String(col.key)))) }, rowKey ? rowKey(item, i) : i)))) })] }), pageSize && c.pageCount > 1 && (_jsxs("div", { className: "kit-table-pager", children: [_jsx("button", { className: "kit-table-page-btn", disabled: c.page === 0, onClick: c.prevPage, children: "\u2039 Prev" }), _jsxs("span", { className: "kit-table-page-info", children: [c.page + 1, " / ", c.pageCount] }), _jsx("button", { className: "kit-table-page-btn", disabled: c.page >= c.pageCount - 1, onClick: c.nextPage, children: "Next \u203A" })] }))] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* DataTable — neutral, restyle via .kit-table* or --kit-* vars. */
|
|
2
|
+
.kit-table-wrap{display:flex;flex-direction:column;gap:10px;font:inherit}
|
|
3
|
+
.kit-table-search{align-self:flex-start;min-width:220px;border:1px solid var(--kit-border,#e5e7eb);border-radius:10px;padding:8px 12px;font:inherit;font-size:14px;background:transparent;color:inherit}
|
|
4
|
+
.kit-table-search:focus{outline:0;border-color:var(--kit-muted,#6b7280)}
|
|
5
|
+
.kit-table{width:100%;border-collapse:collapse;font-size:14px}
|
|
6
|
+
.kit-table th,.kit-table td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--kit-border,#eceef1)}
|
|
7
|
+
.kit-table td[data-align="right"],.kit-table th[data-align="right"]{text-align:right;font-variant-numeric:tabular-nums}
|
|
8
|
+
.kit-table td[data-align="center"],.kit-table th[data-align="center"]{text-align:center}
|
|
9
|
+
.kit-table th{font-weight:600;color:var(--kit-muted,#6b7280);user-select:none;white-space:nowrap}
|
|
10
|
+
.kit-table th[data-sortable]{cursor:pointer}
|
|
11
|
+
.kit-table th[data-active]{color:var(--kit-fg,#111827)}
|
|
12
|
+
.kit-table-caret{font-size:10px}
|
|
13
|
+
.kit-table tbody tr[data-clickable]{cursor:pointer}
|
|
14
|
+
.kit-table tbody tr[data-clickable]:hover{background:var(--kit-row-hover,#f6f7f9)}
|
|
15
|
+
.kit-table-empty td{padding:28px 12px;text-align:center;color:var(--kit-muted,#6b7280)}
|
|
16
|
+
.kit-table-pager{display:flex;align-items:center;gap:12px}
|
|
17
|
+
.kit-table-page-btn{border:1px solid var(--kit-border,#e5e7eb);background:transparent;color:inherit;border-radius:9px;padding:6px 12px;font:inherit;font-size:13px;cursor:pointer}
|
|
18
|
+
.kit-table-page-btn:disabled{opacity:.45;cursor:default}
|
|
19
|
+
.kit-table-page-info{color:var(--kit-muted,#6b7280);font-size:13px;font-variant-numeric:tabular-nums}
|
|
20
|
+
@media (prefers-color-scheme:dark){
|
|
21
|
+
.kit-table-wrap{--kit-border:#2c2e36;--kit-fg:#f5f5f7;--kit-row-hover:#1d1f25}
|
|
22
|
+
}
|
package/dist/data/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { useApi, apiClient, type QueryResult, type UseApiOptions } from "./useApi";
|
|
2
2
|
export { useSubgraph, type UseSubgraphArgs } from "./useSubgraph";
|
|
3
3
|
export { Async, type AsyncProps, type AsyncQuery } from "./Async";
|
|
4
|
+
export { useCollection, processCollection, type Collection, type CollectionConfig, type SortState, type SortDir, } from "./useCollection";
|
|
5
|
+
export { DataTable, type DataTableProps, type Column } from "./DataTable";
|
|
6
|
+
export { useEventSource, type EventSourceState, type UseEventSourceOptions, type StreamStatus } from "./useEventSource";
|
|
7
|
+
export { useWebSocket, type WebSocketState, type UseWebSocketOptions } from "./useWebSocket";
|
package/dist/data/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { useApi, apiClient } from "./useApi";
|
|
2
2
|
export { useSubgraph } from "./useSubgraph";
|
|
3
3
|
export { Async } from "./Async";
|
|
4
|
+
export { useCollection, processCollection, } from "./useCollection";
|
|
5
|
+
export { DataTable } from "./DataTable";
|
|
6
|
+
export { useEventSource } from "./useEventSource";
|
|
7
|
+
export { useWebSocket } from "./useWebSocket";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type SortDir = "asc" | "desc";
|
|
2
|
+
export interface SortState<T> {
|
|
3
|
+
key: keyof T;
|
|
4
|
+
dir: SortDir;
|
|
5
|
+
}
|
|
6
|
+
export interface CollectionConfig<T> {
|
|
7
|
+
/** Keys to full-text search (case-insensitive). Default: all string/number fields. */
|
|
8
|
+
searchKeys?: (keyof T)[];
|
|
9
|
+
/** Custom search predicate (overrides searchKeys). */
|
|
10
|
+
search?: (item: T, query: string) => boolean;
|
|
11
|
+
/** Persistent filter applied before search/sort. */
|
|
12
|
+
filter?: (item: T) => boolean;
|
|
13
|
+
/** Rows per page. Omit/0 to disable paging (one page). */
|
|
14
|
+
pageSize?: number;
|
|
15
|
+
initialSort?: SortState<T>;
|
|
16
|
+
initialQuery?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function processCollection<T>(items: T[], state: {
|
|
19
|
+
query?: string;
|
|
20
|
+
sort?: SortState<T> | null;
|
|
21
|
+
} & Pick<CollectionConfig<T>, "searchKeys" | "search" | "filter">): T[];
|
|
22
|
+
export interface Collection<T> {
|
|
23
|
+
/** The current page of processed rows. */
|
|
24
|
+
items: T[];
|
|
25
|
+
/** All processed (filtered+searched+sorted) rows, across pages. */
|
|
26
|
+
all: T[];
|
|
27
|
+
query: string;
|
|
28
|
+
setQuery: (q: string) => void;
|
|
29
|
+
sort: SortState<T> | null;
|
|
30
|
+
/** Set/toggle the sort key (same key flips asc↔desc). */
|
|
31
|
+
toggleSort: (key: keyof T) => void;
|
|
32
|
+
setSort: (sort: SortState<T> | null) => void;
|
|
33
|
+
page: number;
|
|
34
|
+
setPage: (p: number) => void;
|
|
35
|
+
nextPage: () => void;
|
|
36
|
+
prevPage: () => void;
|
|
37
|
+
pageCount: number;
|
|
38
|
+
/** Count after filter+search (before paging). */
|
|
39
|
+
total: number;
|
|
40
|
+
isEmpty: boolean;
|
|
41
|
+
}
|
|
42
|
+
export declare function useCollection<T>(items: T[], config?: CollectionConfig<T>): Collection<T>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
function compare(a, b) {
|
|
3
|
+
if (a == null)
|
|
4
|
+
return b == null ? 0 : -1;
|
|
5
|
+
if (b == null)
|
|
6
|
+
return 1;
|
|
7
|
+
if (typeof a === "number" && typeof b === "number")
|
|
8
|
+
return a - b;
|
|
9
|
+
if (typeof a === "bigint" && typeof b === "bigint")
|
|
10
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
11
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
|
12
|
+
}
|
|
13
|
+
// Pure: filter → search → sort. Exported for testing and non-React use.
|
|
14
|
+
export function processCollection(items, state) {
|
|
15
|
+
let out = state.filter ? items.filter(state.filter) : items.slice();
|
|
16
|
+
const q = (state.query ?? "").trim().toLowerCase();
|
|
17
|
+
if (q) {
|
|
18
|
+
out = out.filter((item) => {
|
|
19
|
+
if (state.search)
|
|
20
|
+
return state.search(item, q);
|
|
21
|
+
const keys = state.searchKeys ?? Object.keys(item);
|
|
22
|
+
return keys.some((k) => {
|
|
23
|
+
const v = item[k];
|
|
24
|
+
return (typeof v === "string" || typeof v === "number") && String(v).toLowerCase().includes(q);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (state.sort) {
|
|
29
|
+
const { key, dir } = state.sort;
|
|
30
|
+
const f = dir === "asc" ? 1 : -1;
|
|
31
|
+
out.sort((a, b) => compare(a[key], b[key]) * f);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
// Headless client-side data wrangling: search + sort + paginate any array, with
|
|
36
|
+
// React state. Feed `items` (e.g. from useApi/useSubgraph) and render `items`.
|
|
37
|
+
export function useCollection(items, config = {}) {
|
|
38
|
+
const [query, setQueryRaw] = useState(config.initialQuery ?? "");
|
|
39
|
+
const [sort, setSort] = useState(config.initialSort ?? null);
|
|
40
|
+
const [page, setPage] = useState(0);
|
|
41
|
+
const setQuery = (q) => {
|
|
42
|
+
setQueryRaw(q);
|
|
43
|
+
setPage(0);
|
|
44
|
+
};
|
|
45
|
+
const toggleSort = (key) => setSort((s) => (s && s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" }));
|
|
46
|
+
const all = useMemo(() => processCollection(items, { query, sort, searchKeys: config.searchKeys, search: config.search, filter: config.filter }), [items, query, sort, config.searchKeys, config.search, config.filter]);
|
|
47
|
+
const size = config.pageSize && config.pageSize > 0 ? config.pageSize : all.length || 1;
|
|
48
|
+
const pageCount = Math.max(1, Math.ceil(all.length / size));
|
|
49
|
+
const clamped = Math.min(page, pageCount - 1);
|
|
50
|
+
const pageItems = config.pageSize ? all.slice(clamped * size, clamped * size + size) : all;
|
|
51
|
+
return {
|
|
52
|
+
items: pageItems,
|
|
53
|
+
all,
|
|
54
|
+
query,
|
|
55
|
+
setQuery,
|
|
56
|
+
sort,
|
|
57
|
+
toggleSort,
|
|
58
|
+
setSort,
|
|
59
|
+
page: clamped,
|
|
60
|
+
setPage,
|
|
61
|
+
nextPage: () => setPage((p) => Math.min(p + 1, pageCount - 1)),
|
|
62
|
+
prevPage: () => setPage((p) => Math.max(p - 1, 0)),
|
|
63
|
+
pageCount,
|
|
64
|
+
total: all.length,
|
|
65
|
+
isEmpty: all.length === 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { processCollection } from "./useCollection";
|
|
3
|
+
const rows = [
|
|
4
|
+
{ name: "Alice", age: 30 },
|
|
5
|
+
{ name: "bob", age: 25 },
|
|
6
|
+
{ name: "Carol", age: 40 },
|
|
7
|
+
];
|
|
8
|
+
describe("processCollection", () => {
|
|
9
|
+
it("filters, then case-insensitive searches, then sorts (numeric-aware)", () => {
|
|
10
|
+
const sorted = processCollection(rows, { sort: { key: "age", dir: "asc" } });
|
|
11
|
+
expect(sorted.map((r) => r.age)).toEqual([25, 30, 40]);
|
|
12
|
+
const desc = processCollection(rows, { sort: { key: "name", dir: "desc" } });
|
|
13
|
+
expect(desc.map((r) => r.name)).toEqual(["Carol", "bob", "Alice"]);
|
|
14
|
+
const searched = processCollection(rows, { query: "car", searchKeys: ["name"] });
|
|
15
|
+
expect(searched).toEqual([{ name: "Carol", age: 40 }]);
|
|
16
|
+
const filtered = processCollection(rows, { filter: (r) => r.age >= 30, query: "" });
|
|
17
|
+
expect(filtered.map((r) => r.name).sort()).toEqual(["Alice", "Carol"]);
|
|
18
|
+
});
|
|
19
|
+
it("does not mutate the input array", () => {
|
|
20
|
+
const copy = rows.slice();
|
|
21
|
+
processCollection(rows, { sort: { key: "age", dir: "desc" } });
|
|
22
|
+
expect(rows).toEqual(copy);
|
|
23
|
+
});
|
|
24
|
+
it("searches all string/number fields when no searchKeys given", () => {
|
|
25
|
+
expect(processCollection(rows, { query: "40" })).toEqual([{ name: "Carol", age: 40 }]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type StreamStatus = "connecting" | "open" | "error" | "closed";
|
|
2
|
+
export interface EventSourceState<T> {
|
|
3
|
+
/** Last message, JSON-parsed when possible (else the raw string). */
|
|
4
|
+
data: T | undefined;
|
|
5
|
+
status: StreamStatus;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
}
|
|
8
|
+
export interface UseEventSourceOptions {
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Send cookies with the request (same-origin auth). */
|
|
11
|
+
withCredentials?: boolean;
|
|
12
|
+
/** Listen for a named event instead of the default `message`. */
|
|
13
|
+
event?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function useEventSource<T = unknown>(url?: string, opts?: UseEventSourceOptions): EventSourceState<T>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
// Consume a Server-Sent Events stream (e.g. a runtime `sse()` endpoint in your api/).
|
|
3
|
+
// Auto-reconnects (EventSource does), parses JSON payloads, and tears down on unmount.
|
|
4
|
+
export function useEventSource(url, opts = {}) {
|
|
5
|
+
const [data, setData] = useState(undefined);
|
|
6
|
+
const [status, setStatus] = useState("connecting");
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!url || opts.enabled === false || typeof EventSource === "undefined") {
|
|
10
|
+
setStatus("closed");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
setStatus("connecting");
|
|
14
|
+
setError(null);
|
|
15
|
+
const es = new EventSource(url, { withCredentials: opts.withCredentials });
|
|
16
|
+
const onMsg = (e) => {
|
|
17
|
+
setStatus("open");
|
|
18
|
+
try {
|
|
19
|
+
setData(JSON.parse(e.data));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
setData(e.data);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
es.onopen = () => setStatus("open");
|
|
26
|
+
es.onerror = () => {
|
|
27
|
+
setStatus("error");
|
|
28
|
+
setError(new Error("EventSource connection error"));
|
|
29
|
+
};
|
|
30
|
+
if (opts.event)
|
|
31
|
+
es.addEventListener(opts.event, onMsg);
|
|
32
|
+
else
|
|
33
|
+
es.onmessage = onMsg;
|
|
34
|
+
return () => es.close();
|
|
35
|
+
}, [url, opts.enabled, opts.withCredentials, opts.event]);
|
|
36
|
+
return { data, status, error };
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type StreamStatus } from "./useEventSource";
|
|
2
|
+
export interface WebSocketState<T> {
|
|
3
|
+
/** Last message, JSON-parsed when possible (else the raw string). */
|
|
4
|
+
data: T | undefined;
|
|
5
|
+
status: StreamStatus;
|
|
6
|
+
/** Send a message (objects are JSON-stringified). No-op until the socket is open. */
|
|
7
|
+
send: (message: unknown) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface UseWebSocketOptions {
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
protocols?: string | string[];
|
|
12
|
+
/** Reconnect after an unexpected close. Default true. */
|
|
13
|
+
reconnect?: boolean;
|
|
14
|
+
/** Reconnect delay in ms (default 2000). */
|
|
15
|
+
reconnectDelayMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function useWebSocket<T = unknown>(url?: string, opts?: UseWebSocketOptions): WebSocketState<T>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import {} from "./useEventSource";
|
|
3
|
+
// Subscribe to a WebSocket with JSON parsing, an imperative `send`, and optional
|
|
4
|
+
// auto-reconnect. Tears down on unmount / url change.
|
|
5
|
+
export function useWebSocket(url, opts = {}) {
|
|
6
|
+
const { enabled = true, protocols, reconnect = true, reconnectDelayMs = 2000 } = opts;
|
|
7
|
+
const [data, setData] = useState(undefined);
|
|
8
|
+
const [status, setStatus] = useState("connecting");
|
|
9
|
+
const sockRef = useRef(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (!url || !enabled || typeof WebSocket === "undefined") {
|
|
12
|
+
setStatus("closed");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let closed = false;
|
|
16
|
+
let timer;
|
|
17
|
+
const connect = () => {
|
|
18
|
+
if (closed)
|
|
19
|
+
return;
|
|
20
|
+
setStatus("connecting");
|
|
21
|
+
const ws = new WebSocket(url, protocols);
|
|
22
|
+
sockRef.current = ws;
|
|
23
|
+
ws.onopen = () => setStatus("open");
|
|
24
|
+
ws.onmessage = (e) => {
|
|
25
|
+
try {
|
|
26
|
+
setData(JSON.parse(typeof e.data === "string" ? e.data : ""));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
setData(e.data);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
ws.onerror = () => setStatus("error");
|
|
33
|
+
ws.onclose = () => {
|
|
34
|
+
if (closed)
|
|
35
|
+
return;
|
|
36
|
+
setStatus("closed");
|
|
37
|
+
if (reconnect)
|
|
38
|
+
timer = setTimeout(connect, reconnectDelayMs);
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
connect();
|
|
42
|
+
return () => {
|
|
43
|
+
closed = true;
|
|
44
|
+
if (timer)
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
try {
|
|
47
|
+
sockRef.current?.close();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* already closed */
|
|
51
|
+
}
|
|
52
|
+
sockRef.current = null;
|
|
53
|
+
};
|
|
54
|
+
}, [url, enabled, protocols, reconnect, reconnectDelayMs]);
|
|
55
|
+
const send = (message) => {
|
|
56
|
+
const ws = sockRef.current;
|
|
57
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
58
|
+
ws.send(typeof message === "string" ? message : JSON.stringify(message));
|
|
59
|
+
};
|
|
60
|
+
return { data, status, send };
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import "./dataviz.css";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
export interface StatProps {
|
|
4
|
+
label: ReactNode;
|
|
5
|
+
value: ReactNode;
|
|
6
|
+
/** Signed change shown with +/- and up/down color. */
|
|
7
|
+
change?: number;
|
|
8
|
+
/** Suffix for the change (default "%"). */
|
|
9
|
+
changeSuffix?: string;
|
|
10
|
+
hint?: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function Stat({ label, value, change, changeSuffix, hint, className }: StatProps): import("react").JSX.Element;
|
|
14
|
+
export interface StatGroupProps {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function StatGroup({ children, className }: StatGroupProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "./dataviz.css";
|
|
3
|
+
import {} from "react";
|
|
4
|
+
import { cx } from "../util";
|
|
5
|
+
// A single dashboard metric: label, big value, optional colored change + hint.
|
|
6
|
+
// Neutral — style via .kit-stat*.
|
|
7
|
+
export function Stat({ label, value, change, changeSuffix = "%", hint, className }) {
|
|
8
|
+
const dir = change == null ? undefined : change > 0 ? "up" : change < 0 ? "down" : "flat";
|
|
9
|
+
return (_jsxs("div", { className: cx("kit-stat", className), children: [_jsx("div", { className: "kit-stat-label", children: label }), _jsx("div", { className: "kit-stat-value", children: value }), change != null && (_jsxs("div", { className: "kit-stat-change", "data-dir": dir, children: [change > 0 ? "+" : "", change, changeSuffix] })), hint != null && _jsx("div", { className: "kit-stat-hint", children: hint })] }));
|
|
10
|
+
}
|
|
11
|
+
// A responsive row/grid of <Stat> cards for a dashboard header.
|
|
12
|
+
export function StatGroup({ children, className }) {
|
|
13
|
+
return _jsx("div", { className: cx("kit-stat-group", className), children: children });
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* Sparkline + Stat — neutral dashboard primitives. */
|
|
2
|
+
.kit-spark{display:inline-block;vertical-align:middle;overflow:visible}
|
|
3
|
+
|
|
4
|
+
.kit-stat-group{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}
|
|
5
|
+
.kit-stat{display:flex;flex-direction:column;gap:2px;padding:14px 16px;border:1px solid var(--kit-border,#e5e7eb);border-radius:var(--kit-radius,14px);background:var(--kit-bg,#fff);font:inherit}
|
|
6
|
+
.kit-stat-label{font-size:12px;color:var(--kit-muted,#6b7280);text-transform:uppercase;letter-spacing:.03em}
|
|
7
|
+
.kit-stat-value{font-size:22px;font-weight:700;color:var(--kit-fg,#111827);font-variant-numeric:tabular-nums}
|
|
8
|
+
.kit-stat-change{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
9
|
+
.kit-stat-change[data-dir="up"]{color:var(--kit-up,#15803d)}
|
|
10
|
+
.kit-stat-change[data-dir="down"]{color:var(--kit-down,#b91c1c)}
|
|
11
|
+
.kit-stat-change[data-dir="flat"]{color:var(--kit-muted,#6b7280)}
|
|
12
|
+
.kit-stat-hint{font-size:12px;color:var(--kit-muted,#6b7280)}
|
|
13
|
+
@media (prefers-color-scheme:dark){
|
|
14
|
+
.kit-stat{--kit-border:#2c2e36;--kit-bg:#15171c;--kit-fg:#f5f5f7}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "./dataviz.css";
|
|
2
|
+
/** Pure: map a value series to an SVG polyline path string in a width×height box. */
|
|
3
|
+
export declare function sparklinePath(data: number[], width: number, height: number): string;
|
|
4
|
+
export interface SparklineProps {
|
|
5
|
+
data: number[];
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
/** Stroke color. Defaults to green/red by overall direction. */
|
|
9
|
+
stroke?: string;
|
|
10
|
+
strokeWidth?: number;
|
|
11
|
+
/** Fill under the line (e.g. "rgba(22,163,74,.12)"). */
|
|
12
|
+
fill?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function Sparkline({ data, width, height, stroke, strokeWidth, fill, className }: SparklineProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "./dataviz.css";
|
|
3
|
+
import { cx } from "../util";
|
|
4
|
+
/** Pure: map a value series to an SVG polyline path string in a width×height box. */
|
|
5
|
+
export function sparklinePath(data, width, height) {
|
|
6
|
+
const n = data.length;
|
|
7
|
+
if (n === 0)
|
|
8
|
+
return "";
|
|
9
|
+
const min = Math.min(...data);
|
|
10
|
+
const max = Math.max(...data);
|
|
11
|
+
const range = max - min || 1;
|
|
12
|
+
const dx = n > 1 ? width / (n - 1) : 0;
|
|
13
|
+
return data
|
|
14
|
+
.map((v, i) => {
|
|
15
|
+
const x = i * dx;
|
|
16
|
+
const y = height - ((v - min) / range) * height;
|
|
17
|
+
return `${i ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
18
|
+
})
|
|
19
|
+
.join(" ");
|
|
20
|
+
}
|
|
21
|
+
// A tiny inline SVG trend line — great in table rows / stat cards. Dependency-free,
|
|
22
|
+
// auto-colored by direction (last vs first). Neutral; override via props or CSS.
|
|
23
|
+
export function Sparkline({ data, width = 100, height = 28, stroke, strokeWidth = 1.5, fill, className }) {
|
|
24
|
+
const d = sparklinePath(data, width, height);
|
|
25
|
+
if (!d)
|
|
26
|
+
return _jsx("svg", { className: cx("kit-spark", className), width: width, height: height });
|
|
27
|
+
const up = data[data.length - 1] >= data[0];
|
|
28
|
+
const color = stroke ?? (up ? "var(--kit-up, #16a34a)" : "var(--kit-down, #dc2626)");
|
|
29
|
+
const area = fill ? `${d} L ${width} ${height} L 0 ${height} Z` : "";
|
|
30
|
+
return (_jsxs("svg", { className: cx("kit-spark", className), width: width, height: height, viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", children: [fill && _jsx("path", { d: area, fill: fill, stroke: "none" }), _jsx("path", { d: d, fill: "none", stroke: color, strokeWidth: strokeWidth, strokeLinejoin: "round", strokeLinecap: "round" })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { sparklinePath } from "./sparkline";
|
|
3
|
+
describe("sparklinePath", () => {
|
|
4
|
+
it("maps min→bottom and max→top across the width", () => {
|
|
5
|
+
// data [0,1,2] in a 100×10 box: x at 0,50,100; y at 10 (min), 5, 0 (max)
|
|
6
|
+
expect(sparklinePath([0, 1, 2], 100, 10)).toBe("M0.00 10.00 L50.00 5.00 L100.00 0.00");
|
|
7
|
+
});
|
|
8
|
+
it("flat series sits on the baseline (range guarded)", () => {
|
|
9
|
+
expect(sparklinePath([5, 5], 10, 10)).toBe("M0.00 10.00 L10.00 10.00");
|
|
10
|
+
});
|
|
11
|
+
it("empty data yields an empty path", () => {
|
|
12
|
+
expect(sparklinePath([], 100, 10)).toBe("");
|
|
13
|
+
});
|
|
14
|
+
});
|
package/dist/format.d.ts
CHANGED
|
@@ -12,5 +12,8 @@ export declare function parseToken(value: string, decimals?: number): bigint;
|
|
|
12
12
|
export declare function formatUsd(n: number, opts?: {
|
|
13
13
|
compact?: boolean;
|
|
14
14
|
}): string;
|
|
15
|
+
/** Format a market price with sensible precision (big numbers grouped, small ones
|
|
16
|
+
* given more decimals). `formatPrice(64210.5)` → "64,210.50"; `formatPrice(0.00001234)` → "0.00001234". */
|
|
17
|
+
export declare function formatPrice(n: number): string;
|
|
15
18
|
/** Short relative time from a unix-seconds (or ms) timestamp. `timeAgo(t)` → "3m ago". */
|
|
16
19
|
export declare function timeAgo(ts: number, nowMs?: number): string;
|