/** * Atmosphere Forms — E2EE form builder and response viewer. * * Uses Yjs for real-time collaboration on form schema. * Responses pipeline writes answers to a linked spreadsheet. */ import { applyFeatureGates } from '../lib/feature-gate.js'; import * as Y from 'yjs'; import { importKey } from '../lib/crypto.js'; import { EncryptedProvider } from '../lib/provider.js'; applyFeatureGates(); import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; import { wireKeyWarningForSession } from '../lib/key-warning.js'; import { wireSaveStatus } from '../lib/save-status-ui.js'; import { wireStatusChips } from '../lib/status-chips.js'; import { setupTooltips } from '../lib/tooltips.js'; import { mountOfflineIndicator } from '../lib/offline-indicator.js'; import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; import { createConditionalState, type ConditionalLogicState } from './conditional-logic.js'; import { createChatSidebar, createChatState, loadConfig, initChatWiring } from '../lib/ai-chat.js'; import { createCommandPalette } from '../command-palette.js'; import { renderBuilderQuestions, showAddQuestionDialog } from './render-builder.js'; import { renderPreview } from './render-preview.js'; import { renderResponses } from './render-responses.js'; import { sendChatMessage, type ChatPanelDeps } from './ai-chat-panel.js'; // --- URL parsing --- const url = new URL(window.location.href); const pathMatch = url.pathname.match(/\/forms\/(.+)/); const docId = pathMatch?.[1] ?? ''; const keyB64 = url.hash.slice(1); if (!docId) { document.getElementById('app')!.innerHTML = '

No form ID specified.

