@columenlabs/lumen-ui 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +4 -2
- package/dist/add-column.d.ts +23 -0
- package/dist/add-column.js +62 -0
- package/dist/api-fetch.d.ts +9 -0
- package/dist/api-fetch.js +60 -0
- package/dist/app-switcher.d.ts +21 -0
- package/dist/app-switcher.js +12 -0
- package/dist/app-views.d.ts +18 -0
- package/dist/app-views.js +41 -0
- package/dist/assistant-context.d.ts +72 -0
- package/dist/assistant-context.js +30 -0
- package/dist/assistant-panel.d.ts +58 -0
- package/dist/assistant-panel.js +244 -0
- package/dist/assistant-screen.d.ts +124 -0
- package/dist/assistant-screen.js +252 -0
- package/dist/card-switch.d.ts +16 -0
- package/dist/card-switch.js +16 -0
- package/dist/color-dot.d.ts +14 -0
- package/dist/color-dot.js +19 -0
- package/dist/confirm.d.ts +18 -0
- package/dist/confirm.js +5 -0
- package/dist/connections.d.ts +45 -0
- package/dist/connections.js +275 -0
- package/dist/coverage-calendar-map.d.ts +27 -0
- package/dist/coverage-calendar-map.js +84 -0
- package/dist/coverage-cells.d.ts +55 -0
- package/dist/coverage-cells.js +85 -0
- package/dist/coverage-map.d.ts +112 -0
- package/dist/coverage-map.js +428 -0
- package/dist/details-table.d.ts +20 -0
- package/dist/details-table.js +13 -0
- package/dist/drawer-section.d.ts +27 -0
- package/dist/drawer-section.js +23 -0
- package/dist/floating-head.d.ts +2 -0
- package/dist/floating-head.js +57 -0
- package/dist/fold.d.ts +16 -0
- package/dist/fold.js +70 -0
- package/dist/form-issues.d.ts +32 -0
- package/dist/form-issues.js +60 -0
- package/dist/formula-input.d.ts +17 -0
- package/dist/formula-input.js +104 -0
- package/dist/frame-script.d.ts +7 -0
- package/dist/frame-script.js +103 -0
- package/dist/freshness.d.ts +24 -0
- package/dist/freshness.js +99 -0
- package/dist/group-button.d.ts +33 -0
- package/dist/group-button.js +67 -0
- package/dist/growth-chart.d.ts +77 -0
- package/dist/growth-chart.js +195 -0
- package/dist/hooks/use-keyboard-fit.d.ts +15 -0
- package/dist/hooks/use-keyboard-fit.js +29 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icon-button.d.ts +21 -0
- package/dist/icon-button.js +22 -0
- package/dist/icon-picker.d.ts +14 -0
- package/dist/icon-picker.js +36 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +90 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +73 -0
- package/dist/joins.d.ts +128 -0
- package/dist/joins.js +767 -0
- package/dist/kit-config.d.ts +13 -0
- package/dist/kit-config.js +15 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lumen-boundary.d.ts +7 -0
- package/dist/lumen-boundary.js +10 -0
- package/dist/lumen-code-editor-impl.d.ts +2 -0
- package/dist/lumen-code-editor-impl.js +16 -0
- package/dist/lumen-code-editor.d.ts +11 -0
- package/dist/lumen-code-editor.js +11 -0
- package/dist/lumen-code.d.ts +22 -0
- package/dist/lumen-code.js +75 -0
- package/dist/lumen-drawer.d.ts +30 -0
- package/dist/lumen-drawer.js +27 -0
- package/dist/lumen-echart.d.ts +58 -0
- package/dist/lumen-echart.js +129 -0
- package/dist/lumen-filter.d.ts +75 -0
- package/dist/lumen-filter.js +546 -0
- package/dist/lumen-finder.d.ts +62 -0
- package/dist/lumen-finder.js +275 -0
- package/dist/lumen-form.d.ts +34 -0
- package/dist/lumen-form.js +81 -0
- package/dist/lumen-geo-map.d.ts +9 -0
- package/dist/lumen-geo-map.js +135 -0
- package/dist/lumen-grid.d.ts +49 -0
- package/dist/lumen-grid.js +203 -0
- package/dist/lumen-layout.d.ts +150 -0
- package/dist/lumen-layout.js +661 -0
- package/dist/lumen-links.d.ts +96 -0
- package/dist/lumen-links.js +213 -0
- package/dist/lumen-page-state.d.ts +20 -0
- package/dist/lumen-page-state.js +15 -0
- package/dist/lumen-selector.d.ts +139 -0
- package/dist/lumen-selector.js +308 -0
- package/dist/lumen-source.d.ts +137 -0
- package/dist/lumen-source.js +327 -0
- package/dist/lumen-tab-drawer.d.ts +27 -0
- package/dist/lumen-tab-drawer.js +26 -0
- package/dist/lumen-tabs.d.ts +21 -0
- package/dist/lumen-tabs.js +6 -0
- package/dist/lumen-toast.d.ts +11 -0
- package/dist/lumen-toast.js +16 -0
- package/dist/lumen-trend.d.ts +18 -0
- package/dist/lumen-trend.js +157 -0
- package/dist/lumen-view-card.d.ts +35 -0
- package/dist/lumen-view-card.js +50 -0
- package/dist/lumen-view.d.ts +172 -0
- package/dist/lumen-view.js +645 -0
- package/dist/lumen-widget-charts.d.ts +57 -0
- package/dist/lumen-widget-charts.js +69 -0
- package/dist/lumen-widget.d.ts +720 -0
- package/dist/lumen-widget.js +609 -0
- package/dist/market/compare-chart.d.ts +34 -0
- package/dist/market/compare-chart.js +292 -0
- package/dist/market/indicator-picker.d.ts +29 -0
- package/dist/market/indicator-picker.js +125 -0
- package/dist/market/indicators.d.ts +63 -0
- package/dist/market/indicators.js +314 -0
- package/dist/market/live.d.ts +120 -0
- package/dist/market/live.js +444 -0
- package/dist/market/lumen-chart.d.ts +88 -0
- package/dist/market/lumen-chart.js +645 -0
- package/dist/market/market-widget.d.ts +45 -0
- package/dist/market/market-widget.js +346 -0
- package/dist/market/option-chain.d.ts +19 -0
- package/dist/market/option-chain.js +53 -0
- package/dist/market/price-chart.d.ts +128 -0
- package/dist/market/price-chart.js +619 -0
- package/dist/mcp-settings.d.ts +20 -0
- package/dist/mcp-settings.js +65 -0
- package/dist/nav.d.ts +77 -0
- package/dist/nav.js +73 -0
- package/dist/note-line.d.ts +13 -0
- package/dist/note-line.js +5 -0
- package/dist/notifications.d.ts +9 -0
- package/dist/notifications.js +166 -0
- package/dist/origin-legend.d.ts +14 -0
- package/dist/origin-legend.js +38 -0
- package/dist/overview.d.ts +84 -0
- package/dist/overview.js +105 -0
- package/dist/page-inputs.d.ts +14 -0
- package/dist/page-inputs.js +16 -0
- package/dist/page.d.ts +32 -0
- package/dist/page.js +45 -0
- package/dist/pager.d.ts +10 -0
- package/dist/pager.js +14 -0
- package/dist/prefs.d.ts +71 -0
- package/dist/prefs.js +126 -0
- package/dist/query-client.d.ts +7 -0
- package/dist/query-client.js +19 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.js +19 -0
- package/dist/rename-field.d.ts +11 -0
- package/dist/rename-field.js +26 -0
- package/dist/reset-button.d.ts +9 -0
- package/dist/reset-button.js +10 -0
- package/dist/route-error.d.ts +42 -0
- package/dist/route-error.js +229 -0
- package/dist/schedules.d.ts +88 -0
- package/dist/schedules.js +221 -0
- package/dist/settings.d.ts +51 -0
- package/dist/settings.js +181 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +137 -0
- package/dist/source-health.d.ts +12 -0
- package/dist/source-health.js +58 -0
- package/dist/sourced-card.d.ts +10 -0
- package/dist/sourced-card.js +69 -0
- package/dist/spotlight.d.ts +23 -0
- package/dist/spotlight.js +138 -0
- package/dist/status-health.d.ts +16 -0
- package/dist/status-health.js +41 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +12 -0
- package/dist/summary-cards.d.ts +56 -0
- package/dist/summary-cards.js +270 -0
- package/dist/symbol-sheet-context.d.ts +4 -0
- package/dist/symbol-sheet-context.js +5 -0
- package/dist/symbol-sheet.d.ts +9 -0
- package/dist/symbol-sheet.js +24 -0
- package/dist/tasks.d.ts +29 -0
- package/dist/tasks.js +278 -0
- package/dist/theme.css +606 -0
- package/dist/tile.d.ts +37 -0
- package/dist/tile.js +23 -0
- package/dist/ui/alert-dialog.d.ts +18 -0
- package/dist/ui/alert-dialog.js +41 -0
- package/dist/ui/alert.d.ts +10 -0
- package/dist/ui/alert.js +27 -0
- package/dist/ui/avatar.d.ts +11 -0
- package/dist/ui/avatar.js +22 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +27 -0
- package/dist/ui/breadcrumb.d.ts +11 -0
- package/dist/ui/breadcrumb.js +27 -0
- package/dist/ui/button-group.d.ts +11 -0
- package/dist/ui/button-group.js +27 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +38 -0
- package/dist/ui/card.d.ts +11 -0
- package/dist/ui/card.js +24 -0
- package/dist/ui/chart.d.ts +44 -0
- package/dist/ui/chart.js +117 -0
- package/dist/ui/checkbox.d.ts +4 -0
- package/dist/ui/checkbox.js +8 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/dist/ui/collapsible.js +12 -0
- package/dist/ui/combobox.d.ts +24 -0
- package/dist/ui/combobox.js +60 -0
- package/dist/ui/command.d.ts +18 -0
- package/dist/ui/command.js +34 -0
- package/dist/ui/dialog.d.ts +23 -0
- package/dist/ui/dialog.js +50 -0
- package/dist/ui/dropdown-menu.d.ts +29 -0
- package/dist/ui/dropdown-menu.js +50 -0
- package/dist/ui/emoji-picker.d.ts +6 -0
- package/dist/ui/emoji-picker.js +28 -0
- package/dist/ui/empty.d.ts +11 -0
- package/dist/ui/empty.js +33 -0
- package/dist/ui/field.d.ts +24 -0
- package/dist/ui/field.js +65 -0
- package/dist/ui/hover-card.d.ts +6 -0
- package/dist/ui/hover-card.js +13 -0
- package/dist/ui/input-group.d.ts +16 -0
- package/dist/ui/input-group.js +56 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/item.d.ts +23 -0
- package/dist/ui/item.js +67 -0
- package/dist/ui/kbd.d.ts +3 -0
- package/dist/ui/kbd.js +9 -0
- package/dist/ui/label.d.ts +4 -0
- package/dist/ui/label.js +7 -0
- package/dist/ui/popover.d.ts +10 -0
- package/dist/ui/popover.js +25 -0
- package/dist/ui/progress.d.ts +4 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/resizable.d.ts +7 -0
- package/dist/ui/resizable.js +13 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +10 -0
- package/dist/ui/select.d.ts +15 -0
- package/dist/ui/select.js +36 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +7 -0
- package/dist/ui/sheet.d.ts +14 -0
- package/dist/ui/sheet.js +54 -0
- package/dist/ui/sidebar.d.ts +69 -0
- package/dist/ui/sidebar.js +206 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/slider.d.ts +4 -0
- package/dist/ui/slider.js +9 -0
- package/dist/ui/sonner.d.ts +3 -0
- package/dist/ui/sonner.js +24 -0
- package/dist/ui/spinner.d.ts +2 -0
- package/dist/ui/spinner.js +7 -0
- package/dist/ui/switch.d.ts +6 -0
- package/dist/ui/switch.js +7 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +31 -0
- package/dist/ui/tabs.d.ts +7 -0
- package/dist/ui/tabs.js +22 -0
- package/dist/ui/textarea.d.ts +3 -0
- package/dist/ui/textarea.js +6 -0
- package/dist/ui/toggle-group.d.ts +10 -0
- package/dist/ui/toggle-group.js +22 -0
- package/dist/ui/toggle.d.ts +9 -0
- package/dist/ui/toggle.js +25 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +16 -0
- package/dist/view-edit-cell.d.ts +49 -0
- package/dist/view-edit-cell.js +198 -0
- package/dist/view-editor.d.ts +52 -0
- package/dist/view-editor.js +1081 -0
- package/dist/view-formulas.d.ts +61 -0
- package/dist/view-formulas.js +247 -0
- package/dist/view-icons.d.ts +20 -0
- package/dist/view-icons.js +70 -0
- package/dist/view-model.d.ts +390 -0
- package/dist/view-model.js +316 -0
- package/dist/whats-new.d.ts +3 -0
- package/dist/whats-new.js +53 -0
- package/dist/widget-controls.d.ts +9 -0
- package/dist/widget-controls.js +26 -0
- package/dist/widget-data.d.ts +4 -0
- package/dist/widget-data.js +32 -0
- package/dist/widget-settings.d.ts +516 -0
- package/dist/widget-settings.js +290 -0
- package/dist/widget-thumbs.d.ts +3 -0
- package/dist/widget-thumbs.js +29 -0
- package/dist/workspace-keys.d.ts +12 -0
- package/dist/workspace-keys.js +13 -0
- package/dist/workspaces.d.ts +57 -0
- package/dist/workspaces.js +473 -0
- package/package.json +82 -4
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CheckIcon, CopyIcon, ShieldIcon } from 'lucide-react';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
// Settings ▸ MCP (in Lumen for
|
|
5
|
+
// every app): each MCP server's address and the steps to connect it from each
|
|
6
|
+
// client. Sign-in is OAuth only (no API keys); a client that can't sign in back to the app runs the
|
|
7
|
+
// common `mcp-remote` bridge on the person's own computer, whose sign-in returns to localhost.
|
|
8
|
+
import { LumenSelector } from './lumen-selector.js';
|
|
9
|
+
import { IconButton } from './page.js';
|
|
10
|
+
import { SettingRow } from './settings.js';
|
|
11
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
|
|
12
|
+
import { Tabs, TabsList, TabsTrigger } from './ui/tabs.js';
|
|
13
|
+
const CLIENTS = {
|
|
14
|
+
claude: 'Claude',
|
|
15
|
+
chatgpt: 'ChatGPT',
|
|
16
|
+
code: 'Claude Code',
|
|
17
|
+
cursor: 'Cursor',
|
|
18
|
+
other: 'Other app',
|
|
19
|
+
script: 'Script',
|
|
20
|
+
};
|
|
21
|
+
function useCopy() {
|
|
22
|
+
const [copied, setCopied] = useState(null);
|
|
23
|
+
const copy = (text) => void navigator.clipboard.writeText(text).then(() => {
|
|
24
|
+
setCopied(text);
|
|
25
|
+
setTimeout(() => setCopied((c) => (c === text ? null : c)), 1500);
|
|
26
|
+
});
|
|
27
|
+
return { copied, copy };
|
|
28
|
+
}
|
|
29
|
+
/** A command or config to paste, with Copy in its corner. */
|
|
30
|
+
function Snippet({ text }) {
|
|
31
|
+
const { copied, copy } = useCopy();
|
|
32
|
+
return (_jsxs("div", { className: "relative", children: [_jsx("pre", { className: "rounded-md border bg-muted/40 p-3 pr-10 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap", children: text }), _jsx(IconButton, { icon: copied === text ? CheckIcon : CopyIcon, size: "icon-xs", label: "Copy", className: "absolute top-2 right-2", onClick: () => copy(text) })] }));
|
|
33
|
+
}
|
|
34
|
+
const Steps = ({ children }) => (_jsx("ol", { className: "flex list-decimal flex-col gap-1 pl-5 text-sm", children: children }));
|
|
35
|
+
/** The steps for one client and server. */
|
|
36
|
+
function ClientSteps({ client, server }) {
|
|
37
|
+
const { url, key: name } = server;
|
|
38
|
+
const bridge = JSON.stringify({ mcpServers: { [name]: { command: 'npx', args: ['-y', 'mcp-remote', url] } } }, null, 2);
|
|
39
|
+
switch (client) {
|
|
40
|
+
case 'claude':
|
|
41
|
+
return (_jsxs(Steps, { children: [_jsx("li", { children: "In Claude, open Settings \u25B8 Connectors and press Add custom connector." }), _jsxs("li", { children: ["Name it ", server.label, ", paste the ", server.label, " address and press Add."] }), _jsx("li", { children: "Press Connect and sign in when your browser opens." })] }));
|
|
42
|
+
case 'chatgpt':
|
|
43
|
+
return (_jsxs(Steps, { children: [_jsx("li", { children: "In ChatGPT, open Settings \u25B8 Apps & Connectors \u25B8 Advanced settings and turn on Developer mode." }), _jsxs("li", { children: ["Back in Apps & Connectors, press Create, name it ", server.label, ", paste the address and pick OAuth."] }), _jsx("li", { children: "Sign in when your browser opens." })] }));
|
|
44
|
+
case 'code':
|
|
45
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs(Steps, { children: [_jsx("li", { children: "Run this in a terminal." }), _jsxs("li", { children: ["Type ", _jsx("code", { className: "font-mono text-xs", children: "/mcp" }), " in Claude Code and pick ", name, ". Your browser opens; sign in."] })] }), _jsx(Snippet, { text: `claude mcp add --transport http ${name} ${url}` })] }));
|
|
46
|
+
case 'cursor':
|
|
47
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs(Steps, { children: [_jsxs("li", { children: ["Add this to ", _jsx("code", { className: "font-mono text-xs", children: "~/.cursor/mcp.json" }), "."] }), _jsxs("li", { children: ["In Cursor Settings \u25B8 MCP, press Needs login beside ", name, " and sign in."] })] }), _jsx(Snippet, { text: JSON.stringify({ mcpServers: { [name]: { url } } }, null, 2) })] }));
|
|
48
|
+
case 'other':
|
|
49
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("p", { className: "text-sm", children: ["Apps that sign in through the browser take the address as is. An app whose sign-in can only return to its own website runs the bridge on your computer instead: the app talks to the bridge, the bridge signs in through your browser and talks to ", server.label, "."] }), _jsxs(Steps, { children: [_jsx("li", { children: "Add this server to the app's MCP config (Node.js needed)." }), _jsx("li", { children: "Start the app; your browser opens once to sign in." })] }), _jsx(Snippet, { text: bridge })] }));
|
|
50
|
+
case 'script':
|
|
51
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs(Steps, { children: [_jsx("li", { children: "Sign in once on the computer the script runs on; the sign-in is kept there." }), _jsx("li", { children: "Point the script's MCP client at the same command (stdio). Later runs need no browser." }), server.script?.steps] }), _jsx(Snippet, { text: `npx -y mcp-remote ${url}` }), server.script?.example && _jsx(Snippet, { text: server.script.example })] }));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function McpSettings({ servers, current }) {
|
|
55
|
+
const { copied, copy } = useCopy();
|
|
56
|
+
const [serverId, setServerId] = useState(current ?? servers[0]?.id);
|
|
57
|
+
const [client, setClient] = useState('claude');
|
|
58
|
+
const server = servers.find((d) => d.id === serverId) ?? servers[0];
|
|
59
|
+
if (!server)
|
|
60
|
+
return null;
|
|
61
|
+
return (_jsxs("div", { className: "flex flex-col gap-6", children: [_jsxs("section", { className: "flex flex-col", children: [_jsx("h4", { className: "text-xs font-medium text-muted-foreground", children: "Servers" }), servers.map((d) => {
|
|
62
|
+
const url = d.url;
|
|
63
|
+
return (_jsx(SettingRow, { label: d.label, description: "MCP address", children: _jsxs(InputGroup, { className: "w-56 sm:w-80", children: [_jsx(InputGroupInput, { readOnly: true, value: url, "aria-label": `${d.label} MCP address`, className: "font-mono text-xs", onFocus: (e) => e.currentTarget.select() }), _jsx(InputGroupAddon, { align: "inline-end", children: _jsx(InputGroupButton, { size: "icon-xs", "aria-label": `Copy the ${d.label} address`, onClick: () => copy(url), children: copied === url ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}) }) })] }) }, d.id));
|
|
64
|
+
})] }), _jsxs("section", { className: "flex flex-col gap-3", children: [_jsx("h4", { className: "text-xs font-medium text-muted-foreground", children: "Connect" }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [servers.length > 1 && (_jsx(LumenSelector, { label: "Server", size: "sm", className: "w-40", options: servers.map((d) => ({ value: d.id, label: d.label })), value: server.id, onChange: setServerId })), _jsx(LumenSelector, { label: "App", size: "sm", className: "w-36 md:hidden", options: Object.entries(CLIENTS).map(([k, label]) => ({ value: k, label })), value: client, onChange: (v) => setClient(v) })] }), _jsx(Tabs, { value: client, onValueChange: (v) => setClient(v), className: "max-md:hidden", children: _jsx(TabsList, { children: Object.entries(CLIENTS).map(([k, label]) => (_jsx(TabsTrigger, { value: k, children: label }, k))) }) }), _jsx(ClientSteps, { client: client, server: server })] }), _jsxs("p", { className: "flex items-start gap-1.5 text-xs text-muted-foreground", children: [_jsx(ShieldIcon, { className: "mt-0.5 size-3.5 shrink-0" }), "Every client signs in as you through your browser; there are no API keys. Sign-ins return only to known apps and to http://localhost or http://127.0.0.1 on your own computer."] })] }));
|
|
65
|
+
}
|
package/dist/nav.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import { type AssistantPageType } from './assistant-screen.js';
|
|
3
|
+
/** A nav icon: a lucide icon, or a view's emoji drawn at icon size. */
|
|
4
|
+
export type NavIcon = ComponentType<{
|
|
5
|
+
className?: string;
|
|
6
|
+
}>;
|
|
7
|
+
export interface NavItem {
|
|
8
|
+
title: string;
|
|
9
|
+
to: string;
|
|
10
|
+
icon: NavIcon;
|
|
11
|
+
badge?: ReactNode;
|
|
12
|
+
/** Active only on this exact path, not its children. */
|
|
13
|
+
exact?: boolean;
|
|
14
|
+
/** One nested level, shown under a collapsible parent. */
|
|
15
|
+
items?: Omit<NavItem, 'items'>[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A sidebar group whose header folds its items: bold caps, no icon. At rest
|
|
19
|
+
* it looks as before; hovered, it lights up and a chevron shows after the name, which stays while
|
|
20
|
+
* folded (and always on touch screens). Icons-only sidebars show every item.
|
|
21
|
+
*/
|
|
22
|
+
export declare function FoldGroup({ label, action, children, }: {
|
|
23
|
+
label?: string;
|
|
24
|
+
action?: ReactNode;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}): import("react").JSX.Element;
|
|
27
|
+
export declare function NavGroup({ label, items, pageType, }: {
|
|
28
|
+
label?: string;
|
|
29
|
+
items: NavItem[];
|
|
30
|
+
/** What its pages stand for in the assistant's @ picker; plain pages by default. */
|
|
31
|
+
pageType?: AssistantPageType;
|
|
32
|
+
}): import("react").JSX.Element;
|
|
33
|
+
export interface SectionItem {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
to: string;
|
|
37
|
+
icon: NavIcon;
|
|
38
|
+
/** A count or a value on the right (a % change); hidden while the remove icon shows. */
|
|
39
|
+
badge?: ReactNode;
|
|
40
|
+
/** One nested level, toggled by a chevron; the item itself still links. */
|
|
41
|
+
items?: SectionSubItem[];
|
|
42
|
+
/** Open the nested items on first render (they also open while one is active). */
|
|
43
|
+
defaultOpen?: boolean;
|
|
44
|
+
/** Not removable: no trash icon even when the section has `onRemove`. */
|
|
45
|
+
fixed?: boolean;
|
|
46
|
+
/** What it stands for in the assistant's @ picker, when not the section's `pageType`. */
|
|
47
|
+
type?: AssistantPageType;
|
|
48
|
+
}
|
|
49
|
+
export interface SectionSubItem {
|
|
50
|
+
id: string;
|
|
51
|
+
title: string;
|
|
52
|
+
to: string;
|
|
53
|
+
/** A colour dot before the title, matching the item's line on a chart. */
|
|
54
|
+
color?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A section of things a person keeps (views, trades, a watchlist): `+` adds one, the
|
|
58
|
+
* trash icon on hover removes one. An item may carry a badge and one nested level of links with
|
|
59
|
+
* colour dots. The app supplies the items and both handlers.
|
|
60
|
+
*/
|
|
61
|
+
export declare function SidebarSection({ label, items, onAdd, addLabel, onRemove, removeLabel, loading, empty, children, pageType, assistantSubType, }: {
|
|
62
|
+
label: string;
|
|
63
|
+
items: SectionItem[];
|
|
64
|
+
onAdd?: () => void;
|
|
65
|
+
addLabel?: string;
|
|
66
|
+
onRemove?: (item: SectionItem) => void;
|
|
67
|
+
removeLabel?: (item: SectionItem) => string;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
/** Shown when there are no items. */
|
|
70
|
+
empty?: ReactNode;
|
|
71
|
+
/** Extra content under the items (a search box, a retry line). */
|
|
72
|
+
children?: ReactNode;
|
|
73
|
+
/** What its items stand for in the assistant's @ picker; plain pages by default. */
|
|
74
|
+
pageType?: AssistantPageType;
|
|
75
|
+
/** What its nested items stand for; plain pages by default. */
|
|
76
|
+
assistantSubType?: AssistantPageType;
|
|
77
|
+
}): import("react").JSX.Element;
|
package/dist/nav.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Sidebar building blocks an app composes its nav from (shadcn NavMain pattern): a group of links
|
|
3
|
+
// that may nest one level (chevron on the right, hidden in icon mode), and a section whose items a
|
|
4
|
+
// person adds with `+` and removes with the trash icon. Every item carries an icon; section headers are bold caps without one.
|
|
5
|
+
// A section header folds its items: it lights up on hover with a chevron after
|
|
6
|
+
// the name, a click folds it, the fold is remembered in the browser, and `+` never folds.
|
|
7
|
+
import { Link, useRouterState } from '@tanstack/react-router';
|
|
8
|
+
import { ChevronRightIcon, PlusIcon, Trash2Icon } from 'lucide-react';
|
|
9
|
+
import { useAssistantPages } from './assistant-screen.js';
|
|
10
|
+
import { LumenConfirm } from './confirm.js';
|
|
11
|
+
import { useFolded } from './fold.js';
|
|
12
|
+
import { cn } from './lib/utils.js';
|
|
13
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
|
|
14
|
+
import { SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, useSidebar, } from './ui/sidebar.js';
|
|
15
|
+
function useActive() {
|
|
16
|
+
const path = useRouterState({ select: (s) => s.location.pathname });
|
|
17
|
+
return ({ to, exact }) => path === to || (!exact && path.startsWith(`${to.replace(/\/$/, '')}/`));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A sidebar group whose header folds its items: bold caps, no icon. At rest
|
|
21
|
+
* it looks as before; hovered, it lights up and a chevron shows after the name, which stays while
|
|
22
|
+
* folded (and always on touch screens). Icons-only sidebars show every item.
|
|
23
|
+
*/
|
|
24
|
+
export function FoldGroup({ label, action, children, }) {
|
|
25
|
+
const [folded, setFolded] = useFolded(`nav:${label ?? ''}`);
|
|
26
|
+
const { state, isMobile } = useSidebar();
|
|
27
|
+
if (!label)
|
|
28
|
+
return _jsx(SidebarGroup, { children: children });
|
|
29
|
+
const icons = state === 'collapsed' && !isMobile;
|
|
30
|
+
return (_jsx(Collapsible, { open: icons || !folded, onOpenChange: (open) => setFolded(!open), className: "group/fold", children: _jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { asChild: true, children: _jsxs(CollapsibleTrigger, { className: "group/label w-full cursor-pointer gap-1 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", children: [_jsx("span", { className: "truncate", children: label }), _jsx(ChevronRightIcon, { "aria-hidden": true, className: "text-muted-foreground opacity-0 transition-[opacity,transform] group-hover/label:opacity-100 group-focus-visible/label:opacity-100 group-data-[state=closed]/fold:opacity-100 group-data-[state=open]/fold:rotate-90 [@media(hover:none)]:opacity-100" })] }) }), action, _jsx(CollapsibleContent, { children: children })] }) }));
|
|
31
|
+
}
|
|
32
|
+
export function NavGroup({ label, items, pageType, }) {
|
|
33
|
+
useAssistantPages(items.flatMap((i) => [i, ...(i.items ?? []).map((sub) => ({ ...sub, parent: i.title }))]), label, pageType);
|
|
34
|
+
const active = useActive();
|
|
35
|
+
return (_jsx(FoldGroup, { label: label, children: _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: items.map((item) => item.items?.length ? (_jsx(Collapsible, { asChild: true, defaultOpen: active(item) || item.items.some(active), className: "group/collapsible", children: _jsxs(SidebarMenuItem, { children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(SidebarMenuButton, { tooltip: item.title, isActive: active(item), children: [_jsx(item.icon, {}), _jsx("span", { children: item.title }), _jsx(ChevronRightIcon, { className: "ml-auto transition-transform duration-200 group-data-[collapsible=icon]:hidden group-data-[state=open]/collapsible:rotate-90" })] }) }), _jsx(CollapsibleContent, { children: _jsx(SidebarMenuSub, { children: item.items.map((sub) => (_jsx(SidebarMenuSubItem, { children: _jsx(SidebarMenuSubButton, { asChild: true, isActive: active(sub), children: _jsxs(Link, { to: sub.to, children: [_jsx(sub.icon, {}), _jsx("span", { children: sub.title })] }) }) }, sub.to))) }) })] }) }, item.to)) : (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.title, isActive: active(item), children: _jsxs(Link, { to: item.to, children: [_jsx(item.icon, {}), _jsx("span", { children: item.title })] }) }), item.badge !== undefined && item.badge !== null && (_jsx(SidebarMenuBadge, { className: typeof item.badge === 'number' ? 'num' : undefined, children: item.badge }))] }, item.to))) }) }) }));
|
|
36
|
+
}
|
|
37
|
+
const SECTION_BADGE = 'num group-data-[collapsible=icon]:hidden';
|
|
38
|
+
/** One section item: a link, its badge, and the chevron and nested items when it has any. */
|
|
39
|
+
function SectionRow({ item, active, onRemove, removeLabel, }) {
|
|
40
|
+
const subs = item.items ?? [];
|
|
41
|
+
const subActive = subs.some((s) => active({ to: s.to, exact: true }));
|
|
42
|
+
const isActive = active({ to: item.to, exact: true }) && !subActive;
|
|
43
|
+
// The open item shows no trash icon.
|
|
44
|
+
const remove = item.fixed || isActive ? undefined : onRemove;
|
|
45
|
+
const row = (_jsxs(_Fragment, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.title, isActive: isActive, children: _jsxs(Link, { to: item.to, children: [_jsx(item.icon, {}), _jsx("span", { children: item.title })] }) }), item.badge !== undefined && item.badge !== null && (_jsx(SidebarMenuBadge, { className: cn(SECTION_BADGE, subs.length > 0 && 'right-7',
|
|
46
|
+
// The remove icon shows on hover from md up, and always below it: make room there.
|
|
47
|
+
remove &&
|
|
48
|
+
'max-md:right-7 md:group-focus-within/menu-item:opacity-0 md:group-hover/menu-item:opacity-0'), children: item.badge })), remove && (_jsx(LumenConfirm, { title: `${removeLabel(item)}?`, action: "Remove", onConfirm: () => remove(item), children: _jsx(SidebarMenuAction, { showOnHover: true, "aria-label": removeLabel(item), title: removeLabel(item),
|
|
49
|
+
// Muted while the row is hovered, red while the icon itself is.
|
|
50
|
+
className: "text-muted-foreground peer-hover/menu-button:text-muted-foreground hover:bg-transparent hover:text-destructive", children: _jsx(Trash2Icon, {}) }) }))] }));
|
|
51
|
+
if (!subs.length)
|
|
52
|
+
return _jsx(SidebarMenuItem, { children: row });
|
|
53
|
+
return (_jsx(Collapsible, { asChild: true, defaultOpen: item.defaultOpen || subActive || active(item), className: "group/collapsible", children: _jsxs(SidebarMenuItem, { children: [row, _jsx(CollapsibleTrigger, { asChild: true, children: _jsx(SidebarMenuAction, { "aria-label": `Show or hide ${item.title}`, className: "data-[state=open]:rotate-90", children: _jsx(ChevronRightIcon, {}) }) }), _jsx(CollapsibleContent, { children: _jsx(SidebarMenuSub, { children: subs.map((sub) => (_jsx(SidebarMenuSubItem, { children: _jsx(SidebarMenuSubButton, { asChild: true, isActive: active({ to: sub.to, exact: true }), children: _jsxs(Link, { to: sub.to, children: [sub.color && (_jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-full", style: { background: sub.color } })), _jsx("span", { children: sub.title })] }) }) }, sub.id))) }) })] }) }));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A section of things a person keeps (views, trades, a watchlist): `+` adds one, the
|
|
57
|
+
* trash icon on hover removes one. An item may carry a badge and one nested level of links with
|
|
58
|
+
* colour dots. The app supplies the items and both handlers.
|
|
59
|
+
*/
|
|
60
|
+
export function SidebarSection({ label, items, onAdd, addLabel = `Add to ${label}`, onRemove, removeLabel = (item) => `Remove ${item.title}`, loading, empty, children, pageType, assistantSubType, }) {
|
|
61
|
+
useAssistantPages(items.flatMap((i) => [
|
|
62
|
+
i,
|
|
63
|
+
...(i.items ?? []).map((sub) => ({
|
|
64
|
+
...sub,
|
|
65
|
+
parent: i.title,
|
|
66
|
+
type: assistantSubType ?? 'page',
|
|
67
|
+
})),
|
|
68
|
+
]), label, pageType);
|
|
69
|
+
const active = useActive();
|
|
70
|
+
return (_jsx(FoldGroup, { label: label, action: onAdd && (_jsx(SidebarGroupAction, { title: addLabel, "aria-label": addLabel, onClick: onAdd, children: _jsx(PlusIcon, {}) })), children: _jsxs(SidebarGroupContent, { children: [_jsxs(SidebarMenu, { children: [loading
|
|
71
|
+
? [0, 1].map((i) => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuSkeleton, { showIcon: true }) }, i)))
|
|
72
|
+
: items.map((item) => (_jsx(SectionRow, { item: item, active: active, onRemove: onRemove, removeLabel: removeLabel }, item.id))), !loading && !items.length && empty && (_jsx("li", { className: "px-2 py-1 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden", children: empty }))] }), children] }) }));
|
|
73
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
export interface NoteLineProps {
|
|
4
|
+
icon: LucideIcon;
|
|
5
|
+
/** The point, in a few words. */
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
/** The detail, one line. */
|
|
8
|
+
note?: ReactNode;
|
|
9
|
+
/** A button the note carries. */
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
/** One muted line over a card or page: an icon, what it says, and an optional button. */
|
|
13
|
+
export declare function NoteLine({ icon: Icon, title, note, action }: NoteLineProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** One muted line over a card or page: an icon, what it says, and an optional button. */
|
|
3
|
+
export function NoteLine({ icon: Icon, title, note, action }) {
|
|
4
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-muted px-3 py-2 text-xs", children: [_jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "font-medium", children: title }), note && _jsxs("span", { className: "text-muted-foreground", children: [" ", note] })] }), action] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The Settings ▸ Notifications section's body. */
|
|
2
|
+
export declare function NotificationsSettings({ api: base, product, sender, }: {
|
|
3
|
+
/** The app's Notifications API base, e.g. `/api/notifications`. */
|
|
4
|
+
api: string;
|
|
5
|
+
/** The product's name in the copy ("Get Acme alerts by email"). */
|
|
6
|
+
product: string;
|
|
7
|
+
/** The address email comes from. */
|
|
8
|
+
sender: string;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { AlertTriangleIcon, BellIcon, CheckIcon, CopyIcon, MailIcon, PlusIcon, RefreshCwIcon, Trash2Icon, WebhookIcon, XIcon, } from 'lucide-react';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
// Settings ▸ Notifications: where the product tells the person something
|
|
6
|
+
// happened: email to the sign-in address, ntfy (a private ntfy.sh topic per person, sent with their
|
|
7
|
+
// own paid ntfy.sh token) and webhooks, then which event goes to which target. The app passes its
|
|
8
|
+
// API base (`/api/notifications`), its product's name and the address email comes from.
|
|
9
|
+
import { LumenConfirm } from './confirm.js';
|
|
10
|
+
import { ErrorLine, IntegrationRow, KeyTestLine, TestBadge, } from './integrations.js';
|
|
11
|
+
import { IconButton } from './page.js';
|
|
12
|
+
import { Badge } from './ui/badge.js';
|
|
13
|
+
import { Button } from './ui/button.js';
|
|
14
|
+
import { Checkbox } from './ui/checkbox.js';
|
|
15
|
+
import { Input } from './ui/input.js';
|
|
16
|
+
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './ui/input-group.js';
|
|
17
|
+
import { Label } from './ui/label.js';
|
|
18
|
+
const NTFY_IOS = 'https://apps.apple.com/us/app/ntfy/id1625396347';
|
|
19
|
+
const NTFY_ANDROID = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
|
|
20
|
+
async function call(url, method = 'GET', body) {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
method,
|
|
23
|
+
headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
|
24
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
const data = (await res.json().catch(() => ({})));
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
throw new Error(data.error ?? `${res.status}`);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
/** The section's data and its writes; every write answers with the new state. */
|
|
32
|
+
function useNotifications(api, product, sender) {
|
|
33
|
+
const qc = useQueryClient();
|
|
34
|
+
const key = ['notifications', api];
|
|
35
|
+
const q = useQuery({ queryKey: key, queryFn: () => call(api) });
|
|
36
|
+
const set = (data) => qc.setQueryData(key, data);
|
|
37
|
+
const reload = () => qc.invalidateQueries({ queryKey: key });
|
|
38
|
+
const send = (method, path, body) => call(`${api}${path}`, method, body ?? (method === 'DELETE' ? undefined : {}));
|
|
39
|
+
return { q, set, reload, send, product, sender };
|
|
40
|
+
}
|
|
41
|
+
function Field({ id, label, children }) {
|
|
42
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-1.5", children: [_jsx(Label, { htmlFor: id, className: "text-xs text-muted-foreground", children: label }), children] }));
|
|
43
|
+
}
|
|
44
|
+
function Section({ title, children }) {
|
|
45
|
+
return (_jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "text-xs font-semibold tracking-wide text-muted-foreground uppercase", children: title }), children] }));
|
|
46
|
+
}
|
|
47
|
+
function CopyField({ id, value }) {
|
|
48
|
+
const [copied, setCopied] = useState(false);
|
|
49
|
+
return (_jsxs(InputGroup, { children: [_jsx(InputGroupInput, { id: id, readOnly: true, className: "num", value: value, onFocus: (e) => e.currentTarget.select() }), _jsx(InputGroupAddon, { align: "inline-end", children: _jsxs(InputGroupButton, { size: "xs", "aria-label": "Copy topic", onClick: () => {
|
|
50
|
+
void navigator.clipboard.writeText(value);
|
|
51
|
+
setCopied(true);
|
|
52
|
+
setTimeout(() => setCopied(false), 1500);
|
|
53
|
+
}, children: [copied ? _jsx(CheckIcon, {}) : _jsx(CopyIcon, {}), copied ? 'Copied' : 'Copy'] }) })] }));
|
|
54
|
+
}
|
|
55
|
+
/** A test that answers refreshes the section. */
|
|
56
|
+
function TestLine({ test, run, api }) {
|
|
57
|
+
return _jsx(KeyTestLine, { test: test, run: () => api.send('POST', run).then(api.set) });
|
|
58
|
+
}
|
|
59
|
+
// ── Email ──
|
|
60
|
+
function Email({ status, api }) {
|
|
61
|
+
const create = useMutation({ mutationFn: () => api.send('POST', '/email'), onSuccess: api.set });
|
|
62
|
+
const remove = useMutation({
|
|
63
|
+
mutationFn: () => api.send('DELETE', '/email'),
|
|
64
|
+
onSuccess: () => void api.reload(),
|
|
65
|
+
});
|
|
66
|
+
if (!status.connected)
|
|
67
|
+
return status.available ? (_jsxs("div", { className: "grid grid-cols-1 gap-3", children: [_jsxs("p", { className: "text-sm", children: ["Get ", api.product, " alerts by email at the address you sign in with. Nothing to set up."] }), _jsx(ErrorLine, { error: create.error }), _jsx(IconButton, { icon: create.isPending ? RefreshCwIcon : MailIcon, label: "Turn on email", variant: "outline", className: "justify-self-start", disabled: create.isPending, busy: create.isPending, onClick: () => create.mutate() })] })) : (_jsx("p", { className: "text-sm", children: "Email is not set up on this server." }));
|
|
68
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-5", children: [_jsx(Section, { title: "Sends to", children: _jsxs("p", { className: "text-sm", children: [status.address, ", from ", api.sender] }) }), _jsx(Section, { title: "Test", children: _jsx(TestLine, { test: status.last_test, run: "/email/test", api: api }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(ErrorLine, { error: remove.error }), _jsx("span", { className: "flex-1" }), _jsx(LumenConfirm, { title: "Turn off email notifications?", description: `${api.product} stops emailing you. You can turn it on again any time.`, action: "Turn off", onConfirm: () => remove.mutate(), children: _jsx(IconButton, { icon: Trash2Icon, label: "Turn off", variant: "destructive" }) })] })] }));
|
|
69
|
+
}
|
|
70
|
+
// ── ntfy ──
|
|
71
|
+
/**
|
|
72
|
+
* The person's ntfy.sh access token: ntfy.sh limits anonymous and free sends per
|
|
73
|
+
* address, and the product sends from Cloudflare's shared ones, so sends go out under their paid account.
|
|
74
|
+
*/
|
|
75
|
+
function NtfyToken({ token, api }) {
|
|
76
|
+
const [value, setValue] = useState('');
|
|
77
|
+
const save = useMutation({
|
|
78
|
+
mutationFn: () => api.send('PUT', '/ntfy/token', { token: value.trim() }),
|
|
79
|
+
onSuccess: (data) => {
|
|
80
|
+
api.set(data);
|
|
81
|
+
setValue('');
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return (_jsxs(Section, { title: "Access token", children: [_jsxs("p", { className: "text-sm", children: ["This needs a paid account at", ' ', _jsx("a", { className: "underline", href: "https://ntfy.sh/account", target: "_blank", rel: "noreferrer", children: "ntfy.sh" }), ' ', "(Supporter or above): ntfy.sh limits free accounts by sending address, and ", api.product, ' ', "sends from Cloudflare's shared addresses. Open Account \u25B8 Access tokens, create one and paste it here."] }), token ? (_jsxs("p", { className: "text-sm", children: ["Saved ", _jsx("span", { className: "num", children: token })] })) : null, _jsxs("form", { className: "flex flex-wrap items-end gap-2", onSubmit: (e) => {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
if (value.trim())
|
|
87
|
+
save.mutate();
|
|
88
|
+
}, children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(Field, { id: "ntfy-token", label: token ? 'Replace token' : 'Access token', children: _jsx(Input, { id: "ntfy-token", type: "password", className: "num", autoComplete: "off", placeholder: "tk_\u2026", value: value, onChange: (e) => setValue(e.target.value) }) }) }), _jsxs(Button, { type: "submit", size: "sm", disabled: !value.trim() || save.isPending, children: [save.isPending ? _jsx(RefreshCwIcon, { className: "animate-spin" }) : _jsx(CheckIcon, {}), "Save"] })] }), _jsx(ErrorLine, { error: save.error })] }));
|
|
89
|
+
}
|
|
90
|
+
function Ntfy({ status, api }) {
|
|
91
|
+
const create = useMutation({ mutationFn: () => api.send('POST', '/ntfy'), onSuccess: api.set });
|
|
92
|
+
const remove = useMutation({
|
|
93
|
+
mutationFn: () => api.send('DELETE', '/ntfy'),
|
|
94
|
+
onSuccess: () => void api.reload(),
|
|
95
|
+
});
|
|
96
|
+
if (!status.connected)
|
|
97
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-3", children: [_jsxs("p", { className: "text-sm", children: ["Get ", api.product, " alerts on your phone through the free ntfy app. ", api.product, " makes you a private topic (a random name only you know) and sends to it with the access token of your own paid ntfy.sh account."] }), _jsx(ErrorLine, { error: create.error }), _jsx(IconButton, { icon: create.isPending ? RefreshCwIcon : BellIcon, label: "Turn on ntfy", variant: "outline", className: "justify-self-start", disabled: create.isPending, busy: create.isPending, onClick: () => create.mutate() })] }));
|
|
98
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-5", children: [_jsxs(Section, { title: "Set up your phone", children: [_jsxs("ol", { className: "grid list-decimal grid-cols-1 gap-2 pl-5 text-sm", children: [_jsxs("li", { children: ["Install ntfy:", ' ', _jsx("a", { className: "underline", href: NTFY_IOS, target: "_blank", rel: "noreferrer", children: "iPhone" }), ' ', "or", ' ', _jsx("a", { className: "underline", href: NTFY_ANDROID, target: "_blank", rel: "noreferrer", children: "Android" }), "."] }), _jsx("li", { children: "Open it, tap + (Subscribe to topic) and paste your topic. Keep the server as ntfy.sh." }), _jsx("li", { children: "Allow notifications when your phone asks, add your access token below, then tap Test." })] }), _jsx(Field, { id: "ntfy-topic", label: "Your topic (keep it private: anyone with it can read it)", children: _jsx(CopyField, { id: "ntfy-topic", value: status.topic }) })] }), _jsx(NtfyToken, { token: status.token, api: api }), _jsx(Section, { title: "Test", children: _jsx(TestLine, { test: status.last_test, run: "/ntfy/test", api: api }) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(ErrorLine, { error: remove.error }), _jsx("span", { className: "flex-1" }), _jsx(LumenConfirm, { title: "Turn off ntfy?", description: `${api.product} stops sending and forgets your topic. Turning on again makes a new topic, which your phone must subscribe to again.`, action: "Turn off", onConfirm: () => remove.mutate(), children: _jsx(IconButton, { icon: Trash2Icon, label: "Turn off", variant: "destructive" }) })] })] }));
|
|
99
|
+
}
|
|
100
|
+
// ── Webhooks ──
|
|
101
|
+
function WebhookRow({ hook, api }) {
|
|
102
|
+
const remove = useMutation({
|
|
103
|
+
mutationFn: () => api.send('DELETE', `/webhooks/${encodeURIComponent(hook.id)}`),
|
|
104
|
+
onSuccess: () => void api.reload(),
|
|
105
|
+
});
|
|
106
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-2 border-b pb-3 last:border-b-0", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(WebhookIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "font-medium", children: hook.name }), _jsx("span", { className: "num min-w-0 flex-1 truncate text-xs text-muted-foreground", children: hook.url }), _jsx(LumenConfirm, { title: `Remove ${hook.name}?`, description: `${api.product} stops sending events to this address.`, action: "Remove", onConfirm: () => remove.mutate(), children: _jsx(IconButton, { icon: Trash2Icon, label: `Remove ${hook.name}`, variant: "destructive" }) })] }), hook.headers.length ? (_jsx("p", { className: "num text-xs text-muted-foreground", children: hook.headers.map((h) => `${h.name}: ${h.value}`).join(' · ') })) : null, _jsx(TestLine, { test: hook.last_test, run: `/webhooks/${encodeURIComponent(hook.id)}/test`, api: api }), _jsx(ErrorLine, { error: remove.error })] }));
|
|
107
|
+
}
|
|
108
|
+
function Webhooks({ hooks, api }) {
|
|
109
|
+
const [name, setName] = useState('');
|
|
110
|
+
const [url, setUrl] = useState('');
|
|
111
|
+
const [headers, setHeaders] = useState([]);
|
|
112
|
+
const setHeader = (i, part, v) => setHeaders((hs) => hs.map((h, j) => (j === i ? { ...h, [part]: v } : h)));
|
|
113
|
+
const filled = headers.filter((h) => h.name.trim() || h.value.trim());
|
|
114
|
+
const add = useMutation({
|
|
115
|
+
mutationFn: () => api.send('POST', '/webhooks', {
|
|
116
|
+
name: name.trim(),
|
|
117
|
+
url: url.trim(),
|
|
118
|
+
headers: filled.map((h) => ({ name: h.name.trim(), value: h.value.trim() })),
|
|
119
|
+
}),
|
|
120
|
+
onSuccess: (data) => {
|
|
121
|
+
api.set(data);
|
|
122
|
+
setName('');
|
|
123
|
+
setUrl('');
|
|
124
|
+
setHeaders([]);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-5", children: [_jsxs("p", { className: "text-sm", children: [api.product, " posts each event as JSON to your own addresses: Slack, Discord, Home Assistant, n8n or anything that takes a webhook."] }), hooks.length ? (_jsx(Section, { title: "Your webhooks", children: hooks.map((h) => (_jsx(WebhookRow, { hook: h, api: api }, h.id))) })) : null, _jsx(Section, { title: "Add a webhook", children: _jsxs("form", { className: "grid grid-cols-1 gap-3", onSubmit: (e) => {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
if (name.trim() && url.trim())
|
|
130
|
+
add.mutate();
|
|
131
|
+
}, children: [_jsx(Field, { id: "wh-name", label: "Name", children: _jsx(Input, { id: "wh-name", placeholder: "Discord", value: name, onChange: (e) => setName(e.target.value) }) }), _jsx(Field, { id: "wh-url", label: "Address (https)", children: _jsx(Input, { id: "wh-url", className: "num", autoComplete: "off", placeholder: "https://", value: url, onChange: (e) => setUrl(e.target.value) }) }), headers.length ? (_jsx(Field, { id: "wh-header-0", label: "Headers (sent with each post; hidden once saved)", children: _jsx("div", { className: "grid grid-cols-1 gap-2", children: headers.map((h, i) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { id: `wh-header-${i}`, className: "num w-2/5", autoComplete: "off", placeholder: "Authorization", "aria-label": "Header name", value: h.name, onChange: (e) => setHeader(i, 'name', e.target.value) }), _jsx(Input, { className: "num min-w-0 flex-1", type: "password", autoComplete: "off", placeholder: "Value", "aria-label": "Header value", value: h.value, onChange: (e) => setHeader(i, 'value', e.target.value) }), _jsx(IconButton, { type: "button", icon: XIcon, label: "Remove header", onClick: () => setHeaders((hs) => hs.filter((_, j) => j !== i)) })] }, i))) }) })) : null, _jsx(ErrorLine, { error: add.error }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [headers.length < 5 ? (_jsx(IconButton, { type: "button", icon: PlusIcon, label: "Add header", variant: "outline", onClick: () => setHeaders((hs) => [...hs, { name: '', value: '' }]) })) : null, _jsx("span", { className: "flex-1" }), _jsxs(Button, { type: "submit", size: "sm", disabled: !name.trim() || !url.trim() || add.isPending, children: [add.isPending ? _jsx(RefreshCwIcon, { className: "animate-spin" }) : _jsx(PlusIcon, {}), "Add"] })] })] }) }), _jsxs("p", { className: "text-xs text-muted-foreground", children: ["Each post is ", _jsx("span", { className: "num", children: '{ event, title, message, click, at }' }), ". Messages name no account, holding or amount. For a Cloudflare Access service token, add the headers CF-Access-Client-Id and CF-Access-Client-Secret."] })] }));
|
|
132
|
+
}
|
|
133
|
+
// ── Events: one row per event, one column per target ──
|
|
134
|
+
function EventCell({ event, target, name, api, }) {
|
|
135
|
+
const set = useMutation({
|
|
136
|
+
mutationFn: (on) => api.send('PUT', `/events/${encodeURIComponent(event.key)}`, { target, on }),
|
|
137
|
+
onSuccess: api.set,
|
|
138
|
+
});
|
|
139
|
+
const on = set.isPending ? Boolean(set.variables) : !event.off.includes(target);
|
|
140
|
+
return (_jsx("span", { className: "flex items-center justify-center", children: _jsx(Checkbox, { "aria-label": `${event.label} by ${name}`, checked: on, onCheckedChange: (v) => set.mutate(v === true) }) }));
|
|
141
|
+
}
|
|
142
|
+
function Events({ status, api }) {
|
|
143
|
+
const targets = [
|
|
144
|
+
...(status.email.connected ? [{ id: 'email', name: 'Email' }] : []),
|
|
145
|
+
...(status.ntfy.connected ? [{ id: 'ntfy', name: 'ntfy' }] : []),
|
|
146
|
+
...status.webhooks.map((h) => ({ id: h.id, name: h.name })),
|
|
147
|
+
];
|
|
148
|
+
if (!targets.length)
|
|
149
|
+
return (_jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing to send to yet: turn on email or ntfy, or add a webhook, above." }));
|
|
150
|
+
const grid = { gridTemplateColumns: `minmax(12rem, 2.4fr) repeat(${targets.length}, 5.5rem)` };
|
|
151
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-2", children: [_jsx("div", { className: "overflow-x-auto", children: _jsxs("div", { "aria-label": "Events", className: "grid min-w-max items-center", style: grid, children: [_jsxs("div", { className: "contents", children: [_jsx("span", { className: "pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase", children: "Event" }), targets.map((t) => (_jsx("span", { className: "truncate pb-1 text-center text-xs font-semibold tracking-wide text-muted-foreground uppercase", title: t.name, children: t.name }, t.id)))] }), status.events.map((e) => (_jsxs("div", { className: "contents", children: [_jsxs("span", { className: "grid gap-0.5 border-t py-2 pr-3", children: [_jsx("span", { className: "text-sm", children: e.label }), _jsx("span", { className: "text-xs text-muted-foreground", children: e.detail })] }), targets.map((t) => (_jsx("span", { className: "self-stretch border-t py-2", children: _jsx("span", { className: "flex h-full items-center justify-center", children: _jsx(EventCell, { event: e, target: t.id, name: t.name, api: api }) }) }, t.id)))] }, e.key)))] }) }), _jsx("p", { className: "text-xs text-muted-foreground", children: "A new target starts with every event on." })] }));
|
|
152
|
+
}
|
|
153
|
+
/** The Settings ▸ Notifications section's body. */
|
|
154
|
+
export function NotificationsSettings({ api: base, product, sender, }) {
|
|
155
|
+
const api = useNotifications(base, product, sender);
|
|
156
|
+
const [open, setOpen] = useState(null);
|
|
157
|
+
const toggle = (id) => (o) => setOpen(o ? id : null);
|
|
158
|
+
if (api.q.isPending)
|
|
159
|
+
return _jsx("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" });
|
|
160
|
+
if (api.q.isError)
|
|
161
|
+
return _jsx(ErrorLine, { error: api.q.error });
|
|
162
|
+
const s = api.q.data;
|
|
163
|
+
return (_jsxs("div", { className: "grid grid-cols-1 gap-6", children: [_jsxs("div", { className: "grid grid-cols-1", children: [_jsx(IntegrationRow, { name: "Email", meta: "To your sign-in address", open: open === 'email', onOpenChange: toggle('email'), badge: s.email.connected ? (_jsx(TestBadge, { test: s.email.last_test })) : (_jsx(Badge, { variant: "outline", children: "Off" })), children: _jsx(Email, { status: s.email, api: api }) }), _jsx(IntegrationRow, { name: "ntfy", meta: "Phone notifications through the ntfy app", open: open === 'ntfy', onOpenChange: toggle('ntfy'), badge: s.ntfy.connected ? (_jsx(TestBadge, { test: s.ntfy.last_test })) : (_jsx(Badge, { variant: "outline", children: "Off" })), children: _jsx(Ntfy, { status: s.ntfy, api: api }) }), _jsx(IntegrationRow, { name: "Webhooks", meta: s.webhooks.length
|
|
164
|
+
? s.webhooks.map((h) => h.name).join(' · ')
|
|
165
|
+
: 'Events posted to your own addresses', open: open === 'webhooks', onOpenChange: toggle('webhooks'), badge: s.webhooks.length ? (s.webhooks.some((h) => h.last_test?.ok === false) ? (_jsxs(Badge, { variant: "outline", className: "text-loss", children: [_jsx(AlertTriangleIcon, {}), "Failed"] })) : (_jsxs(Badge, { variant: "outline", children: [s.webhooks.length, " webhook", s.webhooks.length === 1 ? '' : 's'] }))) : (_jsx(Badge, { variant: "outline", children: "None" })), children: _jsx(Webhooks, { hooks: s.webhooks, api: api }) })] }), _jsxs("section", { className: "grid grid-cols-1 gap-2", children: [_jsx("h4", { className: "font-medium", children: "Events" }), _jsx(Events, { status: s, api: api })] })] }));
|
|
166
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ViewDomain, type ViewField } from './view-model.js';
|
|
2
|
+
interface Origin {
|
|
3
|
+
label: string;
|
|
4
|
+
color: string | null;
|
|
5
|
+
}
|
|
6
|
+
/** The origins of `columns` in first-seen order, one per colour and name. */
|
|
7
|
+
export declare function originsOf<T>(domain: ViewDomain<T>, columns: ViewField<T>[]): Origin[];
|
|
8
|
+
/** The legend: the origins when the columns come from two or more, and the freshness marks they
|
|
9
|
+
* carry (Live, Live calc, As of; marks). */
|
|
10
|
+
export declare function OriginLegend<T>({ domain, columns, }: {
|
|
11
|
+
domain: ViewDomain<T>;
|
|
12
|
+
columns: ViewField<T>[];
|
|
13
|
+
}): import("react").JSX.Element | null;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The status bar's legend of where a card's columns come from (Edits in
|
|
3
|
+
// place): one dot and name per origin the shown columns carry, in the header rules' colours:
|
|
4
|
+
// the data model's groups (Holding, Symbol › a broker, ...), the assistant · <frame>, Yours · <frame>
|
|
5
|
+
// and Formula; then the freshness marks the columns carry.
|
|
6
|
+
import { FRESHNESS, FreshnessIcon, marksOf } from './freshness.js';
|
|
7
|
+
import { FORMULAS_GROUP, groupOf } from './view-model.js';
|
|
8
|
+
/** The origins of `columns` in first-seen order, one per colour and name. */
|
|
9
|
+
export function originsOf(domain, columns) {
|
|
10
|
+
if (!domain.groups?.length)
|
|
11
|
+
return [];
|
|
12
|
+
const out = [];
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
for (const f of columns) {
|
|
15
|
+
const id = groupOf(domain, f);
|
|
16
|
+
const g = id === FORMULAS_GROUP ? undefined : domain.groups.find((x) => x.id === id);
|
|
17
|
+
const src = f.topic ? g?.sources?.[f.topic] : undefined;
|
|
18
|
+
const label = id === FORMULAS_GROUP ? 'Formula' : src ? `${g?.label} · ${src.name}` : (g?.label ?? id);
|
|
19
|
+
const color = id === FORMULAS_GROUP ? 'var(--color-muted-foreground)' : (src?.color ?? g?.color ?? null);
|
|
20
|
+
const k = `${label}\u0000${color}`;
|
|
21
|
+
if (seen.has(k))
|
|
22
|
+
continue;
|
|
23
|
+
seen.add(k);
|
|
24
|
+
out.push({ label, color });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** The legend: the origins when the columns come from two or more, and the freshness marks they
|
|
29
|
+
* carry (Live, Live calc, As of; marks). */
|
|
30
|
+
export function OriginLegend({ domain, columns, }) {
|
|
31
|
+
const origins = originsOf(domain, columns);
|
|
32
|
+
const marks = marksOf(domain, columns);
|
|
33
|
+
if (origins.length < 2 && !marks.length)
|
|
34
|
+
return null;
|
|
35
|
+
return (_jsxs("span", { className: "hidden items-center gap-2.5 sm:inline-flex", "aria-label": "Column origins", children: [origins.length >= 2
|
|
36
|
+
? origins.map((o) => (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx("span", { "aria-hidden": true, className: "inline-block h-0.5 w-3 rounded-full bg-current", style: { color: o.color ?? 'var(--color-border)' } }), o.label] }, `${o.label}${o.color}`)))
|
|
37
|
+
: null, marks.map((m) => (_jsxs("span", { className: "inline-flex items-center gap-1", title: FRESHNESS[m].about, children: [_jsx(FreshnessIcon, { kind: m }), FRESHNESS[m].label] }, m)))] }));
|
|
38
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { LumenLoad } from './lumen-source.js';
|
|
3
|
+
export type OverviewTone = 'ok' | 'warn' | 'bad' | 'idle';
|
|
4
|
+
interface WidgetBase {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** Small text on the right of the label (a count, an average). */
|
|
8
|
+
aside?: ReactNode;
|
|
9
|
+
/** One line under the widget. */
|
|
10
|
+
caption?: ReactNode;
|
|
11
|
+
/** `warn` or `bad` outlines the widget. */
|
|
12
|
+
tone?: OverviewTone;
|
|
13
|
+
}
|
|
14
|
+
export type OverviewWidget = WidgetBase & ({
|
|
15
|
+
kind: 'stat';
|
|
16
|
+
value?: ReactNode;
|
|
17
|
+
detail?: ReactNode;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'ring';
|
|
20
|
+
/** Each ring is a share (0 to 1) with the text shown in its middle. */
|
|
21
|
+
rings: {
|
|
22
|
+
label: string;
|
|
23
|
+
value: number;
|
|
24
|
+
text: string;
|
|
25
|
+
tone?: OverviewTone;
|
|
26
|
+
}[];
|
|
27
|
+
/** Beside the rings: a list (`OverviewList`), a line of text or a button. */
|
|
28
|
+
detail?: ReactNode;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'bars';
|
|
31
|
+
bars: {
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
value: number;
|
|
35
|
+
}[];
|
|
36
|
+
/** How a bar's value reads in its tooltip. */
|
|
37
|
+
format?: (v: number) => string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'timeline';
|
|
40
|
+
/** The window in epoch ms, and the busy blocks inside it. */
|
|
41
|
+
from: number;
|
|
42
|
+
to: number;
|
|
43
|
+
blocks: {
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
}[];
|
|
47
|
+
ticks: {
|
|
48
|
+
at: number;
|
|
49
|
+
label: string;
|
|
50
|
+
}[];
|
|
51
|
+
});
|
|
52
|
+
export interface OverviewStage {
|
|
53
|
+
id: string;
|
|
54
|
+
name: string;
|
|
55
|
+
tone: OverviewTone;
|
|
56
|
+
/** One or two words on the stage map chip ("fresh", "2 stale"). */
|
|
57
|
+
verdict: string;
|
|
58
|
+
widgets: OverviewWidget[];
|
|
59
|
+
/** This stage's own reads, when they differ from the page's (the runner's live health). */
|
|
60
|
+
load?: LumenLoad;
|
|
61
|
+
}
|
|
62
|
+
/** A stage's status mark: a dot, or ▲ when it needs a look. */
|
|
63
|
+
export declare function ToneDot({ tone, className }: {
|
|
64
|
+
tone: OverviewTone;
|
|
65
|
+
className?: string;
|
|
66
|
+
}): import("react").JSX.Element;
|
|
67
|
+
/** Label and value rows for a widget's detail (schedules and their last tick, latencies). */
|
|
68
|
+
export declare function OverviewList({ items, }: {
|
|
69
|
+
items: {
|
|
70
|
+
label: ReactNode;
|
|
71
|
+
value: ReactNode;
|
|
72
|
+
tone?: OverviewTone;
|
|
73
|
+
}[];
|
|
74
|
+
}): import("react").JSX.Element;
|
|
75
|
+
export interface OverviewPageProps {
|
|
76
|
+
stages: OverviewStage[];
|
|
77
|
+
/** The reads the stages are built from: every widget's ↻, skeleton, error and load time. */
|
|
78
|
+
load: LumenLoad;
|
|
79
|
+
/** Beside the title: when the numbers were read. */
|
|
80
|
+
updated?: ReactNode;
|
|
81
|
+
}
|
|
82
|
+
/** The Overview on LumenLayout: the stage map leads, each widget is a card. */
|
|
83
|
+
export declare function OverviewPage({ stages, load, updated }: OverviewPageProps): import("react").JSX.Element;
|
|
84
|
+
export {};
|