@viliha/vui-ui 1.1.8 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +79 -9
- package/README.md +60 -0
- package/bin/vui.mjs +211 -0
- package/package.json +10 -2
- package/src/record-view.tsx +59 -17
- package/template/.env.example +20 -0
- package/template/app/(app)/branches/branches-table.tsx +69 -0
- package/template/app/(app)/branches/page.tsx +13 -0
- package/template/app/(app)/businesses/businesses-table.tsx +39 -0
- package/template/app/(app)/businesses/page.tsx +13 -0
- package/template/app/(app)/calendar/page.tsx +725 -0
- package/template/app/(app)/charts/charts-content.tsx +190 -0
- package/template/app/(app)/charts/layout.tsx +8 -0
- package/template/app/(app)/charts/page.tsx +44 -0
- package/template/app/(app)/chat/layout.tsx +14 -0
- package/template/app/(app)/chat/page.tsx +311 -0
- package/template/app/(app)/components/layout.tsx +8 -0
- package/template/app/(app)/components/page.tsx +464 -0
- package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
- package/template/app/(app)/crm/companies/page.tsx +13 -0
- package/template/app/(app)/crm/opportunities/opportunities-board.tsx +359 -0
- package/template/app/(app)/crm/opportunities/page.tsx +13 -0
- package/template/app/(app)/crm/people/page.tsx +13 -0
- package/template/app/(app)/crm/people/people-table.tsx +50 -0
- package/template/app/(app)/dashboard/layout.tsx +8 -0
- package/template/app/(app)/dashboard/page.tsx +239 -0
- package/template/app/(app)/departments/departments-table.tsx +55 -0
- package/template/app/(app)/departments/page.tsx +13 -0
- package/template/app/(app)/employees/employees-table.tsx +64 -0
- package/template/app/(app)/employees/page.tsx +13 -0
- package/template/app/(app)/forms/layout.tsx +8 -0
- package/template/app/(app)/forms/page.tsx +388 -0
- package/template/app/(app)/layout.tsx +74 -0
- package/template/app/(app)/markets/markets-table.tsx +76 -0
- package/template/app/(app)/markets/page.tsx +13 -0
- package/template/app/(app)/organizations/edit/page.tsx +72 -0
- package/template/app/(app)/organizations/new/page.tsx +46 -0
- package/template/app/(app)/organizations/organizations-config.tsx +97 -0
- package/template/app/(app)/organizations/organizations-table.tsx +42 -0
- package/template/app/(app)/organizations/page.tsx +13 -0
- package/template/app/(app)/settings/layout.tsx +8 -0
- package/template/app/(app)/settings/page.tsx +255 -0
- package/template/app/(app)/steps/page.tsx +263 -0
- package/template/app/(app)/support/layout.tsx +14 -0
- package/template/app/(app)/support/page.tsx +345 -0
- package/template/app/(app)/system/cities/cities-table.tsx +34 -0
- package/template/app/(app)/system/cities/page.tsx +13 -0
- package/template/app/(app)/system/countries/countries-table.tsx +34 -0
- package/template/app/(app)/system/countries/page.tsx +13 -0
- package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
- package/template/app/(app)/system/currencies/page.tsx +13 -0
- package/template/app/(app)/system/languages/languages-table.tsx +32 -0
- package/template/app/(app)/system/languages/page.tsx +13 -0
- package/template/app/(app)/system/regions/page.tsx +13 -0
- package/template/app/(app)/system/regions/regions-table.tsx +32 -0
- package/template/app/(app)/users/page.tsx +13 -0
- package/template/app/(app)/users/users-table.tsx +97 -0
- package/template/app/_components/app-sidebar.tsx +565 -0
- package/template/app/_components/auth.tsx +184 -0
- package/template/app/_components/breadcrumbs.tsx +26 -0
- package/template/app/_components/global-search.tsx +209 -0
- package/template/app/_components/logo.tsx +49 -0
- package/template/app/_components/nav-config.ts +138 -0
- package/template/app/_components/open-tabs.tsx +530 -0
- package/template/app/_components/quick-actions.tsx +140 -0
- package/template/app/_components/route-meta.ts +137 -0
- package/template/app/_components/set-page-title.tsx +15 -0
- package/template/app/_components/showcase.tsx +29 -0
- package/template/app/_components/stat-card.tsx +56 -0
- package/template/app/_components/theme-toggle.tsx +46 -0
- package/template/app/_components/top-bar.tsx +109 -0
- package/template/app/_components/user-menu.tsx +109 -0
- package/template/app/_components/wordmark.tsx +45 -0
- package/template/app/apple-icon.png +0 -0
- package/template/app/auth/forgot-password/page.tsx +102 -0
- package/template/app/auth/layout.tsx +43 -0
- package/template/app/auth/page.tsx +13 -0
- package/template/app/auth/reset-password/page.tsx +108 -0
- package/template/app/auth/signin/page.tsx +238 -0
- package/template/app/auth/signup/page.tsx +159 -0
- package/template/app/auth/verify/page.tsx +86 -0
- package/template/app/error.tsx +56 -0
- package/template/app/globals.css +6 -0
- package/template/app/icon.png +0 -0
- package/template/app/icon.svg +4 -0
- package/template/app/layout.tsx +103 -0
- package/template/app/not-found.tsx +29 -0
- package/template/app/onboarding/layout.tsx +31 -0
- package/template/app/onboarding/page.tsx +399 -0
- package/template/app/page.tsx +13 -0
- package/template/app/register-business/layout.tsx +30 -0
- package/template/app/register-business/page.tsx +234 -0
- package/template/components/ui/accordion.tsx +66 -0
- package/template/components/ui/alert-dialog.tsx +196 -0
- package/template/components/ui/alert.tsx +66 -0
- package/template/components/ui/aspect-ratio.tsx +11 -0
- package/template/components/ui/avatar.tsx +109 -0
- package/template/components/ui/badge.tsx +48 -0
- package/template/components/ui/breadcrumb.tsx +109 -0
- package/template/components/ui/button.tsx +64 -0
- package/template/components/ui/calendar.tsx +220 -0
- package/template/components/ui/card.tsx +92 -0
- package/template/components/ui/checkbox.tsx +32 -0
- package/template/components/ui/collapsible.tsx +33 -0
- package/template/components/ui/command.tsx +184 -0
- package/template/components/ui/dialog.tsx +158 -0
- package/template/components/ui/dropdown-menu.tsx +257 -0
- package/template/components/ui/form.tsx +167 -0
- package/template/components/ui/hover-card.tsx +44 -0
- package/template/components/ui/input-otp.tsx +77 -0
- package/template/components/ui/input.tsx +21 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/popover.tsx +89 -0
- package/template/components/ui/progress.tsx +31 -0
- package/template/components/ui/radio-group.tsx +45 -0
- package/template/components/ui/scroll-area.tsx +58 -0
- package/template/components/ui/select.tsx +190 -0
- package/template/components/ui/separator.tsx +28 -0
- package/template/components/ui/sheet.tsx +143 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/slider.tsx +63 -0
- package/template/components/ui/sonner.tsx +40 -0
- package/template/components/ui/switch.tsx +35 -0
- package/template/components/ui/table.tsx +116 -0
- package/template/components/ui/tabs.tsx +91 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +83 -0
- package/template/components/ui/toggle.tsx +47 -0
- package/template/components/ui/tooltip.tsx +57 -0
- package/template/lib/auth-demo.ts +94 -0
- package/template/lib/crm-data.ts +80 -0
- package/template/lib/demo-data.ts +267 -0
- package/template/lib/mock-data.ts +169 -0
- package/template/lib/org-store.ts +50 -0
- package/template/lib/seo.ts +188 -0
- package/template/lib/utils.ts +3 -0
- package/template/next.config.mjs +13 -0
- package/template/postcss.config.mjs +7 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Area,
|
|
5
|
+
AreaChart,
|
|
6
|
+
Bar,
|
|
7
|
+
BarChart,
|
|
8
|
+
CartesianGrid,
|
|
9
|
+
Cell,
|
|
10
|
+
Line,
|
|
11
|
+
LineChart,
|
|
12
|
+
Pie,
|
|
13
|
+
PieChart,
|
|
14
|
+
XAxis,
|
|
15
|
+
YAxis,
|
|
16
|
+
} from "recharts";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
Card,
|
|
20
|
+
CardContent,
|
|
21
|
+
CardDescription,
|
|
22
|
+
CardHeader,
|
|
23
|
+
CardTitle,
|
|
24
|
+
} from "@viliha/vui-ui/card";
|
|
25
|
+
import {
|
|
26
|
+
ChartContainer,
|
|
27
|
+
ChartLegend,
|
|
28
|
+
ChartLegendContent,
|
|
29
|
+
ChartTooltip,
|
|
30
|
+
ChartTooltipContent,
|
|
31
|
+
type ChartConfig,
|
|
32
|
+
} from "@viliha/vui-ui/chart";
|
|
33
|
+
|
|
34
|
+
const revenue = [
|
|
35
|
+
{ month: "Jan", revenue: 18600, expenses: 12400 },
|
|
36
|
+
{ month: "Feb", revenue: 30500, expenses: 13900 },
|
|
37
|
+
{ month: "Mar", revenue: 23700, expenses: 9800 },
|
|
38
|
+
{ month: "Apr", revenue: 27300, expenses: 15200 },
|
|
39
|
+
{ month: "May", revenue: 41900, expenses: 18100 },
|
|
40
|
+
{ month: "Jun", revenue: 38400, expenses: 16700 },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const deals = [
|
|
44
|
+
{ stage: "Lead", deals: 42 },
|
|
45
|
+
{ stage: "Qualified", deals: 31 },
|
|
46
|
+
{ stage: "Proposal", deals: 24 },
|
|
47
|
+
{ stage: "Negotiation", deals: 15 },
|
|
48
|
+
{ stage: "Won", deals: 9 },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const traffic = [
|
|
52
|
+
{ name: "Direct", value: 4200, color: "var(--chart-1)" },
|
|
53
|
+
{ name: "Referral", value: 3100, color: "var(--chart-2)" },
|
|
54
|
+
{ name: "Organic", value: 5400, color: "var(--chart-3)" },
|
|
55
|
+
{ name: "Social", value: 2200, color: "var(--chart-4)" },
|
|
56
|
+
{ name: "Email", value: 1600, color: "var(--chart-5)" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const areaConfig: ChartConfig = {
|
|
60
|
+
revenue: { label: "Revenue", color: "var(--chart-1)" },
|
|
61
|
+
expenses: { label: "Expenses", color: "var(--chart-3)" },
|
|
62
|
+
};
|
|
63
|
+
const barConfig: ChartConfig = {
|
|
64
|
+
deals: { label: "Deals", color: "var(--chart-2)" },
|
|
65
|
+
};
|
|
66
|
+
const lineConfig: ChartConfig = {
|
|
67
|
+
revenue: { label: "Revenue", color: "var(--chart-1)" },
|
|
68
|
+
expenses: { label: "Expenses", color: "var(--chart-4)" },
|
|
69
|
+
};
|
|
70
|
+
const pieConfig: ChartConfig = Object.fromEntries(
|
|
71
|
+
traffic.map((t) => [t.name, { label: t.name, color: t.color }]),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const axis = { tickLine: false, axisLine: false, tickMargin: 8 } as const;
|
|
75
|
+
|
|
76
|
+
export default function ChartsContent() {
|
|
77
|
+
return (
|
|
78
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
79
|
+
<Card>
|
|
80
|
+
<CardHeader>
|
|
81
|
+
<CardTitle>Revenue vs. expenses</CardTitle>
|
|
82
|
+
<CardDescription>Last 6 months</CardDescription>
|
|
83
|
+
</CardHeader>
|
|
84
|
+
<CardContent>
|
|
85
|
+
<ChartContainer config={areaConfig}>
|
|
86
|
+
<AreaChart data={revenue} margin={{ left: 4, right: 8 }}>
|
|
87
|
+
<CartesianGrid vertical={false} />
|
|
88
|
+
<XAxis dataKey="month" {...axis} />
|
|
89
|
+
<YAxis width={56} {...axis} />
|
|
90
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
91
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
92
|
+
<Area
|
|
93
|
+
dataKey="revenue"
|
|
94
|
+
type="natural"
|
|
95
|
+
stroke="var(--color-revenue)"
|
|
96
|
+
fill="var(--color-revenue)"
|
|
97
|
+
fillOpacity={0.15}
|
|
98
|
+
strokeWidth={2}
|
|
99
|
+
/>
|
|
100
|
+
<Area
|
|
101
|
+
dataKey="expenses"
|
|
102
|
+
type="natural"
|
|
103
|
+
stroke="var(--color-expenses)"
|
|
104
|
+
fill="var(--color-expenses)"
|
|
105
|
+
fillOpacity={0.15}
|
|
106
|
+
strokeWidth={2}
|
|
107
|
+
/>
|
|
108
|
+
</AreaChart>
|
|
109
|
+
</ChartContainer>
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
|
|
113
|
+
<Card>
|
|
114
|
+
<CardHeader>
|
|
115
|
+
<CardTitle>Deals by stage</CardTitle>
|
|
116
|
+
<CardDescription>Current pipeline</CardDescription>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent>
|
|
119
|
+
<ChartContainer config={barConfig}>
|
|
120
|
+
<BarChart data={deals} margin={{ left: 4, right: 8 }}>
|
|
121
|
+
<CartesianGrid vertical={false} />
|
|
122
|
+
<XAxis dataKey="stage" {...axis} />
|
|
123
|
+
<YAxis width={32} {...axis} />
|
|
124
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
125
|
+
<Bar dataKey="deals" fill="var(--color-deals)" radius={[4, 4, 0, 0]} />
|
|
126
|
+
</BarChart>
|
|
127
|
+
</ChartContainer>
|
|
128
|
+
</CardContent>
|
|
129
|
+
</Card>
|
|
130
|
+
|
|
131
|
+
<Card>
|
|
132
|
+
<CardHeader>
|
|
133
|
+
<CardTitle>Trend</CardTitle>
|
|
134
|
+
<CardDescription>Revenue vs. expenses over time</CardDescription>
|
|
135
|
+
</CardHeader>
|
|
136
|
+
<CardContent>
|
|
137
|
+
<ChartContainer config={lineConfig}>
|
|
138
|
+
<LineChart data={revenue} margin={{ left: 4, right: 8 }}>
|
|
139
|
+
<CartesianGrid vertical={false} />
|
|
140
|
+
<XAxis dataKey="month" {...axis} />
|
|
141
|
+
<YAxis width={56} {...axis} />
|
|
142
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
143
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
144
|
+
<Line
|
|
145
|
+
dataKey="revenue"
|
|
146
|
+
type="monotone"
|
|
147
|
+
stroke="var(--color-revenue)"
|
|
148
|
+
strokeWidth={2}
|
|
149
|
+
dot={false}
|
|
150
|
+
/>
|
|
151
|
+
<Line
|
|
152
|
+
dataKey="expenses"
|
|
153
|
+
type="monotone"
|
|
154
|
+
stroke="var(--color-expenses)"
|
|
155
|
+
strokeWidth={2}
|
|
156
|
+
dot={false}
|
|
157
|
+
/>
|
|
158
|
+
</LineChart>
|
|
159
|
+
</ChartContainer>
|
|
160
|
+
</CardContent>
|
|
161
|
+
</Card>
|
|
162
|
+
|
|
163
|
+
<Card>
|
|
164
|
+
<CardHeader>
|
|
165
|
+
<CardTitle>Traffic sources</CardTitle>
|
|
166
|
+
<CardDescription>Sessions this month</CardDescription>
|
|
167
|
+
</CardHeader>
|
|
168
|
+
<CardContent>
|
|
169
|
+
<ChartContainer config={pieConfig}>
|
|
170
|
+
<PieChart>
|
|
171
|
+
<ChartTooltip content={<ChartTooltipContent />} />
|
|
172
|
+
<ChartLegend content={<ChartLegendContent />} />
|
|
173
|
+
<Pie
|
|
174
|
+
data={traffic}
|
|
175
|
+
dataKey="value"
|
|
176
|
+
nameKey="name"
|
|
177
|
+
innerRadius={55}
|
|
178
|
+
strokeWidth={2}
|
|
179
|
+
>
|
|
180
|
+
{traffic.map((t) => (
|
|
181
|
+
<Cell key={t.name} fill={t.color} />
|
|
182
|
+
))}
|
|
183
|
+
</Pie>
|
|
184
|
+
</PieChart>
|
|
185
|
+
</ChartContainer>
|
|
186
|
+
</CardContent>
|
|
187
|
+
</Card>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { pageMeta } from "@/lib/seo";
|
|
2
|
+
|
|
3
|
+
// The page is a Client Component and can't export metadata, so it lives here.
|
|
4
|
+
export const metadata = pageMeta("/charts");
|
|
5
|
+
|
|
6
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
7
|
+
return children;
|
|
8
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
import { BarChartIcon } from "@radix-ui/react-icons";
|
|
5
|
+
|
|
6
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
7
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
8
|
+
|
|
9
|
+
// Recharts is heavy and measures the DOM (no SSR benefit), so defer it: the page
|
|
10
|
+
// shell paints immediately and the chart bundle loads on the client.
|
|
11
|
+
const ChartsContent = dynamic(() => import("./charts-content"), {
|
|
12
|
+
ssr: false,
|
|
13
|
+
loading: () => (
|
|
14
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
15
|
+
{Array.from({ length: 4 }, (_, i) => (
|
|
16
|
+
<div
|
|
17
|
+
key={i}
|
|
18
|
+
className="h-72 animate-pulse rounded-lg border border-border bg-muted/40"
|
|
19
|
+
/>
|
|
20
|
+
))}
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export default function ChartsPage() {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex h-full flex-col">
|
|
28
|
+
<SetPageTitle title="Charts" icon={BarChartIcon} />
|
|
29
|
+
|
|
30
|
+
{/* Action header — breadcrumbs (left) + note (right) */}
|
|
31
|
+
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
|
|
32
|
+
<Breadcrumbs />
|
|
33
|
+
<span className="hidden truncate text-muted-foreground md:block">
|
|
34
|
+
Themed Recharts examples driven by the --chart-* tokens.
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{/* Content */}
|
|
39
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
40
|
+
<ChartsContent />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: "Chat",
|
|
5
|
+
description: "A two-pane messaging demo — conversation list, thread, composer.",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function ChatLayout({
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
ChatBubbleIcon,
|
|
6
|
+
Cross2Icon,
|
|
7
|
+
FileIcon,
|
|
8
|
+
PaperPlaneIcon,
|
|
9
|
+
Pencil1Icon,
|
|
10
|
+
PlusIcon,
|
|
11
|
+
} from "@radix-ui/react-icons";
|
|
12
|
+
|
|
13
|
+
import { cn } from "@viliha/vui-ui/utils";
|
|
14
|
+
import { Button } from "@viliha/vui-ui/button";
|
|
15
|
+
import { Breadcrumbs } from "@/app/_components/breadcrumbs";
|
|
16
|
+
import { SetPageTitle } from "@/app/_components/set-page-title";
|
|
17
|
+
|
|
18
|
+
type Attachment = { id: number; name: string; size: number; url: string; isImage: boolean };
|
|
19
|
+
type Msg = { id: number; role: "user" | "assistant"; text: string; attachments: Attachment[] };
|
|
20
|
+
type Chat = { id: number; title: string; messages: Msg[] };
|
|
21
|
+
|
|
22
|
+
const WELCOME: Msg = {
|
|
23
|
+
id: 0,
|
|
24
|
+
role: "assistant",
|
|
25
|
+
text: "Hi! I'm your assistant. Ask me anything, or attach an image or file and I'll take a look.",
|
|
26
|
+
attachments: [],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const FIRST_CHAT: Chat = { id: 1, title: "New chat", messages: [WELCOME] };
|
|
30
|
+
const SEED: Chat[] = [FIRST_CHAT];
|
|
31
|
+
|
|
32
|
+
const humanSize = (bytes: number) => {
|
|
33
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
34
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
35
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let uid = 100;
|
|
39
|
+
const nextId = () => ++uid;
|
|
40
|
+
|
|
41
|
+
export default function ChatPage() {
|
|
42
|
+
const [chats, setChats] = React.useState<Chat[]>(SEED);
|
|
43
|
+
const [activeId, setActiveId] = React.useState(FIRST_CHAT.id);
|
|
44
|
+
const [draft, setDraft] = React.useState("");
|
|
45
|
+
const [pending, setPending] = React.useState<Attachment[]>([]);
|
|
46
|
+
const fileRef = React.useRef<HTMLInputElement>(null);
|
|
47
|
+
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
48
|
+
const taRef = React.useRef<HTMLTextAreaElement>(null);
|
|
49
|
+
|
|
50
|
+
const active = chats.find((c) => c.id === activeId) ?? FIRST_CHAT;
|
|
51
|
+
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
const el = scrollRef.current;
|
|
54
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
55
|
+
}, [active.messages.length, activeId]);
|
|
56
|
+
|
|
57
|
+
// Auto-grow the composer up to a few lines.
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
const ta = taRef.current;
|
|
60
|
+
if (!ta) return;
|
|
61
|
+
ta.style.height = "auto";
|
|
62
|
+
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
|
|
63
|
+
}, [draft]);
|
|
64
|
+
|
|
65
|
+
const addFiles = (files: FileList | null) => {
|
|
66
|
+
if (!files) return;
|
|
67
|
+
const next: Attachment[] = Array.from(files).map((f) => ({
|
|
68
|
+
id: nextId(),
|
|
69
|
+
name: f.name,
|
|
70
|
+
size: f.size,
|
|
71
|
+
url: URL.createObjectURL(f), // ponytail: object URL for demo preview; swap for an upload URL
|
|
72
|
+
isImage: f.type.startsWith("image/"),
|
|
73
|
+
}));
|
|
74
|
+
setPending((prev) => [...prev, ...next]);
|
|
75
|
+
};
|
|
76
|
+
const removePending = (id: number) =>
|
|
77
|
+
setPending((prev) => prev.filter((a) => a.id !== id));
|
|
78
|
+
|
|
79
|
+
const newChat = () => {
|
|
80
|
+
const chat: Chat = { id: nextId(), title: "New chat", messages: [WELCOME] };
|
|
81
|
+
setChats((prev) => [chat, ...prev]);
|
|
82
|
+
setActiveId(chat.id);
|
|
83
|
+
setDraft("");
|
|
84
|
+
setPending([]);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const send = () => {
|
|
88
|
+
const text = draft.trim();
|
|
89
|
+
if (!text && pending.length === 0) return;
|
|
90
|
+
const userMsg: Msg = { id: nextId(), role: "user", text, attachments: pending };
|
|
91
|
+
const reply: Msg = {
|
|
92
|
+
id: nextId(),
|
|
93
|
+
role: "assistant",
|
|
94
|
+
// ponytail: canned echo so the UI feels live; replace with a Claude API call.
|
|
95
|
+
text: pending.length
|
|
96
|
+
? `Got your ${pending.length} attachment${pending.length > 1 ? "s" : ""}. This is a demo response — wire this to the Claude API to make it real.`
|
|
97
|
+
: "This is a demo response — wire this composer to the Claude API to make it real.",
|
|
98
|
+
attachments: [],
|
|
99
|
+
};
|
|
100
|
+
setChats((prev) =>
|
|
101
|
+
prev.map((c) =>
|
|
102
|
+
c.id === activeId
|
|
103
|
+
? {
|
|
104
|
+
...c,
|
|
105
|
+
title: c.title === "New chat" && text ? text.slice(0, 40) : c.title,
|
|
106
|
+
messages: [...c.messages, userMsg, reply],
|
|
107
|
+
}
|
|
108
|
+
: c,
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
setDraft("");
|
|
112
|
+
setPending([]);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
116
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
send();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="flex h-full flex-col">
|
|
124
|
+
<SetPageTitle title="Chat" icon={ChatBubbleIcon} />
|
|
125
|
+
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
|
|
126
|
+
<Breadcrumbs />
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex min-h-0 flex-1">
|
|
130
|
+
{/* Conversation history */}
|
|
131
|
+
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-muted/20">
|
|
132
|
+
<div className="p-3">
|
|
133
|
+
<Button className="w-full justify-start" onClick={newChat}>
|
|
134
|
+
<Pencil1Icon className="size-4" />
|
|
135
|
+
New chat
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
<nav className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-2">
|
|
139
|
+
{chats.map((c) => (
|
|
140
|
+
<button
|
|
141
|
+
key={c.id}
|
|
142
|
+
type="button"
|
|
143
|
+
onClick={() => setActiveId(c.id)}
|
|
144
|
+
className={cn(
|
|
145
|
+
"flex w-full items-center gap-2 truncate rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent/60",
|
|
146
|
+
c.id === activeId && "bg-accent",
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<ChatBubbleIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
150
|
+
<span className="truncate">{c.title}</span>
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
</nav>
|
|
154
|
+
</aside>
|
|
155
|
+
|
|
156
|
+
{/* Conversation */}
|
|
157
|
+
<section className="flex min-w-0 flex-1 flex-col">
|
|
158
|
+
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
|
159
|
+
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-4 py-6">
|
|
160
|
+
{active.messages.map((m) => (
|
|
161
|
+
<MessageRow key={m.id} msg={m} />
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Composer */}
|
|
167
|
+
<div className="shrink-0 px-4 pb-4">
|
|
168
|
+
<div className="mx-auto max-w-3xl">
|
|
169
|
+
<div className="rounded-2xl border border-border bg-card shadow-sm focus-within:border-[var(--button-primary)]">
|
|
170
|
+
{pending.length > 0 && (
|
|
171
|
+
<div className="flex flex-wrap gap-2 border-b border-border p-2.5">
|
|
172
|
+
{pending.map((a) => (
|
|
173
|
+
<AttachmentChip key={a.id} a={a} onRemove={() => removePending(a.id)} />
|
|
174
|
+
))}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
<textarea
|
|
178
|
+
ref={taRef}
|
|
179
|
+
value={draft}
|
|
180
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
181
|
+
onKeyDown={onKeyDown}
|
|
182
|
+
rows={1}
|
|
183
|
+
placeholder="Message the assistant…"
|
|
184
|
+
aria-label="Message"
|
|
185
|
+
className="block max-h-40 w-full resize-none bg-transparent px-4 py-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none"
|
|
186
|
+
/>
|
|
187
|
+
<div className="flex items-center justify-between p-2">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => fileRef.current?.click()}
|
|
191
|
+
aria-label="Attach files"
|
|
192
|
+
title="Attach images or files"
|
|
193
|
+
className="grid size-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
194
|
+
>
|
|
195
|
+
<PlusIcon className="size-5" />
|
|
196
|
+
</button>
|
|
197
|
+
<input
|
|
198
|
+
ref={fileRef}
|
|
199
|
+
type="file"
|
|
200
|
+
multiple
|
|
201
|
+
accept="image/*,.pdf,.doc,.docx,.txt,.csv,.xlsx"
|
|
202
|
+
className="hidden"
|
|
203
|
+
onChange={(e) => {
|
|
204
|
+
addFiles(e.target.files);
|
|
205
|
+
e.target.value = ""; // allow re-selecting the same file
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
<Button
|
|
209
|
+
variant="primary"
|
|
210
|
+
size="icon"
|
|
211
|
+
onClick={send}
|
|
212
|
+
disabled={!draft.trim() && pending.length === 0}
|
|
213
|
+
aria-label="Send"
|
|
214
|
+
>
|
|
215
|
+
<PaperPlaneIcon className="size-4" />
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<p className="mt-2 text-center text-[11px] text-muted-foreground">
|
|
220
|
+
Demo interface — responses are canned. Attachments stay in your
|
|
221
|
+
browser.
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function MessageRow({ msg }: { msg: Msg }) {
|
|
232
|
+
const isUser = msg.role === "user";
|
|
233
|
+
return (
|
|
234
|
+
<div className="flex gap-3">
|
|
235
|
+
<span
|
|
236
|
+
className={cn(
|
|
237
|
+
"grid size-8 shrink-0 place-items-center rounded-full text-xs font-semibold",
|
|
238
|
+
isUser
|
|
239
|
+
? "bg-[var(--button-primary)] text-[var(--button-primary-foreground)]"
|
|
240
|
+
: "bg-muted text-foreground",
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{isUser ? "You" : "AI"}
|
|
244
|
+
</span>
|
|
245
|
+
<div className="min-w-0 flex-1 pt-1">
|
|
246
|
+
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
|
247
|
+
{isUser ? "You" : "Assistant"}
|
|
248
|
+
</div>
|
|
249
|
+
{msg.attachments.length > 0 && (
|
|
250
|
+
<div className="mb-2 flex flex-wrap gap-2">
|
|
251
|
+
{msg.attachments.map((a) =>
|
|
252
|
+
a.isImage ? (
|
|
253
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
254
|
+
<img
|
|
255
|
+
key={a.id}
|
|
256
|
+
src={a.url}
|
|
257
|
+
alt={a.name}
|
|
258
|
+
className="size-28 rounded-lg border border-border object-cover"
|
|
259
|
+
/>
|
|
260
|
+
) : (
|
|
261
|
+
<a
|
|
262
|
+
key={a.id}
|
|
263
|
+
href={a.url}
|
|
264
|
+
target="_blank"
|
|
265
|
+
rel="noreferrer"
|
|
266
|
+
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm hover:bg-accent/40"
|
|
267
|
+
>
|
|
268
|
+
<FileIcon className="size-4 text-muted-foreground" />
|
|
269
|
+
<span className="max-w-48 truncate">{a.name}</span>
|
|
270
|
+
<span className="text-xs text-muted-foreground">
|
|
271
|
+
{humanSize(a.size)}
|
|
272
|
+
</span>
|
|
273
|
+
</a>
|
|
274
|
+
),
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
{msg.text && (
|
|
279
|
+
<p className="whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</p>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function AttachmentChip({ a, onRemove }: { a: Attachment; onRemove: () => void }) {
|
|
287
|
+
return (
|
|
288
|
+
<span className="group relative flex items-center gap-2 rounded-lg border border-border bg-background py-1.5 pl-1.5 pr-7 text-sm">
|
|
289
|
+
{a.isImage ? (
|
|
290
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
291
|
+
<img src={a.url} alt={a.name} className="size-8 rounded object-cover" />
|
|
292
|
+
) : (
|
|
293
|
+
<span className="grid size-8 place-items-center rounded bg-muted">
|
|
294
|
+
<FileIcon className="size-4 text-muted-foreground" />
|
|
295
|
+
</span>
|
|
296
|
+
)}
|
|
297
|
+
<span className="flex flex-col">
|
|
298
|
+
<span className="max-w-40 truncate text-xs font-medium">{a.name}</span>
|
|
299
|
+
<span className="text-[11px] text-muted-foreground">{humanSize(a.size)}</span>
|
|
300
|
+
</span>
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
onClick={onRemove}
|
|
304
|
+
aria-label={`Remove ${a.name}`}
|
|
305
|
+
className="absolute right-1.5 top-1/2 grid size-5 -translate-y-1/2 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
306
|
+
>
|
|
307
|
+
<Cross2Icon className="size-3.5" />
|
|
308
|
+
</button>
|
|
309
|
+
</span>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { pageMeta } from "@/lib/seo";
|
|
2
|
+
|
|
3
|
+
// The page is a Client Component and can't export metadata, so it lives here.
|
|
4
|
+
export const metadata = pageMeta("/components");
|
|
5
|
+
|
|
6
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
7
|
+
return children;
|
|
8
|
+
}
|