stagent 0.6.3 → 0.8.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 (139) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +226 -1
  3. package/docs/.coverage-gaps.json +66 -16
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/dashboard-kanban.md +13 -7
  6. package/docs/features/settings.md +15 -3
  7. package/docs/features/tables.md +122 -0
  8. package/docs/index.md +3 -2
  9. package/docs/journeys/developer.md +26 -16
  10. package/docs/journeys/personal-use.md +23 -9
  11. package/docs/journeys/power-user.md +40 -14
  12. package/docs/journeys/work-use.md +43 -15
  13. package/docs/manifest.json +27 -17
  14. package/package.json +3 -1
  15. package/src/app/api/chat/entities/search/route.ts +12 -3
  16. package/src/app/api/projects/[id]/route.ts +37 -0
  17. package/src/app/api/projects/__tests__/delete-project.test.ts +12 -0
  18. package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
  19. package/src/app/api/snapshots/[id]/route.ts +44 -0
  20. package/src/app/api/snapshots/route.ts +54 -0
  21. package/src/app/api/snapshots/settings/route.ts +67 -0
  22. package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
  23. package/src/app/api/tables/[id]/charts/route.ts +72 -0
  24. package/src/app/api/tables/[id]/columns/route.ts +70 -0
  25. package/src/app/api/tables/[id]/export/route.ts +94 -0
  26. package/src/app/api/tables/[id]/history/route.ts +15 -0
  27. package/src/app/api/tables/[id]/import/route.ts +111 -0
  28. package/src/app/api/tables/[id]/route.ts +86 -0
  29. package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
  30. package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
  31. package/src/app/api/tables/[id]/rows/route.ts +101 -0
  32. package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
  33. package/src/app/api/tables/[id]/triggers/route.ts +122 -0
  34. package/src/app/api/tables/route.ts +65 -0
  35. package/src/app/api/tables/templates/route.ts +92 -0
  36. package/src/app/globals.css +14 -0
  37. package/src/app/settings/page.tsx +2 -0
  38. package/src/app/tables/[id]/page.tsx +67 -0
  39. package/src/app/tables/page.tsx +21 -0
  40. package/src/app/tables/templates/page.tsx +19 -0
  41. package/src/components/book/book-reader.tsx +62 -9
  42. package/src/components/book/content-blocks.tsx +6 -1
  43. package/src/components/chat/chat-table-result.tsx +139 -0
  44. package/src/components/documents/document-browser.tsx +1 -1
  45. package/src/components/projects/project-form-sheet.tsx +3 -27
  46. package/src/components/schedules/schedule-form.tsx +5 -27
  47. package/src/components/settings/data-management-section.tsx +17 -12
  48. package/src/components/settings/database-snapshots-section.tsx +469 -0
  49. package/src/components/shared/app-sidebar.tsx +2 -0
  50. package/src/components/shared/document-picker-sheet.tsx +214 -11
  51. package/src/components/tables/table-browser.tsx +234 -0
  52. package/src/components/tables/table-cell-editor.tsx +226 -0
  53. package/src/components/tables/table-chart-builder.tsx +288 -0
  54. package/src/components/tables/table-chart-view.tsx +146 -0
  55. package/src/components/tables/table-column-header.tsx +103 -0
  56. package/src/components/tables/table-column-sheet.tsx +331 -0
  57. package/src/components/tables/table-create-sheet.tsx +240 -0
  58. package/src/components/tables/table-detail-sheet.tsx +144 -0
  59. package/src/components/tables/table-detail-tabs.tsx +278 -0
  60. package/src/components/tables/table-grid.tsx +61 -0
  61. package/src/components/tables/table-history-tab.tsx +148 -0
  62. package/src/components/tables/table-import-wizard.tsx +542 -0
  63. package/src/components/tables/table-list-table.tsx +95 -0
  64. package/src/components/tables/table-relation-combobox.tsx +217 -0
  65. package/src/components/tables/table-row-sheet.tsx +271 -0
  66. package/src/components/tables/table-spreadsheet.tsx +394 -0
  67. package/src/components/tables/table-template-gallery.tsx +162 -0
  68. package/src/components/tables/table-template-preview.tsx +219 -0
  69. package/src/components/tables/table-toolbar.tsx +79 -0
  70. package/src/components/tables/table-triggers-tab.tsx +446 -0
  71. package/src/components/tables/types.ts +6 -0
  72. package/src/components/tables/use-spreadsheet-keys.ts +171 -0
  73. package/src/components/tables/utils.ts +29 -0
  74. package/src/components/tasks/task-create-panel.tsx +5 -31
  75. package/src/components/tasks/task-edit-dialog.tsx +5 -27
  76. package/src/components/workflows/workflow-form-view.tsx +11 -35
  77. package/src/components/workflows/workflow-status-view.tsx +1 -1
  78. package/src/instrumentation.ts +3 -0
  79. package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
  80. package/src/lib/agents/claude-agent.ts +3 -1
  81. package/src/lib/agents/profiles/builtins/document-writer/SKILL.md +23 -0
  82. package/src/lib/agents/profiles/builtins/technical-writer/SKILL.md +10 -0
  83. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +1 -1
  84. package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
  85. package/src/lib/agents/runtime/openai-direct.ts +29 -0
  86. package/src/lib/book/chapter-generator.ts +81 -5
  87. package/src/lib/book/chapter-mapping.ts +58 -24
  88. package/src/lib/book/content.ts +83 -47
  89. package/src/lib/book/markdown-parser.ts +1 -1
  90. package/src/lib/book/reading-paths.ts +8 -8
  91. package/src/lib/book/types.ts +1 -1
  92. package/src/lib/book/update-detector.ts +4 -1
  93. package/src/lib/chat/stagent-tools.ts +2 -0
  94. package/src/lib/chat/tool-catalog.ts +34 -0
  95. package/src/lib/chat/tools/table-tools.ts +955 -0
  96. package/src/lib/chat/tools/workflow-tools.ts +9 -1
  97. package/src/lib/constants/table-status.ts +68 -0
  98. package/src/lib/data/__tests__/clear.test.ts +1 -1
  99. package/src/lib/data/clear.ts +45 -0
  100. package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
  101. package/src/lib/data/seed-data/conversations.ts +350 -42
  102. package/src/lib/data/seed-data/documents.ts +564 -591
  103. package/src/lib/data/seed-data/learned-context.ts +101 -22
  104. package/src/lib/data/seed-data/notifications.ts +344 -70
  105. package/src/lib/data/seed-data/profile-test-results.ts +92 -11
  106. package/src/lib/data/seed-data/profiles.ts +144 -46
  107. package/src/lib/data/seed-data/projects.ts +50 -18
  108. package/src/lib/data/seed-data/repo-imports.ts +28 -13
  109. package/src/lib/data/seed-data/schedules.ts +208 -41
  110. package/src/lib/data/seed-data/table-templates.ts +234 -0
  111. package/src/lib/data/seed-data/tasks.ts +614 -116
  112. package/src/lib/data/seed-data/usage-ledger.ts +182 -103
  113. package/src/lib/data/seed-data/user-tables.ts +203 -0
  114. package/src/lib/data/seed-data/views.ts +52 -7
  115. package/src/lib/data/seed-data/workflows.ts +231 -84
  116. package/src/lib/data/seed.ts +55 -14
  117. package/src/lib/data/tables.ts +417 -0
  118. package/src/lib/db/bootstrap.ts +227 -0
  119. package/src/lib/db/index.ts +9 -0
  120. package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
  121. package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
  122. package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
  123. package/src/lib/db/schema.ts +368 -0
  124. package/src/lib/snapshots/auto-backup.ts +132 -0
  125. package/src/lib/snapshots/retention.ts +64 -0
  126. package/src/lib/snapshots/snapshot-manager.ts +429 -0
  127. package/src/lib/tables/computed.ts +61 -0
  128. package/src/lib/tables/context-builder.ts +139 -0
  129. package/src/lib/tables/formula-engine.ts +415 -0
  130. package/src/lib/tables/history.ts +115 -0
  131. package/src/lib/tables/import.ts +343 -0
  132. package/src/lib/tables/query-builder.ts +152 -0
  133. package/src/lib/tables/trigger-evaluator.ts +146 -0
  134. package/src/lib/tables/types.ts +141 -0
  135. package/src/lib/tables/validation.ts +119 -0
  136. package/src/lib/utils/stagent-paths.ts +20 -0
  137. package/src/lib/workflows/types.ts +1 -1
  138. package/tsconfig.json +3 -1
  139. /package/docs/features/{playbook.md → user-guide.md} +0 -0
