Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: waves 2-6 — forms, pivot tables, database views, image cells' (#211) from feat/wave2-wave6-completion into main

scott b8147f3a 0d0a279d

+1519 -5
+10
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.18.0] — 2026-03-31 9 + 10 + ### Added 11 + - E2EE form builder with question types, conditional logic, preview, and responses pipeline (#278) 12 + - Pivot table dialog and database view modes (kanban, gallery, calendar) in sheets UI (#277) 13 + - Image cells in sheets with blob storage upload/display (#280) 14 + - Blob storage API for encrypted file uploads (POST/GET/DELETE /api/blobs) (#280) 15 + - Forms entry point with Vite build, server route, and landing page card (#278) 16 + 8 17 ## [0.17.0] — 2026-03-31 9 18 10 19 ### Added 20 + - Add pivot table dialog and database view modes to sheets UI (#277) 11 21 - Landing page overhaul: grid/list view toggle with localStorage persistence (#274) 12 22 - Pinned documents section on landing page showing starred docs as horizontal cards (#274) 13 23 - Grid view with CSS grid layout, card UI with hover actions (#274)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.17.0", 3 + "version": "0.18.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+60
server/index.ts
··· 114 114 getAllUsers: Statement; 115 115 getKeys: Statement; 116 116 putKeys: Statement; 117 + insertBlob: Statement; 118 + getBlob: Statement; 119 + listBlobs: Statement; 120 + deleteBlob: Statement; 121 + deleteBlobsForDoc: Statement; 117 122 } 118 123 119 124 // --- Setup --- ··· 217 222 ) 218 223 `); 219 224 225 + // --- Blob storage (E2EE file uploads) --- 226 + db.exec(` 227 + CREATE TABLE IF NOT EXISTS blobs ( 228 + id TEXT PRIMARY KEY, 229 + document_id TEXT NOT NULL, 230 + file_name TEXT NOT NULL, 231 + mime_type TEXT NOT NULL, 232 + size INTEGER NOT NULL, 233 + data BLOB NOT NULL, 234 + created_at TEXT DEFAULT (datetime('now')) 235 + ) 236 + `); 237 + 220 238 // Migration: add owner column to documents 221 239 try { 222 240 db.prepare("SELECT owner FROM documents LIMIT 1").get(); ··· 266 284 getKeys: db.prepare('SELECT keys_json FROM user_keys WHERE login = ?'), 267 285 putKeys: db.prepare(`INSERT INTO user_keys (login, keys_json, updated_at) VALUES (?, ?, datetime('now')) 268 286 ON CONFLICT(login) DO UPDATE SET keys_json = excluded.keys_json, updated_at = datetime('now')`), 287 + // Blob storage 288 + insertBlob: db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)'), 289 + getBlob: db.prepare('SELECT id, document_id, file_name, mime_type, size, data, created_at FROM blobs WHERE id = ?'), 290 + listBlobs: db.prepare('SELECT id, document_id, file_name, mime_type, size, created_at FROM blobs WHERE document_id = ? ORDER BY created_at DESC'), 291 + deleteBlob: db.prepare('DELETE FROM blobs WHERE id = ?'), 292 + deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 269 293 }; 270 294 271 295 // --- Express --- ··· 642 666 } 643 667 }); 644 668 669 + // --- Blob storage API (E2EE file uploads) --- 670 + const BLOB_MAX_SIZE = 10 * 1024 * 1024; // 10MB per blob 671 + 672 + app.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer>, res: Response) => { 673 + const docId = req.headers['x-document-id'] as string; 674 + const fileName = req.headers['x-file-name'] as string || 'file'; 675 + const mimeType = req.headers['x-mime-type'] as string || 'application/octet-stream'; 676 + if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 677 + const data = req.body; 678 + if (!data || !data.length) return res.status(400).json({ error: 'No data' }); 679 + if (data.length > BLOB_MAX_SIZE) return res.status(413).json({ error: 'Blob too large (max 10MB)' }); 680 + const id = randomUUID(); 681 + stmts.insertBlob.run(id, docId, fileName, mimeType, data.length, data); 682 + res.status(201).json({ id, size: data.length }); 683 + }); 684 + 685 + app.get('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 686 + const row = stmts.getBlob.get(req.params.id) as { id: string; document_id: string; file_name: string; mime_type: string; size: number; data: Buffer; created_at: string } | undefined; 687 + if (!row) return res.status(404).json({ error: 'Not found' }); 688 + res.set('Content-Type', row.mime_type); 689 + res.set('Content-Length', String(row.size)); 690 + res.set('Content-Disposition', `inline; filename="${row.file_name}"`); 691 + res.send(row.data); 692 + }); 693 + 694 + app.get('/api/documents/:id/blobs', (req: Request<{ id: string }>, res: Response) => { 695 + const rows = stmts.listBlobs.all(req.params.id) as { id: string; file_name: string; mime_type: string; size: number; created_at: string }[]; 696 + res.json(rows); 697 + }); 698 + 699 + app.delete('/api/blobs/:id', (req: Request<{ id: string }>, res: Response) => { 700 + stmts.deleteBlob.run(req.params.id); 701 + res.json({ ok: true }); 702 + }); 703 + 645 704 // Health check 646 705 app.get('/health', (_req: Request, res: Response) => { 647 706 try { ··· 681 740 // SPA fallback: serve the correct index.html for each sub-app 682 741 app.get('/docs/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'docs/index.html')); }); 683 742 app.get('/sheets/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'sheets/index.html')); }); 743 + app.get('/forms/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'forms/index.html')); }); 684 744 app.get('*', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'index.html')); }); 685 745 686 746 // --- HTTP + HTTPS + WebSocket server ---
+225
src/css/app.css
··· 2304 2304 white-space: nowrap; 2305 2305 } 2306 2306 2307 + /* --- Image Cell Styles --- */ 2308 + .cell-image-container { display: flex; align-items: center; justify-content: center; padding: 2px; min-height: 24px; } 2309 + .cell-image-container img { border-radius: 2px; } 2310 + 2311 + /* --- Pivot Table Styles --- */ 2312 + .pivot-section { padding: var(--space-sm) var(--space-md); } 2313 + .pivot-container { 2314 + position: relative; 2315 + border: 1px solid var(--color-border); 2316 + border-radius: var(--radius-md); 2317 + padding: var(--space-md); 2318 + background: var(--color-bg); 2319 + margin-bottom: var(--space-md); 2320 + overflow-x: auto; 2321 + } 2322 + .pivot-container h4 { margin: 0 0 var(--space-sm) 0; font-size: 0.9rem; } 2323 + .pivot-actions { 2324 + position: absolute; top: var(--space-xs); right: var(--space-xs); 2325 + display: flex; gap: var(--space-xs); 2326 + } 2327 + .pivot-actions button { 2328 + font-size: 0.75rem; padding: 2px 8px; border-radius: var(--radius-sm); 2329 + border: 1px solid var(--color-border); background: var(--color-bg); cursor: pointer; 2330 + } 2331 + .pivot-actions button:hover { background: var(--color-bg-hover); } 2332 + .pivot-table { 2333 + width: 100%; border-collapse: collapse; font-size: 0.8rem; 2334 + } 2335 + .pivot-table th, .pivot-table td { 2336 + border: 1px solid var(--color-border); padding: 4px 8px; text-align: right; 2337 + } 2338 + .pivot-table th { background: var(--color-bg-secondary); font-weight: 600; text-align: left; } 2339 + .pivot-table td.pivot-row-header { font-weight: 600; text-align: left; background: var(--color-bg-secondary); } 2340 + .pivot-table td.pivot-total { font-weight: 600; background: oklch(0.95 0.02 80); } 2341 + [data-theme="dark"] .pivot-table td.pivot-total { background: oklch(0.25 0.02 80); } 2342 + .pivot-table tfoot td { font-weight: 600; background: var(--color-bg-secondary); } 2343 + 2344 + /* --- Database View Styles --- */ 2345 + .database-view-section { padding: var(--space-sm) var(--space-md); } 2346 + .db-view-toolbar { 2347 + display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-md); 2348 + padding: var(--space-xs) var(--space-sm); 2349 + border: 1px solid var(--color-border); border-radius: var(--radius-md); 2350 + background: var(--color-bg-secondary); 2351 + } 2352 + .db-view-toolbar select, .db-view-toolbar button { 2353 + font-size: 0.8rem; padding: 4px 8px; border-radius: var(--radius-sm); 2354 + border: 1px solid var(--color-border); background: var(--color-bg); 2355 + } 2356 + .db-view-close { margin-left: auto; cursor: pointer; } 2357 + 2358 + /* Kanban */ 2359 + .kanban-board { 2360 + display: flex; gap: var(--space-md); overflow-x: auto; padding-bottom: var(--space-sm); 2361 + min-height: 200px; 2362 + } 2363 + .kanban-column { 2364 + min-width: 220px; max-width: 280px; flex-shrink: 0; 2365 + background: var(--color-bg-secondary); border-radius: var(--radius-md); 2366 + padding: var(--space-sm); 2367 + } 2368 + .kanban-column-header { 2369 + font-weight: 600; font-size: 0.85rem; margin-bottom: var(--space-sm); 2370 + padding-bottom: var(--space-xs); border-bottom: 2px solid var(--color-border); 2371 + display: flex; justify-content: space-between; align-items: center; 2372 + } 2373 + .kanban-column-count { 2374 + font-size: 0.7rem; color: var(--color-text-secondary); 2375 + background: var(--color-bg); border-radius: 99px; padding: 1px 6px; 2376 + } 2377 + .kanban-card { 2378 + background: var(--color-bg); border: 1px solid var(--color-border); 2379 + border-radius: var(--radius-sm); padding: var(--space-xs) var(--space-sm); 2380 + margin-bottom: var(--space-xs); cursor: pointer; font-size: 0.8rem; 2381 + transition: box-shadow 0.15s; 2382 + } 2383 + .kanban-card:hover { box-shadow: 0 2px 8px oklch(0 0 0 / 0.1); } 2384 + .kanban-card-title { font-weight: 600; margin-bottom: 2px; } 2385 + .kanban-card-field { color: var(--color-text-secondary); font-size: 0.75rem; } 2386 + 2387 + /* Gallery */ 2388 + .gallery-grid { 2389 + display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 2390 + gap: var(--space-md); 2391 + } 2392 + .gallery-card { 2393 + background: var(--color-bg); border: 1px solid var(--color-border); 2394 + border-radius: var(--radius-md); padding: var(--space-md); 2395 + cursor: pointer; transition: box-shadow 0.15s; 2396 + } 2397 + .gallery-card:hover { box-shadow: 0 2px 8px oklch(0 0 0 / 0.1); } 2398 + .gallery-card-title { font-weight: 600; font-size: 0.9rem; margin-bottom: var(--space-xs); } 2399 + .gallery-card-field { font-size: 0.8rem; color: var(--color-text-secondary); } 2400 + .gallery-card-field-label { font-weight: 500; } 2401 + 2402 + /* Calendar */ 2403 + .calendar-view { max-width: 900px; } 2404 + .calendar-month-header { 2405 + font-size: 1rem; font-weight: 600; margin: var(--space-md) 0 var(--space-sm); 2406 + display: flex; align-items: center; gap: var(--space-sm); 2407 + } 2408 + .calendar-month-nav { font-size: 0.85rem; cursor: pointer; background: none; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 2px 8px; } 2409 + .calendar-grid { 2410 + display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; 2411 + background: var(--color-border); border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2412 + } 2413 + .calendar-day-header { 2414 + background: var(--color-bg-secondary); padding: 4px; text-align: center; 2415 + font-size: 0.75rem; font-weight: 600; 2416 + } 2417 + .calendar-day { 2418 + background: var(--color-bg); min-height: 80px; padding: 4px; 2419 + font-size: 0.75rem; vertical-align: top; 2420 + } 2421 + .calendar-day.calendar-day-other { opacity: 0.4; } 2422 + .calendar-day-number { font-weight: 600; margin-bottom: 2px; } 2423 + .calendar-event { 2424 + background: oklch(0.85 0.1 180); color: oklch(0.25 0.05 180); 2425 + border-radius: 3px; padding: 1px 4px; margin-bottom: 1px; 2426 + font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 2427 + cursor: pointer; 2428 + } 2429 + [data-theme="dark"] .calendar-event { background: oklch(0.35 0.1 180); color: oklch(0.85 0.05 180); } 2430 + .calendar-event:hover { opacity: 0.8; } 2431 + 2432 + /* --- Form Builder Styles --- */ 2433 + .form-builder-main { max-width: 720px; margin: 0 auto; padding: var(--space-md); } 2434 + .form-builder-toolbar { 2435 + display: flex; gap: var(--space-sm); margin-bottom: var(--space-md); 2436 + padding: var(--space-sm); border: 1px solid var(--color-border); 2437 + border-radius: var(--radius-md); background: var(--color-bg-secondary); 2438 + } 2439 + .form-header { margin-bottom: var(--space-md); } 2440 + .form-description { 2441 + width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2442 + padding: var(--space-sm); font-size: 0.9rem; resize: vertical; 2443 + background: var(--color-bg); color: var(--color-text); 2444 + } 2445 + .form-question-card { 2446 + border: 1px solid var(--color-border); border-radius: var(--radius-md); 2447 + padding: var(--space-md); margin-bottom: var(--space-sm); 2448 + background: var(--color-bg); transition: border-color 0.15s; 2449 + } 2450 + .form-question-card:focus-within { border-color: var(--color-accent); } 2451 + .form-question-header { 2452 + display: flex; align-items: center; gap: var(--space-xs); margin-bottom: var(--space-sm); 2453 + } 2454 + .form-question-number { 2455 + width: 24px; height: 24px; border-radius: 50%; background: var(--color-accent); 2456 + color: white; display: flex; align-items: center; justify-content: center; 2457 + font-size: 0.75rem; font-weight: 600; flex-shrink: 0; 2458 + } 2459 + .form-question-type-badge { 2460 + font-size: 0.7rem; padding: 2px 6px; border-radius: 99px; 2461 + background: var(--color-bg-secondary); color: var(--color-text-secondary); 2462 + } 2463 + .form-question-label, .form-question-desc { 2464 + width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2465 + padding: var(--space-xs) var(--space-sm); font-size: 0.9rem; 2466 + margin-bottom: var(--space-xs); background: var(--color-bg); color: var(--color-text); 2467 + } 2468 + .form-question-label { font-weight: 500; } 2469 + .form-question-desc { font-size: 0.8rem; color: var(--color-text-secondary); } 2470 + .form-question-required-label { font-size: 0.75rem; display: flex; align-items: center; gap: 4px; } 2471 + .form-question-header button { 2472 + background: none; border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2473 + padding: 2px 6px; cursor: pointer; font-size: 0.8rem; 2474 + } 2475 + .form-question-header button:hover { background: var(--color-bg-hover); } 2476 + .form-question-header button:disabled { opacity: 0.3; cursor: default; } 2477 + .form-question-options { margin-top: var(--space-xs); } 2478 + .form-option-row { 2479 + display: flex; align-items: center; gap: var(--space-xs); margin-bottom: 4px; 2480 + } 2481 + .form-option-input { 2482 + flex: 1; border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2483 + padding: 4px 8px; font-size: 0.8rem; background: var(--color-bg); color: var(--color-text); 2484 + } 2485 + .form-option-remove, .form-add-option { 2486 + font-size: 0.75rem; background: none; border: 1px solid var(--color-border); 2487 + border-radius: var(--radius-sm); padding: 2px 6px; cursor: pointer; 2488 + } 2489 + .form-add-option { margin-top: 4px; } 2490 + .form-question-type-grid { 2491 + display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-xs); 2492 + } 2493 + .form-type-btn { 2494 + display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm); 2495 + border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2496 + background: var(--color-bg); cursor: pointer; font-size: 0.85rem; 2497 + } 2498 + .form-type-btn:hover { background: var(--color-bg-hover); border-color: var(--color-accent); } 2499 + .form-type-icon { font-size: 1.1rem; width: 24px; text-align: center; } 2500 + 2501 + /* Form preview */ 2502 + .form-preview-container { max-width: 600px; margin: 0 auto; } 2503 + .form-preview-desc { color: var(--color-text-secondary); margin-bottom: var(--space-md); } 2504 + .form-preview-question { margin-bottom: var(--space-md); } 2505 + .form-preview-label { font-weight: 500; display: block; margin-bottom: 4px; } 2506 + .form-required-mark { color: oklch(0.6 0.2 25); } 2507 + .form-preview-hint { font-size: 0.8rem; color: var(--color-text-secondary); margin: 2px 0 6px; } 2508 + .form-preview-input, .form-preview-textarea, .form-preview-select { 2509 + width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2510 + padding: var(--space-xs) var(--space-sm); font-size: 0.9rem; 2511 + background: var(--color-bg); color: var(--color-text); 2512 + } 2513 + .form-preview-radio, .form-preview-checkbox { 2514 + display: block; padding: 4px 0; font-size: 0.9rem; cursor: pointer; 2515 + } 2516 + .form-preview-rating, .form-preview-scale { display: flex; gap: 4px; } 2517 + .form-rating-star { 2518 + font-size: 1.5rem; background: none; border: none; cursor: pointer; 2519 + color: var(--color-border); transition: color 0.1s; 2520 + } 2521 + .form-rating-star:hover, .form-rating-star.active { color: oklch(0.75 0.15 80); } 2522 + .form-scale-btn { 2523 + width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2524 + background: var(--color-bg); cursor: pointer; font-size: 0.85rem; 2525 + } 2526 + .form-scale-btn:hover, .form-scale-btn.active { 2527 + background: var(--color-accent); color: white; border-color: var(--color-accent); 2528 + } 2529 + .form-preview-error { color: oklch(0.6 0.2 25); font-size: 0.8rem; min-height: 1.2em; } 2530 + .form-preview-actions { margin-top: var(--space-lg); } 2531 + 2307 2532 .sheet-grid td.editing .cell-display { display: none; } 2308 2533 2309 2534 .cell-editor {
+62
src/forms/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 6 + <link rel="manifest" href="/manifest.json"> 7 + <meta name="description" content="E2EE form builder. End-to-end encrypted, real-time collaboration."> 8 + <title>Tools — Forms</title> 9 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 10 + <link rel="stylesheet" href="../css/app.css"> 11 + <script> 12 + (function() { 13 + var saved = localStorage.getItem('tools-theme'); 14 + if (saved === 'dark' || saved === 'light') { 15 + document.documentElement.setAttribute('data-theme', saved); 16 + } 17 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 18 + })(); 19 + </script> 20 + </head> 21 + <body> 22 + <a class="skip-link" href="#main-content">Skip to content</a> 23 + <div class="forms-app" id="app"> 24 + <!-- Top bar --> 25 + <div class="app-topbar"> 26 + <a class="app-logo" href="/">Tools</a> 27 + <input class="doc-title-input" id="form-title" type="text" value="Untitled Form" spellcheck="false"> 28 + <span class="topbar-spacer"></span> 29 + <span class="save-status" id="save-status"></span> 30 + </div> 31 + 32 + <!-- Form builder --> 33 + <main class="form-builder-main" id="main-content"> 34 + <!-- Builder mode toolbar --> 35 + <div class="form-builder-toolbar" id="form-toolbar"> 36 + <button class="btn-secondary" id="btn-add-question">+ Add Question</button> 37 + <button class="btn-secondary" id="btn-preview">Preview</button> 38 + <button class="btn-secondary" id="btn-responses">Responses</button> 39 + <button class="btn-secondary" id="btn-settings">Settings</button> 40 + </div> 41 + 42 + <!-- Form description --> 43 + <div class="form-header" id="form-header"> 44 + <textarea class="form-description" id="form-description" placeholder="Form description (optional)" rows="2"></textarea> 45 + </div> 46 + 47 + <!-- Question list --> 48 + <div class="form-questions" id="form-questions"></div> 49 + 50 + <!-- Preview pane (hidden by default) --> 51 + <div class="form-preview" id="form-preview" style="display:none"></div> 52 + 53 + <!-- Responses view (hidden by default) --> 54 + <div class="form-responses-view" id="form-responses-view" style="display:none"></div> 55 + </main> 56 + </div> 57 + 58 + <div class="version-badge">v%APP_VERSION%</div> 59 + 60 + <script type="module" src="./main.ts"></script> 61 + </body> 62 + </html>
+474
src/forms/main.ts
··· 1 + /** 2 + * Tools Forms — E2EE form builder and response viewer. 3 + * 4 + * Uses Yjs for real-time collaboration on form schema. 5 + * Responses pipeline writes answers to a linked spreadsheet. 6 + */ 7 + 8 + import * as Y from 'yjs'; 9 + import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 10 + import { EncryptedProvider } from '../lib/provider.js'; 11 + import { 12 + createForm, 13 + addQuestion, 14 + removeQuestion, 15 + updateQuestion, 16 + moveQuestion, 17 + addOption, 18 + setTargetSheet, 19 + validateSubmission, 20 + questionCount, 21 + type FormSchema, 22 + type QuestionType, 23 + type Question, 24 + } from './form-builder.js'; 25 + import { 26 + createConditionalState, 27 + addRule, 28 + getVisibleQuestions, 29 + type ConditionalLogicState, 30 + } from './conditional-logic.js'; 31 + import { 32 + createResponse, 33 + createPipelineConfig, 34 + responseToRow, 35 + pipelineHeaders, 36 + type FormResponse, 37 + } from './responses.js'; 38 + 39 + // --- URL parsing --- 40 + const url = new URL(window.location.href); 41 + const pathMatch = url.pathname.match(/\/forms\/(.+)/); 42 + const docId = pathMatch?.[1] ?? ''; 43 + const keyB64 = url.hash.slice(1); 44 + 45 + if (!docId) { 46 + document.getElementById('app')!.innerHTML = '<p style="padding:2rem">No form ID specified.</p>'; 47 + throw new Error('No form ID'); 48 + } 49 + 50 + // --- Encryption --- 51 + let cryptoKey: CryptoKey | null = null; 52 + 53 + async function initCrypto() { 54 + if (keyB64) { 55 + cryptoKey = await importKey(keyB64); 56 + } 57 + } 58 + 59 + // --- Yjs setup --- 60 + const ydoc = new Y.Doc(); 61 + const yForm = ydoc.getMap('form'); 62 + const yResponses = ydoc.getArray('responses'); 63 + 64 + // --- State --- 65 + let form: FormSchema = createForm('Untitled Form'); 66 + let condLogic = createConditionalState(); 67 + let mode: 'builder' | 'preview' | 'responses' = 'builder'; 68 + 69 + // --- DOM refs --- 70 + const titleInput = document.getElementById('form-title') as HTMLInputElement; 71 + const descInput = document.getElementById('form-description') as HTMLTextAreaElement; 72 + const questionsContainer = document.getElementById('form-questions')!; 73 + const previewPane = document.getElementById('form-preview')!; 74 + const responsesPane = document.getElementById('form-responses-view')!; 75 + const toolbar = document.getElementById('form-toolbar')!; 76 + 77 + // --- Sync form to Yjs --- 78 + function syncFormToYjs() { 79 + ydoc.transact(() => { 80 + yForm.set('schema', JSON.stringify(form)); 81 + yForm.set('condLogic', JSON.stringify(condLogic)); 82 + }); 83 + } 84 + 85 + function loadFormFromYjs() { 86 + const schemaStr = yForm.get('schema'); 87 + if (typeof schemaStr === 'string') { 88 + try { form = JSON.parse(schemaStr); } catch { /* keep default */ } 89 + } 90 + const logicStr = yForm.get('condLogic'); 91 + if (typeof logicStr === 'string') { 92 + try { condLogic = JSON.parse(logicStr); } catch { /* keep default */ } 93 + } 94 + } 95 + 96 + // --- Question type definitions --- 97 + const QUESTION_TYPES: Array<{ type: QuestionType; label: string; icon: string }> = [ 98 + { type: 'short_text', label: 'Short Text', icon: 'Aa' }, 99 + { type: 'long_text', label: 'Long Text', icon: '¶' }, 100 + { type: 'number', label: 'Number', icon: '#' }, 101 + { type: 'email', label: 'Email', icon: '@' }, 102 + { type: 'single_choice', label: 'Single Choice', icon: '○' }, 103 + { type: 'multiple_choice', label: 'Multiple Choice', icon: '☐' }, 104 + { type: 'dropdown', label: 'Dropdown', icon: '▾' }, 105 + { type: 'date', label: 'Date', icon: '📅' }, 106 + { type: 'rating', label: 'Rating', icon: '★' }, 107 + { type: 'scale', label: 'Scale', icon: '⊞' }, 108 + ]; 109 + 110 + // --- Render builder --- 111 + function renderBuilder() { 112 + toolbar.style.display = ''; 113 + questionsContainer.style.display = ''; 114 + previewPane.style.display = 'none'; 115 + responsesPane.style.display = 'none'; 116 + 117 + titleInput.value = form.title; 118 + descInput.value = form.description; 119 + 120 + questionsContainer.innerHTML = ''; 121 + 122 + if (form.questions.length === 0) { 123 + questionsContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--color-text-secondary)">No questions yet. Click "+ Add Question" to get started.</div>'; 124 + return; 125 + } 126 + 127 + for (let i = 0; i < form.questions.length; i++) { 128 + const q = form.questions[i]!; 129 + const el = document.createElement('div'); 130 + el.className = 'form-question-card'; 131 + el.dataset.questionId = q.id; 132 + 133 + const typeLabel = QUESTION_TYPES.find(t => t.type === q.type)?.label ?? q.type; 134 + const isChoice = ['single_choice', 'multiple_choice', 'dropdown'].includes(q.type); 135 + 136 + let optionsHtml = ''; 137 + if (isChoice) { 138 + optionsHtml = '<div class="form-question-options">'; 139 + for (const opt of q.options) { 140 + optionsHtml += `<div class="form-option-row"><input type="text" value="${opt.label}" data-option-id="${opt.id}" class="form-option-input" placeholder="Option label"><button class="form-option-remove" data-option-id="${opt.id}" title="Remove">✕</button></div>`; 141 + } 142 + optionsHtml += `<button class="form-add-option" data-question-id="${q.id}">+ Add option</button></div>`; 143 + } 144 + 145 + el.innerHTML = ` 146 + <div class="form-question-header"> 147 + <span class="form-question-number">${i + 1}</span> 148 + <span class="form-question-type-badge">${typeLabel}</span> 149 + <span style="flex:1"></span> 150 + <label class="form-question-required-label"><input type="checkbox" class="form-question-required" ${q.required ? 'checked' : ''}> Required</label> 151 + <button class="form-question-move-up" title="Move up" ${i === 0 ? 'disabled' : ''}>↑</button> 152 + <button class="form-question-move-down" title="Move down" ${i === form.questions.length - 1 ? 'disabled' : ''}>↓</button> 153 + <button class="form-question-delete" title="Delete">✕</button> 154 + </div> 155 + <input type="text" class="form-question-label" value="${q.label}" placeholder="Question text"> 156 + <input type="text" class="form-question-desc" value="${q.description}" placeholder="Description (optional)"> 157 + ${optionsHtml} 158 + `; 159 + 160 + // Wire events 161 + el.querySelector('.form-question-label')!.addEventListener('change', (e) => { 162 + form = updateQuestion(form, q.id, { label: (e.target as HTMLInputElement).value }); 163 + syncFormToYjs(); 164 + }); 165 + el.querySelector('.form-question-desc')!.addEventListener('change', (e) => { 166 + form = updateQuestion(form, q.id, { description: (e.target as HTMLInputElement).value }); 167 + syncFormToYjs(); 168 + }); 169 + el.querySelector('.form-question-required')!.addEventListener('change', (e) => { 170 + form = updateQuestion(form, q.id, { required: (e.target as HTMLInputElement).checked }); 171 + syncFormToYjs(); 172 + }); 173 + el.querySelector('.form-question-delete')!.addEventListener('click', () => { 174 + form = removeQuestion(form, q.id); 175 + syncFormToYjs(); 176 + renderBuilder(); 177 + }); 178 + el.querySelector('.form-question-move-up')?.addEventListener('click', () => { 179 + form = moveQuestion(form, q.id, i - 1); 180 + syncFormToYjs(); 181 + renderBuilder(); 182 + }); 183 + el.querySelector('.form-question-move-down')?.addEventListener('click', () => { 184 + form = moveQuestion(form, q.id, i + 1); 185 + syncFormToYjs(); 186 + renderBuilder(); 187 + }); 188 + 189 + // Option events 190 + if (isChoice) { 191 + el.querySelector('.form-add-option')?.addEventListener('click', () => { 192 + form = addOption(form, q.id, `Option ${q.options.length + 1}`); 193 + syncFormToYjs(); 194 + renderBuilder(); 195 + }); 196 + el.querySelectorAll('.form-option-input').forEach(input => { 197 + input.addEventListener('change', (e) => { 198 + const optId = (e.target as HTMLElement).dataset.optionId; 199 + const newLabel = (e.target as HTMLInputElement).value; 200 + const updatedOptions = q.options.map(o => o.id === optId ? { ...o, label: newLabel } : o); 201 + form = updateQuestion(form, q.id, { options: updatedOptions }); 202 + syncFormToYjs(); 203 + }); 204 + }); 205 + el.querySelectorAll('.form-option-remove').forEach(btn => { 206 + btn.addEventListener('click', (e) => { 207 + const optId = (e.target as HTMLElement).dataset.optionId; 208 + const updatedOptions = q.options.filter(o => o.id !== optId); 209 + form = updateQuestion(form, q.id, { options: updatedOptions }); 210 + syncFormToYjs(); 211 + renderBuilder(); 212 + }); 213 + }); 214 + } 215 + 216 + questionsContainer.appendChild(el); 217 + } 218 + } 219 + 220 + // --- Add question dialog --- 221 + function showAddQuestionDialog() { 222 + if (document.querySelector('.add-question-overlay')) return; 223 + const overlay = document.createElement('div'); 224 + overlay.className = 'sheet-dialog-overlay add-question-overlay'; 225 + 226 + overlay.innerHTML = ` 227 + <div class="sheet-dialog" style="max-width:400px"> 228 + <h3>Add Question</h3> 229 + <div class="form-question-type-grid"> 230 + ${QUESTION_TYPES.map(t => `<button class="form-type-btn" data-type="${t.type}"><span class="form-type-icon">${t.icon}</span><span>${t.label}</span></button>`).join('')} 231 + </div> 232 + <div class="sheet-dialog-actions"> 233 + <button id="add-q-cancel">Cancel</button> 234 + </div> 235 + </div> 236 + `; 237 + document.body.appendChild(overlay); 238 + 239 + overlay.querySelector('#add-q-cancel')!.addEventListener('click', () => overlay.remove()); 240 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 241 + 242 + overlay.querySelectorAll('.form-type-btn').forEach(btn => { 243 + btn.addEventListener('click', () => { 244 + const type = (btn as HTMLElement).dataset.type as QuestionType; 245 + const defaultOpts = ['single_choice', 'multiple_choice', 'dropdown'].includes(type) 246 + ? { options: [{ id: `opt-${Date.now()}-1`, label: 'Option 1' }, { id: `opt-${Date.now()}-2`, label: 'Option 2' }] } 247 + : {}; 248 + form = addQuestion(form, type, '', defaultOpts); 249 + syncFormToYjs(); 250 + overlay.remove(); 251 + renderBuilder(); 252 + }); 253 + }); 254 + } 255 + 256 + // --- Preview mode --- 257 + function renderPreview() { 258 + toolbar.style.display = ''; 259 + questionsContainer.style.display = 'none'; 260 + previewPane.style.display = ''; 261 + responsesPane.style.display = 'none'; 262 + 263 + const answers = new Map<string, unknown>(); 264 + const visibleIds = getVisibleQuestions(form.questions.map(q => q.id), condLogic, answers); 265 + 266 + previewPane.innerHTML = ` 267 + <div class="form-preview-container"> 268 + <h2>${form.title}</h2> 269 + ${form.description ? `<p class="form-preview-desc">${form.description}</p>` : ''} 270 + <div class="form-preview-questions" id="preview-questions"></div> 271 + <div class="form-preview-actions"> 272 + <button class="btn-primary" id="preview-submit">Submit</button> 273 + </div> 274 + </div> 275 + `; 276 + 277 + const questionsEl = previewPane.querySelector('#preview-questions')!; 278 + 279 + for (const q of form.questions) { 280 + if (!visibleIds.includes(q.id)) continue; 281 + 282 + const qEl = document.createElement('div'); 283 + qEl.className = 'form-preview-question'; 284 + 285 + let inputHtml = ''; 286 + switch (q.type) { 287 + case 'short_text': 288 + case 'email': 289 + case 'url': 290 + inputHtml = `<input type="${q.type === 'email' ? 'email' : q.type === 'url' ? 'url' : 'text'}" class="form-preview-input" data-qid="${q.id}" placeholder="Your answer">`; 291 + break; 292 + case 'long_text': 293 + inputHtml = `<textarea class="form-preview-textarea" data-qid="${q.id}" rows="3" placeholder="Your answer"></textarea>`; 294 + break; 295 + case 'number': 296 + inputHtml = `<input type="number" class="form-preview-input" data-qid="${q.id}" placeholder="0">`; 297 + break; 298 + case 'date': 299 + inputHtml = `<input type="date" class="form-preview-input" data-qid="${q.id}">`; 300 + break; 301 + case 'single_choice': 302 + inputHtml = q.options.map(o => `<label class="form-preview-radio"><input type="radio" name="q-${q.id}" value="${o.id}" data-qid="${q.id}"> ${o.label}</label>`).join(''); 303 + break; 304 + case 'multiple_choice': 305 + inputHtml = q.options.map(o => `<label class="form-preview-checkbox"><input type="checkbox" value="${o.id}" data-qid="${q.id}"> ${o.label}</label>`).join(''); 306 + break; 307 + case 'dropdown': 308 + inputHtml = `<select class="form-preview-select" data-qid="${q.id}"><option value="">Select...</option>${q.options.map(o => `<option value="${o.id}">${o.label}</option>`).join('')}</select>`; 309 + break; 310 + case 'rating': 311 + inputHtml = `<div class="form-preview-rating" data-qid="${q.id}">${[1, 2, 3, 4, 5].map(n => `<button class="form-rating-star" data-value="${n}">★</button>`).join('')}</div>`; 312 + break; 313 + case 'scale': { 314 + const min = q.scaleMin ?? 1; 315 + const max = q.scaleMax ?? 10; 316 + inputHtml = `<div class="form-preview-scale" data-qid="${q.id}">${Array.from({ length: max - min + 1 }, (_, i) => `<button class="form-scale-btn" data-value="${min + i}">${min + i}</button>`).join('')}</div>`; 317 + break; 318 + } 319 + } 320 + 321 + qEl.innerHTML = ` 322 + <label class="form-preview-label">${q.label}${q.required ? ' <span class="form-required-mark">*</span>' : ''}</label> 323 + ${q.description ? `<p class="form-preview-hint">${q.description}</p>` : ''} 324 + ${inputHtml} 325 + <div class="form-preview-error" data-error-qid="${q.id}"></div> 326 + `; 327 + questionsEl.appendChild(qEl); 328 + } 329 + 330 + // Submit handler 331 + previewPane.querySelector('#preview-submit')!.addEventListener('click', () => { 332 + const formAnswers = new Map<string, unknown>(); 333 + 334 + // Collect answers 335 + previewPane.querySelectorAll('[data-qid]').forEach(el => { 336 + const qid = (el as HTMLElement).dataset.qid!; 337 + if (el instanceof HTMLInputElement) { 338 + if (el.type === 'radio') { 339 + if (el.checked) formAnswers.set(qid, el.value); 340 + } else if (el.type === 'checkbox') { 341 + const existing = (formAnswers.get(qid) as string[]) || []; 342 + if (el.checked) existing.push(el.value); 343 + formAnswers.set(qid, existing); 344 + } else { 345 + formAnswers.set(qid, el.value); 346 + } 347 + } else if (el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) { 348 + formAnswers.set(qid, el.value); 349 + } 350 + }); 351 + 352 + const errors = validateSubmission(form, formAnswers); 353 + // Show errors 354 + previewPane.querySelectorAll('.form-preview-error').forEach(el => { 355 + const qid = (el as HTMLElement).dataset.errorQid!; 356 + (el as HTMLElement).textContent = errors.get(qid) ?? ''; 357 + }); 358 + 359 + if (errors.size === 0) { 360 + const response = createResponse(form.id, formAnswers); 361 + yResponses.push([JSON.stringify(response)]); 362 + previewPane.innerHTML = '<div style="text-align:center;padding:3rem"><h2>Response submitted!</h2><p>Your response has been recorded.</p></div>'; 363 + } 364 + }); 365 + } 366 + 367 + // --- Responses view --- 368 + function renderResponses() { 369 + toolbar.style.display = ''; 370 + questionsContainer.style.display = 'none'; 371 + previewPane.style.display = 'none'; 372 + responsesPane.style.display = ''; 373 + 374 + const responses: FormResponse[] = []; 375 + for (let i = 0; i < yResponses.length; i++) { 376 + try { 377 + const r = JSON.parse(yResponses.get(i) as string); 378 + r.answers = new Map(Object.entries(r.answers)); 379 + responses.push(r); 380 + } catch { /* skip invalid */ } 381 + } 382 + 383 + if (responses.length === 0) { 384 + responsesPane.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--color-text-secondary)">No responses yet.</div>'; 385 + return; 386 + } 387 + 388 + const config = createPipelineConfig( 389 + form.id, 390 + form.targetSheetId ?? '', 391 + form.questions.map(q => q.id), 392 + form.questions.map(q => q.label || 'Untitled'), 393 + ); 394 + 395 + const headers = pipelineHeaders(config); 396 + const rows = responses.map(r => responseToRow(r, config)); 397 + 398 + let tableHtml = '<table class="pivot-table"><thead><tr>'; 399 + for (const h of headers) tableHtml += `<th>${h}</th>`; 400 + tableHtml += '</tr></thead><tbody>'; 401 + for (const row of rows) { 402 + tableHtml += '<tr>'; 403 + for (const cell of row) tableHtml += `<td>${cell ?? ''}</td>`; 404 + tableHtml += '</tr>'; 405 + } 406 + tableHtml += '</tbody></table>'; 407 + 408 + responsesPane.innerHTML = ` 409 + <div style="padding:var(--space-md)"> 410 + <h3>${responses.length} Response${responses.length === 1 ? '' : 's'}</h3> 411 + <div style="overflow-x:auto">${tableHtml}</div> 412 + </div> 413 + `; 414 + } 415 + 416 + // --- Toolbar events --- 417 + document.getElementById('btn-add-question')!.addEventListener('click', showAddQuestionDialog); 418 + 419 + document.getElementById('btn-preview')!.addEventListener('click', () => { 420 + mode = mode === 'preview' ? 'builder' : 'preview'; 421 + if (mode === 'preview') renderPreview(); 422 + else renderBuilder(); 423 + }); 424 + 425 + document.getElementById('btn-responses')!.addEventListener('click', () => { 426 + mode = mode === 'responses' ? 'builder' : 'responses'; 427 + if (mode === 'responses') renderResponses(); 428 + else renderBuilder(); 429 + }); 430 + 431 + document.getElementById('btn-settings')!.addEventListener('click', () => { 432 + const sheetId = prompt('Link to sheet (enter sheet document ID):', form.targetSheetId ?? ''); 433 + if (sheetId !== null) { 434 + form = setTargetSheet(form, sheetId || null); 435 + syncFormToYjs(); 436 + } 437 + }); 438 + 439 + // Title and description sync 440 + titleInput.addEventListener('change', () => { 441 + form = { ...form, title: titleInput.value, updatedAt: Date.now() }; 442 + syncFormToYjs(); 443 + }); 444 + descInput.addEventListener('change', () => { 445 + form = { ...form, description: descInput.value, updatedAt: Date.now() }; 446 + syncFormToYjs(); 447 + }); 448 + 449 + // --- Yjs observer --- 450 + yForm.observe(() => { 451 + loadFormFromYjs(); 452 + if (mode === 'builder') renderBuilder(); 453 + }); 454 + 455 + // --- Initialize --- 456 + async function init() { 457 + await initCrypto(); 458 + 459 + if (cryptoKey) { 460 + const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 461 + 462 + provider.on('sync', () => { 463 + loadFormFromYjs(); 464 + if (form.questions.length === 0 && !yForm.has('schema')) { 465 + syncFormToYjs(); 466 + } 467 + renderBuilder(); 468 + }); 469 + } else { 470 + renderBuilder(); 471 + } 472 + } 473 + 474 + init();
+5
src/index.html
··· 53 53 <span class="create-card-title">New Spreadsheet</span> 54 54 <span class="create-card-desc">Formulas, formatting, multiple sheets, and real-time collaboration</span> 55 55 </a> 56 + <a class="create-card" id="new-form" href="#"> 57 + <span class="create-card-icon">&#9744;</span> 58 + <span class="create-card-title">New Form</span> 59 + <span class="create-card-desc">E2EE form builder with responses pipeline to sheets</span> 60 + </a> 56 61 <a class="create-card create-card-accent" id="daily-note" href="#"> 57 62 <span class="create-card-icon">&#128197;</span> 58 63 <span class="create-card-title">Today's Note</span>
+7 -4
src/landing.ts
··· 37 37 const noResultsEl = document.getElementById('no-results') as HTMLElement; 38 38 const newDocBtn = document.getElementById('new-doc') as HTMLElement; 39 39 const newSheetBtn = document.getElementById('new-sheet') as HTMLElement; 40 + const newFormBtn = document.getElementById('new-form') as HTMLElement; 40 41 const dailyNoteBtn = document.getElementById('daily-note') as HTMLElement; 41 42 const searchInput = document.getElementById('search-input') as HTMLInputElement; 42 43 const searchClear = document.getElementById('search-clear') as HTMLElement; ··· 189 190 }); 190 191 191 192 // --- Create document --- 192 - async function createDocument(type: 'doc' | 'sheet'): Promise<void> { 193 + async function createDocument(type: 'doc' | 'sheet' | 'form'): Promise<void> { 193 194 const key = await generateKey(); 194 195 const keyStr = await exportKey(key); 195 196 196 - const defaultName = type === 'doc' ? 'Untitled Document' : 'Untitled Spreadsheet'; 197 + const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form' }; 198 + const defaultName = nameMap[type]; 197 199 const nameBytes = new TextEncoder().encode(defaultName); 198 200 const { encrypt } = await import('./lib/crypto.js'); 199 201 const encryptedName = await encrypt(nameBytes, key); ··· 218 220 recentIds = trackRecentDoc(recentIds, id); 219 221 localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 220 222 221 - const path = type === 'doc' ? '/docs' : '/sheets'; 222 - window.location.href = `${path}/${id}#${keyStr}`; 223 + const pathMap = { doc: '/docs', sheet: '/sheets', form: '/forms' }; 224 + window.location.href = `${pathMap[type]}/${id}#${keyStr}`; 223 225 } 224 226 225 227 async function createFromTemplate(templateId: string): Promise<void> { ··· 262 264 263 265 newDocBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('doc'); }); 264 266 newSheetBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('sheet'); }); 267 + newFormBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('form'); }); 265 268 266 269 // --- Daily Note --- 267 270 async function openDailyNote(): Promise<void> {
+89
src/lib/blob-upload.ts
··· 1 + /** 2 + * Client-side blob upload/download helper. 3 + * Handles uploading encrypted blobs to the server and retrieving them. 4 + */ 5 + 6 + export interface UploadResult { 7 + id: string; 8 + size: number; 9 + } 10 + 11 + /** 12 + * Upload an encrypted blob to the server. 13 + * The caller is responsible for encrypting the data before passing it here. 14 + */ 15 + export async function uploadBlob( 16 + documentId: string, 17 + data: ArrayBuffer | Uint8Array, 18 + fileName: string, 19 + mimeType: string, 20 + ): Promise<UploadResult> { 21 + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; 22 + const blob = new Blob([bytes as BlobPart], { type: 'application/octet-stream' }); 23 + const res = await fetch('/api/blobs', { 24 + method: 'POST', 25 + headers: { 26 + 'Content-Type': 'application/octet-stream', 27 + 'x-document-id': documentId, 28 + 'x-file-name': fileName, 29 + 'x-mime-type': mimeType, 30 + }, 31 + body: blob, 32 + }); 33 + if (!res.ok) { 34 + const err = await res.json().catch(() => ({ error: 'Upload failed' })); 35 + throw new Error((err as { error: string }).error || `Upload failed: ${res.status}`); 36 + } 37 + return res.json() as Promise<UploadResult>; 38 + } 39 + 40 + /** 41 + * Download a blob from the server. 42 + * Returns raw bytes — the caller decrypts if needed. 43 + */ 44 + export async function downloadBlob(blobId: string): Promise<{ data: ArrayBuffer; mimeType: string; fileName: string }> { 45 + const res = await fetch(`/api/blobs/${blobId}`); 46 + if (!res.ok) throw new Error(`Blob not found: ${blobId}`); 47 + const mimeType = res.headers.get('Content-Type') || 'application/octet-stream'; 48 + const disposition = res.headers.get('Content-Disposition') || ''; 49 + const fileNameMatch = disposition.match(/filename="(.+?)"/); 50 + const fileName = fileNameMatch?.[1] ?? 'file'; 51 + const data = await res.arrayBuffer(); 52 + return { data, mimeType, fileName }; 53 + } 54 + 55 + /** 56 + * List blobs for a document. 57 + */ 58 + export async function listDocBlobs(documentId: string): Promise<{ id: string; file_name: string; mime_type: string; size: number; created_at: string }[]> { 59 + const res = await fetch(`/api/documents/${documentId}/blobs`); 60 + if (!res.ok) return []; 61 + return res.json() as Promise<{ id: string; file_name: string; mime_type: string; size: number; created_at: string }[]>; 62 + } 63 + 64 + /** 65 + * Delete a blob. 66 + */ 67 + export async function deleteBlob(blobId: string): Promise<void> { 68 + await fetch(`/api/blobs/${blobId}`, { method: 'DELETE' }); 69 + } 70 + 71 + /** 72 + * Read a File object as ArrayBuffer. 73 + */ 74 + export function readFileAsBuffer(file: File): Promise<ArrayBuffer> { 75 + return new Promise((resolve, reject) => { 76 + const reader = new FileReader(); 77 + reader.onload = () => resolve(reader.result as ArrayBuffer); 78 + reader.onerror = () => reject(new Error('Failed to read file')); 79 + reader.readAsArrayBuffer(file); 80 + }); 81 + } 82 + 83 + /** 84 + * Create an object URL from blob data for display. 85 + */ 86 + export function blobToObjectUrl(data: ArrayBuffer, mimeType: string): string { 87 + const blob = new Blob([data], { type: mimeType }); 88 + return URL.createObjectURL(blob); 89 + }
+16
src/sheets/index.html
··· 249 249 <button class="toolbar-dropdown-item" id="tb-validation" title="Data validation" role="menuitem"> 250 250 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M3 8l3 3 7-7"/></svg></span><span class="item-label">Data validation</span> 251 251 </button> 252 + <button class="toolbar-dropdown-item" id="tb-pivot" title="Create pivot table" role="menuitem"> 253 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="1" fill="none"/><line x1="1" y1="5" x2="15" y2="5"/><line x1="5" y1="1" x2="5" y2="15"/><rect x="6" y="6" width="4" height="3" rx="0.3" fill="currentColor" opacity="0.3"/></svg></span><span class="item-label">Pivot table</span> 254 + </button> 255 + <button class="toolbar-dropdown-item" id="tb-view-mode" title="Switch view mode" role="menuitem"> 256 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2"/><rect x="9" y="1" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2"/><rect x="1" y="7" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2"/><rect x="9" y="7" width="6" height="4" rx="0.5" fill="currentColor" opacity="0.2"/><rect x="1" y="13" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.15"/></svg></span><span class="item-label">Database views</span> 257 + </button> 252 258 <div class="toolbar-dropdown-divider"></div> 253 259 254 260 <!-- Section: Cell Types --> ··· 257 263 </button> 258 264 <button class="toolbar-dropdown-item" id="tb-celltype-rating" title="Set as star rating" role="menuitem"> 259 265 <span class="item-icon">★</span><span class="item-label">Rating</span> 266 + </button> 267 + <button class="toolbar-dropdown-item" id="tb-insert-image" title="Insert image into cell" role="menuitem"> 268 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1" y="2" width="14" height="12" rx="1.5" fill="none" stroke="currentColor"/><circle cx="5" cy="6" r="1.5" fill="currentColor" opacity="0.4"/><path d="M1 12l4-4 2 2 3-3 5 5H1z" fill="currentColor" opacity="0.3"/></svg></span><span class="item-label">Insert image</span> 260 269 </button> 261 270 <button class="toolbar-dropdown-item" id="tb-celltype-progress" title="Set as progress bar" role="menuitem"> 262 271 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="1" y="6" width="14" height="4" rx="1" fill="none" stroke="currentColor"/><rect x="1" y="6" width="9" height="4" rx="1" fill="currentColor" opacity="0.5"/></svg></span><span class="item-label">Progress bar</span> ··· 303 312 304 313 <!-- Charts section --> 305 314 <div class="charts-section" id="charts-section"></div> 315 + 316 + <!-- Pivot tables section --> 317 + <div class="pivot-section" id="pivot-section"></div> 318 + 319 + <!-- Database views section (kanban/gallery/calendar) --> 320 + <div class="database-view-section" id="database-view-section" style="display:none"></div> 306 321 307 322 <!-- Status bar --> 308 323 <div class="status-bar" id="status-bar"> ··· 408 423 }).catch(function() {}); 409 424 } 410 425 </script> 426 + <input type="file" id="image-upload-input" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none"> 411 427 <div class="version-badge">v%APP_VERSION%</div> 412 428 </body> 413 429 </html>
+569
src/sheets/main.ts
··· 51 51 } from '../lib/ai-chat.js'; 52 52 import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 53 53 import { executeSheetAction } from './ai-sheet-actions.js'; 54 + import { computePivot, formatAggregateValue } from './pivot-table.js'; 55 + import type { PivotConfig, AggregateFunction } from './pivot-table.js'; 56 + import { buildKanbanColumns, buildGalleryCards, buildCalendarEvents, groupEventsByMonth, eventsForDate, createViewConfig, getViewTypes } from './database-views.js'; 57 + import type { ViewConfig, ViewType } from './database-views.js'; 58 + import { uploadBlob, downloadBlob, readFileAsBuffer, blobToObjectUrl } from '../lib/blob-upload.js'; 59 + import { createImageCellState, setCellImage, getCellImage, hasImage, imageCellIds } from './image-cells.js'; 60 + import type { ImageCellState } from './image-cells.js'; 54 61 55 62 // --- Constants --- 56 63 const DEFAULT_ROWS = 100; ··· 608 615 const errClass = errInfo ? ' cell-error' : ''; 609 616 const errData = errInfo ? ' data-error-title="' + escapeHtml(errInfo.title) + '" data-error-desc="' + escapeHtml(errInfo.description) + '" data-error-hint="' + escapeHtml(errInfo.hint) + '"' : ''; 610 617 618 + // Image cells 619 + if (cellData?.img) { 620 + tbodyHtml += '<div class="cell-display cell-image-container" data-img-cell="' + id + '" data-blob-id="' + escapeHtml(String(cellData.img)) + '" style="' + getCellStyle(cellData, cfStyleStr) + '"></div>'; 621 + tbodyHtml += '</td>'; 622 + continue; 623 + } 624 + 611 625 // Rich cell types (checkbox, rating, progress bar) 612 626 const richHtml = renderInteractiveCell(cellData?.s?.cellType, cellData?.v ?? displayValue); 613 627 const cellContent = richHtml ?? escapeHtml(displayValue); ··· 646 660 updateFreezeToolbarState(); 647 661 renderNoteIndicators(); 648 662 renderSparklines(); 663 + renderImageCells(); 649 664 } 650 665 651 666 function getCellData(id) { ··· 4044 4059 // Charts re-render is handled by the main cell observer + scheduleRenderGrid 4045 4060 4046 4061 // ======================================================== 4062 + // Pivot Table Feature 4063 + // ======================================================== 4064 + 4065 + const pivotSection = document.getElementById('pivot-section'); 4066 + 4067 + function getPivots() { 4068 + const sheet = getActiveSheet(); 4069 + if (!sheet.has('pivots')) sheet.set('pivots', new Y.Map()); 4070 + return sheet.get('pivots'); 4071 + } 4072 + 4073 + function showPivotDialog(existingId?: string, existingConfig?: PivotConfig & { title?: string }) { 4074 + if (document.querySelector('.pivot-dialog-overlay')) return; 4075 + const overlay = document.createElement('div'); 4076 + overlay.className = 'sheet-dialog-overlay pivot-dialog-overlay'; 4077 + 4078 + const isEdit = !!existingId; 4079 + const sheet = getActiveSheet(); 4080 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 4081 + 4082 + // Build column options from headers (row 1) 4083 + const colOptions: string[] = []; 4084 + for (let c = 1; c <= colCount; c++) { 4085 + const data = getCellData(cellId(c, 1)); 4086 + const label = data?.v ? String(data.v) : colToLetter(c); 4087 + colOptions.push(`<option value="${c}">${colToLetter(c)}: ${label}</option>`); 4088 + } 4089 + 4090 + const cfg = existingConfig || { rowFields: [], colFields: [], valueField: 1, aggregation: 'sum' as AggregateFunction, title: '' }; 4091 + const aggTypes: AggregateFunction[] = ['sum', 'count', 'avg', 'min', 'max', 'countDistinct']; 4092 + 4093 + overlay.innerHTML = ` 4094 + <div class="sheet-dialog"> 4095 + <h3>${isEdit ? 'Edit' : 'Create'} Pivot Table</h3> 4096 + <label>Title</label> 4097 + <input id="pivot-title" value="${cfg.title || ''}" placeholder="Pivot table title"> 4098 + <label>Row Fields (group by)</label> 4099 + <select id="pivot-rows" multiple size="4">${colOptions.map((o, i) => 4100 + o.replace('>', cfg.rowFields.includes(i + 1) ? ' selected>' : '>') 4101 + ).join('')}</select> 4102 + <label>Column Fields (optional)</label> 4103 + <select id="pivot-cols" multiple size="3">${colOptions.map((o, i) => 4104 + o.replace('>', cfg.colFields.includes(i + 1) ? ' selected>' : '>') 4105 + ).join('')}</select> 4106 + <label>Value Field</label> 4107 + <select id="pivot-value">${colOptions.map((o, i) => 4108 + o.replace('>', (i + 1) === cfg.valueField ? ' selected>' : '>') 4109 + ).join('')}</select> 4110 + <label>Aggregation</label> 4111 + <select id="pivot-agg">${aggTypes.map(a => 4112 + `<option value="${a}" ${a === cfg.aggregation ? 'selected' : ''}>${a}</option>` 4113 + ).join('')}</select> 4114 + <div class="sheet-dialog-actions"> 4115 + <button id="pivot-cancel">Cancel</button> 4116 + <button id="pivot-ok" class="btn-primary">${isEdit ? 'Update' : 'Create'}</button> 4117 + </div> 4118 + </div> 4119 + `; 4120 + document.body.appendChild(overlay); 4121 + 4122 + overlay.querySelector('#pivot-cancel')!.addEventListener('click', () => overlay.remove()); 4123 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 4124 + 4125 + overlay.querySelector('#pivot-ok')!.addEventListener('click', () => { 4126 + const rowFields = Array.from(overlay.querySelector('#pivot-rows')!.selectedOptions, o => Number(o.value)); 4127 + const colFields = Array.from(overlay.querySelector('#pivot-cols')!.selectedOptions, o => Number(o.value)); 4128 + const valueField = Number(overlay.querySelector('#pivot-value')!.value); 4129 + const aggregation = overlay.querySelector('#pivot-agg')!.value as AggregateFunction; 4130 + const title = overlay.querySelector('#pivot-title')!.value.trim(); 4131 + 4132 + if (rowFields.length === 0) { 4133 + alert('Select at least one row field.'); 4134 + return; 4135 + } 4136 + 4137 + const config = { rowFields, colFields, valueField, aggregation, title }; 4138 + const pivots = getPivots(); 4139 + const id = existingId || 'pivot_' + Date.now(); 4140 + pivots.set(id, JSON.stringify(config)); 4141 + overlay.remove(); 4142 + renderPivots(); 4143 + }); 4144 + 4145 + setTimeout(() => overlay.querySelector('#pivot-title')?.focus(), 50); 4146 + } 4147 + 4148 + function renderPivots() { 4149 + const pivots = getPivots(); 4150 + pivotSection.innerHTML = ''; 4151 + 4152 + pivots.forEach((cfgStr: string, id: string) => { 4153 + const config = typeof cfgStr === 'string' ? JSON.parse(cfgStr) : cfgStr; 4154 + 4155 + // Build data from sheet rows (skip header row 1) 4156 + const sheet = getActiveSheet(); 4157 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 4158 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 4159 + const dataRows: Map<string, unknown>[] = []; 4160 + 4161 + for (let r = 2; r <= rowCount; r++) { 4162 + const row = new Map<string, unknown>(); 4163 + let hasData = false; 4164 + for (let c = 1; c <= colCount; c++) { 4165 + const data = getCellData(cellId(c, r)); 4166 + const val = data?.v ?? ''; 4167 + if (val !== '') hasData = true; 4168 + row.set(cellId(c, r), val); 4169 + } 4170 + if (hasData) dataRows.push(row); 4171 + } 4172 + 4173 + if (dataRows.length === 0) return; 4174 + 4175 + const result = computePivot(dataRows, config, colToLetter, 2); 4176 + 4177 + // Build HTML table 4178 + const container = document.createElement('div'); 4179 + container.className = 'pivot-container'; 4180 + container.dataset.pivotId = id; 4181 + 4182 + const actions = document.createElement('div'); 4183 + actions.className = 'pivot-actions'; 4184 + actions.innerHTML = '<button class="pivot-edit">Edit</button><button class="pivot-delete">Delete</button>'; 4185 + container.appendChild(actions); 4186 + 4187 + if (config.title) { 4188 + const heading = document.createElement('h4'); 4189 + heading.textContent = config.title; 4190 + container.appendChild(heading); 4191 + } 4192 + 4193 + const table = document.createElement('table'); 4194 + table.className = 'pivot-table'; 4195 + 4196 + // Header row 4197 + const thead = document.createElement('thead'); 4198 + const headerRow = document.createElement('tr'); 4199 + 4200 + // Row field headers 4201 + for (const f of config.rowFields) { 4202 + const th = document.createElement('th'); 4203 + const hdr = getCellData(cellId(f, 1)); 4204 + th.textContent = hdr?.v ? String(hdr.v) : colToLetter(f); 4205 + headerRow.appendChild(th); 4206 + } 4207 + 4208 + // Column group headers 4209 + if (result.colKeys.length > 0 && result.colKeys[0].length > 0) { 4210 + for (const ck of result.colKeys) { 4211 + const th = document.createElement('th'); 4212 + th.textContent = ck.join(' / ') || '(empty)'; 4213 + headerRow.appendChild(th); 4214 + } 4215 + } else { 4216 + const th = document.createElement('th'); 4217 + const vHdr = getCellData(cellId(config.valueField, 1)); 4218 + th.textContent = (vHdr?.v ? String(vHdr.v) : colToLetter(config.valueField)) + ` (${config.aggregation})`; 4219 + headerRow.appendChild(th); 4220 + } 4221 + 4222 + const totalTh = document.createElement('th'); 4223 + totalTh.textContent = 'Total'; 4224 + headerRow.appendChild(totalTh); 4225 + thead.appendChild(headerRow); 4226 + table.appendChild(thead); 4227 + 4228 + // Body rows 4229 + const tbody = document.createElement('tbody'); 4230 + for (let ri = 0; ri < result.rowKeys.length; ri++) { 4231 + const tr = document.createElement('tr'); 4232 + for (const val of result.rowKeys[ri]) { 4233 + const td = document.createElement('td'); 4234 + td.className = 'pivot-row-header'; 4235 + td.textContent = val || '(empty)'; 4236 + tr.appendChild(td); 4237 + } 4238 + for (let ci = 0; ci < (result.colKeys.length || 1); ci++) { 4239 + const td = document.createElement('td'); 4240 + const cell = result.cells[ri]?.[ci]; 4241 + td.textContent = cell ? formatAggregateValue(cell.value, config.aggregation) : ''; 4242 + tr.appendChild(td); 4243 + } 4244 + const totalTd = document.createElement('td'); 4245 + totalTd.className = 'pivot-total'; 4246 + totalTd.textContent = formatAggregateValue(result.rowTotals[ri]?.value ?? 0, config.aggregation); 4247 + tr.appendChild(totalTd); 4248 + tbody.appendChild(tr); 4249 + } 4250 + table.appendChild(tbody); 4251 + 4252 + // Footer (column totals) 4253 + const tfoot = document.createElement('tfoot'); 4254 + const footRow = document.createElement('tr'); 4255 + const footLabel = document.createElement('td'); 4256 + footLabel.colSpan = config.rowFields.length; 4257 + footLabel.textContent = 'Total'; 4258 + footRow.appendChild(footLabel); 4259 + for (let ci = 0; ci < (result.colKeys.length || 1); ci++) { 4260 + const td = document.createElement('td'); 4261 + td.textContent = formatAggregateValue(result.colTotals[ci]?.value ?? 0, config.aggregation); 4262 + footRow.appendChild(td); 4263 + } 4264 + const grandTd = document.createElement('td'); 4265 + grandTd.className = 'pivot-total'; 4266 + grandTd.textContent = formatAggregateValue(result.grandTotal.value, config.aggregation); 4267 + footRow.appendChild(grandTd); 4268 + tfoot.appendChild(footRow); 4269 + table.appendChild(tfoot); 4270 + 4271 + container.appendChild(table); 4272 + pivotSection.appendChild(container); 4273 + 4274 + actions.querySelector('.pivot-edit')!.addEventListener('click', () => showPivotDialog(id, config)); 4275 + actions.querySelector('.pivot-delete')!.addEventListener('click', () => { 4276 + ydoc.transact(() => getPivots().delete(id)); 4277 + renderPivots(); 4278 + }); 4279 + }); 4280 + } 4281 + 4282 + document.getElementById('tb-pivot')!.addEventListener('click', () => { 4283 + showPivotDialog(); 4284 + closeAllDropdowns(); 4285 + }); 4286 + 4287 + // ======================================================== 4288 + // Image Cells Feature 4289 + // ======================================================== 4290 + 4291 + let imageCellState: ImageCellState = createImageCellState(); 4292 + const imageUploadInput = document.getElementById('image-upload-input') as HTMLInputElement; 4293 + const imageCache = new Map<string, string>(); // blobId → objectURL 4294 + 4295 + document.getElementById('tb-insert-image')!.addEventListener('click', () => { 4296 + imageUploadInput.click(); 4297 + closeAllDropdowns(); 4298 + }); 4299 + 4300 + imageUploadInput.addEventListener('change', async () => { 4301 + const file = imageUploadInput.files?.[0]; 4302 + if (!file) return; 4303 + imageUploadInput.value = ''; 4304 + 4305 + const targetCell = cellId(selCol, selRow); 4306 + try { 4307 + const buf = await readFileAsBuffer(file); 4308 + const docId = window.location.pathname.split('/').pop() || ''; 4309 + const result = await uploadBlob(docId, new Uint8Array(buf), file.name, file.type); 4310 + 4311 + // Get natural dimensions 4312 + const img = new Image(); 4313 + const url = blobToObjectUrl(buf, file.type); 4314 + imageCache.set(result.id, url); 4315 + 4316 + img.onload = () => { 4317 + imageCellState = setCellImage(imageCellState, targetCell, result.id, img.naturalWidth, img.naturalHeight, { alt: file.name }); 4318 + // Store image ref in cell data so it persists via Yjs 4319 + const existing = getCellData(targetCell) || {}; 4320 + setCellData(targetCell, { ...existing, img: result.id }); 4321 + renderViewport(); 4322 + }; 4323 + img.src = url; 4324 + } catch (err) { 4325 + console.error('Image upload failed:', err); 4326 + } 4327 + }); 4328 + 4329 + // Post-render: populate image cell placeholders 4330 + function renderImageCells() { 4331 + grid.querySelectorAll('.cell-image-container[data-blob-id]').forEach((el: Element) => { 4332 + const td = el as HTMLElement; 4333 + const blobId = td.dataset.blobId; 4334 + if (!blobId) return; 4335 + renderCellImage(td, blobId); 4336 + }); 4337 + } 4338 + 4339 + // Render image in cell from blob ID 4340 + function renderCellImage(td: HTMLElement, blobId: string): void { 4341 + // Check cache first 4342 + if (imageCache.has(blobId)) { 4343 + const img = document.createElement('img'); 4344 + img.src = imageCache.get(blobId)!; 4345 + img.alt = ''; 4346 + img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 4347 + td.textContent = ''; 4348 + td.appendChild(img); 4349 + return; 4350 + } 4351 + 4352 + // Load async 4353 + td.textContent = '...'; 4354 + downloadBlob(blobId).then(({ data, mimeType }) => { 4355 + const url = blobToObjectUrl(data, mimeType); 4356 + imageCache.set(blobId, url); 4357 + const img = document.createElement('img'); 4358 + img.src = url; 4359 + img.alt = ''; 4360 + img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;pointer-events:none;'; 4361 + td.textContent = ''; 4362 + td.appendChild(img); 4363 + }).catch(() => { 4364 + td.textContent = '[img]'; 4365 + }); 4366 + } 4367 + 4368 + // ======================================================== 4369 + // Database Views Feature (Kanban / Gallery / Calendar) 4370 + // ======================================================== 4371 + 4372 + const dbViewSection = document.getElementById('database-view-section'); 4373 + let activeDbView: ViewConfig | null = null; 4374 + 4375 + function getCellValueForView(row: number, col: number): string { 4376 + const data = getCellData(cellId(col, row)); 4377 + if (!data) return ''; 4378 + if (data.f) { 4379 + const result = evaluateFormula(data.f); 4380 + return result == null ? '' : String(result); 4381 + } 4382 + return data.v == null ? '' : String(data.v); 4383 + } 4384 + 4385 + function getDataRowIndices(): number[] { 4386 + const sheet = getActiveSheet(); 4387 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 4388 + const indices: number[] = []; 4389 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 4390 + for (let r = 2; r <= rowCount; r++) { 4391 + let hasData = false; 4392 + for (let c = 1; c <= colCount; c++) { 4393 + const data = getCellData(cellId(c, r)); 4394 + if (data?.v !== '' && data?.v != null) { hasData = true; break; } 4395 + } 4396 + if (hasData) indices.push(r); 4397 + } 4398 + return indices; 4399 + } 4400 + 4401 + function showDbViewDialog() { 4402 + if (document.querySelector('.dbview-dialog-overlay')) return; 4403 + const overlay = document.createElement('div'); 4404 + overlay.className = 'sheet-dialog-overlay dbview-dialog-overlay'; 4405 + 4406 + const sheet = getActiveSheet(); 4407 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 4408 + const colOptions: string[] = []; 4409 + for (let c = 1; c <= colCount; c++) { 4410 + const data = getCellData(cellId(c, 1)); 4411 + const label = data?.v ? String(data.v) : colToLetter(c); 4412 + colOptions.push(`<option value="${c}">${colToLetter(c)}: ${label}</option>`); 4413 + } 4414 + 4415 + overlay.innerHTML = ` 4416 + <div class="sheet-dialog"> 4417 + <h3>Database View</h3> 4418 + <label>View Type</label> 4419 + <select id="dbview-type"> 4420 + <option value="kanban">Kanban</option> 4421 + <option value="gallery">Gallery</option> 4422 + <option value="calendar">Calendar</option> 4423 + </select> 4424 + <label>Group By / Date Column</label> 4425 + <select id="dbview-group">${colOptions.join('')}</select> 4426 + <label>Title Column</label> 4427 + <select id="dbview-title">${colOptions.join('')}</select> 4428 + <label>Display Columns</label> 4429 + <select id="dbview-display" multiple size="4">${colOptions.join('')}</select> 4430 + <div class="sheet-dialog-actions"> 4431 + <button id="dbview-cancel">Cancel</button> 4432 + <button id="dbview-ok" class="btn-primary">Open View</button> 4433 + </div> 4434 + </div> 4435 + `; 4436 + document.body.appendChild(overlay); 4437 + 4438 + overlay.querySelector('#dbview-cancel')!.addEventListener('click', () => overlay.remove()); 4439 + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 4440 + 4441 + overlay.querySelector('#dbview-ok')!.addEventListener('click', () => { 4442 + const viewType = overlay.querySelector('#dbview-type')!.value as ViewType; 4443 + const groupByColumn = Number(overlay.querySelector('#dbview-group')!.value); 4444 + const titleColumn = Number(overlay.querySelector('#dbview-title')!.value); 4445 + const displayColumns = Array.from(overlay.querySelector('#dbview-display')!.selectedOptions, o => Number(o.value)); 4446 + 4447 + activeDbView = createViewConfig(viewType, groupByColumn, titleColumn); 4448 + activeDbView.displayColumns = displayColumns; 4449 + overlay.remove(); 4450 + renderDbView(); 4451 + }); 4452 + } 4453 + 4454 + function renderDbView() { 4455 + if (!activeDbView) { 4456 + dbViewSection.style.display = 'none'; 4457 + return; 4458 + } 4459 + 4460 + dbViewSection.style.display = ''; 4461 + dbViewSection.innerHTML = ''; 4462 + 4463 + // Toolbar 4464 + const toolbar = document.createElement('div'); 4465 + toolbar.className = 'db-view-toolbar'; 4466 + toolbar.innerHTML = ` 4467 + <strong>${activeDbView.type.charAt(0).toUpperCase() + activeDbView.type.slice(1)} View</strong> 4468 + <button class="db-view-close" title="Close view">✕ Close</button> 4469 + `; 4470 + dbViewSection.appendChild(toolbar); 4471 + toolbar.querySelector('.db-view-close')!.addEventListener('click', () => { 4472 + activeDbView = null; 4473 + renderDbView(); 4474 + }); 4475 + 4476 + const rowIndices = getDataRowIndices(); 4477 + 4478 + if (activeDbView.type === 'kanban') { 4479 + renderKanbanView(rowIndices); 4480 + } else if (activeDbView.type === 'gallery') { 4481 + renderGalleryView(rowIndices); 4482 + } else if (activeDbView.type === 'calendar') { 4483 + renderCalendarView(rowIndices); 4484 + } 4485 + } 4486 + 4487 + function renderKanbanView(rowIndices: number[]) { 4488 + const columns = buildKanbanColumns(rowIndices, getCellValueForView, activeDbView!); 4489 + const board = document.createElement('div'); 4490 + board.className = 'kanban-board'; 4491 + 4492 + for (const col of columns) { 4493 + const column = document.createElement('div'); 4494 + column.className = 'kanban-column'; 4495 + column.innerHTML = ` 4496 + <div class="kanban-column-header"> 4497 + <span>${col.groupValue}</span> 4498 + <span class="kanban-column-count">${col.cards.length}</span> 4499 + </div> 4500 + `; 4501 + 4502 + for (const card of col.cards) { 4503 + const cardEl = document.createElement('div'); 4504 + cardEl.className = 'kanban-card'; 4505 + cardEl.dataset.row = String(card.rowIndex); 4506 + let fieldsHtml = ''; 4507 + for (const f of card.fields) { 4508 + const hdr = getCellData(cellId(f.columnIndex, 1)); 4509 + const label = hdr?.v ? String(hdr.v) : colToLetter(f.columnIndex); 4510 + fieldsHtml += `<div class="kanban-card-field"><span class="kanban-card-field-label">${label}:</span> ${f.value}</div>`; 4511 + } 4512 + cardEl.innerHTML = `<div class="kanban-card-title">${card.title || '(untitled)'}</div>${fieldsHtml}`; 4513 + column.appendChild(cardEl); 4514 + } 4515 + 4516 + board.appendChild(column); 4517 + } 4518 + 4519 + dbViewSection.appendChild(board); 4520 + } 4521 + 4522 + function renderGalleryView(rowIndices: number[]) { 4523 + const cards = buildGalleryCards(rowIndices, getCellValueForView, activeDbView!); 4524 + const grid = document.createElement('div'); 4525 + grid.className = 'gallery-grid'; 4526 + 4527 + for (const card of cards) { 4528 + const cardEl = document.createElement('div'); 4529 + cardEl.className = 'gallery-card'; 4530 + cardEl.dataset.row = String(card.rowIndex); 4531 + let fieldsHtml = ''; 4532 + for (const f of card.fields) { 4533 + const hdr = getCellData(cellId(f.columnIndex, 1)); 4534 + const label = hdr?.v ? String(hdr.v) : colToLetter(f.columnIndex); 4535 + fieldsHtml += `<div class="gallery-card-field"><span class="gallery-card-field-label">${label}:</span> ${f.value}</div>`; 4536 + } 4537 + cardEl.innerHTML = `<div class="gallery-card-title">${card.title || '(untitled)'}</div>${fieldsHtml}`; 4538 + grid.appendChild(cardEl); 4539 + } 4540 + 4541 + dbViewSection.appendChild(grid); 4542 + } 4543 + 4544 + function renderCalendarView(rowIndices: number[]) { 4545 + const events = buildCalendarEvents(rowIndices, getCellValueForView, activeDbView!); 4546 + const months = groupEventsByMonth(events); 4547 + 4548 + if (months.length === 0) { 4549 + dbViewSection.innerHTML += '<p style="color:var(--color-text-secondary);padding:var(--space-md)">No dates found in the selected column.</p>'; 4550 + return; 4551 + } 4552 + 4553 + for (const month of months) { 4554 + const monthEl = document.createElement('div'); 4555 + monthEl.className = 'calendar-view'; 4556 + 4557 + const monthName = new Date(month.year, month.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); 4558 + monthEl.innerHTML = `<div class="calendar-month-header">${monthName}</div>`; 4559 + 4560 + const grid = document.createElement('div'); 4561 + grid.className = 'calendar-grid'; 4562 + 4563 + // Day headers 4564 + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 4565 + for (const d of dayNames) { 4566 + const dh = document.createElement('div'); 4567 + dh.className = 'calendar-day-header'; 4568 + dh.textContent = d; 4569 + grid.appendChild(dh); 4570 + } 4571 + 4572 + // Calendar days 4573 + const firstDay = new Date(month.year, month.month, 1); 4574 + const lastDay = new Date(month.year, month.month + 1, 0); 4575 + const startOffset = firstDay.getDay(); 4576 + 4577 + // Previous month filler 4578 + for (let i = 0; i < startOffset; i++) { 4579 + const filler = document.createElement('div'); 4580 + filler.className = 'calendar-day calendar-day-other'; 4581 + grid.appendChild(filler); 4582 + } 4583 + 4584 + // Actual days 4585 + for (let d = 1; d <= lastDay.getDate(); d++) { 4586 + const dateStr = `${month.year}-${String(month.month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; 4587 + const dayEvents = eventsForDate(events, dateStr); 4588 + 4589 + const dayEl = document.createElement('div'); 4590 + dayEl.className = 'calendar-day'; 4591 + dayEl.innerHTML = `<div class="calendar-day-number">${d}</div>`; 4592 + 4593 + for (const evt of dayEvents) { 4594 + const evtEl = document.createElement('div'); 4595 + evtEl.className = 'calendar-event'; 4596 + evtEl.textContent = evt.title || '(untitled)'; 4597 + evtEl.title = evt.title; 4598 + dayEl.appendChild(evtEl); 4599 + } 4600 + 4601 + grid.appendChild(dayEl); 4602 + } 4603 + 4604 + monthEl.appendChild(grid); 4605 + dbViewSection.appendChild(monthEl); 4606 + } 4607 + } 4608 + 4609 + document.getElementById('tb-view-mode')!.addEventListener('click', () => { 4610 + showDbViewDialog(); 4611 + closeAllDropdowns(); 4612 + }); 4613 + 4614 + // ======================================================== 4047 4615 // Multi-Column Filter Feature 4048 4616 // ======================================================== 4049 4617 ··· 5742 6310 updateFormulaBar(); 5743 6311 updateMergeButtonState(); 5744 6312 renderCharts(); 6313 + renderPivots(); 5745 6314 updateStripedButtonState(); 5746 6315 renderNoteIndicators();
+1
vite.config.ts
··· 30 30 main: resolve(__dirname, 'src/index.html'), 31 31 docs: resolve(__dirname, 'src/docs/index.html'), 32 32 sheets: resolve(__dirname, 'src/sheets/index.html'), 33 + forms: resolve(__dirname, 'src/forms/index.html'), 33 34 }, 34 35 }, 35 36 },