stagent 0.6.2 → 0.7.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 (176) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +272 -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 -2
  15. package/src/app/api/chat/entities/search/route.ts +12 -3
  16. package/src/app/api/documents/[id]/route.ts +5 -1
  17. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  18. package/src/app/api/documents/route.ts +5 -1
  19. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  20. package/src/app/api/projects/[id]/route.ts +72 -3
  21. package/src/app/api/projects/__tests__/delete-project.test.ts +13 -0
  22. package/src/app/api/schedules/route.ts +19 -1
  23. package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
  24. package/src/app/api/snapshots/[id]/route.ts +44 -0
  25. package/src/app/api/snapshots/route.ts +54 -0
  26. package/src/app/api/snapshots/settings/route.ts +67 -0
  27. package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
  28. package/src/app/api/tables/[id]/charts/route.ts +72 -0
  29. package/src/app/api/tables/[id]/columns/route.ts +70 -0
  30. package/src/app/api/tables/[id]/export/route.ts +94 -0
  31. package/src/app/api/tables/[id]/history/route.ts +15 -0
  32. package/src/app/api/tables/[id]/import/route.ts +111 -0
  33. package/src/app/api/tables/[id]/route.ts +86 -0
  34. package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
  35. package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
  36. package/src/app/api/tables/[id]/rows/route.ts +101 -0
  37. package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
  38. package/src/app/api/tables/[id]/triggers/route.ts +122 -0
  39. package/src/app/api/tables/route.ts +65 -0
  40. package/src/app/api/tables/templates/route.ts +92 -0
  41. package/src/app/api/tasks/[id]/route.ts +37 -2
  42. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  43. package/src/app/api/tasks/route.ts +8 -9
  44. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  45. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  46. package/src/app/api/workflows/[id]/route.ts +16 -3
  47. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  48. package/src/app/api/workflows/route.ts +13 -2
  49. package/src/app/documents/page.tsx +5 -1
  50. package/src/app/layout.tsx +0 -1
  51. package/src/app/manifest.ts +3 -3
  52. package/src/app/projects/[id]/page.tsx +62 -2
  53. package/src/app/settings/page.tsx +2 -0
  54. package/src/app/tables/[id]/page.tsx +67 -0
  55. package/src/app/tables/page.tsx +21 -0
  56. package/src/app/tables/templates/page.tsx +19 -0
  57. package/src/components/chat/chat-table-result.tsx +139 -0
  58. package/src/components/documents/document-browser.tsx +1 -1
  59. package/src/components/documents/document-chip-bar.tsx +17 -1
  60. package/src/components/documents/document-detail-view.tsx +51 -0
  61. package/src/components/documents/document-grid.tsx +5 -0
  62. package/src/components/documents/document-table.tsx +4 -0
  63. package/src/components/documents/types.ts +3 -0
  64. package/src/components/projects/project-form-sheet.tsx +109 -2
  65. package/src/components/schedules/schedule-form.tsx +91 -1
  66. package/src/components/settings/data-management-section.tsx +17 -12
  67. package/src/components/settings/database-snapshots-section.tsx +469 -0
  68. package/src/components/shared/app-sidebar.tsx +2 -0
  69. package/src/components/shared/document-picker-sheet.tsx +486 -0
  70. package/src/components/tables/table-browser.tsx +234 -0
  71. package/src/components/tables/table-cell-editor.tsx +226 -0
  72. package/src/components/tables/table-chart-builder.tsx +288 -0
  73. package/src/components/tables/table-chart-view.tsx +146 -0
  74. package/src/components/tables/table-column-header.tsx +103 -0
  75. package/src/components/tables/table-column-sheet.tsx +331 -0
  76. package/src/components/tables/table-create-sheet.tsx +240 -0
  77. package/src/components/tables/table-detail-sheet.tsx +144 -0
  78. package/src/components/tables/table-detail-tabs.tsx +278 -0
  79. package/src/components/tables/table-grid.tsx +61 -0
  80. package/src/components/tables/table-history-tab.tsx +148 -0
  81. package/src/components/tables/table-import-wizard.tsx +542 -0
  82. package/src/components/tables/table-list-table.tsx +95 -0
  83. package/src/components/tables/table-relation-combobox.tsx +217 -0
  84. package/src/components/tables/table-spreadsheet.tsx +499 -0
  85. package/src/components/tables/table-template-gallery.tsx +162 -0
  86. package/src/components/tables/table-template-preview.tsx +219 -0
  87. package/src/components/tables/table-toolbar.tsx +79 -0
  88. package/src/components/tables/table-triggers-tab.tsx +446 -0
  89. package/src/components/tables/types.ts +6 -0
  90. package/src/components/tables/use-spreadsheet-keys.ts +171 -0
  91. package/src/components/tables/utils.ts +29 -0
  92. package/src/components/tasks/task-card.tsx +8 -1
  93. package/src/components/tasks/task-create-panel.tsx +111 -14
  94. package/src/components/tasks/task-detail-view.tsx +47 -0
  95. package/src/components/tasks/task-edit-dialog.tsx +103 -2
  96. package/src/components/workflows/workflow-form-view.tsx +207 -7
  97. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  98. package/src/components/workflows/workflow-list.tsx +90 -45
  99. package/src/components/workflows/workflow-status-view.tsx +168 -23
  100. package/src/instrumentation.ts +3 -0
  101. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  102. package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
  103. package/src/lib/agents/claude-agent.ts +3 -1
  104. package/src/lib/agents/profiles/registry.ts +6 -3
  105. package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
  106. package/src/lib/agents/runtime/openai-direct.ts +29 -0
  107. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  108. package/src/lib/book/chapter-generator.ts +4 -19
  109. package/src/lib/book/chapter-mapping.ts +17 -0
  110. package/src/lib/book/content.ts +5 -16
  111. package/src/lib/book/update-detector.ts +3 -16
  112. package/src/lib/chat/engine.ts +1 -0
  113. package/src/lib/chat/stagent-tools.ts +2 -0
  114. package/src/lib/chat/system-prompt.ts +9 -1
  115. package/src/lib/chat/tool-catalog.ts +35 -0
  116. package/src/lib/chat/tools/settings-tools.ts +109 -0
  117. package/src/lib/chat/tools/table-tools.ts +955 -0
  118. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  119. package/src/lib/constants/table-status.ts +68 -0
  120. package/src/lib/data/__tests__/clear.test.ts +1 -1
  121. package/src/lib/data/clear.ts +57 -0
  122. package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
  123. package/src/lib/data/seed-data/conversations.ts +350 -42
  124. package/src/lib/data/seed-data/documents.ts +564 -591
  125. package/src/lib/data/seed-data/learned-context.ts +101 -22
  126. package/src/lib/data/seed-data/notifications.ts +344 -70
  127. package/src/lib/data/seed-data/profile-test-results.ts +92 -11
  128. package/src/lib/data/seed-data/profiles.ts +144 -46
  129. package/src/lib/data/seed-data/projects.ts +50 -18
  130. package/src/lib/data/seed-data/repo-imports.ts +28 -13
  131. package/src/lib/data/seed-data/schedules.ts +208 -41
  132. package/src/lib/data/seed-data/table-templates.ts +234 -0
  133. package/src/lib/data/seed-data/tasks.ts +614 -116
  134. package/src/lib/data/seed-data/usage-ledger.ts +182 -103
  135. package/src/lib/data/seed-data/user-tables.ts +203 -0
  136. package/src/lib/data/seed-data/views.ts +52 -7
  137. package/src/lib/data/seed-data/workflows.ts +231 -84
  138. package/src/lib/data/seed.ts +55 -14
  139. package/src/lib/data/tables.ts +417 -0
  140. package/src/lib/db/bootstrap.ts +275 -0
  141. package/src/lib/db/index.ts +9 -0
  142. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  143. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  144. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  145. package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
  146. package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
  147. package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
  148. package/src/lib/db/schema.ts +445 -0
  149. package/src/lib/docs/reader.ts +2 -3
  150. package/src/lib/documents/context-builder.ts +75 -2
  151. package/src/lib/documents/document-resolver.ts +119 -0
  152. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  153. package/src/lib/schedules/scheduler.ts +31 -1
  154. package/src/lib/snapshots/auto-backup.ts +132 -0
  155. package/src/lib/snapshots/retention.ts +64 -0
  156. package/src/lib/snapshots/snapshot-manager.ts +429 -0
  157. package/src/lib/tables/computed.ts +61 -0
  158. package/src/lib/tables/context-builder.ts +139 -0
  159. package/src/lib/tables/formula-engine.ts +415 -0
  160. package/src/lib/tables/history.ts +115 -0
  161. package/src/lib/tables/import.ts +343 -0
  162. package/src/lib/tables/query-builder.ts +152 -0
  163. package/src/lib/tables/trigger-evaluator.ts +146 -0
  164. package/src/lib/tables/types.ts +141 -0
  165. package/src/lib/tables/validation.ts +119 -0
  166. package/src/lib/utils/app-root.ts +20 -0
  167. package/src/lib/utils/stagent-paths.ts +20 -0
  168. package/src/lib/validators/__tests__/task.test.ts +43 -10
  169. package/src/lib/validators/task.ts +7 -1
  170. package/src/lib/workflows/blueprints/registry.ts +3 -3
  171. package/src/lib/workflows/engine.ts +24 -8
  172. package/src/lib/workflows/types.ts +14 -0
  173. package/tsconfig.json +3 -1
  174. package/public/icon.svg +0 -13
  175. package/src/components/tasks/file-upload.tsx +0 -120
  176. /package/docs/features/{playbook.md → user-guide.md} +0 -0
