feat: FE solid-ui (shadcn port) — Tailwind + 14 components, all pages rewritten
This commit is contained in:
+11
-2
@@ -9,12 +9,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "1.9.15",
|
||||
"@kobalte/core": "^0.13.14",
|
||||
"@solidjs/router": "0.15.3",
|
||||
"autoprefixer": "^10.6.0",
|
||||
"chart.js": "4.4.7",
|
||||
"solid-chartjs": "1.3.11"
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"postcss": "^8.5.28",
|
||||
"solid-chartjs": "1.3.11",
|
||||
"solid-js": "1.9.15",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.5.1",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "6.4.3",
|
||||
"vite-plugin-solid": "2.11.6"
|
||||
|
||||
Generated
+798
-8
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,45 +1,61 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import { fmtIDR } from "../lib/api";
|
||||
import { Citations } from "./Citations";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
|
||||
|
||||
export function AccumulationTable(props: { rows: { ticker: string; net_sum: number; brokers: number }[] }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Top accumulation</h3>
|
||||
<table class="fs-table">
|
||||
<thead><tr><th>Ticker</th><th>Net</th><th>Brokers</th></tr></thead>
|
||||
<tbody>
|
||||
<For each={props.rows}>
|
||||
{(r) => <tr><td><a href={`/report/${r.ticker}`}>{r.ticker}</a></td><td>{fmtIDR(r.net_sum)}</td><td>{r.brokers}</td></tr>}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={!props.rows?.length}><p class="fs-muted">No accumulation snapshots yet.</p></Show>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top accumulation</CardTitle>
|
||||
<CardDescription>Net broker accumulation per ticker.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Ticker</TableHead><TableHead class="text-right">Net</TableHead><TableHead class="text-right">Brokers</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<For each={props.rows}>
|
||||
{(r) => <TableRow><TableCell><a class="font-semibold text-primary hover:underline" href={`/report/${r.ticker}`}>{r.ticker}</a></TableCell><TableCell class="text-right font-mono">{fmtIDR(r.net_sum)}</TableCell><TableCell class="text-right">{r.brokers}</TableCell></TableRow>}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Show when={!props.rows?.length}><p class="mt-3 text-sm text-muted-foreground">No accumulation snapshots yet.</p></Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function RotationMap(props: { signal?: string; citations?: Parameters<typeof Citations>[0]["items"] }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Sector rotation</h3>
|
||||
<p>{props.signal || "No rotation flip detected."}</p>
|
||||
<Citations items={props.citations} />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sector rotation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm">{props.signal || "No rotation flip detected."}</p>
|
||||
<Citations items={props.citations} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityFeed(props: { events: { id?: number; ticker?: string; message?: string; date?: string }[] }) {
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Activity</h3>
|
||||
<Show when={props.events?.length} fallback={<p class="fs-muted">No events yet.</p>}>
|
||||
<ul>
|
||||
<For each={props.events}>
|
||||
{(e) => <li><strong>{e.ticker}</strong> — {e.message} <span class="fs-muted">{e.date}</span></li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={props.events?.length} fallback={<p class="text-sm text-muted-foreground">No events yet.</p>}>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<For each={props.events}>
|
||||
{(e) => <li><Badge variant="secondary">{e.ticker}</Badge> {e.message} <span class="text-muted-foreground">{e.date}</span></li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { For } from "solid-js";
|
||||
import type { Citation } from "../lib/api";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export function Citations(props: { items?: Citation[] | string }) {
|
||||
const list = (): Citation[] => {
|
||||
@@ -10,12 +12,17 @@ export function Citations(props: { items?: Citation[] | string }) {
|
||||
return props.items;
|
||||
};
|
||||
return (
|
||||
<span>
|
||||
<span class="inline-flex flex-wrap gap-1">
|
||||
<For each={list()}>
|
||||
{(c) => (
|
||||
<span class={`fs-cite${c.stale ? " stale" : ""}`} title={`${c.endpoint} @ ${c.snapshot_at}${c.stale ? " (stale)" : ""}`}>
|
||||
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant={c.stale ? "secondary" : "outline"} class={c.stale ? "opacity-70" : ""}>
|
||||
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.endpoint} @ {c.snapshot_at}{c.stale ? " (stale)" : ""}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { TextField, TextFieldInput } from "./ui/text-field";
|
||||
|
||||
// WatchlistDrawer: global watchlist add/remove backed by /api/watchlist.
|
||||
export function WatchlistDrawer() {
|
||||
const [wl, { refetch }] = createResource(() => api.watchlist());
|
||||
const [ticker, setTicker] = createSignal("");
|
||||
@@ -19,17 +22,22 @@ export function WatchlistDrawer() {
|
||||
refetch();
|
||||
}
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>Watchlist</h3>
|
||||
<input class="fs-input" placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} style={{ width: "90px" }} />
|
||||
<button class="fs-btn" onClick={add}>Add</button>
|
||||
<Show when={err()}><p class="fs-muted">{err()}</p></Show>
|
||||
<ul><For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}>{t}</a> <button class="fs-btn ghost" onClick={() => del(t)}>×</button></li>}</For></ul>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader class="pb-2"><CardTitle class="text-base">Watchlist</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<TextField class="w-24"><TextFieldInput placeholder="BBCA" value={ticker()} onInput={(e) => setTicker(e.currentTarget.value)} /></TextField>
|
||||
<Button size="sm" onClick={add}>Add</Button>
|
||||
</div>
|
||||
<Show when={err()}><p class="text-xs text-destructive">{err()}</p></Show>
|
||||
<ul class="flex flex-wrap gap-1.5">
|
||||
<For each={wl()?.watchlist || []}>{(t) => <li><a href={`/report/${t}`}><Badge variant="secondary">{t}</Badge></a> <button class="text-xs text-muted-foreground hover:text-foreground" onClick={() => del(t)}>×</button></li>}</For>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ChatSidebar: context-aware Q&A backed by POST /api/chat.
|
||||
export function ChatSidebar() {
|
||||
const [msg, setMsg] = createSignal("");
|
||||
const [log, setLog] = createSignal<{ q: string; a: string }[]>([]);
|
||||
@@ -45,13 +53,19 @@ export function ChatSidebar() {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>AI Chat</h3>
|
||||
<For each={log()}>
|
||||
{(m) => <div><p><strong>You:</strong> {m.q}</p><p class="fs-muted">{m.a.slice(0, 500)}</p></div>}
|
||||
</For>
|
||||
<input class="fs-input" placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} style={{ width: "220px" }} />
|
||||
<button class="fs-btn" onClick={send}>Send</button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader class="pb-2"><CardTitle class="text-base">AI Chat</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<For each={log()}>
|
||||
{(m) => <div class="text-sm"><p><strong>You:</strong> {m.q}</p><p class="text-muted-foreground">{m.a.slice(0, 500)}</p></div>}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<TextField class="flex-1"><TextFieldInput placeholder="Ask about your watchlist…" value={msg()} onInput={(e) => setMsg(e.currentTarget.value)} onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") send(); }} /></TextField>
|
||||
<Button size="sm" onClick={send}>Send</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-11
@@ -1,14 +1,15 @@
|
||||
import { createResource, createSignal, Show } from "solid-js";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
import { Button } from "./ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
|
||||
// AuthState: loads /api/auth/me once; exposes login/logout + theme toggle.
|
||||
// Theme: .dark class on <html> (shadcn convention). Default dark.
|
||||
function theme(): string {
|
||||
return localStorage.getItem("fs-theme") ||
|
||||
(matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
|
||||
return localStorage.getItem("fs-theme") || "dark";
|
||||
}
|
||||
|
||||
export function applyTheme(t: string) {
|
||||
document.documentElement.dataset.theme = t;
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
localStorage.setItem("fs-theme", t);
|
||||
}
|
||||
applyTheme(theme());
|
||||
@@ -16,12 +17,12 @@ applyTheme(theme());
|
||||
export function ThemeToggle() {
|
||||
const [t, setT] = createSignal(theme());
|
||||
return (
|
||||
<button class="fs-iconbtn" title="Toggle light/dark" onClick={() => {
|
||||
<Button variant="ghost" size="icon" title="Toggle light/dark" onClick={() => {
|
||||
const n = t() === "dark" ? "light" : "dark";
|
||||
setT(n); applyTheme(n);
|
||||
}}>
|
||||
{t() === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,13 +36,16 @@ export function useAuth() {
|
||||
export function AuthButton(props: { me: AuthUser | null | undefined; onLogout: () => void }) {
|
||||
return (
|
||||
<Show when={props.me} fallback={
|
||||
<a class="fs-btn" href="/api/auth/start">Sign in with Google</a>
|
||||
<a href="/api/auth/start"><Button>Sign in with Google</Button></a>
|
||||
}>
|
||||
{(u) => (
|
||||
<span class="fs-user">
|
||||
{u().avatar_url ? <img class="fs-avatar" src={u().avatar_url} alt="" /> : null}
|
||||
{u().name || u().email}
|
||||
<button class="fs-iconbtn" onClick={props.onLogout}>Logout</button>
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar class="size-8">
|
||||
{u().avatar_url ? <AvatarImage src={u().avatar_url} alt="" /> : null}
|
||||
<AvatarFallback>{(u().name || u().email || "?").slice(0, 1).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="hidden text-sm font-medium md:inline">{u().name || u().email}</span>
|
||||
<Button variant="ghost" size="sm" onClick={props.onLogout}>Logout</Button>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
+72
-50
@@ -1,66 +1,88 @@
|
||||
import { For, Show, type JSX } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import type { Citation } from "../lib/api";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// Shared building blocks: StatCard, EmptyState, Badge, SignalBar, Skeleton.
|
||||
|
||||
export function StatCard(props: { title: string; value: string; sub?: string; children?: JSX.Element }) {
|
||||
export function Citations(props: { items?: Citation[] | string }) {
|
||||
const list = (): Citation[] => {
|
||||
if (!props.items) return [];
|
||||
if (typeof props.items === "string") {
|
||||
try { return JSON.parse(props.items); } catch { return []; }
|
||||
}
|
||||
return props.items;
|
||||
};
|
||||
return (
|
||||
<div class="fs-card">
|
||||
<h3>{props.title}</h3>
|
||||
<p class="fs-stat">{props.value}</p>
|
||||
<Show when={props.sub}><p class="fs-muted" style={{ margin: 0 }}>{props.sub}</p></Show>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState(props: { icon: string; title: string; hint?: string; action?: JSX.Element }) {
|
||||
return (
|
||||
<div class="fs-card fs-empty">
|
||||
<div class="big">{props.icon}</div>
|
||||
<strong>{props.title}</strong>
|
||||
<Show when={props.hint}><p class="fs-muted">{props.hint}</p></Show>
|
||||
<Show when={props.action}><div style={{ "margin-top": "10px" }}>{props.action}</div></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton(props: { rows?: number }) {
|
||||
const n = props.rows ?? 3;
|
||||
return (
|
||||
<div class="fs-stack">
|
||||
<For each={Array.from({ length: n })}>{() => <div class="fs-sk" style={{ height: "56px" }} />}</For>
|
||||
</div>
|
||||
<span class="inline-flex flex-wrap gap-1">
|
||||
<For each={list()}>
|
||||
{(c) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Badge variant={c.stale ? "secondary" : "outline"} class={c.stale ? "opacity-70" : ""}>
|
||||
{c.endpoint} {c.ticker || ""} @ {(c.snapshot_at || "").slice(0, 10)}{c.stale ? " · stale" : ""}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.endpoint} @ {c.snapshot_at}{c.stale ? " (stale)" : ""}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecBadge(props: { rec: string }) {
|
||||
const cls = props.rec === "BUY" ? "buy" : props.rec === "HOLD" ? "hold" : "avoid";
|
||||
return <span class={`fs-badge ${cls}`}>{props.rec}</span>;
|
||||
const v = () =>
|
||||
/buy/i.test(props.rec) ? { variant: "success" as const, icon: "▲" }
|
||||
: /avoid|sell/i.test(props.rec) ? { variant: "error" as const, icon: "▼" }
|
||||
: { variant: "warning" as const, icon: "●" };
|
||||
return <Badge variant={v().variant}>{v().icon} {props.rec}</Badge>;
|
||||
}
|
||||
|
||||
// SignalBar renders one -100..+100 signal as a labeled bar.
|
||||
export function SignalBar(props: { label: string; value: number }) {
|
||||
const v = Math.max(-100, Math.min(100, props.value));
|
||||
const left = v < 0 ? `${50 + v / 2}%` : "50%";
|
||||
const width = `${Math.abs(v) / 2}%`;
|
||||
const cls = v < 0 ? "neg" : v > 0 ? "pos" : "";
|
||||
export function BreakdownBars(props: { breakdown: Record<string, unknown> }) {
|
||||
const entries = () => Object.entries(props.breakdown)
|
||||
.filter(([, v]) => typeof v === "number")
|
||||
.sort((a, b) => Math.abs(b[1] as number) - Math.abs(a[1] as number))
|
||||
.slice(0, 6);
|
||||
return (
|
||||
<div class="fs-sig">
|
||||
<span class="fs-muted">{props.label}</span>
|
||||
<span class="bar"><i class={cls} style={{ left, width }} /></span>
|
||||
<b>{v}</b>
|
||||
<div class="space-y-1.5">
|
||||
<For each={entries()}>
|
||||
{([k, v]) => {
|
||||
const n = v as number;
|
||||
const pct = Math.min(100, Math.abs(n));
|
||||
return (
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="w-24 shrink-0 truncate text-muted-foreground">{k}</span>
|
||||
<div class="h-2 flex-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
classList={{ "bg-success": n >= 0, "bg-error": n < 0 }}
|
||||
style={{ width: `${pct}%`, "margin-left": n < 0 ? "auto" : undefined }}
|
||||
/>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right font-mono">{n.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// BreakdownBars renders a screen/report signal breakdown map as bars.
|
||||
// Unknown numeric fields are shown; booleans/strings are skipped except flags.
|
||||
export function BreakdownBars(props: { breakdown: Record<string, unknown> }) {
|
||||
const entries = () =>
|
||||
Object.entries(props.breakdown || {}).filter(([, v]) => typeof v === "number") as [string, number][];
|
||||
export function PageHead(props: { title: string; sub?: string }) {
|
||||
return (
|
||||
<Show when={entries().length} fallback={<span class="fs-muted">no signals</span>}>
|
||||
<For each={entries()}>{([k, v]) => <SignalBar label={k.replace(/_/g, " ")} value={v} />}</For>
|
||||
</Show>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">{props.title}</h1>
|
||||
{props.sub ? <p class="text-sm text-muted-foreground">{props.sub}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState(props: { icon: string; title: string; hint?: string; action?: import("solid-js").JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-10 text-center">
|
||||
<div class="text-3xl">{props.icon}</div>
|
||||
<p class="font-medium">{props.title}</p>
|
||||
{props.hint ? <p class="text-sm text-muted-foreground">{props.hint}</p> : null}
|
||||
{props.action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as ImagePrimitive from "@kobalte/core/image"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
type AvatarRootProps<T extends ValidComponent = "span"> = ImagePrimitive.ImageRootProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const Avatar = <T extends ValidComponent = "span">(
|
||||
props: PolymorphicProps<T, AvatarRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as AvatarRootProps, ["class"])
|
||||
return (
|
||||
<ImagePrimitive.Root
|
||||
class={cn("relative flex size-10 shrink-0 overflow-hidden rounded-full", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type AvatarImageProps<T extends ValidComponent = "img"> = ImagePrimitive.ImageImgProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const AvatarImage = <T extends ValidComponent = "img">(
|
||||
props: PolymorphicProps<T, AvatarImageProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as AvatarImageProps, ["class"])
|
||||
return <ImagePrimitive.Img class={cn("aspect-square size-full", local.class)} {...others} />
|
||||
}
|
||||
|
||||
type AvatarFallbackProps<T extends ValidComponent = "span"> =
|
||||
ImagePrimitive.ImageFallbackProps<T> & { class?: string | undefined }
|
||||
|
||||
const AvatarFallback = <T extends ValidComponent = "span">(
|
||||
props: PolymorphicProps<T, AvatarFallbackProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as AvatarFallbackProps, ["class"])
|
||||
return (
|
||||
<ImagePrimitive.Fallback
|
||||
class={cn("flex size-full items-center justify-center bg-muted", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "text-foreground",
|
||||
success: "border-success-foreground bg-success text-success-foreground",
|
||||
warning: "border-warning-foreground bg-warning text-warning-foreground",
|
||||
error: "border-error-foreground bg-error text-error-foreground"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type BadgeProps = ComponentProps<"div"> &
|
||||
VariantProps<typeof badgeVariants> & {
|
||||
round?: boolean
|
||||
}
|
||||
|
||||
const Badge: Component<BadgeProps> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "variant", "round"])
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
badgeVariants({ variant: local.variant }),
|
||||
local.round && "rounded-full",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { BadgeProps }
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as ButtonPrimitive from "@kobalte/core/button"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
lg: "h-11 px-8",
|
||||
icon: "size-10"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type ButtonProps<T extends ValidComponent = "button"> = ButtonPrimitive.ButtonRootProps<T> &
|
||||
VariantProps<typeof buttonVariants> & { class?: string | undefined; children?: JSX.Element }
|
||||
|
||||
const Button = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, ButtonProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"])
|
||||
return (
|
||||
<ButtonPrimitive.Root
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export type { ButtonProps }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Card: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn("rounded-lg border bg-card text-card-foreground shadow-sm", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CardHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("flex flex-col space-y-1.5 p-6", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardTitle: Component<ComponentProps<"h3">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<h3 class={cn("text-lg font-semibold leading-none tracking-tight", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const CardDescription: Component<ComponentProps<"p">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <p class={cn("text-sm text-muted-foreground", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("p-6 pt-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("flex items-center p-6 pt-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Label: Component<ComponentProps<"label">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<label
|
||||
class={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Component, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as ProgressPrimitive from "@kobalte/core/progress"
|
||||
|
||||
import { Label } from "./label"
|
||||
|
||||
type ProgressRootProps<T extends ValidComponent = "div"> =
|
||||
ProgressPrimitive.ProgressRootProps<T> & { children?: JSX.Element }
|
||||
|
||||
const Progress = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, ProgressRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ProgressRootProps, ["children"])
|
||||
return (
|
||||
<ProgressPrimitive.Root {...others}>
|
||||
{local.children}
|
||||
<ProgressPrimitive.Track class="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<ProgressPrimitive.Fill class="h-full w-[var(--kb-progress-fill-width)] flex-1 bg-primary transition-all" />
|
||||
</ProgressPrimitive.Track>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const ProgressLabel: Component<ProgressPrimitive.ProgressLabelProps> = (props) => {
|
||||
return <ProgressPrimitive.Label {...props} />
|
||||
}
|
||||
|
||||
const ProgressValueLabel: Component<ProgressPrimitive.ProgressValueLabelProps> = (props) => {
|
||||
return <ProgressPrimitive.ValueLabel as={Label} {...props} />
|
||||
}
|
||||
|
||||
export { Progress, ProgressLabel, ProgressValueLabel }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as SeparatorPrimitive from "@kobalte/core/separator"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
type SeparatorRootProps<T extends ValidComponent = "hr"> =
|
||||
SeparatorPrimitive.SeparatorRootProps<T> & { class?: string | undefined }
|
||||
|
||||
const Separator = <T extends ValidComponent = "hr">(
|
||||
props: PolymorphicProps<T, SeparatorRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SeparatorRootProps, ["class", "orientation"])
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
orientation={local.orientation ?? "horizontal"}
|
||||
class={cn(
|
||||
"shrink-0 bg-border",
|
||||
local.orientation === "vertical" ? "h-full w-px" : "h-px w-full",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as SkeletonPrimitive from "@kobalte/core/skeleton"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
type SkeletonRootProps<T extends ValidComponent = "div"> =
|
||||
SkeletonPrimitive.SkeletonRootProps<T> & { class?: string | undefined }
|
||||
|
||||
const Skeleton = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, SkeletonRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SkeletonRootProps, ["class"])
|
||||
return (
|
||||
<SkeletonPrimitive.Root
|
||||
class={cn("bg-primary/10 data-[animate='true']:animate-pulse", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core"
|
||||
import * as SwitchPrimitive from "@kobalte/core/switch"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Switch = SwitchPrimitive.Root
|
||||
const SwitchDescription = SwitchPrimitive.Description
|
||||
const SwitchErrorMessage = SwitchPrimitive.ErrorMessage
|
||||
|
||||
type SwitchControlProps = SwitchPrimitive.SwitchControlProps & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const SwitchControl = <T extends ValidComponent = "input">(
|
||||
props: PolymorphicProps<T, SwitchControlProps>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SwitchControlProps, ["class", "children"])
|
||||
return (
|
||||
<>
|
||||
<SwitchPrimitive.Input
|
||||
class={cn(
|
||||
"[&:focus-visible+div]:outline-none [&:focus-visible+div]:ring-2 [&:focus-visible+div]:ring-ring [&:focus-visible+div]:ring-offset-2 [&:focus-visible+div]:ring-offset-background",
|
||||
local.class
|
||||
)}
|
||||
/>
|
||||
<SwitchPrimitive.Control
|
||||
class={cn(
|
||||
"inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-[color,background-color,box-shadow] data-[disabled]:cursor-not-allowed data-[checked]:bg-primary data-[disabled]:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</SwitchPrimitive.Control>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type SwitchThumbProps = SwitchPrimitive.SwitchThumbProps & { class?: string | undefined }
|
||||
|
||||
const SwitchThumb = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, SwitchThumbProps>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SwitchThumbProps, ["class"])
|
||||
return (
|
||||
<SwitchPrimitive.Thumb
|
||||
class={cn(
|
||||
"pointer-events-none block size-5 translate-x-0 rounded-full bg-background shadow-lg ring-0 transition-transform data-[checked]:translate-x-5",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SwitchLabelProps = SwitchPrimitive.SwitchLabelProps & { class?: string | undefined }
|
||||
|
||||
const SwitchLabel = <T extends ValidComponent = "label">(
|
||||
props: PolymorphicProps<T, SwitchLabelProps>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SwitchLabelProps, ["class"])
|
||||
return (
|
||||
<SwitchPrimitive.Label
|
||||
class={cn(
|
||||
"text-sm font-medium leading-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch, SwitchControl, SwitchThumb, SwitchLabel, SwitchDescription, SwitchErrorMessage }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Table: Component<ComponentProps<"table">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table class={cn("w-full caption-bottom text-sm", local.class)} {...others} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TableHeader: Component<ComponentProps<"thead">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <thead class={cn("[&_tr]:border-b", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const TableBody: Component<ComponentProps<"tbody">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <tbody class={cn("[&_tr:last-child]:border-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const TableFooter: Component<ComponentProps<"tfoot">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<tfoot class={cn("bg-primary font-medium text-primary-foreground", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const TableRow: Component<ComponentProps<"tr">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<tr
|
||||
class={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TableHead: Component<ComponentProps<"th">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<th
|
||||
class={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TableCell: Component<ComponentProps<"td">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<td class={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const TableCaption: Component<ComponentProps<"caption">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <caption class={cn("mt-4 text-sm text-muted-foreground", local.class)} {...others} />
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as TabsPrimitive from "@kobalte/core/tabs"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
type TabsListProps<T extends ValidComponent = "div"> = TabsPrimitive.TabsListProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TabsList = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TabsListProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TabsListProps, ["class"])
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
class={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsTriggerProps<T extends ValidComponent = "button"> = TabsPrimitive.TabsTriggerProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TabsTrigger = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, TabsTriggerProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TabsTriggerProps, ["class"])
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
class={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[selected]:bg-background data-[selected]:text-foreground data-[selected]:shadow-sm",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsContentProps<T extends ValidComponent = "div"> = TabsPrimitive.TabsContentProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TabsContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TabsContentProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TabsContentProps, ["class"])
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
class={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsIndicatorProps<T extends ValidComponent = "div"> = TabsPrimitive.TabsIndicatorProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TabsIndicator = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TabsIndicatorProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TabsIndicatorProps, ["class"])
|
||||
return (
|
||||
<TabsPrimitive.Indicator
|
||||
class={cn(
|
||||
"duration-250ms absolute transition-all data-[orientation=horizontal]:-bottom-px data-[orientation=vertical]:-right-px data-[orientation=horizontal]:h-[2px] data-[orientation=vertical]:w-[2px]",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, TabsIndicator }
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { mergeProps, splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core"
|
||||
import * as TextFieldPrimitive from "@kobalte/core/text-field"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
type TextFieldRootProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldRootProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextField = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldRootProps, ["class"])
|
||||
return <TextFieldPrimitive.Root class={cn("flex flex-col gap-1", local.class)} {...others} />
|
||||
}
|
||||
|
||||
type TextFieldInputProps<T extends ValidComponent = "input"> =
|
||||
TextFieldPrimitive.TextFieldInputProps<T> & {
|
||||
class?: string | undefined
|
||||
type?:
|
||||
| "button"
|
||||
| "checkbox"
|
||||
| "color"
|
||||
| "date"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "file"
|
||||
| "hidden"
|
||||
| "image"
|
||||
| "month"
|
||||
| "number"
|
||||
| "password"
|
||||
| "radio"
|
||||
| "range"
|
||||
| "reset"
|
||||
| "search"
|
||||
| "submit"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "url"
|
||||
| "week"
|
||||
}
|
||||
|
||||
const TextFieldInput = <T extends ValidComponent = "input">(
|
||||
rawProps: PolymorphicProps<T, TextFieldInputProps<T>>
|
||||
) => {
|
||||
const props = mergeProps<TextFieldInputProps<T>[]>({ type: "text" }, rawProps)
|
||||
const [local, others] = splitProps(props as TextFieldInputProps, ["type", "class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Input
|
||||
type={local.type}
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-error-foreground data-[invalid]:text-error-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextFieldTextAreaProps<T extends ValidComponent = "textarea"> =
|
||||
TextFieldPrimitive.TextFieldTextAreaProps<T> & { class?: string | undefined }
|
||||
|
||||
const TextFieldTextArea = <T extends ValidComponent = "textarea">(
|
||||
props: PolymorphicProps<T, TextFieldTextAreaProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldTextAreaProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.TextArea
|
||||
class={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
label: "data-[invalid]:text-destructive",
|
||||
description: "font-normal text-muted-foreground",
|
||||
error: "text-xs text-destructive"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "label"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type TextFieldLabelProps<T extends ValidComponent = "label"> =
|
||||
TextFieldPrimitive.TextFieldLabelProps<T> & { class?: string | undefined }
|
||||
|
||||
const TextFieldLabel = <T extends ValidComponent = "label">(
|
||||
props: PolymorphicProps<T, TextFieldLabelProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldLabelProps, ["class"])
|
||||
return <TextFieldPrimitive.Label class={cn(labelVariants(), local.class)} {...others} />
|
||||
}
|
||||
|
||||
type TextFieldDescriptionProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldDescriptionProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextFieldDescription = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldDescriptionProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Description
|
||||
class={cn(labelVariants({ variant: "description" }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextFieldErrorMessageProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldErrorMessageProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextFieldErrorMessage = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldErrorMessageProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldErrorMessageProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.ErrorMessage
|
||||
class={cn(labelVariants({ variant: "error" }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
TextField,
|
||||
TextFieldInput,
|
||||
TextFieldTextArea,
|
||||
TextFieldLabel,
|
||||
TextFieldDescription,
|
||||
TextFieldErrorMessage
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps, type Component } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as TooltipPrimitive from "@kobalte/core/tooltip"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const Tooltip: Component<TooltipPrimitive.TooltipRootProps> = (props) => {
|
||||
return <TooltipPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
type TooltipContentProps<T extends ValidComponent = "div"> =
|
||||
TooltipPrimitive.TooltipContentProps<T> & { class?: string | undefined }
|
||||
|
||||
const TooltipContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TooltipContentProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TooltipContentProps, ["class"])
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
class={cn(
|
||||
"z-50 origin-[var(--kb-popover-content-transform-origin)] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent }
|
||||
|
||||
+63
-31
@@ -3,6 +3,9 @@ import { Router, Route, useLocation, useNavigate, useParams, type RouteSectionPr
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { WatchlistDrawer, ChatSidebar } from "./components/WatchlistChat";
|
||||
import { ThemeToggle, useAuth, AuthButton } from "./components/auth";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card";
|
||||
import { TextField, TextFieldInput } from "./components/ui/text-field";
|
||||
import { api } from "./lib/api";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Routines from "./pages/Routines";
|
||||
@@ -10,24 +13,47 @@ import Screener from "./pages/Screener";
|
||||
import Alerts from "./pages/Alerts";
|
||||
import Report from "./pages/Report";
|
||||
import Portfolio from "./pages/Portfolio";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/globals.css";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", icon: "📊", label: "Dashboard" },
|
||||
{ href: "/routines", icon: "🗓️", label: "Routines" },
|
||||
{ href: "/screener", icon: "🔍", label: "Screener" },
|
||||
{ href: "/alerts", icon: "🔔", label: "Alerts" },
|
||||
{ href: "/portfolio", icon: "💼", label: "Portfolio" },
|
||||
];
|
||||
|
||||
function Nav() {
|
||||
const loc = useLocation();
|
||||
const link = (href: string, icon: string, label: string) => (
|
||||
<a href={href} class={loc.pathname === href ? "active" : ""}><span>{icon}</span> {label}</a>
|
||||
);
|
||||
return (
|
||||
<nav class="fs-nav" id="fs-nav">
|
||||
<div class="fs-brand">Flow<span>Sight</span></div>
|
||||
{link("/", "📊", "Dashboard")}
|
||||
{link("/routines", "🗓️", "Routines")}
|
||||
{link("/screener", "🔍", "Screener")}
|
||||
{link("/alerts", "🔔", "Alerts")}
|
||||
{link("/portfolio", "💼", "Portfolio")}
|
||||
<div class="fs-nav-foot">
|
||||
<ThemeToggle />
|
||||
<nav class="flex w-56 shrink-0 flex-col gap-1 border-r bg-card p-4 max-lg:hidden">
|
||||
<div class="mb-4 px-2 text-xl font-bold tracking-tight">
|
||||
Flow<span class="text-primary">Sight</span>
|
||||
</div>
|
||||
{NAV.map((n) => (
|
||||
<a
|
||||
href={n.href}
|
||||
class={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${loc.pathname === n.href ? "bg-accent text-accent-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
<span>{n.icon}</span> {n.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNav() {
|
||||
const loc = useLocation();
|
||||
return (
|
||||
<nav class="fixed inset-x-0 bottom-0 z-40 flex border-t bg-card px-2 py-1 lg:hidden">
|
||||
{NAV.map((n) => (
|
||||
<a
|
||||
href={n.href}
|
||||
class={`flex flex-1 flex-col items-center rounded-md py-1.5 text-[11px] font-medium ${loc.pathname === n.href ? "text-primary" : "text-muted-foreground"}`}
|
||||
>
|
||||
<span class="text-base">{n.icon}</span> {n.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -40,10 +66,12 @@ function TickerSearch() {
|
||||
if (t) nav(`/report/${t}`);
|
||||
};
|
||||
return (
|
||||
<form class="fs-search" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||
<input class="fs-input" placeholder="Ticker → report (e.g. BBCA)" value={q()}
|
||||
onInput={(e) => setQ(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" type="submit">Open</button>
|
||||
<form class="flex max-w-sm flex-1 items-center gap-2" onSubmit={(e) => { e.preventDefault(); go(); }}>
|
||||
<TextField class="flex-1">
|
||||
<TextFieldInput placeholder="Ticker → report (e.g. BBCA)" value={q()}
|
||||
onInput={(e) => setQ(e.currentTarget.value)} />
|
||||
</TextField>
|
||||
<Button type="submit" variant="secondary">Open</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -53,13 +81,17 @@ function LoginPage() {
|
||||
const err = () => new URLSearchParams(window.location.search).get("error");
|
||||
void params;
|
||||
return (
|
||||
<div style={{ "max-width": "440px", margin: "8vh auto" }}>
|
||||
<div class="fs-card" style={{ padding: "28px", "text-align": "center" }}>
|
||||
<h1 style={{ margin: "0 0 8px" }}>Welcome to FlowSight</h1>
|
||||
<p class="fs-muted">Sign in with Google to get your own watchlist, routines, alerts and reports.</p>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>Login failed: {err()}</p></Show>
|
||||
<a class="fs-btn" href="/api/auth/start" style={{ display: "inline-block", "margin-top": "12px" }}>Sign in with Google</a>
|
||||
</div>
|
||||
<div class="mx-auto max-w-md py-[8vh]">
|
||||
<Card>
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">Welcome to FlowSight</CardTitle>
|
||||
<CardDescription>Sign in with Google to get your own watchlist, routines, alerts and reports.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="text-center">
|
||||
<Show when={err()}><p class="mb-3 text-sm text-destructive">Login failed: {err()}</p></Show>
|
||||
<a href="/api/auth/start"><Button size="lg">Sign in with Google</Button></a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,22 +100,22 @@ function Shell(props: RouteSectionProps) {
|
||||
const { me, refetch } = useAuth();
|
||||
const logout = async () => { await api.logout(); refetch(); };
|
||||
return (
|
||||
<div class="fs-app">
|
||||
<div class="flex min-h-screen bg-background text-foreground">
|
||||
<Nav />
|
||||
<div class="fs-body">
|
||||
<header class="fs-topbar">
|
||||
<button id="fs-menu-btn" class="fs-iconbtn" onClick={() => document.getElementById("fs-nav")?.classList.toggle("open")}>☰</button>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="sticky top-0 z-30 flex items-center gap-3 border-b bg-background/95 px-4 py-2.5 backdrop-blur">
|
||||
<TickerSearch />
|
||||
<span class="fs-spacer" />
|
||||
<span class="flex-1" />
|
||||
<ThemeToggle />
|
||||
<AuthButton me={me()} onLogout={logout} />
|
||||
</header>
|
||||
<main class="fs-main">{props.children}</main>
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 space-y-6 p-4 pb-20 lg:p-6">{props.children}</main>
|
||||
</div>
|
||||
<aside class="fs-side">
|
||||
<aside class="hidden w-80 shrink-0 flex-col gap-4 border-l bg-card p-4 xl:flex">
|
||||
<WatchlistDrawer />
|
||||
<ChatSidebar />
|
||||
</aside>
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
+89
-67
@@ -1,6 +1,10 @@
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { EmptyState, Skeleton } from "../components/ui";
|
||||
import { EmptyState, PageHead } from "../components/ui";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
||||
|
||||
const FIELDS = ["score", "broker_score", "foreign_net", "volume_mult", "composite"];
|
||||
const OPS = [">=", "<=", ">", "<", "=="];
|
||||
@@ -58,73 +62,91 @@ export default function Alerts() {
|
||||
}
|
||||
async function delDest(id: number) { await api.deleteDestination(id); refetchDests(); }
|
||||
return (
|
||||
<div>
|
||||
<div class="fs-page-head"><h1>Alerts</h1><p class="fs-muted">Rules evaluated over snapshots each cycle.</p></div>
|
||||
<div class="fs-card">
|
||||
<h3>Push destinations (per login)</h3>
|
||||
<p class="fs-muted">Secrets stay server-side — the list never shows tokens.</p>
|
||||
<div class="fs-row">
|
||||
<select class="fs-input" value={dkind()} onChange={(e) => setDkind(e.currentTarget.value)}>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="discord">Discord</option>
|
||||
</select>
|
||||
<input class="fs-input" placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} style={{ width: "160px" }} />
|
||||
{dkind() === "telegram" ? (
|
||||
<>
|
||||
<input class="fs-input" placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} style={{ width: "200px" }} />
|
||||
<input class="fs-input" placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} style={{ width: "120px" }} />
|
||||
</>
|
||||
) : (
|
||||
<input class="fs-input" placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
)}
|
||||
<button class="fs-btn" onClick={addDest}>Add destination</button>
|
||||
</div>
|
||||
{derr() ? <p style={{ color: "var(--fs-avoid)" }}>{derr()}</p> : null}
|
||||
<ul>
|
||||
{(dests()?.destinations || []).map((d) => (
|
||||
<li><span class={`fs-badge ${d.enabled ? "buy" : "info"}`}>{d.kind}</span> {d.label || `#${d.id}`} <span class="fs-muted">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
|
||||
<button class="fs-btn ghost" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</button>
|
||||
<button class="fs-btn danger" onClick={() => delDest(d.id)}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>New rule</h3>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div class="fs-row" style={{ "margin-top": "10px" }}>
|
||||
<select class="fs-input" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
|
||||
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
|
||||
</select>
|
||||
<select class="fs-input" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
|
||||
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
|
||||
</select>
|
||||
<input class="fs-input" type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} style={{ width: "100px" }} />
|
||||
<button class="fs-btn ghost" onClick={addCond}>+ condition</button>
|
||||
</div>
|
||||
<Show when={conds().length}>
|
||||
<ul><For each={conds()}>{(c, i) => <li class="fs-muted">{c.field} {c.op} {c.value} <button class="fs-btn ghost" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</button></li>}</For></ul>
|
||||
</Show>
|
||||
<p class="fs-muted">Rule JSON: <code>{rulePreview()}</code></p>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" onClick={create} disabled={busy()}>Create rule</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<div class="fs-card"><h3>Rules</h3>
|
||||
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
|
||||
<ul><For each={alerts()?.alerts || []}>{(a) => <li>{a.name} <span class="fs-muted">last: {a.last_fired || "never"}</span> <button class="fs-btn danger" onClick={() => del(a.id)}>Delete</button></li>}</For></ul>
|
||||
<div class="space-y-4">
|
||||
<PageHead title="Alerts" sub="Rules evaluated over snapshots each cycle." />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Push destinations (per login)</CardTitle>
|
||||
<CardDescription>Secrets stay server-side — the list never shows tokens.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex gap-1 rounded-md bg-muted p-1">
|
||||
<Button size="sm" variant={dkind() === "telegram" ? "default" : "ghost"} onClick={() => setDkind("telegram")}>Telegram</Button>
|
||||
<Button size="sm" variant={dkind() === "discord" ? "default" : "ghost"} onClick={() => setDkind("discord")}>Discord</Button>
|
||||
</div>
|
||||
<TextField class="w-40"><TextFieldInput placeholder="label (e.g. my phone)" value={dlabel()} onInput={(e) => setDlabel(e.currentTarget.value)} /></TextField>
|
||||
{dkind() === "telegram" ? (
|
||||
<>
|
||||
<TextField class="w-52"><TextFieldInput placeholder="bot token" value={dbot()} onInput={(e) => setDbot(e.currentTarget.value)} /></TextField>
|
||||
<TextField class="w-32"><TextFieldInput placeholder="chat id" value={dchat()} onInput={(e) => setDchat(e.currentTarget.value)} /></TextField>
|
||||
</>
|
||||
) : (
|
||||
<TextField class="min-w-64 flex-1"><TextFieldInput placeholder="https://discord webhook url" value={dhook()} onInput={(e) => setDhook(e.currentTarget.value)} /></TextField>
|
||||
)}
|
||||
<Button onClick={addDest}>Add destination</Button>
|
||||
</div>
|
||||
<Show when={derr()}><p class="text-sm text-destructive">{derr()}</p></Show>
|
||||
<ul class="space-y-1.5 text-sm">
|
||||
<For each={(dests()?.destinations || [])}>
|
||||
{(d) => (
|
||||
<li class="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={d.enabled ? "success" : "secondary"}>{d.kind}</Badge>
|
||||
<span>{d.label || `#${d.id}`}</span>
|
||||
<span class="text-muted-foreground">{d.enabled ? "on" : "off"} · {d.configured ? "configured" : "missing secret"}</span>
|
||||
<span class="flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => toggleDest(d.id, d.enabled)}>{d.enabled ? "Disable" : "Enable"}</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => delDest(d.id)}>Delete</Button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>New rule</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<TextField><TextFieldInput placeholder="rule name" value={name()} onInput={(e) => setName(e.currentTarget.value)} /></TextField>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select class="h-10 rounded-md border border-input bg-background px-3 text-sm" value={field()} onChange={(e) => setField(e.currentTarget.value)}>
|
||||
<For each={FIELDS}>{(f) => <option value={f}>{f}</option>}</For>
|
||||
</select>
|
||||
<select class="h-10 rounded-md border border-input bg-background px-3 text-sm" value={op()} onChange={(e) => setOp(e.currentTarget.value)}>
|
||||
<For each={OPS}>{(o) => <option value={o}>{o}</option>}</For>
|
||||
</select>
|
||||
<TextField class="w-24"><TextFieldInput type="number" value={value()} onInput={(e) => setValue(Number(e.currentTarget.value))} /></TextField>
|
||||
<Button variant="outline" onClick={addCond}>+ condition</Button>
|
||||
</div>
|
||||
<Show when={conds().length}>
|
||||
<ul class="space-y-1 text-sm text-muted-foreground"><For each={conds()}>{(c, i) => <li>{c.field} {c.op} {c.value} <Button variant="ghost" size="sm" onClick={() => setConds((p) => p.filter((_, j) => j !== i()))}>×</Button></li>}</For></ul>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="fs-card"><h3>Event history</h3>
|
||||
<button class="fs-btn ghost" onClick={() => refetchEv()}>Refresh</button>
|
||||
<Show when={(events() || []).length} fallback={<p class="fs-muted">No events yet.</p>}>
|
||||
<ul><For each={events() || []}>{(e) => <li><strong>{String(e.ticker)}</strong> — {String(e.message)}</li>}</For></ul>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">Rule JSON: <code class="font-mono">{rulePreview()}</code></p>
|
||||
<div class="flex gap-2">
|
||||
<TextField class="flex-1"><TextFieldInput placeholder="channels, comma-separated (blank = record only)" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
|
||||
<Button onClick={create} disabled={busy()}>Create rule</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Rules</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={(alerts()?.alerts || []).length} fallback={<EmptyState icon="🔔" title="No rules" hint="Create one above." />}>
|
||||
<ul class="space-y-1.5 text-sm"><For each={alerts()?.alerts || []}>{(a) => <li class="flex items-center gap-2">{a.name} <span class="text-muted-foreground">last: {a.last_fired || "never"}</span><span class="flex-1" /><Button variant="destructive" size="sm" onClick={() => del(a.id)}>Delete</Button></li>}</For></ul>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle>Event history</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => refetchEv()}>Refresh</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Show when={(events() || []).length} fallback={<p class="text-sm text-muted-foreground">No events yet.</p>}>
|
||||
<ul class="space-y-1.5 text-sm"><For each={events() || []}>{(e) => <li><Badge variant="secondary">{String(e.ticker)}</Badge> {String(e.message)}</li>}</For></ul>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+43
-31
@@ -2,7 +2,9 @@ import { createResource, createSignal, onCleanup, For, Show } from "solid-js";
|
||||
import { api, subscribeSSE, fmtIDR } from "../lib/api";
|
||||
import { AccumulationTable, ActivityFeed, RotationMap } from "../components/Cards";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { StatCard, EmptyState, Skeleton } from "../components/ui";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { Line } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
Chart.register(...registerables);
|
||||
@@ -24,45 +26,55 @@ export default function Dashboard() {
|
||||
datasets: [{ label: "BBCA foreign net", data: (foreign()?.nets || []) as number[] }],
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div class="fs-page-head">
|
||||
<h1>Smart Money Dashboard</h1>
|
||||
<p class="fs-muted">Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}</p>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Smart Money Dashboard</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Credits {health()?.credits_today ?? "…"} · Scheduler {health()?.scheduler_ok ? "ok" : "down"} · Last cycle {health()?.last_cycle_at || "never"}
|
||||
</p>
|
||||
</div>
|
||||
<Show when={health()?.stale_flags?.length}>
|
||||
<p class="fs-muted">Stale: <For each={health()!.stale_flags}>{(f) => <span>{f}; </span>}</For></p>
|
||||
<p class="text-sm text-muted-foreground">Stale: <For each={health()!.stale_flags}>{(f) => <Badge variant="outline">{f}</Badge>}</For></p>
|
||||
</Show>
|
||||
<Show when={!flow.loading} fallback={<Skeleton rows={4} />}>
|
||||
<div class="fs-grid">
|
||||
<StatCard title="Foreign net total" value={flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}>
|
||||
<Citations items={flow()?.citations} />
|
||||
</StatCard>
|
||||
<Show when={!flow.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Foreign net total</CardTitle><CardDescription>Aggregate across snapshots.</CardDescription></CardHeader>
|
||||
<CardContent>
|
||||
<p class="font-mono text-2xl font-bold">{flow() ? fmtIDR(flow()!.foreign_net_total) : "…"}</p>
|
||||
<Citations items={flow()?.citations} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AccumulationTable rows={flow()?.top_accumulation || []} />
|
||||
<RotationMap signal={undefined} citations={flow()?.citations} />
|
||||
<div class="fs-card">
|
||||
<h3>Foreign flow (BBCA)</h3>
|
||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="fs-muted">No foreign-flow series yet.</p>}>
|
||||
<Line data={flowChart()} options={{ responsive: true }} />
|
||||
<Show when={foreign()?.reversal}><p>Reversal flag set.</p></Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Foreign flow (BBCA)</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={(foreign()?.dates || []).length} fallback={<p class="text-sm text-muted-foreground">No foreign-flow series yet.</p>}>
|
||||
<Line data={flowChart()} options={{ responsive: true }} />
|
||||
<Show when={foreign()?.reversal}><p class="text-sm">Reversal flag set.</p></Show>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ActivityFeed events={(events() || []).map((e) => ({ ticker: String(e.ticker || ""), message: String(e.message || ""), date: String(e.date || "") }))} />
|
||||
<div class="fs-card">
|
||||
<h3>Live agent panel</h3>
|
||||
<Show when={live().length} fallback={<p class="fs-muted">Waiting for SSE events…</p>}>
|
||||
<ul><For each={live()}>{(l) => <li class="fs-muted">{l}</li>}</For></ul>
|
||||
</Show>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Live agent panel</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={live().length} fallback={<p class="text-sm text-muted-foreground">Waiting for SSE events…</p>}>
|
||||
<ul class="space-y-1 text-sm text-muted-foreground"><For each={live()}>{(l) => <li>{l}</li>}</For></ul>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
<div style={{ "margin-top": "14px" }}>
|
||||
<div class="fs-card">
|
||||
<h3>Morning briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet — subscribe to the morning-briefing routine.</p>}>
|
||||
<pre class="fs-muted">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Morning briefing</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — subscribe to the morning-briefing routine.</p>}>
|
||||
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+49
-39
@@ -2,7 +2,10 @@ import { createResource, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Bar } from "solid-chartjs";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import { StatCard, Skeleton } from "../components/ui";
|
||||
import { PageHead } from "../components/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
Chart.register(...registerables);
|
||||
|
||||
export default function Portfolio() {
|
||||
@@ -13,32 +16,40 @@ export default function Portfolio() {
|
||||
datasets: [{ label: "Weight %", data: (risk()?.concentration || []).map((c) => +(c.weight * 100).toFixed(1)) }],
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div class="fs-page-head"><h1>Portfolio Risk + Accuracy Ledger</h1><p class="fs-muted">Computed from your watchlist.</p></div>
|
||||
<Show when={!risk.loading && !acc.loading} fallback={<Skeleton rows={4} />}>
|
||||
<div class="fs-grid">
|
||||
<div class="fs-card">
|
||||
<h3>Concentration</h3>
|
||||
<Show when={(risk()?.concentration || []).length} fallback={<p class="fs-muted">Add tickers to your watchlist to see concentration.</p>}>
|
||||
<Bar data={chartData()} options={{ responsive: true }} />
|
||||
</Show>
|
||||
<For each={risk()?.warnings || []}>{(w) => <p>⚠️ {w}</p>}</For>
|
||||
</div>
|
||||
<StatCard title="Beta vs index" value={risk() ? String(risk()!.beta) : "…"}>
|
||||
<h3 style={{ "margin-top": "12px" }}>Correlation heatmap</h3>
|
||||
<CorrHeatmap matrix={risk()?.correlation || {}} />
|
||||
</StatCard>
|
||||
<div class="fs-card">
|
||||
<h3>Agent accuracy</h3>
|
||||
<table class="fs-table">
|
||||
<thead><tr><th>Agent</th><th>Calls</th><th>Resolved</th><th>Hit %</th></tr></thead>
|
||||
<tbody>
|
||||
<For each={acc()?.agents || []}>
|
||||
{(a) => <tr><td>{a.agent}</td><td>{a.calls}</td><td>{a.resolved}</td><td>{(a.hit_rate * 100).toFixed(0)}%</td></tr>}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<PageHead title="Portfolio Risk + Accuracy Ledger" sub="Computed from your watchlist." />
|
||||
<Show when={!risk.loading && !acc.loading} fallback={<div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div>}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Concentration</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={(risk()?.concentration || []).length} fallback={<p class="text-sm text-muted-foreground">Add tickers to your watchlist to see concentration.</p>}>
|
||||
<Bar data={chartData()} options={{ responsive: true }} />
|
||||
</Show>
|
||||
<For each={risk()?.warnings || []}>{(w) => <p class="text-sm">⚠️ {w}</p>}</For>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Beta vs index</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<p class="font-mono text-2xl font-bold">{risk() ? String(risk()!.beta) : "…"}</p>
|
||||
<h3 class="text-sm font-semibold">Correlation heatmap</h3>
|
||||
<CorrHeatmap matrix={risk()?.correlation || {}} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Agent accuracy</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>Agent</TableHead><TableHead class="text-right">Calls</TableHead><TableHead class="text-right">Resolved</TableHead><TableHead class="text-right">Hit %</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<For each={acc()?.agents || []}>
|
||||
{(a) => <TableRow><TableCell>{a.agent}</TableCell><TableCell class="text-right">{a.calls}</TableCell><TableCell class="text-right">{a.resolved}</TableCell><TableCell class="text-right">{(a.hit_rate * 100).toFixed(0)}%</TableCell></TableRow>}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -49,29 +60,28 @@ function CorrHeatmap(props: { matrix: Record<string, Record<string, number>> })
|
||||
const keys = () => Object.keys(props.matrix);
|
||||
const cell = (v: number) => {
|
||||
const a = Math.min(1, Math.abs(v));
|
||||
const bg = v >= 0 ? `rgba(63,185,80,${(0.15 + 0.65 * a).toFixed(2)})` : `rgba(248,81,73,${(0.15 + 0.65 * a).toFixed(2)})`;
|
||||
return bg;
|
||||
return v >= 0 ? `rgba(34,197,94,${(0.12 + 0.6 * a).toFixed(2)})` : `rgba(239,68,68,${(0.12 + 0.6 * a).toFixed(2)})`;
|
||||
};
|
||||
return (
|
||||
<Show when={keys().length} fallback={<p class="fs-muted">No correlation data yet.</p>}>
|
||||
<div class="fs-scroll"><table class="fs-table">
|
||||
<thead><tr><th></th><For each={keys()}>{(k) => <th>{k}</th>}</For></tr></thead>
|
||||
<tbody>
|
||||
<Show when={keys().length} fallback={<p class="text-sm text-muted-foreground">No correlation data yet.</p>}>
|
||||
<div class="overflow-x-auto"><Table>
|
||||
<TableHeader><TableRow><TableHead></TableHead><For each={keys()}>{(k) => <TableHead>{k}</TableHead>}</For></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<For each={keys()}>
|
||||
{(r) => (
|
||||
<tr>
|
||||
<td><strong>{r}</strong></td>
|
||||
<TableRow>
|
||||
<TableCell class="font-bold">{r}</TableCell>
|
||||
<For each={keys()}>
|
||||
{(c) => {
|
||||
const v = props.matrix[r]?.[c] ?? 0;
|
||||
return <td style={{ background: cell(v) }} title={`${r}/${c} = ${v.toFixed(2)}`}>{v.toFixed(2)}</td>;
|
||||
return <TableCell style={{ background: cell(v) }} title={`${r}/${c} = ${v.toFixed(2)}`}>{v.toFixed(2)}</TableCell>;
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</TableBody>
|
||||
</Table></div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
+46
-37
@@ -2,7 +2,12 @@ import { createSignal, For, Show, onMount } from "solid-js";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { api, type ReportPayload } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { RecBadge, Skeleton } from "../components/ui";
|
||||
import { PageHead, RecBadge } from "../components/ui";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
||||
|
||||
export default function Report() {
|
||||
const params = useParams();
|
||||
@@ -35,54 +40,58 @@ export default function Report() {
|
||||
a.href = url; a.download = name; a.click();
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div class="fs-page-head">
|
||||
<h1>Report: {params.ticker}</h1>
|
||||
<button class="fs-btn ghost" onClick={load}>↻ Regenerate</button>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<PageHead title={`Report: ${params.ticker}`} />
|
||||
<Button variant="outline" onClick={load}>↻ Regenerate</Button>
|
||||
</div>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>{err()}</p></Show>
|
||||
<Show when={busy()}><Skeleton rows={5} /></Show>
|
||||
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
|
||||
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-32 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
|
||||
<Show when={rep()}>
|
||||
<div class="fs-card" style={{ "border-left": "4px solid var(--fs-accent)" }}>
|
||||
<div class="fs-row">
|
||||
<Card class="border-l-4 border-l-primary">
|
||||
<CardContent class="flex flex-wrap items-center gap-2 pt-6">
|
||||
<RecBadge rec={rep()!.synthesis.recommendation} />
|
||||
<strong>conviction {rep()!.synthesis.conviction}/5</strong>
|
||||
<Show when={rep()!.synthesis.conflict}><span class="fs-badge info">conflict flagged</span></Show>
|
||||
<span class="fs-muted">position {rep()!.synthesis.position_pct}%</span>
|
||||
</div>
|
||||
<p>{rep()!.synthesis.thesis}</p>
|
||||
<Citations items={rep()!.citations} />
|
||||
</div>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Export</h3>
|
||||
<div class="fs-row">
|
||||
<button class="fs-btn ghost" onClick={exportMd}>Markdown</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
<Show when={rep()!.synthesis.conflict}><Badge variant="secondary">conflict flagged</Badge></Show>
|
||||
<span class="text-sm text-muted-foreground">position {rep()!.synthesis.position_pct}%</span>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<p class="text-sm">{rep()!.synthesis.thesis}</p>
|
||||
<div class="mt-2"><Citations items={rep()!.citations} /></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Export</CardTitle></CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={exportMd}>Markdown</Button>
|
||||
<Button variant="outline" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=html`, { method: "POST", headers: authHeaders() });
|
||||
dl(URL.createObjectURL(new Blob([await r.text()], { type: "text/html" })), `${params.ticker}-report.html`);
|
||||
}}>HTML</button>
|
||||
<button class="fs-btn ghost" onClick={() => {
|
||||
}}>HTML</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
dl(URL.createObjectURL(new Blob([JSON.stringify(rep(), null, 1)], { type: "application/json" })), `${params.ticker}-report.json`);
|
||||
}}>JSON</button>
|
||||
<button class="fs-btn ghost" onClick={async () => {
|
||||
}}>JSON</Button>
|
||||
<Button variant="outline" onClick={async () => {
|
||||
const r = await fetch(`/api/report/${params.ticker}?format=pdf`, { method: "POST", headers: authHeaders() });
|
||||
dl(URL.createObjectURL(await r.blob()), `${params.ticker}-report.pdf`);
|
||||
}}>PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
}}>PDF</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<For each={rep()!.sections}>
|
||||
{(s) => <div class="fs-card" style={{ "margin-top": "14px" }}><h3>{s.name}</h3><p style={{ "white-space": "pre-wrap" }}>{s.body}</p><Citations items={s.citations} /></div>}
|
||||
{(s) => <Card><CardHeader><CardTitle>{s.name}</CardTitle></CardHeader><CardContent><p class="whitespace-pre-wrap text-sm">{s.body}</p><div class="mt-2"><Citations items={s.citations} /></div></CardContent></Card>}
|
||||
</For>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Interrogate this report</h3>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="Ask about this report…" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} style={{ flex: 1 }} />
|
||||
<button class="fs-btn" onClick={ask} disabled={asking()}>{asking() ? "…" : "Ask"}</button>
|
||||
</div>
|
||||
<Show when={answer()}><p style={{ "margin-top": "10px" }}>{answer()}</p></Show>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Interrogate this report</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<TextField class="flex-1"><TextFieldInput placeholder="Ask about this report…" value={question()} onInput={(e) => setQuestion(e.currentTarget.value)} /></TextField>
|
||||
<Button onClick={ask} disabled={asking()}>{asking() ? "…" : "Ask"}</Button>
|
||||
</div>
|
||||
<Show when={answer()}><p class="text-sm">{answer()}</p></Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Show>
|
||||
<Show when={md()}><div class="fs-card" style={{ "margin-top": "14px" }}><pre class="fs-muted">{md().slice(0, 2000)}</pre></div></Show>
|
||||
<Show when={md()}><Card><CardContent class="pt-6"><pre class="whitespace-pre-wrap text-sm text-muted-foreground">{md().slice(0, 2000)}</pre></CardContent></Card></Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+54
-42
@@ -1,7 +1,11 @@
|
||||
import { createResource, createSignal, For, Show } from "solid-js";
|
||||
import { api } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { EmptyState } from "../components/ui";
|
||||
import { EmptyState, PageHead } from "../components/ui";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
||||
|
||||
const TYPES = ["morning-briefing", "accumulation-radar", "foreign-reversal", "insider-tape", "earnings-countdown", "dividend-calendar", "weekend-review"];
|
||||
|
||||
@@ -12,7 +16,6 @@ export default function Routines() {
|
||||
const [schedule, setSchedule] = createSignal("");
|
||||
const [channels, setChannels] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [runNowId, setRunNowId] = createSignal<number | null>(null);
|
||||
const [briefing] = createResource(() => api.briefing().catch(() => null));
|
||||
async function subscribe() {
|
||||
setBusy(true);
|
||||
@@ -34,54 +37,63 @@ export default function Routines() {
|
||||
refetch();
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div class="fs-page-head"><h1>Routine Manager</h1><p class="fs-muted">Scheduled deliveries with citations.</p></div>
|
||||
<div class="fs-card">
|
||||
<h3>Subscribe</h3>
|
||||
<div class="fs-row">
|
||||
<select class="fs-input" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
|
||||
<For each={TYPES}>{(t) => <option value={t}>{t}</option>}</For>
|
||||
</select>
|
||||
<input class="fs-input" placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} style={{ width: "240px" }} />
|
||||
<input class="fs-input" placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} style={{ width: "200px" }} />
|
||||
<button class="fs-btn" onClick={subscribe} disabled={busy()}>Subscribe</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<PageHead title="Routine Manager" sub="Scheduled deliveries with citations." />
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Subscribe</CardTitle></CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<TextField class="w-52">
|
||||
<select class="h-10 w-full rounded-md border border-input bg-background px-3 text-sm" value={type_()} onChange={(e) => setType(e.currentTarget.value)}>
|
||||
<For each={TYPES}>{(t) => <option value={t}>{t}</option>}</For>
|
||||
</select>
|
||||
</TextField>
|
||||
<TextField class="w-60"><TextFieldInput placeholder="schedule cron (blank = default)" value={schedule()} onInput={(e) => setSchedule(e.currentTarget.value)} /></TextField>
|
||||
<TextField class="w-52"><TextFieldInput placeholder="channels, comma-separated" value={channels()} onInput={(e) => setChannels(e.currentTarget.value)} /></TextField>
|
||||
<Button onClick={subscribe} disabled={busy()}>Subscribe</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Show when={(data()?.routines || []).length} fallback={
|
||||
<div style={{ "margin-top": "14px" }}><EmptyState icon="🗓️" title="No routines yet" hint="Subscribe above — the morning briefing runs 07:30 WIB daily." /></div>
|
||||
<EmptyState icon="🗓️" title="No routines yet" hint="Subscribe above — the morning briefing runs 07:30 WIB daily." />
|
||||
}>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={data()?.routines || []}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<div class="fs-row" style={{ "justify-content": "space-between" }}>
|
||||
<strong>{r.type}</strong>
|
||||
<span class={`fs-badge ${r.enabled ? "buy" : "info"}`}>{r.enabled ? "on" : "off"}</span>
|
||||
</div>
|
||||
<p class="fs-muted">{r.schedule_cron}</p>
|
||||
<div class="fs-row">
|
||||
<button class="fs-btn ghost" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</button>
|
||||
<button class="fs-btn ghost" disabled={runNowId() === r.id}>Run</button>
|
||||
<button class="fs-btn danger" onClick={() => del(r.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-base">{r.type}</CardTitle>
|
||||
<Badge variant={r.enabled ? "success" : "secondary"}>{r.enabled ? "on" : "off"}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<p class="font-mono text-xs text-muted-foreground">{r.schedule_cron}</p>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => toggle(r.id, r.enabled)}>{r.enabled ? "Disable" : "Enable"}</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => del(r.id)}>Delete</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<div class="fs-card">
|
||||
<h3>Latest briefing</h3>
|
||||
<Show when={briefing()} fallback={<p class="fs-muted">No briefing yet — it generates after the first morning-briefing run.</p>}>
|
||||
<pre class="fs-muted" style={{ "white-space": "pre-wrap" }}>{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
<Citations items={(briefing() as { citations: string })?.citations} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="fs-card">
|
||||
<h3>Run history</h3>
|
||||
<button class="fs-btn ghost" onClick={() => refetchRuns()}>Refresh</button>
|
||||
<ul><For each={runs() || []}>{(r) => <li class="fs-muted">{String(r.started_at)} — {String(r.status)}</li>}</For></ul>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Latest briefing</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Show when={briefing()} fallback={<p class="text-sm text-muted-foreground">No briefing yet — it generates after the first morning-briefing run.</p>}>
|
||||
<pre class="whitespace-pre-wrap text-sm text-muted-foreground">{(briefing() as { payload: string })?.payload?.slice(0, 800)}</pre>
|
||||
<div class="mt-2"><Citations items={(briefing() as { citations: string })?.citations} /></div>
|
||||
</Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle>Run history</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => refetchRuns()}>Refresh</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul class="space-y-1 text-sm text-muted-foreground"><For each={runs() || []}>{(r) => <li>{String(r.started_at)} — {String(r.status)}</li>}</For></ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+64
-41
@@ -1,11 +1,16 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { api, type ScreenRow } from "../lib/api";
|
||||
import { Citations } from "../components/Citations";
|
||||
import { BreakdownBars, EmptyState, RecBadge, Skeleton } from "../components/ui";
|
||||
import { BreakdownBars, EmptyState, PageHead, RecBadge } from "../components/ui";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Skeleton } from "../components/ui/skeleton";
|
||||
import { TextField, TextFieldInput } from "../components/ui/text-field";
|
||||
import { Switch, SwitchControl, SwitchLabel, SwitchThumb } from "../components/ui/switch";
|
||||
|
||||
const SAVED_KEY = "fs-saved-screeners";
|
||||
|
||||
// scoreOf extracts a -100..+100 composite from the row breakdown for the badge.
|
||||
function scoreOf(r: ScreenRow): number {
|
||||
const b = r.breakdown as Record<string, unknown>;
|
||||
for (const k of ["composite", "score", "broker_score", "broker"]) {
|
||||
@@ -49,55 +54,73 @@ export default function Screener() {
|
||||
setSaved(all);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div class="fs-page-head"><h1>Institutional Screener</h1><p class="fs-muted">Rank tickers by smart-money signals.</p></div>
|
||||
<div class="fs-card">
|
||||
<div class="fs-row">
|
||||
<select class="fs-input" value={mode()} onChange={(e) => setMode(e.currentTarget.value as "where" | "q")}>
|
||||
<option value="q">Natural language (q)</option>
|
||||
<option value="where">SQL-like (where)</option>
|
||||
</select>
|
||||
<input class="fs-input" placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} style={{ flex: 1, "min-width": "200px" }} />
|
||||
<button class="fs-btn" onClick={run} disabled={busy()}>{busy() ? " screening…" : "Screen"}</button>
|
||||
</div>
|
||||
<div class="fs-row" style={{ "margin-top": "10px" }}>
|
||||
<label class="fs-muted">broker ≥ <input class="fs-input" type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} style={{ width: "80px" }} /></label>
|
||||
<label class="fs-muted"><input type="checkbox" checked={foreignOnly()} onChange={(e) => setForeignOnly(e.currentTarget.checked)} /> foreign inflow</label>
|
||||
<label class="fs-muted"><input type="checkbox" checked={insiderOnly()} onChange={(e) => setInsiderOnly(e.currentTarget.checked)} /> insider buying</label>
|
||||
</div>
|
||||
<Show when={err()}><p style={{ color: "var(--fs-avoid)" }}>{err()}</p></Show>
|
||||
</div>
|
||||
<Show when={busy()}><div style={{ "margin-top": "14px" }}><Skeleton rows={3} /></div></Show>
|
||||
<div class="space-y-4">
|
||||
<PageHead title="Institutional Screener" sub="Rank tickers by smart-money signals." />
|
||||
<Card>
|
||||
<CardContent class="space-y-3 pt-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex gap-1 rounded-md bg-muted p-1">
|
||||
<Button size="sm" variant={mode() === "q" ? "default" : "ghost"} onClick={() => setMode("q")}>Natural language</Button>
|
||||
<Button size="sm" variant={mode() === "where" ? "default" : "ghost"} onClick={() => setMode("where")}>SQL-like</Button>
|
||||
</div>
|
||||
<TextField class="min-w-52 flex-1">
|
||||
<TextFieldInput placeholder={mode() === "q" ? "e.g. large banks with foreign inflow" : "e.g. market_cap > 10T"} value={q()} onInput={(e) => setQ(e.currentTarget.value)} />
|
||||
</TextField>
|
||||
<Button onClick={run} disabled={busy()}>{busy() ? "Screening…" : "Screen"}</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label class="flex items-center gap-2 text-muted-foreground">broker ≥
|
||||
<TextField class="w-20"><TextFieldInput type="number" value={brokerMin()} onInput={(e) => setBrokerMin(Number(e.currentTarget.value))} /></TextField>
|
||||
</label>
|
||||
<Switch checked={foreignOnly()} onChange={setForeignOnly}>
|
||||
<SwitchControl><SwitchThumb /></SwitchControl>
|
||||
<SwitchLabel>foreign inflow</SwitchLabel>
|
||||
</Switch>
|
||||
<Switch checked={insiderOnly()} onChange={setInsiderOnly}>
|
||||
<SwitchControl><SwitchThumb /></SwitchControl>
|
||||
<SwitchLabel>insider buying</SwitchLabel>
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={err()}><p class="text-sm text-destructive">{err()}</p></Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Show when={busy()}><div class="space-y-2"><Skeleton class="h-24 w-full" /><Skeleton class="h-24 w-full" /></div></Show>
|
||||
<Show when={ran() && !busy()}>
|
||||
<Show when={rows().length} fallback={<div style={{ "margin-top": "14px" }}><EmptyState icon="🔍" title="No matches" hint="Loosen the filters and try again." /></div>}>
|
||||
<div class="fs-grid" style={{ "margin-top": "14px" }}>
|
||||
<Show when={rows().length} fallback={<EmptyState icon="🔍" title="No matches" hint="Loosen the filters and try again." />}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<For each={rows()}>
|
||||
{(r) => (
|
||||
<div class="fs-card">
|
||||
<div class="fs-row" style={{ "justify-content": "space-between" }}>
|
||||
<strong><a href={`/report/${r.symbol}`}>{r.symbol}</a></strong>
|
||||
<span class="fs-muted">{r.composite.toFixed(1)}</span>
|
||||
</div>
|
||||
<div style={{ "margin": "8px 0" }}><BreakdownBars breakdown={r.breakdown as Record<string, unknown>} /></div>
|
||||
<Citations items={r.citations} />
|
||||
<Show when={scoreOf(r) !== 0}><div style={{ "margin-top": "6px" }}><RecBadge rec={scoreOf(r) >= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /></div></Show>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<a class="font-bold text-primary hover:underline" href={`/report/${r.symbol}`}>{r.symbol}</a>
|
||||
<Badge variant="secondary" class="font-mono">{r.composite.toFixed(1)}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<BreakdownBars breakdown={r.breakdown as Record<string, unknown>} />
|
||||
<Citations items={r.citations} />
|
||||
<Show when={scoreOf(r) !== 0}><RecBadge rec={scoreOf(r) >= 40 ? "BUY" : scoreOf(r) <= -40 ? "AVOID" : "HOLD"} /></Show>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!ran() && !busy()}>
|
||||
<div style={{ "margin-top": "14px" }}><EmptyState icon="🔍" title="Run a screen" hint="Ranked rows with per-signal bars appear here." /></div>
|
||||
<EmptyState icon="🔍" title="Run a screen" hint="Ranked rows with per-signal bars appear here." />
|
||||
</Show>
|
||||
<div class="fs-card" style={{ "margin-top": "14px" }}>
|
||||
<h3>Saved screeners</h3>
|
||||
<div class="fs-row">
|
||||
<input class="fs-input" placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} style={{ width: "140px" }} />
|
||||
<button class="fs-btn ghost" onClick={save}>Save current</button>
|
||||
</div>
|
||||
<ul><For each={Object.entries(saved())}>{([n, query]) => <li><button class="fs-btn ghost" onClick={() => { setQ(query); run(); }}>{n}</button> <span class="fs-muted">{query.slice(0, 60)}</span></li>}</For></ul>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Saved screeners</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-3">
|
||||
<div class="flex gap-2">
|
||||
<TextField class="w-36"><TextFieldInput placeholder="name" value={sname()} onInput={(e) => setSname(e.currentTarget.value)} /></TextField>
|
||||
<Button variant="outline" onClick={save}>Save current</Button>
|
||||
</div>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<For each={Object.entries(saved())}>{([n, query]) => <li><Button variant="ghost" size="sm" onClick={() => { setQ(query); run(); }}>{n}</Button> <span class="text-muted-foreground">{query.slice(0, 60)}</span></li>}</For>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 96%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 220 14% 96%;
|
||||
--muted-foreground: 220 9% 42%;
|
||||
--accent: 220 14% 96%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--success: 142 76% 92%;
|
||||
--success-foreground: 142 72% 29%;
|
||||
--warning: 48 96% 89%;
|
||||
--warning-foreground: 32 95% 34%;
|
||||
--error: 0 84% 92%;
|
||||
--error-foreground: 0 72% 41%;
|
||||
--border: 220 13% 90%;
|
||||
--input: 220 13% 90%;
|
||||
--ring: 221 83% 53%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 224 20% 6%;
|
||||
--foreground: 220 14% 93%;
|
||||
--card: 224 20% 9%;
|
||||
--card-foreground: 220 14% 93%;
|
||||
--popover: 224 20% 9%;
|
||||
--popover-foreground: 220 14% 93%;
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 224 15% 14%;
|
||||
--secondary-foreground: 220 14% 93%;
|
||||
--muted: 224 15% 14%;
|
||||
--muted-foreground: 220 9% 60%;
|
||||
--accent: 224 15% 14%;
|
||||
--accent-foreground: 220 14% 93%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--success: 142 40% 16%;
|
||||
--success-foreground: 142 70% 60%;
|
||||
--warning: 40 50% 20%;
|
||||
--warning-foreground: 45 90% 60%;
|
||||
--error: 0 50% 22%;
|
||||
--error-foreground: 0 85% 70%;
|
||||
--border: 224 15% 18%;
|
||||
--input: 224 15% 18%;
|
||||
--ring: 217 91% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground text-[15px] leading-relaxed antialiased; }
|
||||
}
|
||||
|
||||
.fs-scroll { overflow-x: auto; }
|
||||
@@ -1,135 +0,0 @@
|
||||
/* FlowSight design tokens v2: light default, dark via [data-theme="dark"].
|
||||
Single source of truth for color, type, spacing. */
|
||||
:root {
|
||||
--fs-bg: #f6f8fb;
|
||||
--fs-surface: #ffffff;
|
||||
--fs-surface-2: #eef2f7;
|
||||
--fs-border: #dfe6ef;
|
||||
--fs-text: #0f1c2e;
|
||||
--fs-muted: #5b6b82;
|
||||
--fs-accent: #1d6ff2;
|
||||
--fs-accent-soft: #e3edff;
|
||||
--fs-buy: #15803d;
|
||||
--fs-buy-soft: #dcfce7;
|
||||
--fs-hold: #b45309;
|
||||
--fs-hold-soft: #fef3c7;
|
||||
--fs-avoid: #dc2626;
|
||||
--fs-avoid-soft: #fee2e2;
|
||||
--fs-stale: #7c3aed;
|
||||
--fs-font: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--fs-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||
--fs-radius: 10px;
|
||||
--fs-gap: 14px;
|
||||
--fs-shadow: 0 1px 2px rgba(15, 28, 46, 0.06), 0 4px 16px rgba(15, 28, 46, 0.06);
|
||||
--fs-nav-w: 216px;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--fs-bg: #0d1117;
|
||||
--fs-surface: #161b22;
|
||||
--fs-surface-2: #1c2330;
|
||||
--fs-border: #30363d;
|
||||
--fs-text: #e6edf3;
|
||||
--fs-muted: #8b949e;
|
||||
--fs-accent: #2f81f7;
|
||||
--fs-accent-soft: #12233f;
|
||||
--fs-buy: #3fb950;
|
||||
--fs-buy-soft: #12291a;
|
||||
--fs-hold: #d29922;
|
||||
--fs-hold-soft: #2e230d;
|
||||
--fs-avoid: #f85149;
|
||||
--fs-avoid-soft: #341314;
|
||||
--fs-stale: #9e7bea;
|
||||
--fs-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { margin: 0; background: var(--fs-bg); color: var(--fs-text); font-family: var(--fs-font); font-size: 15px; line-height: 1.5; }
|
||||
a { color: var(--fs-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* Layout */
|
||||
.fs-app { display: flex; min-height: 100vh; }
|
||||
.fs-nav { width: var(--fs-nav-w); flex-shrink: 0; padding: 16px 12px; border-right: 1px solid var(--fs-border); background: var(--fs-surface); display: flex; flex-direction: column; gap: 4px; position: sticky; top: 0; height: 100vh; }
|
||||
.fs-brand { font-weight: 800; font-size: 18px; padding: 4px 10px 12px; letter-spacing: -0.01em; }
|
||||
.fs-brand span { color: var(--fs-accent); }
|
||||
.fs-nav a { padding: 9px 12px; border-radius: var(--fs-radius); color: var(--fs-text); font-weight: 500; display: flex; gap: 10px; align-items: center; }
|
||||
.fs-nav a:hover { background: var(--fs-surface-2); text-decoration: none; }
|
||||
.fs-nav a.active { background: var(--fs-accent-soft); color: var(--fs-accent); font-weight: 700; }
|
||||
.fs-nav .fs-nav-foot { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
|
||||
.fs-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.fs-topbar { display: flex; gap: 10px; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--fs-border); background: var(--fs-surface); position: sticky; top: 0; z-index: 10; }
|
||||
.fs-topbar .fs-search { flex: 1; max-width: 420px; display: flex; gap: 8px; }
|
||||
.fs-topbar .fs-spacer { flex: 1; }
|
||||
.fs-main { flex: 1; padding: 22px; max-width: 1280px; width: 100%; margin: 0 auto; }
|
||||
.fs-side { width: 320px; flex-shrink: 0; padding: 22px 18px 22px 0; display: flex; flex-direction: column; gap: var(--fs-gap); }
|
||||
.fs-page-head { display: flex; align-items: baseline; gap: 12px; margin: 2px 0 16px; flex-wrap: wrap; }
|
||||
.fs-page-head h1 { font-size: 24px; margin: 0; letter-spacing: -0.02em; }
|
||||
.fs-page-head p { margin: 0; color: var(--fs-muted); }
|
||||
|
||||
/* Cards, grid, stats */
|
||||
.fs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--fs-gap); }
|
||||
.fs-card { background: var(--fs-surface); border: 1px solid var(--fs-border); border-radius: var(--fs-radius); padding: 16px; box-shadow: var(--fs-shadow); }
|
||||
.fs-card h3 { margin: 0 0 10px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--fs-muted); }
|
||||
.fs-stat { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; margin: 2px 0; font-variant-numeric: tabular-nums; }
|
||||
.fs-muted { color: var(--fs-muted); font-size: 13px; }
|
||||
.fs-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.fs-stack { display: flex; flex-direction: column; gap: var(--fs-gap); }
|
||||
|
||||
/* Badges, signals */
|
||||
.fs-badge { display: inline-block; font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 999px; }
|
||||
.fs-badge.buy { background: var(--fs-buy-soft); color: var(--fs-buy); }
|
||||
.fs-badge.hold { background: var(--fs-hold-soft); color: var(--fs-hold); }
|
||||
.fs-badge.avoid { background: var(--fs-avoid-soft); color: var(--fs-avoid); }
|
||||
.fs-badge.info { background: var(--fs-accent-soft); color: var(--fs-accent); }
|
||||
.fs-sig { display: grid; grid-template-columns: 110px 1fr 52px; gap: 8px; align-items: center; margin: 6px 0; font-size: 13px; }
|
||||
.fs-sig .bar { height: 8px; border-radius: 99px; background: var(--fs-surface-2); position: relative; overflow: hidden; }
|
||||
.fs-sig .bar i { position: absolute; top: 0; bottom: 0; border-radius: 99px; background: var(--fs-accent); }
|
||||
.fs-sig .bar i.neg { background: var(--fs-avoid); }
|
||||
.fs-sig .bar i.pos { background: var(--fs-buy); }
|
||||
.fs-sig b { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Citations */
|
||||
.fs-cite { display: inline-block; font-family: var(--fs-mono); font-size: 11px; border: 1px solid var(--fs-border); border-radius: 6px; padding: 1px 7px; margin: 2px 4px 2px 0; background: var(--fs-surface-2); }
|
||||
.fs-cite.stale { border-color: var(--fs-stale); color: var(--fs-stale); }
|
||||
|
||||
/* Tables, forms */
|
||||
.fs-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.fs-table th, .fs-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--fs-border); }
|
||||
.fs-table th { color: var(--fs-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.fs-table tr:hover td { background: var(--fs-surface-2); }
|
||||
.fs-input, .fs-btn, select.fs-input { font: inherit; padding: 9px 13px; border-radius: var(--fs-radius); border: 1px solid var(--fs-border); }
|
||||
.fs-input { background: var(--fs-surface); color: var(--fs-text); min-width: 0; }
|
||||
.fs-input:focus { outline: 2px solid var(--fs-accent); outline-offset: -1px; border-color: var(--fs-accent); }
|
||||
.fs-btn { background: var(--fs-accent); color: #fff; cursor: pointer; border: 1px solid transparent; font-weight: 600; }
|
||||
.fs-btn:hover { filter: brightness(1.07); }
|
||||
.fs-btn.ghost { background: transparent; border-color: var(--fs-border); color: var(--fs-text); }
|
||||
.fs-btn.danger { background: transparent; border-color: var(--fs-avoid); color: var(--fs-avoid); }
|
||||
.fs-btn:disabled { opacity: 0.55; cursor: wait; }
|
||||
.fs-scroll { overflow-x: auto; }
|
||||
.fs-avatar { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
|
||||
|
||||
/* Skeletons, empty */
|
||||
.fs-sk { border-radius: 8px; background: linear-gradient(90deg, var(--fs-surface-2) 25%, var(--fs-border) 50%, var(--fs-surface-2) 75%); background-size: 200% 100%; animation: fs-sh 1.2s infinite; min-height: 18px; }
|
||||
@keyframes fs-sh { to { background-position: -200% 0; } }
|
||||
.fs-empty { text-align: center; padding: 34px 16px; color: var(--fs-muted); }
|
||||
.fs-empty .big { font-size: 30px; margin-bottom: 6px; }
|
||||
|
||||
.rec-BUY { color: var(--fs-buy); } .rec-HOLD { color: var(--fs-hold); } .rec-AVOID { color: var(--fs-avoid); }
|
||||
|
||||
/* Theme toggle + user chip */
|
||||
.fs-iconbtn { background: transparent; border: 1px solid var(--fs-border); color: var(--fs-text); border-radius: var(--fs-radius); padding: 8px 11px; cursor: pointer; font: inherit; }
|
||||
.fs-iconbtn:hover { background: var(--fs-surface-2); }
|
||||
.fs-user { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 1080px) {
|
||||
.fs-side { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.fs-nav { position: fixed; z-index: 20; transform: translateX(-100%); transition: transform 0.2s; box-shadow: var(--fs-shadow); }
|
||||
.fs-nav.open { transform: none; }
|
||||
.fs-main { padding: 14px; }
|
||||
.fs-topbar { padding: 10px 12px; }
|
||||
#fs-menu-btn { display: inline-block !important; }
|
||||
}
|
||||
#fs-menu-btn { display: none; }
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: { center: true, padding: "2rem", screens: { "2xl": "1400px" } },
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
|
||||
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
|
||||
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
|
||||
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
||||
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
|
||||
popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
|
||||
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
|
||||
success: { DEFAULT: "hsl(var(--success))", foreground: "hsl(var(--success-foreground))" },
|
||||
warning: { DEFAULT: "hsl(var(--warning))", foreground: "hsl(var(--warning-foreground))" },
|
||||
error: { DEFAULT: "hsl(var(--error))", foreground: "hsl(var(--error-foreground))" },
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": { from: { height: "0" }, to: { height: "var(--kb-accordion-content-height)" } },
|
||||
"accordion-up": { from: { height: "var(--kb-accordion-content-height)" }, to: { height: "0" } },
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
Reference in New Issue
Block a user