'; throw new Error('No form ID'); } // --- Encryption --- let cryptoKey: CryptoKey | null = null; async function initCrypto() { if (keyB64) { cryptoKey = await importKey(keyB64); } } // --- Yjs setup --- const ydoc = new Y.Doc(); const yForm = ydoc.getMap('form'); const yResponses = ydoc.getArray('responses'); // --- State --- let form: FormSchema = createForm('Untitled Form'); let condLogic: ConditionalLogicState = createConditionalState(); let mode: 'builder' | 'preview' | 'responses' = 'builder'; // --- DOM refs --- const titleInput = document.getElementById('form-title') as HTMLInputElement; const descInput = document.getElementById('form-description') as HTMLTextAreaElement; const questionsContainer = document.getElementById('form-questions')!; const previewPane = document.getElementById('form-preview')!; const responsesPane = document.getElementById('form-responses-view')!; const toolbar = document.getElementById('form-toolbar')!; const addQuestionBtn = document.getElementById('btn-add-question')!; const formHeader = document.getElementById('form-header')!; // --- Sync form to Yjs --- function syncFormToYjs() { ydoc.transact(() => { yForm.set('schema', JSON.stringify(form)); yForm.set('condLogic', JSON.stringify(condLogic)); }); } function loadFormFromYjs() { const schemaStr = yForm.get('schema'); if (typeof schemaStr === 'string') { try { form = JSON.parse(schemaStr); } catch { /* keep default */ } } const logicStr = yForm.get('condLogic'); if (typeof logicStr === 'string') { try { condLogic = JSON.parse(logicStr); } catch { /* keep default */ } } } // --- Shared deps for extracted modules --- const builderDeps = { getForm: () => form, setForm: (f: FormSchema) => { form = f; }, syncToYjs: syncFormToYjs, }; // --- Mode rendering --- function renderBuilder() { toolbar.style.display = ''; // Builder mode shows + Add Question and the editable description textarea. addQuestionBtn.style.display = ''; formHeader.style.display = ''; questionsContainer.style.display = ''; previewPane.style.display = 'none'; responsesPane.style.display = 'none'; titleInput.value = form.title; descInput.value = form.description; renderBuilderQuestions(questionsContainer, builderDeps); } function showPreview() { toolbar.style.display = ''; // Hide builder-only controls so the preview matches what a respondent will // see. Keep Preview/Responses/Settings so the author can toggle back. See #715. addQuestionBtn.style.display = 'none'; formHeader.style.display = 'none'; questionsContainer.style.display = 'none'; previewPane.style.display = ''; responsesPane.style.display = 'none'; renderPreview(previewPane, { getForm: () => form, getCondLogic: () => condLogic, yResponses, }); } function showResponses() { toolbar.style.display = ''; // Responses view is author-only but also shouldn't show the editable // description textarea / Add Question button — those belong in Builder mode. addQuestionBtn.style.display = 'none'; formHeader.style.display = 'none'; questionsContainer.style.display = 'none'; previewPane.style.display = 'none'; responsesPane.style.display = ''; renderResponses(responsesPane, { getForm: () => form, yResponses, }); } // --- Toolbar events --- document.getElementById('btn-add-question')!.addEventListener('click', () => { showAddQuestionDialog(builderDeps, renderBuilder); }); document.getElementById('btn-preview')!.addEventListener('click', () => { mode = mode === 'preview' ? 'builder' : 'preview'; if (mode === 'preview') showPreview(); else renderBuilder(); }); document.getElementById('btn-responses')!.addEventListener('click', () => { mode = mode === 'responses' ? 'builder' : 'responses'; if (mode === 'responses') showResponses(); else renderBuilder(); }); document.getElementById('btn-settings')!.addEventListener('click', async () => { const { modalPrompt } = await import('../lib/modal-dialog.js'); const sheetId = await modalPrompt({ title: 'Link form to sheet', message: 'Enter the sheet document ID to send responses to. Leave empty to unlink.', initial: form.targetSheetId ?? '', placeholder: 'sheet-xxxxxxx', okLabel: 'Save', }); if (sheetId !== null) { form = setTargetSheet(form, sheetId || null); syncFormToYjs(); } }); // Title and description sync titleInput.addEventListener('change', () => { form = { ...form, title: titleInput.value, updatedAt: Date.now() }; syncFormToYjs(); }); descInput.addEventListener('change', () => { form = { ...form, description: descInput.value, updatedAt: Date.now() }; syncFormToYjs(); }); // --- Yjs observer --- yForm.observe(() => { loadFormFromYjs(); if (mode === 'builder') renderBuilder(); }); // --- AI Chat Panel --- const chatUI = createChatSidebar(); document.getElementById('main-content')!.appendChild(chatUI.container); const chatState = createChatState(); const chatDeps: ChatPanelDeps = { getForm: () => form, setForm: (f: FormSchema) => { form = f; }, syncToYjs: syncFormToYjs, renderBuilder, chatUI, chatState, chatWiring: null!, // assigned below after initChatWiring titleInput, }; const chatWiring = initChatWiring({ chatUI, chatState, chatConfig: loadConfig(), toggleBtn: document.getElementById('btn-ai-chat')!, editorType: 'form', onSend: () => sendChatMessage(chatDeps), }); chatDeps.chatWiring = chatWiring; // --- Command Palette --- createCommandPalette({ actions: [ { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, { id: 'new-form', label: 'New Form', category: 'action', icon: '\u2637', action: () => { window.open('/', '_blank'); } }, { id: 'add-question', label: 'Add Question', category: 'action', icon: '+', action: () => { document.getElementById('btn-add-question')?.click(); } }, { id: 'preview', label: 'Preview Form', category: 'action', icon: '\u25b7', action: () => { document.getElementById('btn-preview')?.click(); } }, { id: 'responses', label: 'View Responses', category: 'action', icon: '\u25a6', action: () => { document.getElementById('btn-responses')?.click(); } }, { id: 'settings', label: 'Form Settings', category: 'action', icon: '\u2699', action: () => { document.getElementById('btn-settings')?.click(); } }, ], }); // --- Initialize --- async function init() { await initCrypto(); setupTooltips(); mountOfflineIndicator(); if (cryptoKey) { const provider = new EncryptedProvider(ydoc, docId, cryptoKey); installDocGoneHandler(provider); wireKeyWarningForSession(docId, document.querySelector('.app-topbar')); wireSaveStatus({ provider, ydoc }); wireStatusChips({ provider }); provider.on('sync', () => { loadFormFromYjs(); if (form.questions.length === 0 && !yForm.has('schema')) { syncFormToYjs(); } renderBuilder(); }); } else { renderBuilder(); } } init();