@@ -0,0 +1,499 @@
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 { CellEditor, 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 { EmptyState } from "@/components/shared/empty-state";
23
+ import { Table2 } from "lucide-react";
24
+ import {
25
+ useSpreadsheetKeys,
26
+ type CellPosition,
27
+ } from "./use-spreadsheet-keys";
28
+ import { evaluateComputedColumns } from "@/lib/tables/computed";
29
+ import type { ColumnDef, SortSpec } from "@/lib/tables/types";
30
+ import type { UserTableRowRow } from "@/lib/db/schema";
31
+
32
+ interface TableSpreadsheetProps {
33
+ tableId: string;
34
+ columns: ColumnDef[];
35
+ initialRows: UserTableRowRow[];
36
+ }
37
+
38
+ interface ParsedRow {
39
+ id: string;
40
+ data: Record<string, unknown>;
41
+ position: number;
42
+ createdBy: string | null;
43
+ }
44
+
45
+ function parseRows(rows: UserTableRowRow[]): ParsedRow[] {
46
+ return rows.map((r) => ({
47
+ id: r.id,
48
+ data: JSON.parse(r.data) as Record<string, unknown>,
49
+ position: r.position,
50
+ createdBy: r.createdBy,
51
+ }));
52
+ }
53
+
54
+ export function TableSpreadsheet({
55
+ tableId,
56
+ columns: initialColumns,
57
+ initialRows,
58
+ }: TableSpreadsheetProps) {
59
+ const [columns, setColumns] = useState<ColumnDef[]>(initialColumns);
60
+ const [rows, setRows] = useState<ParsedRow[]>(() => parseRows(initialRows));
61
+ const [editingCell, setEditingCell] = useState<CellPosition | null>(null);
62
+ const [editValue, setEditValue] = useState<unknown>(null);
63
+ const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
64
+ const [sorts, setSorts] = useState<SortSpec[]>([]);
65
+ const [columnSheetOpen, setColumnSheetOpen] = useState(false);
66
+ const [importOpen, setImportOpen] = useState(false);
67
+ const [pendingSaves, setPendingSaves] = useState<Set<string>>(new Set());
68
+
69
+ const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
70
+
71
+ // ── Refresh helpers ─────────────────────────────────────────────────
72
+
73
+ const refreshTable = useCallback(async () => {
74
+ try {
75
+ const res = await fetch(`/api/tables/${tableId}`);
76
+ if (res.ok) {
77
+ const table = await res.json();
78
+ const parsed = JSON.parse(table.columnSchema) as ColumnDef[];
79
+ setColumns(parsed);
80
+ }
81
+ } catch { /* silent */ }
82
+ }, [tableId]);
83
+
84
+ const refreshRows = useCallback(async () => {
85
+ try {
86
+ const sortsParam = sorts.length > 0 ? `&sorts=${encodeURIComponent(JSON.stringify(sorts))}` : "";
87
+ const res = await fetch(`/api/tables/${tableId}/rows?limit=500${sortsParam}`);
88
+ if (res.ok) {
89
+ const raw = (await res.json()) as UserTableRowRow[];
90
+ // Evaluate computed columns client-side after refresh
91
+ const enriched = evaluateComputedColumns(columns, raw);
92
+ setRows(parseRows(enriched));
93
+ }
94
+ } catch { /* silent */ }
95
+ }, [tableId, sorts, columns]);
96
+
97
+ // ── Cell operations ─────────────────────────────────────────────────
98
+
99
+ const getCellValue = useCallback(
100
+ (pos: CellPosition): unknown => {
101
+ const row = rows[pos.rowIndex];
102
+ const col = columns[pos.colIndex];
103
+ if (!row || !col) return null;
104
+ return row.data[col.name] ?? null;
105
+ },
106
+ [rows, columns]
107
+ );
108
+
109
+ const setCellValue = useCallback(
110
+ (rowId: string, colName: string, value: unknown) => {
111
+ setRows((prev) =>
112
+ prev.map((r) =>
113
+ r.id === rowId
114
+ ? { ...r, data: { ...r.data, [colName]: value } }
115
+ : r
116
+ )
117
+ );
118
+ },
119
+ []
120
+ );
121
+
122
+ const debounceSave = useCallback(
123
+ (rowId: string, colName: string, value: unknown) => {
124
+ const key = `${rowId}:${colName}`;
125
+
126
+ // Clear previous timer
127
+ const existing = debounceTimers.current.get(key);
128
+ if (existing) clearTimeout(existing);
129
+
130
+ setPendingSaves((prev) => new Set(prev).add(key));
131
+
132
+ const timer = setTimeout(async () => {
133
+ try {
134
+ const res = await fetch(`/api/tables/${tableId}/rows/${rowId}`, {
135
+ method: "PATCH",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({ data: { [colName]: value } }),
138
+ });
139
+ if (!res.ok) {
140
+ toast.error("Failed to save cell");
141
+ // Revert would require storing previous value — for now just notify
142
+ }
143
+ } catch {
144
+ toast.error("Failed to save cell");
145
+ } finally {
146
+ setPendingSaves((prev) => {
147
+ const next = new Set(prev);
148
+ next.delete(key);
149
+ return next;
150
+ });
151
+ debounceTimers.current.delete(key);
152
+ }
153
+ }, 300);
154
+
155
+ debounceTimers.current.set(key, timer);
156
+ },
157
+ [tableId]
158
+ );
159
+
160
+ // ── Keyboard hook callbacks ─────────────────────────────────────────
161
+
162
+ const handleStartEdit = useCallback(
163
+ (pos: CellPosition) => {
164
+ setEditingCell(pos);
165
+ setEditValue(getCellValue(pos));
166
+ },
167
+ [getCellValue]
168
+ );
169
+
170
+ const handleConfirmEdit = useCallback(() => {
171
+ if (!editingCell) return;
172
+ const row = rows[editingCell.rowIndex];
173
+ const col = columns[editingCell.colIndex];
174
+ if (!row || !col) return;
175
+
176
+ setCellValue(row.id, col.name, editValue);
177
+ debounceSave(row.id, col.name, editValue);
178
+ setEditingCell(null);
179
+ setEditValue(null);
180
+ }, [editingCell, editValue, rows, columns, setCellValue, debounceSave]);
181
+
182
+ const handleCancelEdit = useCallback(() => {
183
+ setEditingCell(null);
184
+ setEditValue(null);
185
+ }, []);
186
+
187
+ const handleClearCell = useCallback(
188
+ (pos: CellPosition) => {
189
+ const row = rows[pos.rowIndex];
190
+ const col = columns[pos.colIndex];
191
+ if (!row || !col) return;
192
+ if (col.dataType === "computed") return;
193
+
194
+ setCellValue(row.id, col.name, null);
195
+ debounceSave(row.id, col.name, null);
196
+ },
197
+ [rows, columns, setCellValue, debounceSave]
198
+ );
199
+
200
+ // ── Row operations ──────────────────────────────────────────────────
201
+
202
+ const handleAddRow = useCallback(async () => {
203
+ try {
204
+ const emptyData: Record<string, unknown> = {};
205
+ for (const col of columns) {
206
+ emptyData[col.name] = col.defaultValue ?? null;
207
+ }
208
+
209
+ const res = await fetch(`/api/tables/${tableId}/rows`, {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" },
212
+ body: JSON.stringify({ rows: [{ data: emptyData }] }),
213
+ });
214
+
215
+ if (res.ok) {
216
+ await refreshRows();
217
+ } else {
218
+ toast.error("Failed to add row");
219
+ }
220
+ } catch {
221
+ toast.error("Failed to add row");
222
+ }
223
+ }, [tableId, columns, refreshRows]);
224
+
225
+ const handleBulkDelete = useCallback(async () => {
226
+ if (selectedRows.size === 0) return;
227
+
228
+ try {
229
+ const promises = Array.from(selectedRows).map((rowId) =>
230
+ fetch(`/api/tables/${tableId}/rows/${rowId}`, { method: "DELETE" })
231
+ );
232
+ await Promise.all(promises);
233
+ setSelectedRows(new Set());
234
+ await refreshRows();
235
+ toast.success(`Deleted ${selectedRows.size} row(s)`);
236
+ } catch {
237
+ toast.error("Failed to delete rows");
238
+ }
239
+ }, [tableId, selectedRows, refreshRows]);
240
+
241
+ // ── Column operations ───────────────────────────────────────────────
242
+
243
+ const handleSort = useCallback(
244
+ (colName: string, direction: "asc" | "desc") => {
245
+ setSorts([{ column: colName, direction }]);
246
+ },
247
+ []
248
+ );
249
+
250
+ const handleDeleteColumn = useCallback(
251
+ async (_columnName: string) => {
252
+ toast.info("Column deletion coming soon");
253
+ },
254
+ []
255
+ );
256
+
257
+ // Trigger row refresh when sorts change
258
+ const prevSortsRef = useRef(sorts);
259
+ if (prevSortsRef.current !== sorts) {
260
+ prevSortsRef.current = sorts;
261
+ refreshRows();
262
+ }
263
+
264
+ // ── Keyboard navigation ─────────────────────────────────────────────
265
+
266
+ const keyboard = useSpreadsheetKeys({
267
+ rowCount: rows.length,
268
+ colCount: columns.length,
269
+ onStartEdit: handleStartEdit,
270
+ onConfirmEdit: handleConfirmEdit,
271
+ onCancelEdit: handleCancelEdit,
272
+ onClearCell: handleClearCell,
273
+ onAddRow: handleAddRow,
274
+ });
275
+
276
+ // ── Selection helpers ───────────────────────────────────────────────
277
+
278
+ function toggleRowSelect(rowId: string) {
279
+ setSelectedRows((prev) => {
280
+ const next = new Set(prev);
281
+ if (next.has(rowId)) next.delete(rowId);
282
+ else next.add(rowId);
283
+ return next;
284
+ });
285
+ }
286
+
287
+ function toggleSelectAll() {
288
+ if (selectedRows.size === rows.length) {
289
+ setSelectedRows(new Set());
290
+ } else {
291
+ setSelectedRows(new Set(rows.map((r) => r.id)));
292
+ }
293
+ }
294
+
295
+ // ── Render ──────────────────────────────────────────────────────────
296
+
297
+ if (columns.length === 0) {
298
+ return (
299
+ <div className="space-y-4">
300
+ <TableToolbar
301
+ tableId={tableId}
302
+ rowCount={0}
303
+ selectedCount={0}
304
+ onAddRow={handleAddRow}
305
+ onAddColumn={() => setColumnSheetOpen(true)}
306
+ onBulkDelete={handleBulkDelete}
307
+ onImport={() => setImportOpen(true)}
308
+ />
309
+ <EmptyState
310
+ icon={Table2}
311
+ heading="No columns defined"
312
+ description="Add columns to start building your table structure."
313
+ action={
314
+ <Button onClick={() => setColumnSheetOpen(true)}>
315
+ <Plus className="h-4 w-4 mr-1" />
316
+ Add Column
317
+ </Button>
318
+ }
319
+ />
320
+ <TableColumnSheet
321
+ tableId={tableId}
322
+ open={columnSheetOpen}
323
+ onOpenChange={setColumnSheetOpen}
324
+ onColumnAdded={() => { refreshTable(); refreshRows(); }}
325
+ />
326
+ </div>
327
+ );
328
+ }
329
+
330
+ return (
331
+ <div className="space-y-2" data-spreadsheet>
332
+ <TableToolbar
333
+ tableId={tableId}
334
+ rowCount={rows.length}
335
+ selectedCount={selectedRows.size}
336
+ onAddRow={handleAddRow}
337
+ onAddColumn={() => setColumnSheetOpen(true)}
338
+ onBulkDelete={handleBulkDelete}
339
+ onImport={() => setImportOpen(true)}
340
+ />
341
+
342
+ <div className="rounded-lg border overflow-auto">
343
+ <Table>
344
+ <TableHeader>
345
+ <TableRow>
346
+ <TableHead className="w-10">
347
+ <Checkbox
348
+ checked={rows.length > 0 && selectedRows.size === rows.length}
349
+ onCheckedChange={toggleSelectAll}
350
+ />
351
+ </TableHead>
352
+ {columns.map((col) => {
353
+ const sortDir = sorts.find((s) => s.column === col.name)?.direction ?? null;
354
+ return (
355
+ <TableHead key={col.name} className="group min-w-[120px]">
356
+ <SpreadsheetColumnHeader
357
+ column={col}
358
+ sortDirection={sortDir}
359
+ onSort={(dir) => handleSort(col.name, dir)}
360
+ onRename={() => toast.info("Rename coming soon")}
361
+ onDelete={() => handleDeleteColumn(col.name)}
362
+ />
363
+ </TableHead>
364
+ );
365
+ })}
366
+ <TableHead className="w-10">
367
+ <Button
368
+ variant="ghost"
369
+ size="icon"
370
+ className="h-6 w-6"
371
+ onClick={() => setColumnSheetOpen(true)}
372
+ >
373
+ <Plus className="h-3 w-3" />
374
+ </Button>
375
+ </TableHead>
376
+ </TableRow>
377
+ </TableHeader>
378
+ <TableBody>
379
+ {rows.length === 0 ? (
380
+ <TableRow>
381
+ <TableCell
382
+ colSpan={columns.length + 2}
383
+ className="h-24 text-center text-muted-foreground"
384
+ >
385
+ No rows yet.{" "}
386
+ <button
387
+ className="underline underline-offset-2 hover:text-foreground"
388
+ onClick={handleAddRow}
389
+ >
390
+ Add your first row
391
+ </button>
392
+ </TableCell>
393
+ </TableRow>
394
+ ) : (
395
+ rows.map((row, rowIndex) => (
396
+ <TableRow
397
+ key={row.id}
398
+ className={cn(
399
+ selectedRows.has(row.id) && "bg-muted/50"
400
+ )}
401
+ >
402
+ <TableCell onClick={(e) => e.stopPropagation()}>
403
+ <Checkbox
404
+ checked={selectedRows.has(row.id)}
405
+ onCheckedChange={() => toggleRowSelect(row.id)}
406
+ />
407
+ </TableCell>
408
+ {columns.map((col, colIndex) => {
409
+ const isActive =
410
+ keyboard.activeCell?.rowIndex === rowIndex &&
411
+ keyboard.activeCell?.colIndex === colIndex;
412
+ const isEditing =
413
+ isActive &&
414
+ editingCell?.rowIndex === rowIndex &&
415
+ editingCell?.colIndex === colIndex;
416
+ const value = row.data[col.name];
417
+ const saveKey = `${row.id}:${col.name}`;
418
+ const isSaving = pendingSaves.has(saveKey);
419
+
420
+ return (
421
+ <TableCell
422
+ key={col.name}
423
+ className={cn(
424
+ "p-0 cursor-cell relative min-w-[120px]",
425
+ isActive && "ring-2 ring-primary ring-inset",
426
+ isSaving && "bg-primary/5"
427
+ )}
428
+ onClick={() =>
429
+ keyboard.handleCellClick({ rowIndex, colIndex })
430
+ }
431
+ onDoubleClick={() =>
432
+ keyboard.handleCellDoubleClick({ rowIndex, colIndex })
433
+ }
434
+ >
435
+ <div className="px-2 py-1 min-h-[32px] flex items-center">
436
+ {isEditing && col.dataType !== "computed" ? (
437
+ <CellEditor
438
+ column={col}
439
+ value={editValue}
440
+ onChange={setEditValue}
441
+ onConfirm={handleConfirmEdit}
442
+ onCancel={handleCancelEdit}
443
+ />
444
+ ) : (
445
+ <CellDisplay
446
+ column={col}
447
+ value={value}
448
+ onToggleBoolean={
449
+ col.dataType === "boolean"
450
+ ? (newVal) => {
451
+ setCellValue(row.id, col.name, newVal);
452
+ debounceSave(row.id, col.name, newVal);
453
+ }
454
+ : undefined
455
+ }
456
+ />
457
+ )}
458
+ </div>
459
+ </TableCell>
460
+ );
461
+ })}
462
+ <TableCell />
463
+ </TableRow>
464
+ ))
465
+ )}
466
+ </TableBody>
467
+ </Table>
468
+ </div>
469
+
470
+ {rows.length > 0 && (
471
+ <div className="flex justify-start">
472
+ <Button
473
+ variant="ghost"
474
+ size="sm"
475
+ className="text-muted-foreground"
476
+ onClick={handleAddRow}
477
+ >
478
+ <Plus className="h-3 w-3 mr-1" />
479
+ Add Row
480
+ </Button>
481
+ </div>
482
+ )}
483
+
484
+ <TableColumnSheet
485
+ tableId={tableId}
486
+ open={columnSheetOpen}
487
+ onOpenChange={setColumnSheetOpen}
488
+ onColumnAdded={() => { refreshTable(); refreshRows(); }}
489
+ />
490
+
491
+ <TableImportWizard
492
+ tableId={tableId}
493
+ open={importOpen}
494
+ onOpenChange={setImportOpen}
495
+ onImported={() => { refreshTable(); refreshRows(); }}
496
+ />
497
+ </div>
498
+ );
499
+ }
@@ -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
+ }