@@ -0,0 +1,394 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef } from "react";
4
+ import { Checkbox } from "@/components/ui/checkbox";
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from "@/components/ui/table";
13
+ import { Button } from "@/components/ui/button";
14
+ import { Plus } from "lucide-react";
15
+ import { toast } from "sonner";
16
+ import { cn } from "@/lib/utils";
17
+ import { CellDisplay } from "./table-cell-editor";
18
+ import { SpreadsheetColumnHeader } from "./table-column-header";
19
+ import { TableColumnSheet } from "./table-column-sheet";
20
+ import { TableToolbar } from "./table-toolbar";
21
+ import { TableImportWizard } from "./table-import-wizard";
22
+ import { TableRowSheet } from "./table-row-sheet";
23
+ import { EmptyState } from "@/components/shared/empty-state";
24
+ import { Table2 } from "lucide-react";
25
+ import { evaluateComputedColumns } from "@/lib/tables/computed";
26
+ import type { ColumnDef, SortSpec } from "@/lib/tables/types";
27
+ import type { UserTableRowRow } from "@/lib/db/schema";
28
+
29
+ interface TableSpreadsheetProps {
30
+ tableId: string;
31
+ columns: ColumnDef[];
32
+ initialRows: UserTableRowRow[];
33
+ }
34
+
35
+ interface ParsedRow {
36
+ id: string;
37
+ data: Record<string, unknown>;
38
+ position: number;
39
+ createdBy: string | null;
40
+ }
41
+
42
+ function parseRows(rows: UserTableRowRow[]): ParsedRow[] {
43
+ return rows.map((r) => ({
44
+ id: r.id,
45
+ data: JSON.parse(r.data) as Record<string, unknown>,
46
+ position: r.position,
47
+ createdBy: r.createdBy,
48
+ }));
49
+ }
50
+
51
+ export function TableSpreadsheet({
52
+ tableId,
53
+ columns: initialColumns,
54
+ initialRows,
55
+ }: TableSpreadsheetProps) {
56
+ const [columns, setColumns] = useState<ColumnDef[]>(initialColumns);
57
+ const [rows, setRows] = useState<ParsedRow[]>(() => parseRows(initialRows));
58
+ const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
59
+ const [sorts, setSorts] = useState<SortSpec[]>([]);
60
+ const [columnSheetOpen, setColumnSheetOpen] = useState(false);
61
+ const [importOpen, setImportOpen] = useState(false);
62
+ const [rowSheetOpen, setRowSheetOpen] = useState(false);
63
+ const [rowSheetRow, setRowSheetRow] = useState<ParsedRow | null>(null);
64
+
65
+ // ── Refresh helpers ─────────────────────────────────────────────────
66
+
67
+ const refreshTable = useCallback(async () => {
68
+ try {
69
+ const res = await fetch(`/api/tables/${tableId}`);
70
+ if (res.ok) {
71
+ const table = await res.json();
72
+ const parsed = JSON.parse(table.columnSchema) as ColumnDef[];
73
+ setColumns(parsed);
74
+ }
75
+ } catch { /* silent */ }
76
+ }, [tableId]);
77
+
78
+ const refreshRows = useCallback(async () => {
79
+ try {
80
+ const sortsParam = sorts.length > 0 ? `&sorts=${encodeURIComponent(JSON.stringify(sorts))}` : "";
81
+ const res = await fetch(`/api/tables/${tableId}/rows?limit=500${sortsParam}`);
82
+ if (res.ok) {
83
+ const raw = (await res.json()) as UserTableRowRow[];
84
+ const enriched = evaluateComputedColumns(columns, raw);
85
+ setRows(parseRows(enriched));
86
+ }
87
+ } catch { /* silent */ }
88
+ }, [tableId, sorts, columns]);
89
+
90
+ // ── Boolean toggle (inline for convenience) ─────────────────────────
91
+
92
+ const handleToggleBoolean = useCallback(
93
+ (rowId: string, colName: string, newValue: boolean) => {
94
+ setRows((prev) =>
95
+ prev.map((r) =>
96
+ r.id === rowId
97
+ ? { ...r, data: { ...r.data, [colName]: newValue } }
98
+ : r
99
+ )
100
+ );
101
+ // Save immediately
102
+ fetch(`/api/tables/${tableId}/rows/${rowId}`, {
103
+ method: "PATCH",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ data: { [colName]: newValue } }),
106
+ }).catch(() => toast.error("Failed to save"));
107
+ },
108
+ [tableId]
109
+ );
110
+
111
+ // ── Row operations ──────────────────────────────────────────────────
112
+
113
+ const handleAddRow = useCallback(async () => {
114
+ try {
115
+ const emptyData: Record<string, unknown> = {};
116
+ for (const col of columns) {
117
+ emptyData[col.name] = col.defaultValue ?? null;
118
+ }
119
+
120
+ const res = await fetch(`/api/tables/${tableId}/rows`, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json" },
123
+ body: JSON.stringify({ rows: [{ data: emptyData }] }),
124
+ });
125
+
126
+ if (res.ok) {
127
+ await refreshRows();
128
+ } else {
129
+ toast.error("Failed to add row");
130
+ }
131
+ } catch {
132
+ toast.error("Failed to add row");
133
+ }
134
+ }, [tableId, columns, refreshRows]);
135
+
136
+ const handleBulkDelete = useCallback(async () => {
137
+ if (selectedRows.size === 0) return;
138
+
139
+ try {
140
+ const promises = Array.from(selectedRows).map((rowId) =>
141
+ fetch(`/api/tables/${tableId}/rows/${rowId}`, { method: "DELETE" })
142
+ );
143
+ await Promise.all(promises);
144
+ setSelectedRows(new Set());
145
+ await refreshRows();
146
+ toast.success(`Deleted ${selectedRows.size} row(s)`);
147
+ } catch {
148
+ toast.error("Failed to delete rows");
149
+ }
150
+ }, [tableId, selectedRows, refreshRows]);
151
+
152
+ // ── Row sheet ──────────────────────────────────────────────────────
153
+
154
+ const handleOpenRowSheet = useCallback((row: ParsedRow) => {
155
+ setRowSheetRow(row);
156
+ setRowSheetOpen(true);
157
+ }, []);
158
+
159
+ const handleRowUpdated = useCallback((rowId: string, data: Record<string, unknown>) => {
160
+ setRows((prev) =>
161
+ prev.map((r) => (r.id === rowId ? { ...r, data } : r))
162
+ );
163
+ }, []);
164
+
165
+ // ── Column operations ───────────────────────────────────────────────
166
+
167
+ const handleSort = useCallback(
168
+ (colName: string, direction: "asc" | "desc") => {
169
+ setSorts([{ column: colName, direction }]);
170
+ },
171
+ []
172
+ );
173
+
174
+ const handleDeleteColumn = useCallback(
175
+ async (_columnName: string) => {
176
+ toast.info("Column deletion coming soon");
177
+ },
178
+ []
179
+ );
180
+
181
+ // Trigger row refresh when sorts change
182
+ const prevSortsRef = useRef(sorts);
183
+ if (prevSortsRef.current !== sorts) {
184
+ prevSortsRef.current = sorts;
185
+ refreshRows();
186
+ }
187
+
188
+ // ── Selection helpers ───────────────────────────────────────────────
189
+
190
+ function toggleRowSelect(rowId: string) {
191
+ setSelectedRows((prev) => {
192
+ const next = new Set(prev);
193
+ if (next.has(rowId)) next.delete(rowId);
194
+ else next.add(rowId);
195
+ return next;
196
+ });
197
+ }
198
+
199
+ function toggleSelectAll() {
200
+ if (selectedRows.size === rows.length) {
201
+ setSelectedRows(new Set());
202
+ } else {
203
+ setSelectedRows(new Set(rows.map((r) => r.id)));
204
+ }
205
+ }
206
+
207
+ // ── Render ──────────────────────────────────────────────────────────
208
+
209
+ if (columns.length === 0) {
210
+ return (
211
+ <div className="space-y-4">
212
+ <TableToolbar
213
+ tableId={tableId}
214
+ rowCount={0}
215
+ selectedCount={0}
216
+ onAddRow={handleAddRow}
217
+ onAddColumn={() => setColumnSheetOpen(true)}
218
+ onBulkDelete={handleBulkDelete}
219
+ onImport={() => setImportOpen(true)}
220
+ />
221
+ <EmptyState
222
+ icon={Table2}
223
+ heading="No columns defined"
224
+ description="Add columns to start building your table structure."
225
+ action={
226
+ <Button onClick={() => setColumnSheetOpen(true)}>
227
+ <Plus className="h-4 w-4 mr-1" />
228
+ Add Column
229
+ </Button>
230
+ }
231
+ />
232
+ <TableColumnSheet
233
+ tableId={tableId}
234
+ open={columnSheetOpen}
235
+ onOpenChange={setColumnSheetOpen}
236
+ onColumnAdded={() => { refreshTable(); refreshRows(); }}
237
+ />
238
+ </div>
239
+ );
240
+ }
241
+
242
+ return (
243
+ <div className="space-y-2">
244
+ <TableToolbar
245
+ tableId={tableId}
246
+ rowCount={rows.length}
247
+ selectedCount={selectedRows.size}
248
+ onAddRow={handleAddRow}
249
+ onAddColumn={() => setColumnSheetOpen(true)}
250
+ onBulkDelete={handleBulkDelete}
251
+ onImport={() => setImportOpen(true)}
252
+ />
253
+
254
+ <div className="rounded-lg border overflow-auto">
255
+ <Table>
256
+ <TableHeader>
257
+ <TableRow>
258
+ <TableHead className="w-10">
259
+ <Checkbox
260
+ checked={rows.length > 0 && selectedRows.size === rows.length}
261
+ onCheckedChange={toggleSelectAll}
262
+ />
263
+ </TableHead>
264
+ {columns.map((col) => {
265
+ const sortDir = sorts.find((s) => s.column === col.name)?.direction ?? null;
266
+ return (
267
+ <TableHead key={col.name} className="group min-w-[120px]">
268
+ <SpreadsheetColumnHeader
269
+ column={col}
270
+ sortDirection={sortDir}
271
+ onSort={(dir) => handleSort(col.name, dir)}
272
+ onRename={() => toast.info("Rename coming soon")}
273
+ onDelete={() => handleDeleteColumn(col.name)}
274
+ />
275
+ </TableHead>
276
+ );
277
+ })}
278
+ <TableHead className="w-10">
279
+ <Button
280
+ variant="ghost"
281
+ size="icon"
282
+ className="h-6 w-6"
283
+ onClick={() => setColumnSheetOpen(true)}
284
+ >
285
+ <Plus className="h-3 w-3" />
286
+ </Button>
287
+ </TableHead>
288
+ </TableRow>
289
+ </TableHeader>
290
+ <TableBody>
291
+ {rows.length === 0 ? (
292
+ <TableRow>
293
+ <TableCell
294
+ colSpan={columns.length + 2}
295
+ className="h-24 text-center text-muted-foreground"
296
+ >
297
+ No rows yet.{" "}
298
+ <button
299
+ className="underline underline-offset-2 hover:text-foreground"
300
+ onClick={handleAddRow}
301
+ >
302
+ Add your first row
303
+ </button>
304
+ </TableCell>
305
+ </TableRow>
306
+ ) : (
307
+ rows.map((row) => (
308
+ <TableRow
309
+ key={row.id}
310
+ className={cn(
311
+ "cursor-pointer",
312
+ selectedRows.has(row.id) && "bg-muted/50"
313
+ )}
314
+ onClick={() => handleOpenRowSheet(row)}
315
+ >
316
+ <TableCell onClick={(e) => e.stopPropagation()}>
317
+ <Checkbox
318
+ checked={selectedRows.has(row.id)}
319
+ onCheckedChange={() => toggleRowSelect(row.id)}
320
+ />
321
+ </TableCell>
322
+ {columns.map((col) => {
323
+ const value = row.data[col.name];
324
+
325
+ return (
326
+ <TableCell
327
+ key={col.name}
328
+ className="min-w-[120px]"
329
+ >
330
+ <div className="px-2 py-1 min-h-[32px] flex items-center">
331
+ <CellDisplay
332
+ column={col}
333
+ value={value}
334
+ onToggleBoolean={
335
+ col.dataType === "boolean"
336
+ ? (newVal) => {
337
+ handleToggleBoolean(row.id, col.name, newVal);
338
+ }
339
+ : undefined
340
+ }
341
+ />
342
+ </div>
343
+ </TableCell>
344
+ );
345
+ })}
346
+ <TableCell />
347
+ </TableRow>
348
+ ))
349
+ )}
350
+ </TableBody>
351
+ </Table>
352
+ </div>
353
+
354
+ {rows.length > 0 && (
355
+ <div className="flex justify-start">
356
+ <Button
357
+ variant="ghost"
358
+ size="sm"
359
+ className="text-muted-foreground"
360
+ onClick={handleAddRow}
361
+ >
362
+ <Plus className="h-3 w-3 mr-1" />
363
+ Add Row
364
+ </Button>
365
+ </div>
366
+ )}
367
+
368
+ <TableColumnSheet
369
+ tableId={tableId}
370
+ open={columnSheetOpen}
371
+ onOpenChange={setColumnSheetOpen}
372
+ onColumnAdded={() => { refreshTable(); refreshRows(); }}
373
+ />
374
+
375
+ <TableImportWizard
376
+ tableId={tableId}
377
+ open={importOpen}
378
+ onOpenChange={setImportOpen}
379
+ onImported={() => { refreshTable(); refreshRows(); }}
380
+ />
381
+
382
+ {rowSheetRow && (
383
+ <TableRowSheet
384
+ tableId={tableId}
385
+ columns={columns}
386
+ row={rowSheetRow}
387
+ open={rowSheetOpen}
388
+ onOpenChange={setRowSheetOpen}
389
+ onRowUpdated={handleRowUpdated}
390
+ />
391
+ )}
392
+ </div>
393
+ );
394
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
8
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
9
+ import {
10
+ Building2,
11
+ User,
12
+ KanbanSquare,
13
+ DollarSign,
14
+ PenLine,
15
+ Search,
16
+ Table2,
17
+ } from "lucide-react";
18
+ import { TableTemplatePreview } from "./table-template-preview";
19
+ import { EmptyState } from "@/components/shared/empty-state";
20
+ import type { UserTableTemplateRow } from "@/lib/db/schema";
21
+ import type { TemplateCategory } from "@/lib/constants/table-status";
22
+ import type { LucideIcon } from "lucide-react";
23
+
24
+ const categoryIcons: Record<TemplateCategory, LucideIcon> = {
25
+ business: Building2,
26
+ personal: User,
27
+ pm: KanbanSquare,
28
+ finance: DollarSign,
29
+ content: PenLine,
30
+ };
31
+
32
+ const categoryLabels: Record<TemplateCategory, string> = {
33
+ business: "Business",
34
+ personal: "Personal",
35
+ pm: "Project Mgmt",
36
+ finance: "Finance",
37
+ content: "Content",
38
+ };
39
+
40
+ interface TableTemplateGalleryProps {
41
+ templates: UserTableTemplateRow[];
42
+ }
43
+
44
+ export function TableTemplateGallery({ templates }: TableTemplateGalleryProps) {
45
+ const [search, setSearch] = useState("");
46
+ const [category, setCategory] = useState<string>("all");
47
+ const [previewTemplate, setPreviewTemplate] = useState<UserTableTemplateRow | null>(null);
48
+ const router = useRouter();
49
+
50
+ const filtered = templates.filter((t) => {
51
+ if (category !== "all" && t.category !== category) return false;
52
+ if (
53
+ search &&
54
+ !t.name.toLowerCase().includes(search.toLowerCase()) &&
55
+ !(t.description ?? "").toLowerCase().includes(search.toLowerCase())
56
+ ) {
57
+ return false;
58
+ }
59
+ return true;
60
+ });
61
+
62
+ function handleCloned(tableId: string) {
63
+ setPreviewTemplate(null);
64
+ router.push(`/tables/${tableId}`);
65
+ }
66
+
67
+ return (
68
+ <div className="space-y-4">
69
+ <div className="flex items-center gap-2">
70
+ <div className="relative flex-1">
71
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
72
+ <Input
73
+ placeholder="Search templates..."
74
+ value={search}
75
+ onChange={(e) => setSearch(e.target.value)}
76
+ className="pl-9"
77
+ />
78
+ </div>
79
+ </div>
80
+
81
+ <Tabs value={category} onValueChange={setCategory}>
82
+ <TabsList>
83
+ <TabsTrigger value="all">All</TabsTrigger>
84
+ {(Object.keys(categoryLabels) as TemplateCategory[]).map((cat) => (
85
+ <TabsTrigger key={cat} value={cat}>
86
+ {categoryLabels[cat]}
87
+ </TabsTrigger>
88
+ ))}
89
+ </TabsList>
90
+ </Tabs>
91
+
92
+ {filtered.length === 0 ? (
93
+ <EmptyState
94
+ icon={Table2}
95
+ heading="No templates found"
96
+ description={search ? "Try a different search term." : "No templates in this category."}
97
+ />
98
+ ) : (
99
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
100
+ {filtered.map((t) => {
101
+ const catKey = t.category as TemplateCategory;
102
+ const Icon = categoryIcons[catKey] ?? Table2;
103
+ let columnCount = 0;
104
+ try {
105
+ columnCount = (JSON.parse(t.columnSchema) as unknown[]).length;
106
+ } catch { /* */ }
107
+
108
+ return (
109
+ <Card
110
+ key={t.id}
111
+ className="cursor-pointer hover:border-primary/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-lg"
112
+ tabIndex={0}
113
+ onClick={() => setPreviewTemplate(t)}
114
+ onKeyDown={(e) => {
115
+ if (e.key === "Enter") setPreviewTemplate(t);
116
+ }}
117
+ >
118
+ <CardHeader className="pb-2">
119
+ <div className="flex items-start justify-between">
120
+ <div className="flex items-center gap-2">
121
+ <Icon className="h-4 w-4 text-muted-foreground" />
122
+ <CardTitle className="text-sm font-medium">
123
+ {t.name}
124
+ </CardTitle>
125
+ </div>
126
+ <Badge variant="outline" className="text-xs">
127
+ {categoryLabels[catKey] ?? t.category}
128
+ </Badge>
129
+ </div>
130
+ </CardHeader>
131
+ <CardContent>
132
+ {t.description && (
133
+ <p className="text-xs text-muted-foreground mb-2 line-clamp-2">
134
+ {t.description}
135
+ </p>
136
+ )}
137
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
138
+ <span>{columnCount} columns</span>
139
+ <Badge variant="secondary" className="text-xs">
140
+ {t.scope}
141
+ </Badge>
142
+ </div>
143
+ </CardContent>
144
+ </Card>
145
+ );
146
+ })}
147
+ </div>
148
+ )}
149
+
150
+ {previewTemplate && (
151
+ <TableTemplatePreview
152
+ template={previewTemplate}
153
+ open={!!previewTemplate}
154
+ onOpenChange={(open) => {
155
+ if (!open) setPreviewTemplate(null);
156
+ }}
157
+ onCloned={handleCloned}
158
+ />
159
+ )}
160
+ </div>
161
+ );
162
+ }