@viliha/vui-ui 1.1.8 → 1.2.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.
Files changed (137) hide show
  1. package/AGENT.md +79 -9
  2. package/README.md +60 -0
  3. package/bin/vui.mjs +211 -0
  4. package/package.json +10 -2
  5. package/template/.env.example +20 -0
  6. package/template/app/(app)/branches/branches-table.tsx +69 -0
  7. package/template/app/(app)/branches/page.tsx +13 -0
  8. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  9. package/template/app/(app)/businesses/page.tsx +13 -0
  10. package/template/app/(app)/calendar/page.tsx +725 -0
  11. package/template/app/(app)/charts/charts-content.tsx +190 -0
  12. package/template/app/(app)/charts/layout.tsx +8 -0
  13. package/template/app/(app)/charts/page.tsx +44 -0
  14. package/template/app/(app)/chat/layout.tsx +14 -0
  15. package/template/app/(app)/chat/page.tsx +311 -0
  16. package/template/app/(app)/components/layout.tsx +8 -0
  17. package/template/app/(app)/components/page.tsx +464 -0
  18. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  19. package/template/app/(app)/crm/companies/page.tsx +13 -0
  20. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
  21. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  22. package/template/app/(app)/crm/people/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  24. package/template/app/(app)/dashboard/layout.tsx +8 -0
  25. package/template/app/(app)/dashboard/page.tsx +239 -0
  26. package/template/app/(app)/departments/departments-table.tsx +55 -0
  27. package/template/app/(app)/departments/page.tsx +13 -0
  28. package/template/app/(app)/employees/employees-table.tsx +64 -0
  29. package/template/app/(app)/employees/page.tsx +13 -0
  30. package/template/app/(app)/forms/layout.tsx +8 -0
  31. package/template/app/(app)/forms/page.tsx +388 -0
  32. package/template/app/(app)/layout.tsx +74 -0
  33. package/template/app/(app)/markets/markets-table.tsx +76 -0
  34. package/template/app/(app)/markets/page.tsx +13 -0
  35. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  36. package/template/app/(app)/organizations/new/page.tsx +46 -0
  37. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  38. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  39. package/template/app/(app)/organizations/page.tsx +13 -0
  40. package/template/app/(app)/settings/layout.tsx +8 -0
  41. package/template/app/(app)/settings/page.tsx +255 -0
  42. package/template/app/(app)/steps/page.tsx +263 -0
  43. package/template/app/(app)/support/layout.tsx +14 -0
  44. package/template/app/(app)/support/page.tsx +345 -0
  45. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  46. package/template/app/(app)/system/cities/page.tsx +13 -0
  47. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  48. package/template/app/(app)/system/countries/page.tsx +13 -0
  49. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  50. package/template/app/(app)/system/currencies/page.tsx +13 -0
  51. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  52. package/template/app/(app)/system/languages/page.tsx +13 -0
  53. package/template/app/(app)/system/regions/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  55. package/template/app/(app)/users/page.tsx +13 -0
  56. package/template/app/(app)/users/users-table.tsx +97 -0
  57. package/template/app/_components/app-sidebar.tsx +565 -0
  58. package/template/app/_components/auth.tsx +184 -0
  59. package/template/app/_components/breadcrumbs.tsx +26 -0
  60. package/template/app/_components/global-search.tsx +209 -0
  61. package/template/app/_components/logo.tsx +49 -0
  62. package/template/app/_components/nav-config.ts +138 -0
  63. package/template/app/_components/open-tabs.tsx +530 -0
  64. package/template/app/_components/quick-actions.tsx +140 -0
  65. package/template/app/_components/route-meta.ts +137 -0
  66. package/template/app/_components/set-page-title.tsx +15 -0
  67. package/template/app/_components/showcase.tsx +29 -0
  68. package/template/app/_components/stat-card.tsx +56 -0
  69. package/template/app/_components/theme-toggle.tsx +46 -0
  70. package/template/app/_components/top-bar.tsx +109 -0
  71. package/template/app/_components/user-menu.tsx +109 -0
  72. package/template/app/_components/wordmark.tsx +45 -0
  73. package/template/app/apple-icon.png +0 -0
  74. package/template/app/auth/forgot-password/page.tsx +102 -0
  75. package/template/app/auth/layout.tsx +43 -0
  76. package/template/app/auth/page.tsx +13 -0
  77. package/template/app/auth/reset-password/page.tsx +108 -0
  78. package/template/app/auth/signin/page.tsx +238 -0
  79. package/template/app/auth/signup/page.tsx +159 -0
  80. package/template/app/auth/verify/page.tsx +86 -0
  81. package/template/app/error.tsx +56 -0
  82. package/template/app/globals.css +6 -0
  83. package/template/app/icon.png +0 -0
  84. package/template/app/icon.svg +4 -0
  85. package/template/app/layout.tsx +103 -0
  86. package/template/app/not-found.tsx +29 -0
  87. package/template/app/onboarding/layout.tsx +31 -0
  88. package/template/app/onboarding/page.tsx +399 -0
  89. package/template/app/page.tsx +13 -0
  90. package/template/app/register-business/layout.tsx +30 -0
  91. package/template/app/register-business/page.tsx +234 -0
  92. package/template/components/ui/accordion.tsx +66 -0
  93. package/template/components/ui/alert-dialog.tsx +196 -0
  94. package/template/components/ui/alert.tsx +66 -0
  95. package/template/components/ui/aspect-ratio.tsx +11 -0
  96. package/template/components/ui/avatar.tsx +109 -0
  97. package/template/components/ui/badge.tsx +48 -0
  98. package/template/components/ui/breadcrumb.tsx +109 -0
  99. package/template/components/ui/button.tsx +64 -0
  100. package/template/components/ui/calendar.tsx +220 -0
  101. package/template/components/ui/card.tsx +92 -0
  102. package/template/components/ui/checkbox.tsx +32 -0
  103. package/template/components/ui/collapsible.tsx +33 -0
  104. package/template/components/ui/command.tsx +184 -0
  105. package/template/components/ui/dialog.tsx +158 -0
  106. package/template/components/ui/dropdown-menu.tsx +257 -0
  107. package/template/components/ui/form.tsx +167 -0
  108. package/template/components/ui/hover-card.tsx +44 -0
  109. package/template/components/ui/input-otp.tsx +77 -0
  110. package/template/components/ui/input.tsx +21 -0
  111. package/template/components/ui/label.tsx +24 -0
  112. package/template/components/ui/popover.tsx +89 -0
  113. package/template/components/ui/progress.tsx +31 -0
  114. package/template/components/ui/radio-group.tsx +45 -0
  115. package/template/components/ui/scroll-area.tsx +58 -0
  116. package/template/components/ui/select.tsx +190 -0
  117. package/template/components/ui/separator.tsx +28 -0
  118. package/template/components/ui/sheet.tsx +143 -0
  119. package/template/components/ui/skeleton.tsx +13 -0
  120. package/template/components/ui/slider.tsx +63 -0
  121. package/template/components/ui/sonner.tsx +40 -0
  122. package/template/components/ui/switch.tsx +35 -0
  123. package/template/components/ui/table.tsx +116 -0
  124. package/template/components/ui/tabs.tsx +91 -0
  125. package/template/components/ui/textarea.tsx +18 -0
  126. package/template/components/ui/toggle-group.tsx +83 -0
  127. package/template/components/ui/toggle.tsx +47 -0
  128. package/template/components/ui/tooltip.tsx +57 -0
  129. package/template/lib/auth-demo.ts +94 -0
  130. package/template/lib/crm-data.ts +80 -0
  131. package/template/lib/demo-data.ts +267 -0
  132. package/template/lib/mock-data.ts +169 -0
  133. package/template/lib/org-store.ts +50 -0
  134. package/template/lib/seo.ts +188 -0
  135. package/template/lib/utils.ts +3 -0
  136. package/template/next.config.mjs +13 -0
  137. 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
+ }