Compare commits
2 Commits
feature/ch
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8754bbef1d | ||
|
|
f240812a04 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,3 +46,7 @@ Thumbs.db
|
||||
|
||||
# Next.js build cache
|
||||
chat-demo/.next/
|
||||
dashboard/.next/
|
||||
dashboard/.next.bak/
|
||||
dashboard/node_modules/
|
||||
dashboard/pnpm-lock.yaml
|
||||
|
||||
13
dashboard/.env.example
Normal file
13
dashboard/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/json_render_dashboard_example
|
||||
|
||||
# AI (optional - for UI generation)
|
||||
AI_GATEWAY_API_KEY=
|
||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
|
||||
# Rate Limiting (Upstash Redis)
|
||||
# Optional - rate limiting is disabled when these are not set
|
||||
KV_REST_API_URL=
|
||||
KV_REST_API_TOKEN=
|
||||
RATE_LIMIT_PER_MINUTE=10
|
||||
RATE_LIMIT_PER_DAY=100
|
||||
91
dashboard/CHANGELOG.md
Normal file
91
dashboard/CHANGELOG.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bf3a7ec]
|
||||
- @json-render/core@0.15.0
|
||||
- @json-render/codegen@0.15.0
|
||||
- @json-render/react@0.15.0
|
||||
|
||||
## 0.1.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [43b7515]
|
||||
- @json-render/core@0.14.1
|
||||
- @json-render/codegen@0.14.1
|
||||
- @json-render/react@0.14.1
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a8afd8b]
|
||||
- @json-render/core@0.14.0
|
||||
- @json-render/codegen@0.14.0
|
||||
- @json-render/react@0.14.0
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5b32de8]
|
||||
- @json-render/core@0.13.0
|
||||
- @json-render/codegen@0.13.0
|
||||
- @json-render/react@0.13.0
|
||||
|
||||
## 0.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [54a1ecf]
|
||||
- @json-render/core@0.12.1
|
||||
- @json-render/codegen@0.12.1
|
||||
- @json-render/react@0.12.1
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [63c339b]
|
||||
- @json-render/core@0.12.0
|
||||
- @json-render/codegen@0.12.0
|
||||
- @json-render/react@0.12.0
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/codegen@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [9cef4e9]
|
||||
- @json-render/core@0.10.0
|
||||
- @json-render/react@0.10.0
|
||||
- @json-render/codegen@0.10.0
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b103676]
|
||||
- @json-render/react@0.9.1
|
||||
- @json-render/core@0.9.1
|
||||
- @json-render/codegen@0.9.1
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1d755c1]
|
||||
- @json-render/core@0.9.0
|
||||
- @json-render/react@0.9.0
|
||||
- @json-render/codegen@0.9.0
|
||||
60
dashboard/app/api/generate/route.ts
Normal file
60
dashboard/app/api/generate/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { streamText } from "ai";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { buildUserPrompt } from "@json-render/core";
|
||||
import { dashboardCatalog } from "@/lib/render/catalog";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
// 使用 Anthropic API(与 chat-demo 相同的配置)
|
||||
const anthropic = createAnthropic({
|
||||
baseURL: (process.env.ANTHROPIC_BASE_URL || "https://api.anthropic.com") + "/v1",
|
||||
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
});
|
||||
|
||||
const DEFAULT_MODEL = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL || "claude-sonnet-4-20250514";
|
||||
|
||||
const SYSTEM_PROMPT = dashboardCatalog.prompt();
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const headersList = await headers();
|
||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const [minuteResult, dailyResult] = await Promise.all([
|
||||
minuteRateLimit.limit(ip),
|
||||
dailyRateLimit.limit(ip),
|
||||
]);
|
||||
|
||||
if (!minuteResult.success || !dailyResult.success) {
|
||||
const isMinuteLimit = !minuteResult.success;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit exceeded",
|
||||
message: isMinuteLimit
|
||||
? "Too many requests. Please wait a moment before trying again."
|
||||
: "Daily limit reached. Please try again tomorrow.",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { prompt, context } = await req.json();
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
state: context?.state,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: anthropic(DEFAULT_MODEL),
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
15
dashboard/app/api/v1/accounts/[id]/route.ts
Normal file
15
dashboard/app/api/v1/accounts/[id]/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getAccount } from "@/lib/db/store";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const account = await getAccount(id);
|
||||
|
||||
if (!account) {
|
||||
return Response.json({ error: "Account not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(account);
|
||||
}
|
||||
27
dashboard/app/api/v1/accounts/route.ts
Normal file
27
dashboard/app/api/v1/accounts/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getAccounts } from "@/lib/db/store";
|
||||
|
||||
export async function GET() {
|
||||
const accountList = await getAccounts();
|
||||
|
||||
return Response.json({
|
||||
data: accountList,
|
||||
total: accountList.length,
|
||||
summary: {
|
||||
totalBalance: accountList.reduce(
|
||||
(sum, a) => sum + parseFloat(a.balance as string),
|
||||
0,
|
||||
),
|
||||
byType: {
|
||||
bank: accountList
|
||||
.filter((a) => a.type === "bank")
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance as string), 0),
|
||||
credit_card: accountList
|
||||
.filter((a) => a.type === "credit_card")
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance as string), 0),
|
||||
cash: accountList
|
||||
.filter((a) => a.type === "cash")
|
||||
.reduce((sum, a) => sum + parseFloat(a.balance as string), 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
44
dashboard/app/api/v1/customers/[id]/route.ts
Normal file
44
dashboard/app/api/v1/customers/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getCustomer, updateCustomer, deleteCustomer } from "@/lib/db/store";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const customer = await getCustomer(id);
|
||||
|
||||
if (!customer) {
|
||||
return Response.json({ error: "Customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(customer);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const customer = await updateCustomer(id, body);
|
||||
|
||||
if (!customer) {
|
||||
return Response.json({ error: "Customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(customer);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const result = await deleteCustomer(id);
|
||||
|
||||
if (!result.success) {
|
||||
return Response.json({ error: "Customer not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
30
dashboard/app/api/v1/customers/route.ts
Normal file
30
dashboard/app/api/v1/customers/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getCustomers, createCustomer } from "@/lib/db/store";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const search = searchParams.get("search") || undefined;
|
||||
const limit = searchParams.get("limit")
|
||||
? parseInt(searchParams.get("limit")!, 10)
|
||||
: undefined;
|
||||
const sort = (searchParams.get("sort") as "newest" | "oldest") || undefined;
|
||||
|
||||
const customerList = await getCustomers({ status, search, limit, sort });
|
||||
|
||||
return Response.json({
|
||||
data: customerList,
|
||||
total: customerList.length,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
|
||||
const customer = await createCustomer({
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
phone: body.phone || undefined,
|
||||
});
|
||||
|
||||
return Response.json(customer, { status: 201 });
|
||||
}
|
||||
18
dashboard/app/api/v1/expenses/[id]/approve/route.ts
Normal file
18
dashboard/app/api/v1/expenses/[id]/approve/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { approveExpense } from "@/lib/db/store";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const result = await approveExpense(id);
|
||||
|
||||
if (result && "error" in result) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
message: "Expense approved",
|
||||
expense: result,
|
||||
});
|
||||
}
|
||||
18
dashboard/app/api/v1/expenses/[id]/reject/route.ts
Normal file
18
dashboard/app/api/v1/expenses/[id]/reject/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rejectExpense } from "@/lib/db/store";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const result = await rejectExpense(id);
|
||||
|
||||
if (result && "error" in result) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
message: "Expense rejected",
|
||||
expense: result,
|
||||
});
|
||||
}
|
||||
44
dashboard/app/api/v1/expenses/[id]/route.ts
Normal file
44
dashboard/app/api/v1/expenses/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getExpense, updateExpense, deleteExpense } from "@/lib/db/store";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const expense = await getExpense(id);
|
||||
|
||||
if (!expense) {
|
||||
return Response.json({ error: "Expense not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(expense);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const expense = await updateExpense(id, body);
|
||||
|
||||
if (!expense) {
|
||||
return Response.json({ error: "Expense not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(expense);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const deleted = await deleteExpense(id);
|
||||
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Expense not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
39
dashboard/app/api/v1/expenses/route.ts
Normal file
39
dashboard/app/api/v1/expenses/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getExpenses, createExpense } from "@/lib/db/store";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const category = searchParams.get("category") || undefined;
|
||||
|
||||
const expenseList = await getExpenses({ status, category });
|
||||
|
||||
return Response.json({
|
||||
data: expenseList,
|
||||
total: expenseList.length,
|
||||
summary: {
|
||||
totalAmount: expenseList.reduce(
|
||||
(sum, e) => sum + parseFloat(e.amount as string),
|
||||
0,
|
||||
),
|
||||
byStatus: {
|
||||
pending: expenseList.filter((e) => e.status === "pending").length,
|
||||
approved: expenseList.filter((e) => e.status === "approved").length,
|
||||
rejected: expenseList.filter((e) => e.status === "rejected").length,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
|
||||
const expense = await createExpense({
|
||||
vendor: body.vendor,
|
||||
category: body.category,
|
||||
amount: body.amount,
|
||||
date: body.date,
|
||||
description: body.description,
|
||||
});
|
||||
|
||||
return Response.json(expense, { status: 201 });
|
||||
}
|
||||
18
dashboard/app/api/v1/invoices/[id]/mark-paid/route.ts
Normal file
18
dashboard/app/api/v1/invoices/[id]/mark-paid/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { markInvoicePaid } from "@/lib/db/store";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const result = await markInvoicePaid(id);
|
||||
|
||||
if (result && "error" in result) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
message: "Invoice marked as paid",
|
||||
invoice: result,
|
||||
});
|
||||
}
|
||||
44
dashboard/app/api/v1/invoices/[id]/route.ts
Normal file
44
dashboard/app/api/v1/invoices/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getInvoice, updateInvoice, deleteInvoice } from "@/lib/db/store";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const invoice = await getInvoice(id);
|
||||
|
||||
if (!invoice) {
|
||||
return Response.json({ error: "Invoice not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(invoice);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const invoice = await updateInvoice(id, body);
|
||||
|
||||
if (!invoice) {
|
||||
return Response.json({ error: "Invoice not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(invoice);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const deleted = await deleteInvoice(id);
|
||||
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Invoice not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
18
dashboard/app/api/v1/invoices/[id]/send/route.ts
Normal file
18
dashboard/app/api/v1/invoices/[id]/send/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { sendInvoice } from "@/lib/db/store";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const result = await sendInvoice(id);
|
||||
|
||||
if (result && "error" in result) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
message: "Invoice sent successfully",
|
||||
invoice: result,
|
||||
});
|
||||
}
|
||||
42
dashboard/app/api/v1/invoices/route.ts
Normal file
42
dashboard/app/api/v1/invoices/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getInvoices, createInvoice } from "@/lib/db/store";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const customerId = searchParams.get("customerId") || undefined;
|
||||
|
||||
const invoiceList = await getInvoices({ status, customerId });
|
||||
|
||||
return Response.json({
|
||||
data: invoiceList,
|
||||
total: invoiceList.length,
|
||||
summary: {
|
||||
totalAmount: invoiceList.reduce(
|
||||
(sum, i) => sum + parseFloat(i.amount as string),
|
||||
0,
|
||||
),
|
||||
byStatus: {
|
||||
draft: invoiceList.filter((i) => i.status === "draft").length,
|
||||
sent: invoiceList.filter((i) => i.status === "sent").length,
|
||||
paid: invoiceList.filter((i) => i.status === "paid").length,
|
||||
overdue: invoiceList.filter((i) => i.status === "overdue").length,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
|
||||
const invoice = await createInvoice({
|
||||
customerId: body.customerId,
|
||||
dueDate: body.dueDate,
|
||||
items: body.items,
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
return Response.json({ error: "Customer not found" }, { status: 400 });
|
||||
}
|
||||
|
||||
return Response.json(invoice, { status: 201 });
|
||||
}
|
||||
45
dashboard/app/api/v1/reports/export/route.ts
Normal file
45
dashboard/app/api/v1/reports/export/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
getDashboardSummary,
|
||||
getInvoices,
|
||||
getExpenses,
|
||||
getCustomers,
|
||||
} from "@/lib/db/store";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const { format = "json", reportType = "summary" } = body;
|
||||
|
||||
let data: unknown;
|
||||
|
||||
switch (reportType) {
|
||||
case "summary":
|
||||
data = await getDashboardSummary();
|
||||
break;
|
||||
case "invoices":
|
||||
data = await getInvoices();
|
||||
break;
|
||||
case "expenses":
|
||||
data = await getExpenses();
|
||||
break;
|
||||
case "customers":
|
||||
data = await getCustomers();
|
||||
break;
|
||||
default:
|
||||
data = await getDashboardSummary();
|
||||
}
|
||||
|
||||
if (format === "csv") {
|
||||
return Response.json({
|
||||
message: "CSV export initiated",
|
||||
downloadUrl: `/api/v1/reports/download/${reportType}.csv`,
|
||||
format: "csv",
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
message: "Report generated",
|
||||
reportType,
|
||||
generatedAt: new Date().toISOString(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
10
dashboard/app/api/v1/reports/profit-loss/route.ts
Normal file
10
dashboard/app/api/v1/reports/profit-loss/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getProfitLossReport } from "@/lib/db/store";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const startDate = searchParams.get("startDate") || undefined;
|
||||
const endDate = searchParams.get("endDate") || undefined;
|
||||
|
||||
const report = await getProfitLossReport(startDate, endDate);
|
||||
return Response.json(report);
|
||||
}
|
||||
6
dashboard/app/api/v1/reset/route.ts
Normal file
6
dashboard/app/api/v1/reset/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { resetDatabase } from "@/lib/db/store";
|
||||
|
||||
export async function POST() {
|
||||
const result = await resetDatabase();
|
||||
return Response.json(result);
|
||||
}
|
||||
44
dashboard/app/api/v1/widgets/[id]/route.ts
Normal file
44
dashboard/app/api/v1/widgets/[id]/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getWidget, updateWidget, deleteWidget } from "@/lib/db/store";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const widget = await getWidget(id);
|
||||
|
||||
if (!widget) {
|
||||
return Response.json({ error: "Widget not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(widget);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const widget = await updateWidget(id, body);
|
||||
|
||||
if (!widget) {
|
||||
return Response.json({ error: "Widget not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(widget);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const deleted = await deleteWidget(id);
|
||||
|
||||
if (!deleted) {
|
||||
return Response.json({ error: "Widget not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
15
dashboard/app/api/v1/widgets/reorder/route.ts
Normal file
15
dashboard/app/api/v1/widgets/reorder/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { reorderWidgets } from "@/lib/db/store";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
|
||||
if (!body.orderedIds || !Array.isArray(body.orderedIds)) {
|
||||
return Response.json(
|
||||
{ error: "orderedIds array is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await reorderWidgets(body.orderedIds);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
28
dashboard/app/api/v1/widgets/route.ts
Normal file
28
dashboard/app/api/v1/widgets/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getWidgets, createWidget } from "@/lib/db/store";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const widgetList = await getWidgets();
|
||||
return Response.json({ data: widgetList, total: widgetList.length });
|
||||
} catch {
|
||||
return Response.json({ data: [], total: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
|
||||
if (!body.prompt || !body.spec) {
|
||||
return Response.json(
|
||||
{ error: "prompt and spec are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const widget = await createWidget({
|
||||
prompt: body.prompt,
|
||||
spec: body.spec,
|
||||
});
|
||||
|
||||
return Response.json(widget, { status: 201 });
|
||||
}
|
||||
BIN
dashboard/app/fonts/GeistMonoVF.woff
Normal file
BIN
dashboard/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
dashboard/app/fonts/GeistVF.woff
Normal file
BIN
dashboard/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
176
dashboard/app/globals.css
Normal file
176
dashboard/app/globals.css
Normal file
@@ -0,0 +1,176 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-background: #000;
|
||||
--color-foreground: #fafafa;
|
||||
--color-card: #0a0a0a;
|
||||
--color-card-foreground: #fafafa;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #000;
|
||||
--color-secondary: #262626;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a3a3a3;
|
||||
--color-accent: #262626;
|
||||
--color-accent-foreground: #fafafa;
|
||||
--color-destructive: #dc2626;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-border: #262626;
|
||||
--color-input: #262626;
|
||||
--color-ring: #fafafa;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
code, pre, .font-mono {
|
||||
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shiki dual theme support */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
color: var(--shiki-light) !important;
|
||||
background-color: var(--shiki-light-bg) !important;
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
39
dashboard/app/layout.tsx
Normal file
39
dashboard/app/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard | json-render",
|
||||
description: "AI-generated dashboard widgets with guardrails",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
BIN
dashboard/app/opengraph-image.png
Normal file
BIN
dashboard/app/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
153
dashboard/app/page.tsx
Normal file
153
dashboard/app/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { Widget } from "@/components/widget";
|
||||
import { Header } from "@/components/header";
|
||||
import { AddWidgetCard } from "@/components/add-widget-card";
|
||||
import { SortableWidget, type SavedWidget } from "@/components/sortable-widget";
|
||||
|
||||
function DashboardContent() {
|
||||
const [savedWidgets, setSavedWidgets] = useState<SavedWidget[]>([]);
|
||||
const [newWidgetCount, setNewWidgetCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Load saved widgets on mount
|
||||
useEffect(() => {
|
||||
async function loadWidgets() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/widgets");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setSavedWidgets(data.data || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load widgets:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
loadWidgets();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleWidgetSaved = useCallback((_id: string) => {
|
||||
// Reload saved widgets to get the new one
|
||||
fetch("/api/v1/widgets")
|
||||
.then((res) => (res.ok ? res.json() : { data: [] }))
|
||||
.then((data) => {
|
||||
setSavedWidgets(data.data || []);
|
||||
// Remove the new widget slot since it's now saved
|
||||
setNewWidgetCount((prev) => Math.max(0, prev - 1));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddWidget = useCallback(() => {
|
||||
setNewWidgetCount((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handleClearWidget = useCallback(async (id?: string) => {
|
||||
if (id) {
|
||||
// Delete from database
|
||||
await fetch(`/api/v1/widgets/${id}`, { method: "DELETE" });
|
||||
setSavedWidgets((prev) => prev.filter((w) => w.id !== id));
|
||||
} else {
|
||||
// Just remove the new widget slot
|
||||
setNewWidgetCount((prev) => Math.max(0, prev - 1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setSavedWidgets((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Persist the new order to the server
|
||||
fetch("/api/v1/widgets/reorder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderedIds: newItems.map((w) => w.id) }),
|
||||
}).catch((err) => console.error("Failed to save widget order:", err));
|
||||
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 p-6">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={savedWidgets.map((w) => w.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Saved widgets */}
|
||||
{savedWidgets.map((widget) => (
|
||||
<SortableWidget
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onDeleted={() => handleClearWidget(widget.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* New empty widgets */}
|
||||
{Array.from({ length: newWidgetCount }).map((_, i) => (
|
||||
<Widget
|
||||
key={`new-${i}`}
|
||||
onSaved={handleWidgetSaved}
|
||||
onDeleted={() => handleClearWidget()}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add widget card */}
|
||||
<AddWidgetCard onClick={handleAddWidget} />
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardContent />;
|
||||
}
|
||||
23
dashboard/components.json
Normal file
23
dashboard/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
19
dashboard/components/add-widget-card.tsx
Normal file
19
dashboard/components/add-widget-card.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface AddWidgetCardProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AddWidgetCard({ onClick }: AddWidgetCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="aspect-[4/3] flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<Plus className="h-8 w-8 text-muted-foreground/50" />
|
||||
<span className="mt-2 text-sm text-muted-foreground/50">Add Widget</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
173
dashboard/components/code-highlight.tsx
Normal file
173
dashboard/components/code-highlight.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { codeToHtml } from "shiki";
|
||||
|
||||
const vercelDarkTheme = {
|
||||
name: "vercel-dark",
|
||||
type: "dark" as const,
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#EDEDED",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#666666" },
|
||||
},
|
||||
{
|
||||
scope: ["string", "string.quoted", "string.template"],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"constant.language.boolean",
|
||||
"constant.language.null",
|
||||
],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#FF0080" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#FF0080" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#7928CA" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other", "variable.parameter"],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#FF0080" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#888888" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const vercelLightTheme = {
|
||||
name: "vercel-light",
|
||||
type: "light" as const,
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#171717",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#6b7280" },
|
||||
},
|
||||
{
|
||||
scope: ["string", "string.quoted", "string.template"],
|
||||
settings: { foreground: "#067a6e" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"constant.language.boolean",
|
||||
"constant.language.null",
|
||||
],
|
||||
settings: { foreground: "#067a6e" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#d6409f" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#d6409f" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#6e56cf" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other", "variable.parameter"],
|
||||
settings: { foreground: "#171717" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#d6409f" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#6b7280" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
],
|
||||
settings: { foreground: "#171717" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#067a6e" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#067a6e" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface CodeHighlightProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function CodeHighlight({ code, language = "json" }: CodeHighlightProps) {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
light: vercelLightTheme,
|
||||
dark: vercelDarkTheme,
|
||||
},
|
||||
defaultColor: false,
|
||||
}).then(setHtml);
|
||||
}, [code, language]);
|
||||
|
||||
if (!html) {
|
||||
return (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{code}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-xs [&_pre]:!bg-transparent [&_pre]:!p-0 [&_code]:!bg-transparent [&_.shiki]:!bg-transparent"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
78
dashboard/components/header.tsx
Normal file
78
dashboard/components/header.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-background">
|
||||
<div className="flex h-14 items-center justify-between px-4 gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="https://vercel.com" title="Made with love by Vercel">
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="18"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="18"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 1L16 15H0L8 1Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<span className="text-(--ds-gray-500)">
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="font-medium tracking-tight text-lg">
|
||||
Dashboard Example
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://json-render.vercel.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/vercel-labs/json-render"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className="h-4 w-4"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">GitHub</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
46
dashboard/components/sortable-widget.tsx
Normal file
46
dashboard/components/sortable-widget.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { Spec } from "@json-render/react";
|
||||
import { Widget } from "./widget";
|
||||
|
||||
export interface SavedWidget {
|
||||
id: string;
|
||||
prompt: string;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
interface SortableWidgetProps {
|
||||
widget: SavedWidget;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function SortableWidget({ widget, onDeleted }: SortableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: widget.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<Widget
|
||||
id={widget.id}
|
||||
initialPrompt={widget.prompt}
|
||||
savedSpec={widget.spec}
|
||||
onDeleted={onDeleted}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
dashboard/components/theme-provider.tsx
Normal file
16
dashboard/components/theme-provider.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
36
dashboard/components/theme-toggle.tsx
Normal file
36
dashboard/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className="p-2 rounded-md border border-border bg-card">
|
||||
<Sun className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="p-2 rounded-md border border-border bg-card hover:bg-accent transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-4 w-4" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
66
dashboard/components/ui/accordion.tsx
Normal file
66
dashboard/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
66
dashboard/components/ui/alert.tsx
Normal file
66
dashboard/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
53
dashboard/components/ui/animated-border.tsx
Normal file
53
dashboard/components/ui/animated-border.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedBorderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AnimatedBorder = ({ className }: AnimatedBorderProps) => {
|
||||
return (
|
||||
<>
|
||||
<style jsx>{`
|
||||
@property --angle {
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes border-rotate {
|
||||
from {
|
||||
--angle: 0deg;
|
||||
}
|
||||
to {
|
||||
--angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-border-mask {
|
||||
animation: border-rotate 2s linear infinite;
|
||||
mask-image: conic-gradient(
|
||||
from var(--angle),
|
||||
transparent 70%,
|
||||
black 90%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] animate-border-mask z-10",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-[inherit] border border-blue-400"
|
||||
style={{
|
||||
boxShadow: "0 0 6px 1px #3b82f6, inset 0 0 2px 0 #3b82f6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
109
dashboard/components/ui/avatar.tsx
Normal file
109
dashboard/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarBadge,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
};
|
||||
48
dashboard/components/ui/badge.tsx
Normal file
48
dashboard/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
64
dashboard/components/ui/button.tsx
Normal file
64
dashboard/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
dashboard/components/ui/card.tsx
Normal file
92
dashboard/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
357
dashboard/components/ui/chart.tsx
Normal file
357
dashboard/components/ui/chart.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
32
dashboard/components/ui/checkbox.tsx
Normal file
32
dashboard/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
158
dashboard/components/ui/dialog.tsx
Normal file
158
dashboard/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
135
dashboard/components/ui/drawer.tsx
Normal file
135
dashboard/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
257
dashboard/components/ui/dropdown-menu.tsx
Normal file
257
dashboard/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
21
dashboard/components/ui/input.tsx
Normal file
21
dashboard/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
24
dashboard/components/ui/label.tsx
Normal file
24
dashboard/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Label as LabelPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
127
dashboard/components/ui/pagination.tsx
Normal file
127
dashboard/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants, type Button } from "@/components/ui/button";
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
89
dashboard/components/ui/popover.tsx
Normal file
89
dashboard/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
};
|
||||
31
dashboard/components/ui/progress.tsx
Normal file
31
dashboard/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Progress as ProgressPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
45
dashboard/components/ui/radio-group.tsx
Normal file
45
dashboard/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
190
dashboard/components/ui/select.tsx
Normal file
190
dashboard/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
28
dashboard/components/ui/separator.tsx
Normal file
28
dashboard/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
13
dashboard/components/ui/skeleton.tsx
Normal file
13
dashboard/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
35
dashboard/components/ui/switch.tsx
Normal file
35
dashboard/components/ui/switch.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Switch as SwitchPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
116
dashboard/components/ui/table.tsx
Normal file
116
dashboard/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
91
dashboard/components/ui/tabs.tsx
Normal file
91
dashboard/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
18
dashboard/components/ui/textarea.tsx
Normal file
18
dashboard/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
57
dashboard/components/ui/tooltip.tsx
Normal file
57
dashboard/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
538
dashboard/components/widget.tsx
Normal file
538
dashboard/components/widget.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Check,
|
||||
Code,
|
||||
Copy,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useUIStream, type Spec } from "@json-render/react";
|
||||
import { DashboardRenderer } from "@/lib/render/renderer";
|
||||
import { executeAction } from "@/lib/render/registry";
|
||||
import { CodeHighlight } from "@/components/code-highlight";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AnimatedBorder } from "@/components/ui/animated-border";
|
||||
|
||||
const suggestions = [
|
||||
"Customer list with delete",
|
||||
"Create customer form",
|
||||
"Invoice summary",
|
||||
"Revenue metrics",
|
||||
];
|
||||
|
||||
interface WidgetProps {
|
||||
id?: string;
|
||||
initialPrompt?: string;
|
||||
initialSpec?: Spec;
|
||||
savedSpec?: Spec; // 新增:用于已保存的 widget
|
||||
onGenerated?: () => void;
|
||||
onCleared?: () => void;
|
||||
onDeleted?: () => void;
|
||||
onSaved?: (id: string) => void;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export function Widget({
|
||||
id: initialId,
|
||||
initialPrompt,
|
||||
initialSpec,
|
||||
savedSpec, // 新增
|
||||
onGenerated,
|
||||
onCleared,
|
||||
onDeleted,
|
||||
onSaved,
|
||||
dragHandleProps,
|
||||
}: WidgetProps): React.ReactElement {
|
||||
const [prompt, setPrompt] = useState(initialPrompt || "");
|
||||
const [state, setState] = useState<Record<string, unknown>>({});
|
||||
const [widgetId, setWidgetId] = useState<string | undefined>(initialId);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const promptRef = useRef(prompt);
|
||||
const stateRef = useRef(state);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const promptInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep stateRef in sync
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
// Auto-focus prompt input for new widgets
|
||||
useEffect(() => {
|
||||
if (!initialSpec && promptInputRef.current) {
|
||||
promptInputRef.current.focus();
|
||||
}
|
||||
}, [initialSpec]);
|
||||
|
||||
const { spec, isStreaming, error, send, clear } = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err) => console.error("Widget generation error:", err),
|
||||
onComplete: async (completedSpec) => {
|
||||
// Save to database when generation completes
|
||||
const currentPrompt = promptRef.current;
|
||||
if (completedSpec && currentPrompt) {
|
||||
try {
|
||||
if (widgetId) {
|
||||
// Update existing widget
|
||||
await fetch(`/api/v1/widgets/${widgetId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: currentPrompt,
|
||||
spec: completedSpec,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// Create new widget
|
||||
const res = await fetch("/api/v1/widgets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: currentPrompt,
|
||||
spec: completedSpec,
|
||||
}),
|
||||
});
|
||||
const saved = await res.json();
|
||||
setWidgetId(saved.id);
|
||||
onSaved?.(saved.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save widget:", err);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Keep promptRef in sync
|
||||
useEffect(() => {
|
||||
promptRef.current = prompt;
|
||||
}, [prompt]);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
async (text?: string) => {
|
||||
const p = text || prompt;
|
||||
if (!p.trim()) return;
|
||||
if (text) setPrompt(text);
|
||||
await send(p, { state });
|
||||
onGenerated?.();
|
||||
},
|
||||
[prompt, send, state, onGenerated],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleClear = useCallback(() => {
|
||||
clear();
|
||||
setPrompt("");
|
||||
setState({});
|
||||
onCleared?.();
|
||||
}, [clear, onCleared]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
},
|
||||
[handleGenerate],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setEditPrompt("");
|
||||
setIsEditing(true);
|
||||
setTimeout(() => editInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const handleEditSubmit = useCallback(async () => {
|
||||
if (editPrompt.trim()) {
|
||||
setIsEditing(false);
|
||||
// Pass the current spec so AI can modify it instead of replacing
|
||||
const existingSpec = spec || initialSpec;
|
||||
await send(editPrompt, { state, previousSpec: existingSpec });
|
||||
onGenerated?.();
|
||||
}
|
||||
}, [editPrompt, send, state, spec, initialSpec, onGenerated]);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEditSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[handleEditSubmit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (widgetId) {
|
||||
try {
|
||||
await fetch(`/api/v1/widgets/${widgetId}`, { method: "DELETE" });
|
||||
toast.success("Widget deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete widget:", err);
|
||||
toast.error("Failed to delete widget");
|
||||
}
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
onDeleted?.();
|
||||
}, [widgetId, onDeleted]);
|
||||
|
||||
const handleStateChange = useCallback(
|
||||
(changes: Array<{ path: string; value: unknown }>) => {
|
||||
setState((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const { path, value } of changes) {
|
||||
const parts = path.split("/");
|
||||
let current: Record<string, unknown> = next;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
if (!(part in current) || typeof current[part] !== "object") {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
const lastPart = parts[parts.length - 1]!;
|
||||
current[lastPart] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Use spec from stream, or savedSpec/initialSpec for saved widgets
|
||||
const currentSpec = spec || savedSpec || initialSpec;
|
||||
|
||||
// Auto-run initial actions when spec loads (for saved widgets)
|
||||
useEffect(() => {
|
||||
if (!currentSpec || isStreaming) return;
|
||||
|
||||
// Check for initialActions in spec metadata
|
||||
const specWithMeta = currentSpec as Spec & {
|
||||
initialActions?: Array<{
|
||||
action: string;
|
||||
params?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
if (specWithMeta.initialActions) {
|
||||
specWithMeta.initialActions.forEach(({ action, params }) => {
|
||||
executeAction(action, params, setState);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-detect: find Tables/Charts and run their data-fetching actions
|
||||
const elements = currentSpec.elements || {};
|
||||
const dataComponents = ["Table", "BarChart", "LineChart"];
|
||||
const actionsRun = new Set<string>();
|
||||
|
||||
for (const el of Object.values(elements)) {
|
||||
const element = el as { type: string; props?: Record<string, unknown> };
|
||||
if (dataComponents.includes(element.type) && element.props?.statePath) {
|
||||
const statePath = element.props.statePath as string;
|
||||
// Extract resource name (e.g., "customers" from "customers.data")
|
||||
const resource = statePath.split(".")[0];
|
||||
if (!resource || actionsRun.has(resource)) continue;
|
||||
// Find a button that loads this data
|
||||
for (const btnEl of Object.values(elements)) {
|
||||
const btn = btnEl as {
|
||||
type: string;
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
if (btn.type === "Button" && btn.props?.action) {
|
||||
const action = btn.props.action as string;
|
||||
if (
|
||||
action.toLowerCase().includes(resource) &&
|
||||
(action.startsWith("view") ||
|
||||
action.startsWith("refresh") ||
|
||||
action.startsWith("load"))
|
||||
) {
|
||||
// Run this action with its params
|
||||
executeAction(
|
||||
action,
|
||||
btn.props.actionParams as Record<string, unknown>,
|
||||
setState,
|
||||
);
|
||||
actionsRun.add(resource);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentSpec, isStreaming]);
|
||||
|
||||
// Auto-load data for widgets with data arrays (customers, invoices, expenses, etc.)
|
||||
useEffect(() => {
|
||||
// Use savedSpec for saved widgets, or spec for streaming widgets
|
||||
const activeSpec = savedSpec || spec;
|
||||
|
||||
console.log('[Auto-load] Checking widget:', {
|
||||
hasSpec: !!spec,
|
||||
hasSavedSpec: !!savedSpec,
|
||||
hasActiveSpec: !!activeSpec,
|
||||
hasState: !!(activeSpec?.state),
|
||||
hasInitialSpec: !!initialSpec,
|
||||
isStreaming
|
||||
});
|
||||
// Only auto-load when:
|
||||
// 1. activeSpec exists and has state (widget is fully loaded)
|
||||
// 2. Not editing (no initialSpec)
|
||||
// 3. Is a saved widget (has savedSpec)
|
||||
// 4. Not currently streaming (isStreaming is false)
|
||||
if (!activeSpec || !activeSpec.state || initialSpec || !savedSpec || isStreaming) return;
|
||||
console.log('[Auto-load] Widget passed initial check');
|
||||
|
||||
// Find data array keys in state (e.g., customers, invoices, expenses)
|
||||
const state = activeSpec.state || {};
|
||||
console.log('[Auto-load] State keys:', Object.keys(state));
|
||||
const dataKeys = Object.keys(state).filter(key =>
|
||||
key.endsWith('s') && // Plural nouns typically indicate lists
|
||||
Array.isArray(state[key]) &&
|
||||
(state[key] as unknown[]).length === 0 // Only load if empty
|
||||
);
|
||||
|
||||
console.log('[Auto-load] Found data keys:', dataKeys);
|
||||
if (dataKeys.length === 0) return;
|
||||
|
||||
// Map data keys to action names
|
||||
const actionMap: Record<string, string> = {
|
||||
customers: 'viewCustomers',
|
||||
invoices: 'viewInvoices',
|
||||
expenses: 'viewExpenses',
|
||||
accounts: 'viewAccounts',
|
||||
transactions: 'viewTransactions',
|
||||
};
|
||||
|
||||
// Call actions to load data
|
||||
dataKeys.forEach(key => {
|
||||
const actionName = actionMap[key];
|
||||
if (actionName) {
|
||||
console.log('[Auto-load] Calling action:', actionName, 'for key:', key);
|
||||
executeAction(actionName, {}, setState)
|
||||
.catch(err => console.error(`Failed to load ${key}:`, err));
|
||||
}
|
||||
});
|
||||
}, [spec, initialSpec, savedSpec, isStreaming, setState]);
|
||||
|
||||
const hasContent =
|
||||
currentSpec && Object.keys(currentSpec.elements).length > 0;
|
||||
|
||||
// Title derived from prompt
|
||||
const title = prompt.trim() || "New Widget";
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
if (currentSpec) {
|
||||
await navigator.clipboard.writeText(JSON.stringify(currentSpec, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group relative flex flex-col aspect-[4/3] py-0 gap-0">
|
||||
{isStreaming && <AnimatedBorder />}
|
||||
{/* Title bar */}
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between bg-muted/30">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{dragHandleProps && (
|
||||
<button
|
||||
{...dragHandleProps}
|
||||
className="h-6 w-6 p-0 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing touch-none"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate flex-1" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Hover actions */}
|
||||
{hasContent && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 w-6 p-0 text-muted-foreground hover:text-foreground ${showCode ? "bg-muted" : ""}`}
|
||||
title={showCode ? "Show preview" : "Show code"}
|
||||
>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<CardContent className="relative flex-1 overflow-auto p-4">
|
||||
{error ? (
|
||||
<div className="text-destructive text-sm">{error.message}</div>
|
||||
) : !hasContent && !isStreaming ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3">
|
||||
<p className="text-muted-foreground text-sm">Try one of these:</p>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center max-w-[300px]">
|
||||
{suggestions.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
onClick={() => handleGenerate(s)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : currentSpec ? (
|
||||
showCode ? (
|
||||
<div className="relative h-full">
|
||||
<Button
|
||||
onClick={handleCopyCode}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<CodeHighlight code={JSON.stringify(currentSpec, null, 2)} />
|
||||
</div>
|
||||
) : (
|
||||
<DashboardRenderer
|
||||
spec={currentSpec}
|
||||
state={state}
|
||||
setState={setState}
|
||||
onStateChange={handleStateChange}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Edit overlay */}
|
||||
{isEditing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
|
||||
<div className="w-full max-w-sm px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editPrompt}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
placeholder="Update widget prompt..."
|
||||
className="flex-1 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button onClick={handleEditSubmit} size="sm">
|
||||
Go
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsEditing(false)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Prompt input at bottom - only show for new widgets */}
|
||||
{!hasContent && (
|
||||
<div className="p-3 border-t flex gap-2">
|
||||
<Input
|
||||
ref={promptInputRef}
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Describe this widget..."
|
||||
disabled={isStreaming}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleGenerate()}
|
||||
disabled={isStreaming || !prompt.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{isStreaming ? "..." : "Go"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete widget</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this widget? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDelete} variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
10
dashboard/drizzle.config.ts
Normal file
10
dashboard/drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./lib/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
19
dashboard/eslint.config.js
Normal file
19
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
// Disable prop-types - we use TypeScript for type checking
|
||||
"react/prop-types": "off",
|
||||
// Allow styled-jsx
|
||||
"react/no-unknown-property": ["error", { ignore: ["jsx"] }],
|
||||
// Allow DATABASE_URL env var
|
||||
"turbo/no-undeclared-env-vars": [
|
||||
"error",
|
||||
{ allowList: ["DATABASE_URL"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
36
dashboard/lib/db/connection.ts
Normal file
36
dashboard/lib/db/connection.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Lazy initialization to allow builds without DATABASE_URL
|
||||
let _db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
let _migrationClient: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
function getConnectionString(): string {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL environment variable is not set");
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
// For query purposes - lazily initialized
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle<typeof schema>>, {
|
||||
get(_target, prop) {
|
||||
if (!_db) {
|
||||
const connectionString = getConnectionString();
|
||||
const queryClient = postgres(connectionString);
|
||||
_db = drizzle(queryClient, { schema });
|
||||
}
|
||||
return Reflect.get(_db, prop);
|
||||
},
|
||||
});
|
||||
|
||||
// For migrations - lazily initialized
|
||||
export function getMigrationClient() {
|
||||
if (!_migrationClient) {
|
||||
const connectionString = getConnectionString();
|
||||
_migrationClient = postgres(connectionString, { max: 1 });
|
||||
}
|
||||
return _migrationClient;
|
||||
}
|
||||
133
dashboard/lib/db/schema.ts
Normal file
133
dashboard/lib/db/schema.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
pgTable,
|
||||
varchar,
|
||||
text,
|
||||
integer,
|
||||
decimal,
|
||||
timestamp,
|
||||
pgEnum,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// Enums
|
||||
export const customerStatusEnum = pgEnum("customer_status", [
|
||||
"active",
|
||||
"inactive",
|
||||
]);
|
||||
|
||||
export const invoiceStatusEnum = pgEnum("invoice_status", [
|
||||
"draft",
|
||||
"sent",
|
||||
"paid",
|
||||
"overdue",
|
||||
]);
|
||||
|
||||
export const expenseStatusEnum = pgEnum("expense_status", [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
]);
|
||||
|
||||
export const accountTypeEnum = pgEnum("account_type", [
|
||||
"bank",
|
||||
"credit_card",
|
||||
"cash",
|
||||
]);
|
||||
|
||||
export const transactionTypeEnum = pgEnum("transaction_type", [
|
||||
"income",
|
||||
"expense",
|
||||
"transfer",
|
||||
]);
|
||||
|
||||
// Tables
|
||||
export const customers = pgTable("customers", {
|
||||
id: varchar("id", { length: 50 }).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
phone: varchar("phone", { length: 50 }),
|
||||
balance: decimal("balance", { precision: 12, scale: 2 })
|
||||
.notNull()
|
||||
.default("0"),
|
||||
status: customerStatusEnum("status").notNull().default("active"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const invoices = pgTable("invoices", {
|
||||
id: varchar("id", { length: 50 }).primaryKey(),
|
||||
customerId: varchar("customer_id", { length: 50 })
|
||||
.notNull()
|
||||
.references(() => customers.id),
|
||||
customerName: varchar("customer_name", { length: 255 }).notNull(),
|
||||
amount: decimal("amount", { precision: 12, scale: 2 }).notNull(),
|
||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||
dueDate: timestamp("due_date").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
items: jsonb("items").$type<InvoiceItem[]>().notNull().default([]),
|
||||
});
|
||||
|
||||
export const expenses = pgTable("expenses", {
|
||||
id: varchar("id", { length: 50 }).primaryKey(),
|
||||
vendor: varchar("vendor", { length: 255 }).notNull(),
|
||||
category: varchar("category", { length: 100 }).notNull(),
|
||||
amount: decimal("amount", { precision: 12, scale: 2 }).notNull(),
|
||||
date: timestamp("date").notNull(),
|
||||
status: expenseStatusEnum("status").notNull().default("pending"),
|
||||
description: text("description"),
|
||||
});
|
||||
|
||||
export const accounts = pgTable("accounts", {
|
||||
id: varchar("id", { length: 50 }).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: accountTypeEnum("type").notNull(),
|
||||
balance: decimal("balance", { precision: 12, scale: 2 }).notNull(),
|
||||
lastSync: timestamp("last_sync").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const transactions = pgTable("transactions", {
|
||||
id: varchar("id", { length: 50 }).primaryKey(),
|
||||
accountId: varchar("account_id", { length: 50 })
|
||||
.notNull()
|
||||
.references(() => accounts.id),
|
||||
type: transactionTypeEnum("type").notNull(),
|
||||
amount: decimal("amount", { precision: 12, scale: 2 }).notNull(),
|
||||
description: text("description").notNull(),
|
||||
category: varchar("category", { length: 100 }).notNull(),
|
||||
date: timestamp("date").notNull(),
|
||||
});
|
||||
|
||||
export const widgets = pgTable("widgets", {
|
||||
id: varchar("id", { length: 50 }).primaryKey(),
|
||||
prompt: text("prompt").notNull(),
|
||||
spec: jsonb("spec").$type<WidgetSpec>().notNull(),
|
||||
order: integer("order").notNull().default(0),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Types
|
||||
export interface WidgetSpec {
|
||||
root: string;
|
||||
elements: Record<string, unknown>;
|
||||
}
|
||||
export interface InvoiceItem {
|
||||
description: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export type Customer = typeof customers.$inferSelect;
|
||||
export type NewCustomer = typeof customers.$inferInsert;
|
||||
|
||||
export type Invoice = typeof invoices.$inferSelect;
|
||||
export type NewInvoice = typeof invoices.$inferInsert;
|
||||
|
||||
export type Expense = typeof expenses.$inferSelect;
|
||||
export type NewExpense = typeof expenses.$inferInsert;
|
||||
|
||||
export type Account = typeof accounts.$inferSelect;
|
||||
export type Transaction = typeof transactions.$inferSelect;
|
||||
|
||||
export type Widget = typeof widgets.$inferSelect;
|
||||
export type NewWidget = typeof widgets.$inferInsert;
|
||||
1109
dashboard/lib/db/store.ts
Normal file
1109
dashboard/lib/db/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
dashboard/lib/rate-limit.ts
Normal file
57
dashboard/lib/rate-limit.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
// Lazy initialization to avoid errors when Redis env vars are not configured
|
||||
let _minuteRateLimit: Ratelimit | null = null;
|
||||
let _dailyRateLimit: Ratelimit | null = null;
|
||||
|
||||
function getRedis(): Redis | null {
|
||||
const url = process.env.KV_REST_API_URL;
|
||||
const token = process.env.KV_REST_API_TOKEN;
|
||||
|
||||
if (!url || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Redis({ url, token });
|
||||
}
|
||||
|
||||
// No-op rate limiter for when Redis is not configured
|
||||
const noopRateLimiter = {
|
||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||
};
|
||||
|
||||
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||
|
||||
// Requests per minute (sliding window)
|
||||
export const minuteRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_minuteRateLimit) {
|
||||
const redis = getRedis();
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_minuteRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||
prefix: "ratelimit:dashboard:minute",
|
||||
});
|
||||
}
|
||||
return _minuteRateLimit.limit(identifier);
|
||||
},
|
||||
};
|
||||
|
||||
// Requests per day (fixed window)
|
||||
export const dailyRateLimit = {
|
||||
limit: async (identifier: string) => {
|
||||
if (!_dailyRateLimit) {
|
||||
const redis = getRedis();
|
||||
if (!redis) return noopRateLimiter.limit();
|
||||
_dailyRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||
prefix: "ratelimit:dashboard:daily",
|
||||
});
|
||||
}
|
||||
return _dailyRateLimit.limit(identifier);
|
||||
},
|
||||
};
|
||||
503
dashboard/lib/render/catalog.ts
Normal file
503
dashboard/lib/render/catalog.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Dashboard Catalog
|
||||
*
|
||||
* Components map directly to shadcn/ui components.
|
||||
* Actions correspond to real API endpoints in /api/v1/*
|
||||
*/
|
||||
export const dashboardCatalog = defineCatalog(schema, {
|
||||
guidelines: `
|
||||
CRITICAL DATA FETCHING RULES - MUST FOLLOW:
|
||||
1. When generating widgets that display data lists (customers, invoices, expenses, accounts, transactions):
|
||||
- NEVER generate hardcoded example data in the state
|
||||
- ALWAYS initialize state with empty arrays
|
||||
- Example: state = { customers: [], invoices: [], expenses: [], accounts: [], transactions: [] }
|
||||
|
||||
2. MUST use the corresponding viewXxx action to fetch real data:
|
||||
- Customer list → use viewCustomers action
|
||||
- Invoice list → use viewInvoices action
|
||||
- Expense list → use viewExpenses action
|
||||
- Account list → use viewAccounts action
|
||||
- Transaction list → view from account data
|
||||
|
||||
3. Always include a refresh button that calls the corresponding refreshXxx action:
|
||||
- refreshCustomers, refreshInvoices, refreshExpenses
|
||||
|
||||
4. For charts and visualizations:
|
||||
- Use empty arrays for initial data
|
||||
- Add refresh button to load real data via action
|
||||
|
||||
5. The widget will automatically load data via the auto-load mechanism on mount
|
||||
|
||||
VIOLATION: Generating hardcoded/example data in state is strictly prohibited.
|
||||
`,
|
||||
|
||||
components: {
|
||||
// Layout
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex layout container",
|
||||
example: { direction: "vertical", gap: "md" },
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Collapsible accordion container",
|
||||
},
|
||||
|
||||
AccordionItem: {
|
||||
props: z.object({
|
||||
value: z.string(),
|
||||
title: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Accordion item with trigger and content",
|
||||
},
|
||||
|
||||
// Form
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline", "ghost"])
|
||||
.nullable(),
|
||||
action: z.string(),
|
||||
actionParams: z.record(z.string(), z.unknown()).nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. Use actionParams to pass parameters to the action (e.g., { limit: 5, sort: 'newest' })",
|
||||
example: { label: "Save", variant: "default", action: "formSubmit" },
|
||||
},
|
||||
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
type: z.enum(["text", "email", "password", "number", "tel"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Text input field. Use value with $bindState for two-way binding",
|
||||
example: {
|
||||
label: "Email",
|
||||
value: { $bindState: "/form/email" },
|
||||
placeholder: "you@example.com",
|
||||
type: "email",
|
||||
},
|
||||
},
|
||||
|
||||
Form: {
|
||||
props: z.object({
|
||||
submitAction: z.string(),
|
||||
submitActionParams: z.record(z.string(), z.unknown()).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Form container that enables Enter key submission. Wrap form inputs (Input, Select, Checkbox, etc.) and a submit Button inside this component.",
|
||||
},
|
||||
|
||||
// Display
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
example: { text: "Active", variant: "default" },
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
variant: z.enum(["default", "destructive"]).nullable(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
description: "Alert message",
|
||||
},
|
||||
|
||||
Separator: {
|
||||
props: z.object({}),
|
||||
description: "Visual divider",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
alt: z.string().nullable(),
|
||||
fallback: z.string(),
|
||||
}),
|
||||
description: "User avatar image with fallback initials",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Checkbox input. Use checked with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Dialog: {
|
||||
props: z.object({
|
||||
trigger: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Modal dialog with trigger button",
|
||||
},
|
||||
|
||||
Drawer: {
|
||||
props: z.object({
|
||||
trigger: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
side: z.enum(["top", "bottom", "left", "right"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Slide-out drawer panel",
|
||||
},
|
||||
|
||||
DropdownMenu: {
|
||||
props: z.object({
|
||||
trigger: z.string(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
action: z.string().nullable(),
|
||||
actionParams: z.record(z.string(), z.unknown()).nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Dropdown menu with action items",
|
||||
},
|
||||
|
||||
Label: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
htmlFor: z.string().nullable(),
|
||||
}),
|
||||
description: "Form label",
|
||||
},
|
||||
|
||||
Pagination: {
|
||||
props: z.object({
|
||||
currentPage: z.number(),
|
||||
totalPages: z.number(),
|
||||
onPageChange: z.string().nullable(),
|
||||
}),
|
||||
description: "Page navigation",
|
||||
},
|
||||
|
||||
Popover: {
|
||||
props: z.object({
|
||||
trigger: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Popover with trigger",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
}),
|
||||
description: "Progress bar",
|
||||
},
|
||||
|
||||
RadioGroup: {
|
||||
props: z.object({
|
||||
value: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Radio button group. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description:
|
||||
"Dropdown select input. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
props: z.object({
|
||||
width: z.string().nullable(),
|
||||
height: z.string().nullable(),
|
||||
}),
|
||||
description: "Loading placeholder",
|
||||
},
|
||||
|
||||
Spinner: {
|
||||
props: z.object({
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
description: "Loading spinner",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
checked: z.boolean().nullable(),
|
||||
defaultChecked: z.boolean().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Toggle switch. Use checked with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
props: z.object({
|
||||
defaultValue: z.string().nullable(),
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Tabbed content container",
|
||||
},
|
||||
|
||||
TabContent: {
|
||||
props: z.object({
|
||||
value: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Content for a specific tab",
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Multi-line text input. Use value with $bindState for two-way binding",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Tooltip on hover",
|
||||
},
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
),
|
||||
rowActions: z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
action: z.string(),
|
||||
variant: z
|
||||
.enum([
|
||||
"default",
|
||||
"secondary",
|
||||
"destructive",
|
||||
"outline",
|
||||
"ghost",
|
||||
])
|
||||
.nullable(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
emptyMessage: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Data table with optional row actions. Use { $state } on data to bind to an array of objects.",
|
||||
example: {
|
||||
data: { $state: "/customers/data" },
|
||||
columns: [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "email", label: "Email" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Typography
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Section heading",
|
||||
example: { text: "Dashboard", level: "h1" },
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
muted: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Text content",
|
||||
example: { content: "Welcome back! Here is your overview." },
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Bar chart visualization. Use { $state } on data to point to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(z.record(z.string(), z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
color: z.string().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Line chart visualization. Use { $state } on data to point to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
// Customers - use these for customer-related widgets
|
||||
viewCustomers: {
|
||||
params: z.object({
|
||||
status: z.enum(["active", "inactive"]).nullable(),
|
||||
limit: z.number().nullable(),
|
||||
sort: z.enum(["newest", "oldest"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Fetch customers from GET /api/v1/customers. Supports ?limit=N&sort=newest|oldest. Data available at 'customers.data'",
|
||||
},
|
||||
refreshCustomers: {
|
||||
params: z.object({
|
||||
limit: z.number().nullable(),
|
||||
sort: z.enum(["newest", "oldest"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Refresh customers from GET /api/v1/customers. Supports ?limit=N&sort=newest|oldest. Data available at 'customers.data'",
|
||||
},
|
||||
createCustomer: {
|
||||
params: z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
}),
|
||||
description: "Create new customer via POST /api/v1/customers",
|
||||
},
|
||||
deleteCustomer: {
|
||||
params: z.object({
|
||||
customerId: z.string(),
|
||||
}),
|
||||
description: "Delete a customer via DELETE /api/v1/customers/:id",
|
||||
},
|
||||
|
||||
// Invoices - use these for invoice-related widgets
|
||||
viewInvoices: {
|
||||
params: z.object({
|
||||
status: z.enum(["draft", "sent", "paid", "overdue"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Fetch invoices from GET /api/v1/invoices. Data available at 'invoices.data'",
|
||||
},
|
||||
refreshInvoices: {
|
||||
params: z.object({}),
|
||||
description:
|
||||
"Refresh invoices from GET /api/v1/invoices. Data available at 'invoices.data'",
|
||||
},
|
||||
createInvoice: {
|
||||
params: z.object({
|
||||
customerId: z.string(),
|
||||
dueDate: z.string(),
|
||||
}),
|
||||
description: "Create new invoice via POST /api/v1/invoices",
|
||||
},
|
||||
sendInvoice: {
|
||||
params: z.object({
|
||||
invoiceId: z.string(),
|
||||
}),
|
||||
description: "Send invoice to customer",
|
||||
},
|
||||
markInvoicePaid: {
|
||||
params: z.object({
|
||||
invoiceId: z.string(),
|
||||
}),
|
||||
description: "Mark invoice as paid",
|
||||
},
|
||||
|
||||
// Expenses - use these for expense-related widgets
|
||||
viewExpenses: {
|
||||
params: z.object({
|
||||
status: z.enum(["pending", "approved", "rejected"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Fetch expenses from GET /api/v1/expenses. Data available at 'expenses.data'",
|
||||
},
|
||||
refreshExpenses: {
|
||||
params: z.object({}),
|
||||
description:
|
||||
"Refresh expenses from GET /api/v1/expenses. Data available at 'expenses.data'",
|
||||
},
|
||||
createExpense: {
|
||||
params: z.object({
|
||||
vendor: z.string(),
|
||||
category: z.string(),
|
||||
amount: z.number(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
description: "Create new expense via POST /api/v1/expenses",
|
||||
},
|
||||
approveExpense: {
|
||||
params: z.object({
|
||||
expenseId: z.string(),
|
||||
}),
|
||||
description: "Approve expense",
|
||||
},
|
||||
},
|
||||
});
|
||||
975
dashboard/lib/render/registry.tsx
Normal file
975
dashboard/lib/render/registry.tsx
Normal file
@@ -0,0 +1,975 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { findFormValue } from "@json-render/core";
|
||||
import { useBoundProp, defineRegistry } from "@json-render/react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart as RechartsLineChart,
|
||||
XAxis,
|
||||
} from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
// shadcn components
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationLink,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import { dashboardCatalog } from "./catalog";
|
||||
|
||||
// =============================================================================
|
||||
// Registry
|
||||
// =============================================================================
|
||||
|
||||
export const { registry, handlers, executeAction } = defineRegistry(
|
||||
dashboardCatalog,
|
||||
{
|
||||
components: {
|
||||
Stack: ({ props, children }) => {
|
||||
const gapClass =
|
||||
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ??
|
||||
"gap-4";
|
||||
return (
|
||||
<div
|
||||
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Accordion: ({ props, children }) => (
|
||||
<Accordion type={props.type ?? "single"} collapsible>
|
||||
{children}
|
||||
</Accordion>
|
||||
),
|
||||
|
||||
AccordionItem: ({ props, children }) => (
|
||||
<AccordionItem value={props.value}>
|
||||
<AccordionTrigger>{props.title}</AccordionTrigger>
|
||||
<AccordionContent>{children}</AccordionContent>
|
||||
</AccordionItem>
|
||||
),
|
||||
|
||||
Button: ({ props, emit, loading }) => (
|
||||
<Button
|
||||
variant={props.variant ?? "default"}
|
||||
disabled={loading || (props.disabled ?? false)}
|
||||
onClick={() => emit("press")}
|
||||
>
|
||||
{loading ? "..." : props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
Input: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Input
|
||||
type={props.type ?? "text"}
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Form: ({ children, emit }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
emit("submit");
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
),
|
||||
|
||||
Badge: ({ props }) => (
|
||||
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
|
||||
),
|
||||
|
||||
Alert: ({ props }) => (
|
||||
<Alert variant={props.variant ?? "default"}>
|
||||
<AlertTitle>{props.title}</AlertTitle>
|
||||
{props.description ? (
|
||||
<AlertDescription>{props.description}</AlertDescription>
|
||||
) : null}
|
||||
</Alert>
|
||||
),
|
||||
|
||||
Separator: () => <Separator />,
|
||||
|
||||
Avatar: ({ props }) => (
|
||||
<Avatar>
|
||||
{props.src ? (
|
||||
<AvatarImage src={props.src} alt={props.alt ?? ""} />
|
||||
) : null}
|
||||
<AvatarFallback>{props.fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
),
|
||||
|
||||
Checkbox: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isChecked = checked ?? props.defaultChecked ?? false;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={bindings?.checked ?? "checkbox"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(value) => setChecked(value === true)}
|
||||
/>
|
||||
{props.label ? (
|
||||
<Label htmlFor={bindings?.checked ?? "checkbox"}>
|
||||
{props.label}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Dialog: ({ props, children }) => (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{props.trigger}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
{props.description ? (
|
||||
<DialogDescription>{props.description}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
|
||||
Drawer: ({ props, children }) => (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">{props.trigger}</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{props.title}</DrawerTitle>
|
||||
{props.description ? (
|
||||
<DrawerDescription>{props.description}</DrawerDescription>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
<div className="p-4">{children}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
),
|
||||
|
||||
DropdownMenu: ({ props }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{props.trigger}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{props.items.map((item, i) => (
|
||||
<DropdownMenuItem key={i}>{item.label}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
|
||||
Label: ({ props }) => (
|
||||
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
|
||||
),
|
||||
|
||||
Pagination: ({ props }) => {
|
||||
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious href="#" />
|
||||
</PaginationItem>
|
||||
{pages.map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={page === props.currentPage}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext href="#" />
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
},
|
||||
|
||||
Popover: ({ props, children }) => (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">{props.trigger}</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>{children}</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
|
||||
Progress: ({ props }) => (
|
||||
<Progress value={props.value} max={props.max ?? 100} />
|
||||
),
|
||||
|
||||
RadioGroup: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
const current = value ?? props.defaultValue ?? "";
|
||||
return (
|
||||
<RadioGroup value={current} onValueChange={(v) => setValue(v)}>
|
||||
{props.options.map((option) => (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={option.value} id={option.value} />
|
||||
<Label htmlFor={option.value}>{option.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
},
|
||||
|
||||
Select: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<Select value={value ?? ""} onValueChange={(v) => setValue(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={props.placeholder ?? "Select..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
|
||||
Skeleton: ({ props }) => (
|
||||
<Skeleton
|
||||
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
|
||||
/>
|
||||
),
|
||||
|
||||
Spinner: ({ props }) => {
|
||||
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
|
||||
const size = sizes[props.size ?? "md"];
|
||||
return (
|
||||
<div
|
||||
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({ props, bindings }) => {
|
||||
const [checked, setChecked] = useBoundProp<boolean>(
|
||||
props.checked as boolean | undefined,
|
||||
bindings?.checked,
|
||||
);
|
||||
const isChecked = checked ?? props.defaultChecked ?? false;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={bindings?.checked ?? "switch"}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(value) => setChecked(value)}
|
||||
/>
|
||||
{props.label ? (
|
||||
<Label htmlFor={bindings?.checked ?? "switch"}>
|
||||
{props.label}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Tabs: ({ props, children }) => (
|
||||
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
|
||||
<TabsList>
|
||||
{props.tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{children}
|
||||
</Tabs>
|
||||
),
|
||||
|
||||
TabContent: ({ props, children }) => (
|
||||
<TabsContent value={props.value}>{children}</TabsContent>
|
||||
),
|
||||
|
||||
Textarea: ({ props, bindings }) => {
|
||||
const [value, setValue] = useBoundProp<string>(
|
||||
props.value as string | undefined,
|
||||
bindings?.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Textarea
|
||||
value={value ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Tooltip: ({ props, children }) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent>{props.content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
),
|
||||
|
||||
// Heading is intentionally not rendered - widgets already have a title bar
|
||||
Heading: () => null,
|
||||
|
||||
Text: ({ props }) => (
|
||||
<p className={props.muted ? "text-muted-foreground" : ""}>
|
||||
{props.content}
|
||||
</p>
|
||||
),
|
||||
|
||||
Table: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.items)
|
||||
? ((rawData as Record<string, unknown>).items as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
{props.emptyMessage ?? "No data"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasRowActions = props.rowActions && props.rowActions.length > 0;
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{props.columns.map((col) => (
|
||||
<TableHead key={col.key}>{col.label}</TableHead>
|
||||
))}
|
||||
{hasRowActions ? <TableHead>Actions</TableHead> : null}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, i) => (
|
||||
<TableRow key={i}>
|
||||
{props.columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
{String(item[col.key] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
{hasRowActions ? (
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{props.rowActions!.map((rowAction) => (
|
||||
<Button
|
||||
key={rowAction.action}
|
||||
variant={rowAction.variant ?? "ghost"}
|
||||
size="sm"
|
||||
>
|
||||
{rowAction.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
// Process and aggregate data
|
||||
const { items, valueKey } = processChartData(
|
||||
rawItems,
|
||||
props.xKey,
|
||||
props.yKey,
|
||||
props.aggregate,
|
||||
);
|
||||
|
||||
const chartColor = props.color ?? "var(--chart-1)";
|
||||
|
||||
const chartConfig = {
|
||||
[valueKey]: {
|
||||
label: valueKey,
|
||||
color: chartColor,
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full"
|
||||
style={{ height: props.height ?? 300 }}
|
||||
>
|
||||
<RechartsBarChart accessibilityLayer data={items}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar
|
||||
dataKey={valueKey}
|
||||
fill={`var(--color-${valueKey})`}
|
||||
radius={4}
|
||||
/>
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
LineChart: ({ props }) => {
|
||||
const rawData = props.data;
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
: Array.isArray((rawData as Record<string, unknown>)?.data)
|
||||
? ((rawData as Record<string, unknown>).data as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
|
||||
// Process and aggregate data
|
||||
const { items, valueKey } = processChartData(
|
||||
rawItems,
|
||||
props.xKey,
|
||||
props.yKey,
|
||||
props.aggregate,
|
||||
);
|
||||
|
||||
const chartColor = props.color ?? "var(--chart-1)";
|
||||
|
||||
const chartConfig = {
|
||||
[valueKey]: {
|
||||
label: valueKey,
|
||||
color: chartColor,
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No data available
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="min-h-[200px] w-full"
|
||||
style={{ height: props.height ?? 300 }}
|
||||
>
|
||||
<RechartsLineChart accessibilityLayer data={items}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={valueKey}
|
||||
stroke={`var(--color-${valueKey})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
viewCustomers: async (params, setState) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.set("limit", String(params.limit));
|
||||
if (params?.sort) queryParams.set("sort", String(params.sort));
|
||||
if (params?.status) queryParams.set("status", String(params.status));
|
||||
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const customers = await res.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
},
|
||||
|
||||
refreshCustomers: async (params, setState) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.set("limit", String(params.limit));
|
||||
if (params?.sort) queryParams.set("sort", String(params.sort));
|
||||
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const customers = await res.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
},
|
||||
|
||||
createCustomer: async (params, setState, state) => {
|
||||
const name = findFormValue("name", params, state) as string;
|
||||
const email =
|
||||
(findFormValue("email", params, state) as string) ||
|
||||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
|
||||
const phone = findFormValue("phone", params, state) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!name) {
|
||||
toast.error("Customer name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/v1/customers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, phone }),
|
||||
});
|
||||
const customer = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(`Customer "${customer.name}" created`);
|
||||
const listRes = await fetch("/api/v1/customers");
|
||||
const customers = await listRes.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
} else {
|
||||
toast.error(customer.error || "Failed to create customer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create customer:", err);
|
||||
toast.error("Failed to create customer");
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomer: async (params, setState, state) => {
|
||||
const customerId =
|
||||
findFormValue("customerId", params, state) ||
|
||||
findFormValue("id", params, state);
|
||||
if (!customerId) {
|
||||
toast.error("Customer ID required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/customers/${customerId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success("Customer deleted");
|
||||
const listRes = await fetch("/api/v1/customers");
|
||||
const customers = await listRes.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || "Failed to delete customer");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete customer:", err);
|
||||
toast.error("Failed to delete customer");
|
||||
}
|
||||
},
|
||||
|
||||
viewInvoices: async (params, setState) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.set("status", String(params.status));
|
||||
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const invoices = await res.json();
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
},
|
||||
|
||||
refreshInvoices: async (_params, setState) => {
|
||||
const res = await fetch("/api/v1/invoices");
|
||||
const invoices = await res.json();
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
},
|
||||
|
||||
createInvoice: async (params, setState) => {
|
||||
if (!params?.customerId || !params?.dueDate) {
|
||||
toast.error("Customer ID and due date required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/v1/invoices", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const invoice = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success("Invoice created");
|
||||
const listRes = await fetch("/api/v1/invoices");
|
||||
const invoices = await listRes.json();
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
} else {
|
||||
toast.error(invoice.error || "Failed to create invoice");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create invoice:", err);
|
||||
toast.error("Failed to create invoice");
|
||||
}
|
||||
},
|
||||
|
||||
sendInvoice: async (params) => {
|
||||
if (!params?.invoiceId) {
|
||||
toast.error("Invoice ID required");
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
|
||||
method: "POST",
|
||||
});
|
||||
const result = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(result.message || "Invoice sent");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to send invoice");
|
||||
}
|
||||
},
|
||||
|
||||
markInvoicePaid: async (params) => {
|
||||
if (!params?.invoiceId) {
|
||||
toast.error("Invoice ID required");
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/v1/invoices/${params.invoiceId}/mark-paid`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const result = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(result.message || "Invoice marked paid");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to mark invoice paid");
|
||||
}
|
||||
},
|
||||
|
||||
viewExpenses: async (params, setState) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.set("status", String(params.status));
|
||||
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const expenses = await res.json();
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
},
|
||||
|
||||
refreshExpenses: async (_params, setState) => {
|
||||
const res = await fetch("/api/v1/expenses");
|
||||
const expenses = await res.json();
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
},
|
||||
|
||||
createExpense: async (params, setState) => {
|
||||
if (
|
||||
!params?.vendor ||
|
||||
!params?.category ||
|
||||
params?.amount === undefined
|
||||
) {
|
||||
toast.error("Vendor, category, and amount required");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/v1/expenses", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const expense = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success("Expense created");
|
||||
const listRes = await fetch("/api/v1/expenses");
|
||||
const expenses = await listRes.json();
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
} else {
|
||||
toast.error(expense.error || "Failed to create expense");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create expense:", err);
|
||||
toast.error("Failed to create expense");
|
||||
}
|
||||
},
|
||||
|
||||
approveExpense: async (params) => {
|
||||
if (!params?.expenseId) {
|
||||
toast.error("Expense ID required");
|
||||
return;
|
||||
}
|
||||
const res = await fetch(
|
||||
`/api/v1/expenses/${params.expenseId}/approve`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const result = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(result.message || "Expense approved");
|
||||
} else {
|
||||
toast.error(result.error || "Failed to approve expense");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Chart Helpers
|
||||
// =============================================================================
|
||||
|
||||
function isISODate(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
|
||||
}
|
||||
|
||||
function processChartData(
|
||||
items: Array<Record<string, unknown>>,
|
||||
xKey: string,
|
||||
yKey: string,
|
||||
aggregate: "sum" | "count" | "avg" | null | undefined,
|
||||
): { items: Array<Record<string, unknown>>; valueKey: string } {
|
||||
if (items.length === 0) {
|
||||
return { items: [], valueKey: yKey };
|
||||
}
|
||||
|
||||
const firstXValue = items[0]?.[xKey];
|
||||
const isDateKey = isISODate(firstXValue);
|
||||
|
||||
if (!aggregate) {
|
||||
const formatted = items.map((item) => {
|
||||
const xValue = item[xKey];
|
||||
return {
|
||||
...item,
|
||||
label:
|
||||
isDateKey && typeof xValue === "string"
|
||||
? formatDateLabel(xValue)
|
||||
: String(xValue ?? ""),
|
||||
};
|
||||
});
|
||||
return { items: formatted, valueKey: yKey };
|
||||
}
|
||||
|
||||
const groups = new Map<string, Array<Record<string, unknown>>>();
|
||||
|
||||
for (const item of items) {
|
||||
const xValue = item[xKey];
|
||||
let groupKey: string;
|
||||
|
||||
if (isDateKey && typeof xValue === "string") {
|
||||
groupKey = xValue.split("T")[0] ?? xValue;
|
||||
} else {
|
||||
groupKey = String(xValue ?? "unknown");
|
||||
}
|
||||
|
||||
const group = groups.get(groupKey) ?? [];
|
||||
group.push(item);
|
||||
groups.set(groupKey, group);
|
||||
}
|
||||
|
||||
const valueKey = aggregate === "count" ? "count" : yKey;
|
||||
const aggregated: Array<Record<string, unknown>> = [];
|
||||
const sortedKeys = Array.from(groups.keys()).sort();
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const group = groups.get(key)!;
|
||||
let value: number;
|
||||
|
||||
if (aggregate === "count") {
|
||||
value = group.length;
|
||||
} else if (aggregate === "sum") {
|
||||
value = group.reduce((sum, item) => {
|
||||
const v = item[yKey];
|
||||
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
|
||||
}, 0);
|
||||
} else {
|
||||
const sum = group.reduce((s, item) => {
|
||||
const v = item[yKey];
|
||||
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
|
||||
}, 0);
|
||||
value = group.length > 0 ? sum / group.length : 0;
|
||||
}
|
||||
|
||||
let label: string;
|
||||
if (isDateKey) {
|
||||
const date = new Date(key);
|
||||
label = date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
} else {
|
||||
label = key;
|
||||
}
|
||||
|
||||
aggregated.push({
|
||||
label,
|
||||
[valueKey]: value,
|
||||
_groupKey: key,
|
||||
});
|
||||
}
|
||||
|
||||
return { items: aggregated, valueKey };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fallback Component
|
||||
// =============================================================================
|
||||
|
||||
export function Fallback({ type }: { type: string }) {
|
||||
return (
|
||||
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
|
||||
Unknown component: {type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
dashboard/lib/render/renderer.tsx
Normal file
76
dashboard/lib/render/renderer.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRenderer,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { registry, Fallback, handlers as createHandlers } from "./registry";
|
||||
|
||||
// =============================================================================
|
||||
// DashboardRenderer
|
||||
// =============================================================================
|
||||
|
||||
type SetState = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
interface DashboardRendererProps {
|
||||
spec: Spec | null;
|
||||
state?: Record<string, unknown>;
|
||||
setState?: SetState;
|
||||
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Fallback component for unknown types
|
||||
const fallback: ComponentRenderer = ({ element }) => (
|
||||
<Fallback type={element.type} />
|
||||
);
|
||||
|
||||
export function DashboardRenderer({
|
||||
spec,
|
||||
state = {},
|
||||
setState,
|
||||
onStateChange,
|
||||
loading,
|
||||
}: DashboardRendererProps): ReactNode {
|
||||
// Use refs so action handlers always see the latest state/setState
|
||||
const stateRef = useRef(state);
|
||||
const setStateRef = useRef(setState);
|
||||
stateRef.current = state;
|
||||
setStateRef.current = setState;
|
||||
|
||||
// Create ActionProvider-compatible handlers using getters so they
|
||||
// always read the latest state/setState from refs
|
||||
const actionHandlers = useMemo(
|
||||
() =>
|
||||
createHandlers(
|
||||
() => setStateRef.current,
|
||||
() => stateRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallback}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
6
dashboard/lib/utils.ts
Normal file
6
dashboard/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
6
dashboard/next-env.d.ts
vendored
Normal file
6
dashboard/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
6
dashboard/next.config.js
Normal file
6
dashboard/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['@json-render/core', '@json-render/react'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7465
dashboard/package-lock.json
generated
Normal file
7465
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
dashboard/package.json
Normal file
61
dashboard/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.10",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
"db:clear": "tsx scripts/clear.ts",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:seed": "tsx scripts/seed.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@json-render/core": "^0.14.1",
|
||||
"@json-render/react": "^0.14.1",
|
||||
"@json-render/shadcn": "^0.14.1",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"ai": "^6.0.33",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^2.15.4",
|
||||
"shiki": "^3.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"eslint": "^9.39.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
5
dashboard/postcss.config.mjs
Normal file
5
dashboard/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
14
dashboard/scripts/clear.ts
Normal file
14
dashboard/scripts/clear.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import "dotenv/config";
|
||||
import { clearDatabase } from "../lib/db/store";
|
||||
|
||||
async function main() {
|
||||
console.log("Clearing database...");
|
||||
const result = await clearDatabase();
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error clearing database:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
14
dashboard/scripts/seed.ts
Normal file
14
dashboard/scripts/seed.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import "dotenv/config";
|
||||
import { resetDatabase } from "../lib/db/store";
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding database...");
|
||||
const result = await resetDatabase();
|
||||
console.log(result.message);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error seeding database:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
41
dashboard/tsconfig.json
Normal file
41
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user