tokentrace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +167 -0
- package/.next/app-path-routes-manifest.json +22 -0
- package/.next/build-manifest.json +33 -0
- package/.next/export-marker.json +6 -0
- package/.next/images-manifest.json +58 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +37 -0
- package/.next/react-loadable-manifest.json +1 -0
- package/.next/required-server-files.json +323 -0
- package/.next/routes-manifest.json +119 -0
- package/.next/server/app/_not-found/page.js +2 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +8 -0
- package/.next/server/app/_not-found.rsc +37 -0
- package/.next/server/app/api/analytics/route.js +1 -0
- package/.next/server/app/api/analytics/route.js.nft.json +1 -0
- package/.next/server/app/api/analytics/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/data/route.js +151 -0
- package/.next/server/app/api/data/route.js.nft.json +1 -0
- package/.next/server/app/api/data/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/export/route.js +1 -0
- package/.next/server/app/api/export/route.js.nft.json +1 -0
- package/.next/server/app/api/export/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/files/route.js +1 -0
- package/.next/server/app/api/files/route.js.nft.json +1 -0
- package/.next/server/app/api/files/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/prices/route.js +151 -0
- package/.next/server/app/api/prices/route.js.nft.json +1 -0
- package/.next/server/app/api/prices/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/scan/route.js +144 -0
- package/.next/server/app/api/scan/route.js.nft.json +1 -0
- package/.next/server/app/api/scan/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/settings/route.js +128 -0
- package/.next/server/app/api/settings/route.js.nft.json +1 -0
- package/.next/server/app/api/settings/route_client-reference-manifest.js +1 -0
- package/.next/server/app/debug/page.js +2 -0
- package/.next/server/app/debug/page.js.nft.json +1 -0
- package/.next/server/app/debug/page_client-reference-manifest.js +1 -0
- package/.next/server/app/diagnostics/page.js +2 -0
- package/.next/server/app/diagnostics/page.js.nft.json +1 -0
- package/.next/server/app/diagnostics/page_client-reference-manifest.js +1 -0
- package/.next/server/app/discovery/page.js +2 -0
- package/.next/server/app/discovery/page.js.nft.json +1 -0
- package/.next/server/app/discovery/page_client-reference-manifest.js +1 -0
- package/.next/server/app/models/page.js +2 -0
- package/.next/server/app/models/page.js.nft.json +1 -0
- package/.next/server/app/models/page_client-reference-manifest.js +1 -0
- package/.next/server/app/optimisation/page.js +2 -0
- package/.next/server/app/optimisation/page.js.nft.json +1 -0
- package/.next/server/app/optimisation/page_client-reference-manifest.js +1 -0
- package/.next/server/app/page.js +2 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app/parser-debug/page.js +2 -0
- package/.next/server/app/parser-debug/page.js.nft.json +1 -0
- package/.next/server/app/parser-debug/page_client-reference-manifest.js +1 -0
- package/.next/server/app/pricing/page.js +152 -0
- package/.next/server/app/pricing/page.js.nft.json +1 -0
- package/.next/server/app/pricing/page_client-reference-manifest.js +1 -0
- package/.next/server/app/projects/page.js +2 -0
- package/.next/server/app/projects/page.js.nft.json +1 -0
- package/.next/server/app/projects/page_client-reference-manifest.js +1 -0
- package/.next/server/app/sessions/page.js +2 -0
- package/.next/server/app/sessions/page.js.nft.json +1 -0
- package/.next/server/app/sessions/page_client-reference-manifest.js +1 -0
- package/.next/server/app/settings/page.js +129 -0
- package/.next/server/app/settings/page.js.nft.json +1 -0
- package/.next/server/app/settings/page_client-reference-manifest.js +1 -0
- package/.next/server/app/tools/page.js +2 -0
- package/.next/server/app/tools/page.js.nft.json +1 -0
- package/.next/server/app/tools/page_client-reference-manifest.js +1 -0
- package/.next/server/app-paths-manifest.json +22 -0
- package/.next/server/chunks/123.js +9 -0
- package/.next/server/chunks/153.js +1 -0
- package/.next/server/chunks/237.js +13 -0
- package/.next/server/chunks/331.js +22 -0
- package/.next/server/chunks/366.js +1 -0
- package/.next/server/chunks/444.js +267 -0
- package/.next/server/chunks/611.js +6 -0
- package/.next/server/chunks/692.js +1 -0
- package/.next/server/chunks/779.js +1 -0
- package/.next/server/chunks/815.js +1 -0
- package/.next/server/chunks/868.js +1 -0
- package/.next/server/functions-config-manifest.json +4 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +6 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +19 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +6 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/Fh8usqK3dgfncUx9s3VR1/_buildManifest.js +1 -0
- package/.next/static/Fh8usqK3dgfncUx9s3VR1/_ssgManifest.js +1 -0
- package/.next/static/chunks/125-ab0f8db8f84c1166.js +1 -0
- package/.next/static/chunks/255-e881f48ae1d2333a.js +1 -0
- package/.next/static/chunks/4bd1b696-409494caf8c83275.js +1 -0
- package/.next/static/chunks/619-f072ac750404f9da.js +1 -0
- package/.next/static/chunks/850-8bc31e41590b5831.js +1 -0
- package/.next/static/chunks/938-23236de1c47554ea.js +1 -0
- package/.next/static/chunks/app/_not-found/page-6d75243350d9e0b5.js +1 -0
- package/.next/static/chunks/app/api/analytics/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/api/data/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/api/export/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/api/files/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/api/prices/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/api/scan/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/api/settings/route-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/debug/page-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/diagnostics/page-053a5e810a59e548.js +1 -0
- package/.next/static/chunks/app/discovery/page-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/layout-8942804176ff26f3.js +1 -0
- package/.next/static/chunks/app/models/page-c0acf74dd8197e01.js +1 -0
- package/.next/static/chunks/app/optimisation/page-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/page-b6886ec802c03cbf.js +1 -0
- package/.next/static/chunks/app/parser-debug/page-33d3f29973de91a4.js +1 -0
- package/.next/static/chunks/app/pricing/page-5e27b1ae27314539.js +1 -0
- package/.next/static/chunks/app/projects/page-b6886ec802c03cbf.js +1 -0
- package/.next/static/chunks/app/sessions/page-0abcdc88aac9dcaf.js +1 -0
- package/.next/static/chunks/app/settings/page-59fc80673f0750cd.js +1 -0
- package/.next/static/chunks/app/tools/page-c0acf74dd8197e01.js +1 -0
- package/.next/static/chunks/framework-3457b9c2619cdd96.js +1 -0
- package/.next/static/chunks/main-8744520a8a31e6ae.js +1 -0
- package/.next/static/chunks/main-app-e9ccddef393e28c3.js +1 -0
- package/.next/static/chunks/pages/_app-5addca2b3b969fde.js +1 -0
- package/.next/static/chunks/pages/_error-022e4ac7bbb9914f.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-3fcacae817f3ffab.js +1 -0
- package/.next/static/css/366bb38b386229a5.css +3 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/app/api/analytics/route.ts +8 -0
- package/app/api/data/route.ts +9 -0
- package/app/api/export/route.ts +26 -0
- package/app/api/files/route.ts +8 -0
- package/app/api/prices/route.ts +33 -0
- package/app/api/scan/route.ts +15 -0
- package/app/api/settings/route.ts +25 -0
- package/app/debug/page.tsx +101 -0
- package/app/diagnostics/page.tsx +113 -0
- package/app/discovery/page.tsx +61 -0
- package/app/globals.css +51 -0
- package/app/layout.tsx +30 -0
- package/app/models/page.tsx +97 -0
- package/app/optimisation/page.tsx +67 -0
- package/app/page.tsx +164 -0
- package/app/parser-debug/page.tsx +57 -0
- package/app/pricing/page.tsx +18 -0
- package/app/projects/page.tsx +111 -0
- package/app/sessions/page.tsx +24 -0
- package/app/settings/page.tsx +26 -0
- package/app/tools/page.tsx +92 -0
- package/bin/tokentrace.js +316 -0
- package/components/charts/rank-bar-chart.tsx +69 -0
- package/components/charts/trend-chart.tsx +123 -0
- package/components/empty-state.tsx +14 -0
- package/components/pricing-settings.tsx +171 -0
- package/components/session-explorer.tsx +210 -0
- package/components/settings-panel.tsx +203 -0
- package/components/sidebar.tsx +88 -0
- package/components/ui/badge.tsx +30 -0
- package/components/ui/button.tsx +47 -0
- package/components/ui/card.tsx +22 -0
- package/components/ui/input.tsx +19 -0
- package/components/ui/label.tsx +6 -0
- package/components/ui/table.tsx +31 -0
- package/components/ui/textarea.tsx +18 -0
- package/components.json +16 -0
- package/dist/runtime/db-migrate.mjs +410 -0
- package/dist/runtime/db-seed.mjs +506 -0
- package/dist/runtime/reset.mjs +519 -0
- package/dist/runtime/scan.mjs +1817 -0
- package/fixtures/generic-jsonl/sample.jsonl +2 -0
- package/next.config.mjs +7 -0
- package/package.json +96 -0
- package/postcss.config.mjs +8 -0
- package/scripts/build-cli-runtime.mjs +40 -0
- package/scripts/db-migrate.ts +5 -0
- package/scripts/db-seed.ts +5 -0
- package/scripts/reset.ts +5 -0
- package/scripts/scan.ts +30 -0
- package/src/db/client.ts +32 -0
- package/src/db/migrate-core.ts +147 -0
- package/src/db/reset.ts +14 -0
- package/src/db/schema.ts +259 -0
- package/src/db/seed.ts +110 -0
- package/src/db/settings.ts +47 -0
- package/src/ingestion/adapters/claude-code.ts +78 -0
- package/src/ingestion/adapters/codex-cli.ts +82 -0
- package/src/ingestion/adapters/generic-json.ts +93 -0
- package/src/ingestion/adapters/generic-jsonl.ts +62 -0
- package/src/ingestion/adapters/generic-log.ts +144 -0
- package/src/ingestion/adapters/generic-records.ts +178 -0
- package/src/ingestion/adapters/helpers.ts +309 -0
- package/src/ingestion/adapters/index.ts +15 -0
- package/src/ingestion/discovery.ts +130 -0
- package/src/ingestion/persist.ts +283 -0
- package/src/ingestion/scan.ts +247 -0
- package/src/ingestion/types.ts +78 -0
- package/src/lib/analytics.ts +592 -0
- package/src/lib/cost.ts +62 -0
- package/src/lib/csv.ts +15 -0
- package/src/lib/format.ts +51 -0
- package/src/lib/ids.ts +23 -0
- package/src/lib/pricing.ts +86 -0
- package/src/lib/token-estimator.ts +24 -0
- package/src/lib/utils.ts +6 -0
- package/tailwind.config.ts +53 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Download, Filter } from "lucide-react";
|
|
5
|
+
import type { SessionRow } from "@/src/lib/analytics";
|
|
6
|
+
import { formatCurrency, formatDate, formatDuration, formatTokens } from "@/src/lib/format";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Label } from "@/components/ui/label";
|
|
12
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
13
|
+
|
|
14
|
+
type ExactFilter = "all" | "exact" | "estimated";
|
|
15
|
+
|
|
16
|
+
export function SessionExplorer({
|
|
17
|
+
sessions,
|
|
18
|
+
initialProject
|
|
19
|
+
}: {
|
|
20
|
+
sessions: SessionRow[];
|
|
21
|
+
initialProject?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const [query, setQuery] = useState("");
|
|
24
|
+
const [tool, setTool] = useState("all");
|
|
25
|
+
const [model, setModel] = useState("all");
|
|
26
|
+
const [project, setProject] = useState(initialProject ?? "all");
|
|
27
|
+
const [exact, setExact] = useState<ExactFilter>("all");
|
|
28
|
+
const [from, setFrom] = useState("");
|
|
29
|
+
const [to, setTo] = useState("");
|
|
30
|
+
const [highCost, setHighCost] = useState(false);
|
|
31
|
+
|
|
32
|
+
const tools = useMemo(() => Array.from(new Set(sessions.map((session) => session.tool))).sort(), [sessions]);
|
|
33
|
+
const models = useMemo(
|
|
34
|
+
() =>
|
|
35
|
+
Array.from(
|
|
36
|
+
new Set(
|
|
37
|
+
sessions.flatMap((session) =>
|
|
38
|
+
session.models
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((item) => item.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
).sort(),
|
|
45
|
+
[sessions]
|
|
46
|
+
);
|
|
47
|
+
const projects = useMemo(
|
|
48
|
+
() => Array.from(new Set(sessions.map((session) => session.project))).sort(),
|
|
49
|
+
[sessions]
|
|
50
|
+
);
|
|
51
|
+
const highCostThreshold = useMemo(() => {
|
|
52
|
+
const costs = sessions
|
|
53
|
+
.map((session) => session.cost ?? 0)
|
|
54
|
+
.filter((cost) => cost > 0)
|
|
55
|
+
.sort((a, b) => a - b);
|
|
56
|
+
return costs.length ? costs[Math.floor(costs.length * 0.85)] : 0;
|
|
57
|
+
}, [sessions]);
|
|
58
|
+
|
|
59
|
+
const filtered = useMemo(() => {
|
|
60
|
+
const fromMs = from ? new Date(`${from}T00:00:00`).getTime() : null;
|
|
61
|
+
const toMs = to ? new Date(`${to}T23:59:59`).getTime() : null;
|
|
62
|
+
const normalizedQuery = query.toLowerCase();
|
|
63
|
+
|
|
64
|
+
return sessions.filter((session) => {
|
|
65
|
+
if (tool !== "all" && session.tool !== tool) return false;
|
|
66
|
+
if (model !== "all" && !session.models.split(",").map((item) => item.trim()).includes(model)) return false;
|
|
67
|
+
if (project !== "all" && session.project !== project) return false;
|
|
68
|
+
if (exact === "exact" && session.estimatedTokens) return false;
|
|
69
|
+
if (exact === "estimated" && !session.estimatedTokens) return false;
|
|
70
|
+
if (fromMs && (!session.startedAt || session.startedAt < fromMs)) return false;
|
|
71
|
+
if (toMs && (!session.startedAt || session.startedAt > toMs)) return false;
|
|
72
|
+
if (highCost && (session.cost ?? 0) < highCostThreshold) return false;
|
|
73
|
+
if (!normalizedQuery) return true;
|
|
74
|
+
return [
|
|
75
|
+
session.title,
|
|
76
|
+
session.sourceFile,
|
|
77
|
+
session.tool,
|
|
78
|
+
session.project,
|
|
79
|
+
session.models
|
|
80
|
+
]
|
|
81
|
+
.join(" ")
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.includes(normalizedQuery);
|
|
84
|
+
});
|
|
85
|
+
}, [exact, from, highCost, highCostThreshold, model, project, query, sessions, to, tool]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="space-y-4">
|
|
89
|
+
<Card>
|
|
90
|
+
<CardHeader>
|
|
91
|
+
<CardTitle className="flex items-center gap-2">
|
|
92
|
+
<Filter className="h-4 w-4" />
|
|
93
|
+
Filters
|
|
94
|
+
</CardTitle>
|
|
95
|
+
<CardDescription>Search by date, tool, model, project, estimated status, or high cost.</CardDescription>
|
|
96
|
+
</CardHeader>
|
|
97
|
+
<CardContent>
|
|
98
|
+
<div className="grid gap-3 md:grid-cols-4">
|
|
99
|
+
<div className="space-y-1.5">
|
|
100
|
+
<Label htmlFor="query">Search</Label>
|
|
101
|
+
<Input id="query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Title, file, model" />
|
|
102
|
+
</div>
|
|
103
|
+
<div className="space-y-1.5">
|
|
104
|
+
<Label htmlFor="tool">Tool</Label>
|
|
105
|
+
<select id="tool" className="h-9 w-full rounded-md border bg-card px-3 text-sm" value={tool} onChange={(event) => setTool(event.target.value)}>
|
|
106
|
+
<option value="all">All tools</option>
|
|
107
|
+
{tools.map((item) => (
|
|
108
|
+
<option key={item} value={item}>{item}</option>
|
|
109
|
+
))}
|
|
110
|
+
</select>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="space-y-1.5">
|
|
113
|
+
<Label htmlFor="model">Model</Label>
|
|
114
|
+
<select id="model" className="h-9 w-full rounded-md border bg-card px-3 text-sm" value={model} onChange={(event) => setModel(event.target.value)}>
|
|
115
|
+
<option value="all">All models</option>
|
|
116
|
+
{models.map((item) => (
|
|
117
|
+
<option key={item} value={item}>{item}</option>
|
|
118
|
+
))}
|
|
119
|
+
</select>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="space-y-1.5">
|
|
122
|
+
<Label htmlFor="project">Project</Label>
|
|
123
|
+
<select id="project" className="h-9 w-full rounded-md border bg-card px-3 text-sm" value={project} onChange={(event) => setProject(event.target.value)}>
|
|
124
|
+
<option value="all">All projects</option>
|
|
125
|
+
{projects.map((item) => (
|
|
126
|
+
<option key={item} value={item}>{item}</option>
|
|
127
|
+
))}
|
|
128
|
+
</select>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="space-y-1.5">
|
|
131
|
+
<Label htmlFor="from">From</Label>
|
|
132
|
+
<Input id="from" type="date" value={from} onChange={(event) => setFrom(event.target.value)} />
|
|
133
|
+
</div>
|
|
134
|
+
<div className="space-y-1.5">
|
|
135
|
+
<Label htmlFor="to">To</Label>
|
|
136
|
+
<Input id="to" type="date" value={to} onChange={(event) => setTo(event.target.value)} />
|
|
137
|
+
</div>
|
|
138
|
+
<div className="space-y-1.5">
|
|
139
|
+
<Label htmlFor="exact">Token source</Label>
|
|
140
|
+
<select id="exact" className="h-9 w-full rounded-md border bg-card px-3 text-sm" value={exact} onChange={(event) => setExact(event.target.value as ExactFilter)}>
|
|
141
|
+
<option value="all">Exact and estimated</option>
|
|
142
|
+
<option value="exact">Exact only</option>
|
|
143
|
+
<option value="estimated">Estimated only</option>
|
|
144
|
+
</select>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex items-end">
|
|
147
|
+
<label className="flex h-9 items-center gap-2 rounded-md border bg-card px-3 text-sm">
|
|
148
|
+
<input type="checkbox" checked={highCost} onChange={(event) => setHighCost(event.target.checked)} />
|
|
149
|
+
High-cost sessions
|
|
150
|
+
</label>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
|
|
156
|
+
<Card>
|
|
157
|
+
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
158
|
+
<div>
|
|
159
|
+
<CardTitle>Sessions</CardTitle>
|
|
160
|
+
<CardDescription>{filtered.length.toLocaleString()} sessions match the current filters.</CardDescription>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="flex gap-2">
|
|
163
|
+
<Button asChild variant="outline" size="sm">
|
|
164
|
+
<a href="/api/export?type=sessions">
|
|
165
|
+
<Download className="h-4 w-4" />
|
|
166
|
+
CSV
|
|
167
|
+
</a>
|
|
168
|
+
</Button>
|
|
169
|
+
</div>
|
|
170
|
+
</CardHeader>
|
|
171
|
+
<CardContent className="table-scroll">
|
|
172
|
+
<Table>
|
|
173
|
+
<TableHeader>
|
|
174
|
+
<TableRow>
|
|
175
|
+
<TableHead>Date</TableHead>
|
|
176
|
+
<TableHead>Tool</TableHead>
|
|
177
|
+
<TableHead>Project</TableHead>
|
|
178
|
+
<TableHead>Model</TableHead>
|
|
179
|
+
<TableHead>Tokens</TableHead>
|
|
180
|
+
<TableHead>Cost</TableHead>
|
|
181
|
+
<TableHead>Duration</TableHead>
|
|
182
|
+
<TableHead>Flag</TableHead>
|
|
183
|
+
<TableHead>Source file</TableHead>
|
|
184
|
+
</TableRow>
|
|
185
|
+
</TableHeader>
|
|
186
|
+
<TableBody>
|
|
187
|
+
{filtered.map((session) => (
|
|
188
|
+
<TableRow key={session.id}>
|
|
189
|
+
<TableCell>{formatDate(session.startedAt)}</TableCell>
|
|
190
|
+
<TableCell className="font-medium">{session.tool}</TableCell>
|
|
191
|
+
<TableCell>{session.project}</TableCell>
|
|
192
|
+
<TableCell className="max-w-44 truncate">{session.models}</TableCell>
|
|
193
|
+
<TableCell>{formatTokens(session.totalTokens)}</TableCell>
|
|
194
|
+
<TableCell>{formatCurrency(session.cost)}</TableCell>
|
|
195
|
+
<TableCell>{formatDuration(session.durationMs)}</TableCell>
|
|
196
|
+
<TableCell>
|
|
197
|
+
<Badge variant={session.tokenConfidence === "exact" ? "success" : session.tokenConfidence === "unknown" ? "destructive" : "warning"}>
|
|
198
|
+
{session.tokenConfidence}
|
|
199
|
+
</Badge>
|
|
200
|
+
</TableCell>
|
|
201
|
+
<TableCell className="max-w-72 truncate">{session.sourceFile}</TableCell>
|
|
202
|
+
</TableRow>
|
|
203
|
+
))}
|
|
204
|
+
</TableBody>
|
|
205
|
+
</Table>
|
|
206
|
+
</CardContent>
|
|
207
|
+
</Card>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useTransition } from "react";
|
|
4
|
+
import { FolderPlus, Play, RotateCcw, Trash2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Input } from "@/components/ui/input";
|
|
8
|
+
import { Label } from "@/components/ui/label";
|
|
9
|
+
import { Badge } from "@/components/ui/badge";
|
|
10
|
+
|
|
11
|
+
type SettingsPayload = {
|
|
12
|
+
customFolders: string[];
|
|
13
|
+
storeRawMessageContent: boolean;
|
|
14
|
+
databasePath: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ScanResult = {
|
|
18
|
+
scanRunId: string;
|
|
19
|
+
filesScanned: number;
|
|
20
|
+
recordsImported: number;
|
|
21
|
+
warnings: string[];
|
|
22
|
+
errors: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function SettingsPanel({ initialSettings }: { initialSettings: SettingsPayload }) {
|
|
26
|
+
const [customFolders, setCustomFolders] = useState(initialSettings.customFolders);
|
|
27
|
+
const [storeRaw, setStoreRaw] = useState(initialSettings.storeRawMessageContent);
|
|
28
|
+
const [newFolder, setNewFolder] = useState("");
|
|
29
|
+
const [force, setForce] = useState(false);
|
|
30
|
+
const [message, setMessage] = useState("");
|
|
31
|
+
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
|
32
|
+
const [isPending, startTransition] = useTransition();
|
|
33
|
+
|
|
34
|
+
function addFolder() {
|
|
35
|
+
const folder = newFolder.trim();
|
|
36
|
+
if (!folder) return;
|
|
37
|
+
if (!customFolders.includes(folder)) setCustomFolders((current) => [...current, folder]);
|
|
38
|
+
setNewFolder("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function removeFolder(folder: string) {
|
|
42
|
+
setCustomFolders((current) => current.filter((item) => item !== folder));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveSettings() {
|
|
46
|
+
startTransition(async () => {
|
|
47
|
+
setMessage("");
|
|
48
|
+
const response = await fetch("/api/settings", {
|
|
49
|
+
method: "PUT",
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
customFolders,
|
|
53
|
+
storeRawMessageContent: storeRaw
|
|
54
|
+
})
|
|
55
|
+
});
|
|
56
|
+
setMessage(response.ok ? "Settings saved." : "Settings save failed.");
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runScan() {
|
|
61
|
+
startTransition(async () => {
|
|
62
|
+
setMessage("Scanning local files...");
|
|
63
|
+
setScanResult(null);
|
|
64
|
+
await fetch("/api/settings", {
|
|
65
|
+
method: "PUT",
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
customFolders,
|
|
69
|
+
storeRawMessageContent: storeRaw
|
|
70
|
+
})
|
|
71
|
+
});
|
|
72
|
+
const response = await fetch("/api/scan", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ force })
|
|
76
|
+
});
|
|
77
|
+
const result = (await response.json()) as ScanResult;
|
|
78
|
+
setScanResult(result);
|
|
79
|
+
setMessage(response.ok ? "Scan complete." : "Scan failed.");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearData() {
|
|
84
|
+
if (!window.confirm("Clear imported sessions, interactions, projects, and scan history?")) return;
|
|
85
|
+
startTransition(async () => {
|
|
86
|
+
const response = await fetch("/api/data", { method: "DELETE" });
|
|
87
|
+
setMessage(response.ok ? "Imported data cleared." : "Clear failed.");
|
|
88
|
+
setScanResult(null);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-4">
|
|
94
|
+
<Card>
|
|
95
|
+
<CardHeader>
|
|
96
|
+
<CardTitle>Local Storage</CardTitle>
|
|
97
|
+
<CardDescription>SQLite database location and raw-content controls.</CardDescription>
|
|
98
|
+
</CardHeader>
|
|
99
|
+
<CardContent className="space-y-4">
|
|
100
|
+
<div className="space-y-1.5">
|
|
101
|
+
<Label>Database path</Label>
|
|
102
|
+
<div className="rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
|
103
|
+
{initialSettings.databasePath}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<label className="flex items-center gap-3 rounded-md border bg-card p-3 text-sm">
|
|
107
|
+
<input
|
|
108
|
+
type="checkbox"
|
|
109
|
+
checked={storeRaw}
|
|
110
|
+
onChange={(event) => setStoreRaw(event.target.checked)}
|
|
111
|
+
/>
|
|
112
|
+
Store raw message content
|
|
113
|
+
<Badge variant={storeRaw ? "warning" : "success"}>
|
|
114
|
+
{storeRaw ? "On" : "Default off"}
|
|
115
|
+
</Badge>
|
|
116
|
+
</label>
|
|
117
|
+
</CardContent>
|
|
118
|
+
</Card>
|
|
119
|
+
|
|
120
|
+
<Card>
|
|
121
|
+
<CardHeader>
|
|
122
|
+
<CardTitle>Custom Folders</CardTitle>
|
|
123
|
+
<CardDescription>Add folders outside the default Claude, Codex, OpenAI, and project paths.</CardDescription>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent className="space-y-4">
|
|
126
|
+
<div className="flex flex-col gap-2 sm:flex-row">
|
|
127
|
+
<Input
|
|
128
|
+
value={newFolder}
|
|
129
|
+
onChange={(event) => setNewFolder(event.target.value)}
|
|
130
|
+
placeholder="~/Library/Logs/my-ai-cli"
|
|
131
|
+
/>
|
|
132
|
+
<Button type="button" variant="outline" onClick={addFolder}>
|
|
133
|
+
<FolderPlus className="h-4 w-4" />
|
|
134
|
+
Add
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
{customFolders.length ? (
|
|
139
|
+
customFolders.map((folder) => (
|
|
140
|
+
<div key={folder} className="flex items-center justify-between gap-3 rounded-md border bg-muted/40 p-2">
|
|
141
|
+
<span className="min-w-0 truncate font-mono text-xs">{folder}</span>
|
|
142
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => removeFolder(folder)}>
|
|
143
|
+
Remove
|
|
144
|
+
</Button>
|
|
145
|
+
</div>
|
|
146
|
+
))
|
|
147
|
+
) : (
|
|
148
|
+
<p className="text-sm text-muted-foreground">No custom folders configured.</p>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
</CardContent>
|
|
152
|
+
</Card>
|
|
153
|
+
|
|
154
|
+
<Card>
|
|
155
|
+
<CardHeader>
|
|
156
|
+
<CardTitle>Scan Controls</CardTitle>
|
|
157
|
+
<CardDescription>Run discovery and import locally. Duplicate files are skipped by default.</CardDescription>
|
|
158
|
+
</CardHeader>
|
|
159
|
+
<CardContent className="space-y-4">
|
|
160
|
+
<label className="flex items-center gap-2 text-sm">
|
|
161
|
+
<input type="checkbox" checked={force} onChange={(event) => setForce(event.target.checked)} />
|
|
162
|
+
Force rescan files with previously imported hashes
|
|
163
|
+
</label>
|
|
164
|
+
<div className="flex flex-wrap gap-2">
|
|
165
|
+
<Button onClick={saveSettings} disabled={isPending}>
|
|
166
|
+
<RotateCcw className="h-4 w-4" />
|
|
167
|
+
Save settings
|
|
168
|
+
</Button>
|
|
169
|
+
<Button onClick={runScan} disabled={isPending} variant="secondary">
|
|
170
|
+
<Play className="h-4 w-4" />
|
|
171
|
+
Scan now
|
|
172
|
+
</Button>
|
|
173
|
+
<Button onClick={clearData} disabled={isPending} variant="destructive">
|
|
174
|
+
<Trash2 className="h-4 w-4" />
|
|
175
|
+
Clear imported data
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
{message ? <p className="text-sm text-muted-foreground">{message}</p> : null}
|
|
179
|
+
{scanResult ? (
|
|
180
|
+
<div className="grid gap-3 rounded-md border bg-muted/40 p-3 text-sm sm:grid-cols-4">
|
|
181
|
+
<div>
|
|
182
|
+
<div className="text-xs text-muted-foreground">Files scanned</div>
|
|
183
|
+
<div className="font-semibold">{scanResult.filesScanned.toLocaleString()}</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div>
|
|
186
|
+
<div className="text-xs text-muted-foreground">Records imported</div>
|
|
187
|
+
<div className="font-semibold">{scanResult.recordsImported.toLocaleString()}</div>
|
|
188
|
+
</div>
|
|
189
|
+
<div>
|
|
190
|
+
<div className="text-xs text-muted-foreground">Warnings</div>
|
|
191
|
+
<div className="font-semibold">{scanResult.warnings.length.toLocaleString()}</div>
|
|
192
|
+
</div>
|
|
193
|
+
<div>
|
|
194
|
+
<div className="text-xs text-muted-foreground">Errors</div>
|
|
195
|
+
<div className="font-semibold">{scanResult.errors.length.toLocaleString()}</div>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
199
|
+
</CardContent>
|
|
200
|
+
</Card>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import {
|
|
3
|
+
BarChart3,
|
|
4
|
+
Bot,
|
|
5
|
+
Bug,
|
|
6
|
+
ClipboardList,
|
|
7
|
+
FolderGit2,
|
|
8
|
+
Gauge,
|
|
9
|
+
LineChart,
|
|
10
|
+
Search,
|
|
11
|
+
Settings,
|
|
12
|
+
SlidersHorizontal,
|
|
13
|
+
Sparkles,
|
|
14
|
+
Terminal
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
|
|
17
|
+
const navItems = [
|
|
18
|
+
{ href: "/", label: "Overview", icon: Gauge },
|
|
19
|
+
{ href: "/tools", label: "Tools", icon: Terminal },
|
|
20
|
+
{ href: "/models", label: "Models", icon: Bot },
|
|
21
|
+
{ href: "/projects", label: "Projects", icon: FolderGit2 },
|
|
22
|
+
{ href: "/sessions", label: "Sessions", icon: Search },
|
|
23
|
+
{ href: "/optimisation", label: "Insights", icon: Sparkles },
|
|
24
|
+
{ href: "/pricing", label: "Pricing", icon: SlidersHorizontal },
|
|
25
|
+
{ href: "/diagnostics", label: "Diagnostics", icon: ClipboardList },
|
|
26
|
+
{ href: "/discovery", label: "Discovery", icon: BarChart3 },
|
|
27
|
+
{ href: "/parser-debug", label: "Parsers", icon: Bug },
|
|
28
|
+
{ href: "/debug", label: "Raw Data", icon: Bug },
|
|
29
|
+
{ href: "/settings", label: "Settings", icon: Settings }
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function Sidebar() {
|
|
33
|
+
return (
|
|
34
|
+
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r bg-card md:block">
|
|
35
|
+
<div className="flex h-full flex-col">
|
|
36
|
+
<div className="border-b p-5">
|
|
37
|
+
<div className="flex items-center gap-2">
|
|
38
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
39
|
+
<LineChart className="h-5 w-5" />
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<div className="text-sm font-semibold">TokenTrace CLI</div>
|
|
43
|
+
<div className="text-xs text-muted-foreground">Local analytics</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<nav className="flex-1 space-y-1 p-3">
|
|
48
|
+
{navItems.map((item) => {
|
|
49
|
+
const Icon = item.icon;
|
|
50
|
+
return (
|
|
51
|
+
<Link
|
|
52
|
+
key={item.href}
|
|
53
|
+
href={item.href}
|
|
54
|
+
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
55
|
+
>
|
|
56
|
+
<Icon className="h-4 w-4" />
|
|
57
|
+
{item.label}
|
|
58
|
+
</Link>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</nav>
|
|
62
|
+
<div className="border-t p-4 text-xs text-muted-foreground">
|
|
63
|
+
Local only. No telemetry. Raw content off by default.
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</aside>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function MobileNav() {
|
|
71
|
+
return (
|
|
72
|
+
<nav className="flex gap-2 overflow-x-auto border-b bg-card px-4 py-2 md:hidden">
|
|
73
|
+
{navItems.map((item) => {
|
|
74
|
+
const Icon = item.icon;
|
|
75
|
+
return (
|
|
76
|
+
<Link
|
|
77
|
+
key={item.href}
|
|
78
|
+
href={item.href}
|
|
79
|
+
className="flex shrink-0 items-center gap-2 rounded-md border px-3 py-2 text-xs text-muted-foreground"
|
|
80
|
+
>
|
|
81
|
+
<Icon className="h-3.5 w-3.5" />
|
|
82
|
+
{item.label}
|
|
83
|
+
</Link>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
</nav>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "@/src/lib/utils";
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "border-transparent bg-primary text-primary-foreground",
|
|
11
|
+
secondary: "border-transparent bg-muted text-muted-foreground",
|
|
12
|
+
outline: "text-foreground",
|
|
13
|
+
warning: "border-amber-300 bg-amber-50 text-amber-800",
|
|
14
|
+
success: "border-emerald-300 bg-emerald-50 text-emerald-800",
|
|
15
|
+
destructive: "border-red-300 bg-red-50 text-red-800"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
variant: "default"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export interface BadgeProps
|
|
25
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
26
|
+
VariantProps<typeof badgeVariants> {}
|
|
27
|
+
|
|
28
|
+
export function Badge({ className, variant, ...props }: BadgeProps) {
|
|
29
|
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
30
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { cn } from "@/src/lib/utils";
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"inline-flex h-9 items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
12
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
|
|
13
|
+
outline: "border bg-card hover:bg-muted",
|
|
14
|
+
ghost: "hover:bg-muted",
|
|
15
|
+
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
16
|
+
},
|
|
17
|
+
size: {
|
|
18
|
+
default: "h-9 px-3",
|
|
19
|
+
sm: "h-8 px-2.5 text-xs",
|
|
20
|
+
lg: "h-10 px-4",
|
|
21
|
+
icon: "h-9 w-9 px-0"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
size: "default"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export interface ButtonProps
|
|
32
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
33
|
+
VariantProps<typeof buttonVariants> {
|
|
34
|
+
asChild?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
38
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
39
|
+
const Comp = asChild ? Slot : "button";
|
|
40
|
+
return (
|
|
41
|
+
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
Button.displayName = "Button";
|
|
46
|
+
|
|
47
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/src/lib/utils";
|
|
3
|
+
|
|
4
|
+
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
5
|
+
return <div className={cn("rounded-lg border bg-card text-card-foreground", className)} {...props} />;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
9
|
+
return <div className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
|
13
|
+
return <h3 className={cn("text-sm font-semibold", className)} {...props} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
|
17
|
+
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
21
|
+
return <div className={cn("p-4 pt-0", className)} {...props} />;
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/src/lib/utils";
|
|
3
|
+
|
|
4
|
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
5
|
+
|
|
6
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => (
|
|
7
|
+
<input
|
|
8
|
+
type={type}
|
|
9
|
+
className={cn(
|
|
10
|
+
"flex h-9 w-full rounded-md border bg-card px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
ref={ref}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
));
|
|
17
|
+
Input.displayName = "Input";
|
|
18
|
+
|
|
19
|
+
export { Input };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/src/lib/utils";
|
|
3
|
+
|
|
4
|
+
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
|
|
5
|
+
return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
|
|
9
|
+
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
|
|
13
|
+
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
|
|
17
|
+
return <tr className={cn("border-b transition-colors hover:bg-muted/50", className)} {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
|
|
21
|
+
return (
|
|
22
|
+
<th
|
|
23
|
+
className={cn("h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground", className)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
|
|
30
|
+
return <td className={cn("p-3 align-middle", className)} {...props} />;
|
|
31
|
+
}
|