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

Configure Feed

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

feat: add personal calendar with E2EE and make passphrase opt-in (#305)

scott 781917a1 ba458219

+2360 -24
+1
server/index.ts
··· 126 126 app.get('/forms/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'forms/index.html')); }); 127 127 app.get('/slides/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'slides/index.html')); }); 128 128 app.get('/diagrams/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'diagrams/index.html')); }); 129 + app.get('/calendar/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'calendar/index.html')); }); 129 130 app.get('*', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'index.html')); }); 130 131 131 132 // --- HTTP + HTTPS + WebSocket server ---
+1 -1
server/routes/documents.ts
··· 75 75 const id = randomUUID(); 76 76 const { type, name_encrypted } = req.body; 77 77 if (!isValidDocType(type)) { 78 - res.status(400).json({ error: 'type must be doc, sheet, form, slide, or diagram' }); 78 + res.status(400).json({ error: 'type must be doc, sheet, form, slide, diagram, or calendar' }); 79 79 return; 80 80 } 81 81 const owner = req.tsUser?.login || null;
+2 -2
server/validation.ts
··· 48 48 } 49 49 50 50 /** Validate a document type */ 51 - export function isValidDocType(type: unknown): type is 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' { 52 - return typeof type === 'string' && ['doc', 'sheet', 'form', 'slide', 'diagram'].includes(type); 51 + export function isValidDocType(type: unknown): type is 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' | 'calendar' { 52 + return typeof type === 'string' && ['doc', 'sheet', 'form', 'slide', 'diagram', 'calendar'].includes(type); 53 53 } 54 54 55 55 /** Validate a MIME type string (basic format check) */
+134
src/calendar/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, viewport-fit=cover"> 6 + <link rel="manifest" href="/manifest.json"> 7 + <meta name="description" content="E2EE calendar. End-to-end encrypted, real-time collaboration."> 8 + <meta property="og:title" content="Tools — Calendar"> 9 + <meta property="og:description" content="E2EE calendar. End-to-end encrypted, real-time collaboration."> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:image" content="/favicon.svg"> 12 + <title>Tools — Calendar</title> 13 + <meta name="theme-color" content="#3a8a7a"> 14 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 15 + <link rel="apple-touch-icon" href="/favicon.svg"> 16 + <link rel="stylesheet" href="../css/app.css"> 17 + <script> 18 + (function() { 19 + var saved = localStorage.getItem('tools-theme'); 20 + if (saved === 'dark' || saved === 'light') { 21 + document.documentElement.setAttribute('data-theme', saved); 22 + } 23 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 24 + document.addEventListener('keydown', function(e) { 25 + if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 26 + }); 27 + document.addEventListener('mousedown', function() { 28 + document.documentElement.removeAttribute('data-a11y-focus'); 29 + }); 30 + })(); 31 + </script> 32 + </head> 33 + <body> 34 + <a class="skip-link" href="#main-content">Skip to content</a> 35 + <div class="calendar-app" id="app"> 36 + <!-- Top bar --> 37 + <div class="app-topbar"> 38 + <a class="app-logo" href="/">Tools</a> 39 + <input class="doc-title-input" id="calendar-title" type="text" value="Untitled Calendar" spellcheck="false"> 40 + <span class="topbar-spacer"></span> 41 + <span class="save-status" id="save-status"></span> 42 + <div class="calendar-view-switcher"> 43 + <button class="btn-secondary calendar-view-btn active" data-view="month">Month</button> 44 + <button class="btn-secondary calendar-view-btn" data-view="week">Week</button> 45 + <button class="btn-secondary calendar-view-btn" data-view="day">Day</button> 46 + <button class="btn-secondary calendar-view-btn" data-view="agenda">Agenda</button> 47 + </div> 48 + <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 49 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 50 + </button> 51 + </div> 52 + 53 + <main class="calendar-main" id="main-content"> 54 + <!-- Calendar toolbar --> 55 + <div class="calendar-toolbar" id="calendar-toolbar"> 56 + <button class="btn-secondary" id="btn-today" title="Go to today">Today</button> 57 + <button class="btn-icon" id="btn-prev" title="Previous period">&#9664;</button> 58 + <button class="btn-icon" id="btn-next" title="Next period">&#9654;</button> 59 + <span class="calendar-current-label" id="current-label"></span> 60 + <span class="topbar-spacer"></span> 61 + <div class="calendar-toolbar-views"> 62 + <button class="btn-icon calendar-toolbar-view active" data-view="month" title="Month view">Month</button> 63 + <button class="btn-icon calendar-toolbar-view" data-view="week" title="Week view">Week</button> 64 + <button class="btn-icon calendar-toolbar-view" data-view="day" title="Day view">Day</button> 65 + <button class="btn-icon calendar-toolbar-view" data-view="agenda" title="Agenda view">Agenda</button> 66 + </div> 67 + </div> 68 + 69 + <!-- Calendar grid --> 70 + <div class="calendar-grid" id="calendar-grid"></div> 71 + </main> 72 + 73 + <!-- Event modal --> 74 + <div class="modal-backdrop" id="event-modal-backdrop" style="display:none"> 75 + <div class="event-modal" id="event-modal" role="dialog" aria-labelledby="event-modal-title" aria-modal="true"> 76 + <h2 class="event-modal-title" id="event-modal-title">New Event</h2> 77 + 78 + <div class="event-modal-field"> 79 + <label for="event-title">Title</label> 80 + <input type="text" id="event-title" class="event-modal-input" placeholder="Event title" autofocus> 81 + </div> 82 + 83 + <div class="event-modal-field"> 84 + <label for="event-date">Date</label> 85 + <input type="date" id="event-date" class="event-modal-input"> 86 + </div> 87 + 88 + <div class="event-modal-row"> 89 + <div class="event-modal-field"> 90 + <label for="event-start-time">Start</label> 91 + <input type="time" id="event-start-time" class="event-modal-input"> 92 + </div> 93 + <div class="event-modal-field"> 94 + <label for="event-end-time">End</label> 95 + <input type="time" id="event-end-time" class="event-modal-input"> 96 + </div> 97 + </div> 98 + 99 + <div class="event-modal-field event-modal-checkbox"> 100 + <input type="checkbox" id="event-all-day"> 101 + <label for="event-all-day">All day</label> 102 + </div> 103 + 104 + <div class="event-modal-field"> 105 + <label>Color</label> 106 + <div class="event-color-picker" id="event-color-picker"> 107 + <button class="event-color-swatch active" data-color="#3a8a7a" style="background:#3a8a7a" title="Teal"></button> 108 + <button class="event-color-swatch" data-color="#e85d4a" style="background:#e85d4a" title="Red"></button> 109 + <button class="event-color-swatch" data-color="#e8a94a" style="background:#e8a94a" title="Amber"></button> 110 + <button class="event-color-swatch" data-color="#4a7ee8" style="background:#4a7ee8" title="Blue"></button> 111 + <button class="event-color-swatch" data-color="#8a4ae8" style="background:#8a4ae8" title="Purple"></button> 112 + <button class="event-color-swatch" data-color="#4aad5b" style="background:#4aad5b" title="Green"></button> 113 + </div> 114 + </div> 115 + 116 + <div class="event-modal-field"> 117 + <label for="event-description">Description</label> 118 + <textarea id="event-description" class="event-modal-input event-modal-textarea" rows="3" placeholder="Add a description..."></textarea> 119 + </div> 120 + 121 + <div class="event-modal-actions"> 122 + <button class="btn-danger" id="btn-event-delete" title="Delete event" style="display:none">Delete</button> 123 + <span class="topbar-spacer"></span> 124 + <button class="btn-secondary" id="btn-event-cancel">Cancel</button> 125 + <button class="btn-primary" id="btn-event-save">Save</button> 126 + </div> 127 + </div> 128 + </div> 129 + </div> 130 + 131 + <div class="version-badge">v%APP_VERSION%</div> 132 + <script type="module" src="./main.ts"></script> 133 + </body> 134 + </html>
+1030
src/calendar/main.ts
··· 1 + /** 2 + * Tools Calendar — E2EE collaborative calendar. 3 + * 4 + * Backed by Yjs for real-time collaboration. Events stored as JSON 5 + * strings in a Y.Array, encrypted before leaving the browser. 6 + * 7 + * Views: month, week, day, agenda — all rendered into #calendar-grid. 8 + */ 9 + 10 + import * as Y from 'yjs'; 11 + import { importKey } from '../lib/crypto.js'; 12 + import { EncryptedProvider } from '../lib/provider.js'; 13 + import { setupTooltips } from '../lib/tooltips.js'; 14 + import { createCommandPalette } from '../command-palette.js'; 15 + 16 + // --------------------------------------------------------------------------- 17 + // Types 18 + // --------------------------------------------------------------------------- 19 + 20 + interface CalendarEvent { 21 + id: string; 22 + title: string; 23 + date: string; // YYYY-MM-DD 24 + startTime: string; // HH:MM (24h) or '' for all-day 25 + endTime: string; // HH:MM (24h) or '' for all-day 26 + allDay: boolean; 27 + color: string; 28 + description: string; 29 + createdAt: number; 30 + updatedAt: number; 31 + } 32 + 33 + type CalendarView = 'month' | 'week' | 'day' | 'agenda'; 34 + 35 + interface CalendarState { 36 + currentDate: Date; 37 + view: CalendarView; 38 + events: CalendarEvent[]; 39 + selectedEventId: string | null; 40 + cryptoKey: CryptoKey | null; 41 + docId: string; 42 + } 43 + 44 + const EVENT_COLORS = ['#4a90d9', '#e06666', '#6aa84f', '#f1c232', '#8e7cc3', '#e69138']; 45 + 46 + const HOUR_HEIGHT = 60; // px per hour row in week/day views 47 + const MAX_VISIBLE_PILLS = 3; 48 + const AGENDA_DAYS = 30; 49 + const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 50 + const MONTHS = [ 51 + 'January', 'February', 'March', 'April', 'May', 'June', 52 + 'July', 'August', 'September', 'October', 'November', 'December', 53 + ]; 54 + const MONTHS_SHORT = [ 55 + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 56 + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 57 + ]; 58 + 59 + // --------------------------------------------------------------------------- 60 + // URL parsing 61 + // --------------------------------------------------------------------------- 62 + 63 + const pageUrl = new URL(window.location.href); 64 + const pathMatch = pageUrl.pathname.match(/\/calendar\/(.+)/); 65 + const docId = pathMatch?.[1] ?? ''; 66 + const keyB64 = pageUrl.hash.slice(1); 67 + 68 + if (!docId) { 69 + document.getElementById('app')!.innerHTML = '<p style="padding:2rem">No calendar ID specified.</p>'; 70 + throw new Error('No calendar ID'); 71 + } 72 + 73 + // --------------------------------------------------------------------------- 74 + // Encryption 75 + // --------------------------------------------------------------------------- 76 + 77 + let cryptoKey: CryptoKey | null = null; 78 + 79 + async function initCrypto(): Promise<void> { 80 + if (keyB64) { 81 + cryptoKey = await importKey(keyB64); 82 + } 83 + } 84 + 85 + // --------------------------------------------------------------------------- 86 + // Yjs setup 87 + // --------------------------------------------------------------------------- 88 + 89 + const ydoc = new Y.Doc(); 90 + const yEvents = ydoc.getArray<string>('events'); 91 + 92 + // --------------------------------------------------------------------------- 93 + // State 94 + // --------------------------------------------------------------------------- 95 + 96 + const state: CalendarState = { 97 + currentDate: new Date(), 98 + view: window.innerWidth < 768 ? 'agenda' : 'month', 99 + events: [], 100 + selectedEventId: null, 101 + cryptoKey: null, 102 + docId, 103 + }; 104 + 105 + // --------------------------------------------------------------------------- 106 + // DOM refs 107 + // --------------------------------------------------------------------------- 108 + 109 + const calendarGrid = document.getElementById('calendar-grid')!; 110 + const titleInput = document.getElementById('calendar-title') as HTMLInputElement; 111 + const dateLabel = document.getElementById('current-label')!; 112 + const modalBackdrop = document.getElementById('event-modal-backdrop') as HTMLElement; 113 + 114 + // Modal form fields 115 + const modalTitle = document.getElementById('event-title') as HTMLInputElement; 116 + const modalDate = document.getElementById('event-date') as HTMLInputElement; 117 + const modalStartTime = document.getElementById('event-start-time') as HTMLInputElement; 118 + const modalEndTime = document.getElementById('event-end-time') as HTMLInputElement; 119 + const modalAllDay = document.getElementById('event-all-day') as HTMLInputElement; 120 + const modalColorPicker = document.getElementById('event-color-picker') as HTMLElement; 121 + const modalDescription = document.getElementById('event-description') as HTMLTextAreaElement; 122 + const modalSave = document.getElementById('btn-event-save') as HTMLButtonElement; 123 + const modalDelete = document.getElementById('btn-event-delete') as HTMLButtonElement; 124 + const modalCancel = document.getElementById('btn-event-cancel') as HTMLButtonElement; 125 + const modalTitleEl = document.getElementById('event-modal-title') as HTMLElement; 126 + 127 + // --------------------------------------------------------------------------- 128 + // Helpers 129 + // --------------------------------------------------------------------------- 130 + 131 + function escapeHtml(text: string): string { 132 + const div = document.createElement('div'); 133 + div.textContent = text; 134 + return div.innerHTML; 135 + } 136 + 137 + function formatDate(d: Date): string { 138 + const y = d.getFullYear(); 139 + const m = String(d.getMonth() + 1).padStart(2, '0'); 140 + const day = String(d.getDate()).padStart(2, '0'); 141 + return `${y}-${m}-${day}`; 142 + } 143 + 144 + function parseEventDate(dateStr: string): Date { 145 + const parts = dateStr.split('-').map(Number); 146 + const y = parts[0] ?? 0; 147 + const m = parts[1] ?? 1; 148 + const d = parts[2] ?? 1; 149 + return new Date(y, m - 1, d); 150 + } 151 + 152 + function isSameDay(a: Date, b: Date): boolean { 153 + return a.getFullYear() === b.getFullYear() && 154 + a.getMonth() === b.getMonth() && 155 + a.getDate() === b.getDate(); 156 + } 157 + 158 + function isToday(d: Date): boolean { 159 + return isSameDay(d, new Date()); 160 + } 161 + 162 + function getWeekStart(d: Date): Date { 163 + const result = new Date(d); 164 + result.setDate(result.getDate() - result.getDay()); 165 + return result; 166 + } 167 + 168 + function getWeekEnd(d: Date): Date { 169 + const start = getWeekStart(d); 170 + const end = new Date(start); 171 + end.setDate(end.getDate() + 6); 172 + return end; 173 + } 174 + 175 + function daysInMonth(year: number, month: number): number { 176 + return new Date(year, month + 1, 0).getDate(); 177 + } 178 + 179 + function timeToMinutes(time: string): number { 180 + if (!time) return 0; 181 + const parts = time.split(':').map(Number); 182 + const h = parts[0] ?? 0; 183 + const m = parts[1] ?? 0; 184 + return h * 60 + m; 185 + } 186 + 187 + function minutesToTime(minutes: number): string { 188 + const h = Math.floor(minutes / 60); 189 + const m = minutes % 60; 190 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; 191 + } 192 + 193 + function formatTimeDisplay(time: string): string { 194 + if (!time) return ''; 195 + const parts = time.split(':'); 196 + const hStr = parts[0] ?? '0'; 197 + const mStr = parts[1] ?? '00'; 198 + const h = parseInt(hStr, 10); 199 + const ampm = h >= 12 ? 'pm' : 'am'; 200 + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; 201 + return mStr === '00' ? `${h12}${ampm}` : `${h12}:${mStr}${ampm}`; 202 + } 203 + 204 + function eventsOnDate(dateStr: string): CalendarEvent[] { 205 + return state.events 206 + .filter(e => e.date === dateStr) 207 + .sort((a, b) => { 208 + // All-day events first, then by start time 209 + if (a.allDay && !b.allDay) return -1; 210 + if (!a.allDay && b.allDay) return 1; 211 + return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); 212 + }); 213 + } 214 + 215 + function getDayOfWeekFull(d: Date): string { 216 + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 217 + return days[d.getDay()] ?? 'Sunday'; 218 + } 219 + 220 + // --------------------------------------------------------------------------- 221 + // Yjs sync 222 + // --------------------------------------------------------------------------- 223 + 224 + function loadEventsFromYjs(): void { 225 + const items: CalendarEvent[] = []; 226 + for (let i = 0; i < yEvents.length; i++) { 227 + try { 228 + items.push(JSON.parse(yEvents.get(i))); 229 + } catch { /* skip corrupt entries */ } 230 + } 231 + state.events = items; 232 + } 233 + 234 + function findYjsIndex(eventId: string): number { 235 + for (let i = 0; i < yEvents.length; i++) { 236 + try { 237 + const e = JSON.parse(yEvents.get(i)); 238 + if (e.id === eventId) return i; 239 + } catch { /* skip */ } 240 + } 241 + return -1; 242 + } 243 + 244 + function addEventToYjs(event: CalendarEvent): void { 245 + ydoc.transact(() => { 246 + yEvents.push([JSON.stringify(event)]); 247 + }); 248 + } 249 + 250 + function updateEventInYjs(event: CalendarEvent): void { 251 + const idx = findYjsIndex(event.id); 252 + if (idx === -1) return; 253 + ydoc.transact(() => { 254 + yEvents.delete(idx, 1); 255 + yEvents.insert(idx, [JSON.stringify(event)]); 256 + }); 257 + } 258 + 259 + function deleteEventFromYjs(eventId: string): void { 260 + const idx = findYjsIndex(eventId); 261 + if (idx === -1) return; 262 + ydoc.transact(() => { 263 + yEvents.delete(idx, 1); 264 + }); 265 + } 266 + 267 + // Observe remote changes 268 + yEvents.observe(() => { 269 + loadEventsFromYjs(); 270 + renderView(); 271 + }); 272 + 273 + // --------------------------------------------------------------------------- 274 + // Date label 275 + // --------------------------------------------------------------------------- 276 + 277 + function updateDateLabel(): void { 278 + const d = state.currentDate; 279 + switch (state.view) { 280 + case 'month': 281 + dateLabel.textContent = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; 282 + break; 283 + case 'week': { 284 + const ws = getWeekStart(d); 285 + const we = getWeekEnd(d); 286 + if (ws.getMonth() === we.getMonth()) { 287 + dateLabel.textContent = `${MONTHS_SHORT[ws.getMonth()]} ${ws.getDate()}\u2013${we.getDate()}, ${we.getFullYear()}`; 288 + } else if (ws.getFullYear() === we.getFullYear()) { 289 + dateLabel.textContent = `${MONTHS_SHORT[ws.getMonth()]} ${ws.getDate()} \u2013 ${MONTHS_SHORT[we.getMonth()]} ${we.getDate()}, ${we.getFullYear()}`; 290 + } else { 291 + dateLabel.textContent = `${MONTHS_SHORT[ws.getMonth()]} ${ws.getDate()}, ${ws.getFullYear()} \u2013 ${MONTHS_SHORT[we.getMonth()]} ${we.getDate()}, ${we.getFullYear()}`; 292 + } 293 + break; 294 + } 295 + case 'day': 296 + dateLabel.textContent = `${getDayOfWeekFull(d)}, ${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; 297 + break; 298 + case 'agenda': 299 + dateLabel.textContent = 'Upcoming Events'; 300 + break; 301 + } 302 + } 303 + 304 + // --------------------------------------------------------------------------- 305 + // Navigation 306 + // --------------------------------------------------------------------------- 307 + 308 + function navigatePrev(): void { 309 + const d = state.currentDate; 310 + switch (state.view) { 311 + case 'month': 312 + state.currentDate = new Date(d.getFullYear(), d.getMonth() - 1, 1); 313 + break; 314 + case 'week': 315 + state.currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 7); 316 + break; 317 + case 'day': 318 + state.currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1); 319 + break; 320 + case 'agenda': 321 + state.currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 7); 322 + break; 323 + } 324 + renderView(); 325 + } 326 + 327 + function navigateNext(): void { 328 + const d = state.currentDate; 329 + switch (state.view) { 330 + case 'month': 331 + state.currentDate = new Date(d.getFullYear(), d.getMonth() + 1, 1); 332 + break; 333 + case 'week': 334 + state.currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7); 335 + break; 336 + case 'day': 337 + state.currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1); 338 + break; 339 + case 'agenda': 340 + state.currentDate = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7); 341 + break; 342 + } 343 + renderView(); 344 + } 345 + 346 + function goToday(): void { 347 + state.currentDate = new Date(); 348 + renderView(); 349 + } 350 + 351 + function setView(view: CalendarView): void { 352 + state.view = view; 353 + 354 + // Update active state on view buttons 355 + document.querySelectorAll('[data-view]').forEach(btn => { 356 + btn.classList.toggle('active', (btn as HTMLElement).dataset.view === view); 357 + }); 358 + 359 + renderView(); 360 + } 361 + 362 + // --------------------------------------------------------------------------- 363 + // Render dispatcher 364 + // --------------------------------------------------------------------------- 365 + 366 + function renderView(): void { 367 + updateDateLabel(); 368 + switch (state.view) { 369 + case 'month': renderMonthView(); break; 370 + case 'week': renderWeekView(); break; 371 + case 'day': renderDayView(); break; 372 + case 'agenda': renderAgendaView(); break; 373 + } 374 + } 375 + 376 + // --------------------------------------------------------------------------- 377 + // Month View 378 + // --------------------------------------------------------------------------- 379 + 380 + function renderMonthView(): void { 381 + const d = state.currentDate; 382 + const year = d.getFullYear(); 383 + const month = d.getMonth(); 384 + const firstDay = new Date(year, month, 1).getDay(); // 0=Sun 385 + const totalDays = daysInMonth(year, month); 386 + const prevMonthDays = daysInMonth(year, month - 1); 387 + 388 + let html = '<div class="cal-month-grid">'; 389 + 390 + // Day-of-week headers 391 + for (const day of DAYS_OF_WEEK) { 392 + html += `<div class="cal-day-header">${day}</div>`; 393 + } 394 + 395 + // Calculate how many rows we need 396 + const totalCells = Math.ceil((firstDay + totalDays) / 7) * 7; 397 + 398 + for (let i = 0; i < totalCells; i++) { 399 + const dayNum = i - firstDay + 1; 400 + let cellDate: Date; 401 + let otherMonth = false; 402 + let displayNum: number; 403 + 404 + if (dayNum < 1) { 405 + // Previous month 406 + displayNum = prevMonthDays + dayNum; 407 + cellDate = new Date(year, month - 1, displayNum); 408 + otherMonth = true; 409 + } else if (dayNum > totalDays) { 410 + // Next month 411 + displayNum = dayNum - totalDays; 412 + cellDate = new Date(year, month + 1, displayNum); 413 + otherMonth = true; 414 + } else { 415 + displayNum = dayNum; 416 + cellDate = new Date(year, month, dayNum); 417 + } 418 + 419 + const dateStr = formatDate(cellDate); 420 + const todayClass = isToday(cellDate) ? ' cal-day-today' : ''; 421 + const otherClass = otherMonth ? ' cal-day-other-month' : ''; 422 + const dayEvents = eventsOnDate(dateStr); 423 + 424 + html += `<div class="cal-day-cell${todayClass}${otherClass}" data-date="${dateStr}">`; 425 + html += `<div class="cal-day-number">${displayNum}</div>`; 426 + 427 + const visible = dayEvents.slice(0, MAX_VISIBLE_PILLS); 428 + for (const evt of visible) { 429 + const timeStr = evt.allDay ? '' : formatTimeDisplay(evt.startTime); 430 + const timeLabel = timeStr ? `<span class="cal-pill-time">${escapeHtml(timeStr)}</span> ` : ''; 431 + html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 432 + html += `${timeLabel}${escapeHtml(evt.title || 'Untitled')}`; 433 + html += '</div>'; 434 + } 435 + 436 + if (dayEvents.length > MAX_VISIBLE_PILLS) { 437 + const remaining = dayEvents.length - MAX_VISIBLE_PILLS; 438 + html += `<div class="cal-more-link" data-date="${dateStr}">+${remaining} more</div>`; 439 + } 440 + 441 + html += '</div>'; 442 + } 443 + 444 + html += '</div>'; 445 + calendarGrid.innerHTML = html; 446 + } 447 + 448 + // --------------------------------------------------------------------------- 449 + // Week View 450 + // --------------------------------------------------------------------------- 451 + 452 + function renderWeekView(): void { 453 + const weekStart = getWeekStart(state.currentDate); 454 + 455 + // Collect days and their events 456 + const days: { date: Date; dateStr: string; allDay: CalendarEvent[]; timed: CalendarEvent[] }[] = []; 457 + for (let i = 0; i < 7; i++) { 458 + const d = new Date(weekStart); 459 + d.setDate(d.getDate() + i); 460 + const dateStr = formatDate(d); 461 + const dayEvts = eventsOnDate(dateStr); 462 + days.push({ 463 + date: d, 464 + dateStr, 465 + allDay: dayEvts.filter(e => e.allDay), 466 + timed: dayEvts.filter(e => !e.allDay), 467 + }); 468 + } 469 + 470 + let html = '<div class="cal-week-grid">'; 471 + 472 + // Header row with day labels 473 + html += '<div class="cal-week-header">'; 474 + html += '<div class="cal-time-gutter cal-week-header-gutter"></div>'; 475 + for (const day of days) { 476 + const todayClass = isToday(day.date) ? ' cal-day-today' : ''; 477 + html += `<div class="cal-week-day-header${todayClass}">`; 478 + html += `<span class="cal-week-day-name">${DAYS_OF_WEEK[day.date.getDay()]}</span>`; 479 + html += `<span class="cal-week-day-num">${day.date.getDate()}</span>`; 480 + html += '</div>'; 481 + } 482 + html += '</div>'; 483 + 484 + // All-day event bar 485 + const hasAllDay = days.some(d => d.allDay.length > 0); 486 + if (hasAllDay) { 487 + html += '<div class="cal-allday-bar">'; 488 + html += '<div class="cal-time-gutter cal-allday-label">All day</div>'; 489 + for (const day of days) { 490 + html += `<div class="cal-allday-cell" data-date="${day.dateStr}">`; 491 + for (const evt of day.allDay) { 492 + html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 493 + html += escapeHtml(evt.title || 'Untitled'); 494 + html += '</div>'; 495 + } 496 + html += '</div>'; 497 + } 498 + html += '</div>'; 499 + } 500 + 501 + // Hour grid 502 + html += '<div class="cal-week-body">'; 503 + html += '<div class="cal-time-gutter">'; 504 + for (let h = 0; h < 24; h++) { 505 + const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; 506 + html += `<div class="cal-hour-label" style="height:${HOUR_HEIGHT}px">${label}</div>`; 507 + } 508 + html += '</div>'; 509 + 510 + for (const day of days) { 511 + const todayClass = isToday(day.date) ? ' cal-day-today' : ''; 512 + html += `<div class="cal-week-day-col${todayClass}" data-date="${day.dateStr}">`; 513 + 514 + // Hour rows (click targets) 515 + for (let h = 0; h < 24; h++) { 516 + html += `<div class="cal-hour-row" data-date="${day.dateStr}" data-hour="${h}" style="height:${HOUR_HEIGHT}px"></div>`; 517 + } 518 + 519 + // Positioned timed events 520 + for (const evt of day.timed) { 521 + const startMin = timeToMinutes(evt.startTime); 522 + const endMin = evt.endTime ? timeToMinutes(evt.endTime) : startMin + 60; 523 + const top = (startMin / 60) * HOUR_HEIGHT; 524 + const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 525 + html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;--pill-color:${evt.color}">`; 526 + html += `<div class="cal-event-block-time">${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}</div>`; 527 + html += `<div class="cal-event-block-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 528 + html += '</div>'; 529 + } 530 + 531 + html += '</div>'; 532 + } 533 + 534 + html += '</div>'; // cal-week-body 535 + html += '</div>'; // cal-week-grid 536 + calendarGrid.innerHTML = html; 537 + } 538 + 539 + // --------------------------------------------------------------------------- 540 + // Day View 541 + // --------------------------------------------------------------------------- 542 + 543 + function renderDayView(): void { 544 + const d = state.currentDate; 545 + const dateStr = formatDate(d); 546 + const dayEvts = eventsOnDate(dateStr); 547 + const allDay = dayEvts.filter(e => e.allDay); 548 + const timed = dayEvts.filter(e => !e.allDay); 549 + 550 + let html = '<div class="cal-day-grid">'; 551 + 552 + // All-day section 553 + if (allDay.length > 0) { 554 + html += '<div class="cal-allday-bar cal-day-allday">'; 555 + html += '<div class="cal-time-gutter cal-allday-label">All day</div>'; 556 + html += '<div class="cal-allday-cell">'; 557 + for (const evt of allDay) { 558 + html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 559 + html += escapeHtml(evt.title || 'Untitled'); 560 + html += '</div>'; 561 + } 562 + html += '</div>'; 563 + html += '</div>'; 564 + } 565 + 566 + // Hour grid 567 + html += '<div class="cal-day-body">'; 568 + html += '<div class="cal-time-gutter">'; 569 + for (let h = 0; h < 24; h++) { 570 + const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; 571 + html += `<div class="cal-hour-label" style="height:${HOUR_HEIGHT}px">${label}</div>`; 572 + } 573 + html += '</div>'; 574 + 575 + html += `<div class="cal-day-col" data-date="${dateStr}">`; 576 + 577 + // Hour rows 578 + for (let h = 0; h < 24; h++) { 579 + html += `<div class="cal-hour-row" data-date="${dateStr}" data-hour="${h}" style="height:${HOUR_HEIGHT}px"></div>`; 580 + } 581 + 582 + // Positioned timed events 583 + for (const evt of timed) { 584 + const startMin = timeToMinutes(evt.startTime); 585 + const endMin = evt.endTime ? timeToMinutes(evt.endTime) : startMin + 60; 586 + const top = (startMin / 60) * HOUR_HEIGHT; 587 + const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 588 + html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;--pill-color:${evt.color}">`; 589 + html += `<div class="cal-event-block-time">${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}</div>`; 590 + html += `<div class="cal-event-block-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 591 + html += '</div>'; 592 + } 593 + 594 + html += '</div>'; // cal-day-col 595 + html += '</div>'; // cal-day-body 596 + html += '</div>'; // cal-day-grid 597 + calendarGrid.innerHTML = html; 598 + } 599 + 600 + // --------------------------------------------------------------------------- 601 + // Agenda View 602 + // --------------------------------------------------------------------------- 603 + 604 + function renderAgendaView(): void { 605 + const start = new Date(state.currentDate); 606 + start.setHours(0, 0, 0, 0); 607 + 608 + const end = new Date(start); 609 + end.setDate(end.getDate() + AGENDA_DAYS); 610 + 611 + // Collect events in the range, grouped by date 612 + const dateGroups: Map<string, CalendarEvent[]> = new Map(); 613 + for (const evt of state.events) { 614 + const evtDate = parseEventDate(evt.date); 615 + if (evtDate >= start && evtDate < end) { 616 + const dateStr = evt.date; 617 + if (!dateGroups.has(dateStr)) { 618 + dateGroups.set(dateStr, []); 619 + } 620 + dateGroups.get(dateStr)!.push(evt); 621 + } 622 + } 623 + 624 + // Sort dates 625 + const sortedDates = Array.from(dateGroups.keys()).sort(); 626 + 627 + if (sortedDates.length === 0) { 628 + calendarGrid.innerHTML = ` 629 + <div class="cal-agenda"> 630 + <div class="cal-agenda-empty"> 631 + <p>No upcoming events in the next ${AGENDA_DAYS} days.</p> 632 + <button class="btn-secondary cal-new-event-btn">Create Event</button> 633 + </div> 634 + </div>`; 635 + return; 636 + } 637 + 638 + let html = '<div class="cal-agenda">'; 639 + 640 + for (const dateStr of sortedDates) { 641 + const d = parseEventDate(dateStr); 642 + const dayLabel = isToday(d) ? 'Today' : `${getDayOfWeekFull(d)}, ${MONTHS[d.getMonth()]} ${d.getDate()}`; 643 + const todayClass = isToday(d) ? ' cal-date-header-today' : ''; 644 + 645 + html += `<div class="cal-date-header${todayClass}">${escapeHtml(dayLabel)}</div>`; 646 + 647 + const events = dateGroups.get(dateStr)!; 648 + events.sort((a, b) => { 649 + if (a.allDay && !b.allDay) return -1; 650 + if (!a.allDay && b.allDay) return 1; 651 + return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); 652 + }); 653 + 654 + for (const evt of events) { 655 + const timeStr = evt.allDay 656 + ? 'All day' 657 + : `${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}`; 658 + const desc = evt.description 659 + ? `<div class="cal-agenda-desc">${escapeHtml(evt.description.slice(0, 120))}${evt.description.length > 120 ? '\u2026' : ''}</div>` 660 + : ''; 661 + 662 + html += `<div class="cal-agenda-item" data-event-id="${escapeHtml(evt.id)}">`; 663 + html += `<span class="cal-agenda-color" style="background:${evt.color}"></span>`; 664 + html += '<div class="cal-agenda-content">'; 665 + html += `<div class="cal-agenda-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 666 + html += `<div class="cal-agenda-time">${escapeHtml(timeStr)}</div>`; 667 + html += desc; 668 + html += '</div>'; 669 + html += '</div>'; 670 + } 671 + } 672 + 673 + html += '</div>'; 674 + calendarGrid.innerHTML = html; 675 + } 676 + 677 + // --------------------------------------------------------------------------- 678 + // Event Modal 679 + // --------------------------------------------------------------------------- 680 + 681 + let editingEventId: string | null = null; 682 + 683 + function openModal(defaults?: Partial<CalendarEvent>): void { 684 + editingEventId = defaults?.id ?? null; 685 + 686 + const existing = editingEventId 687 + ? state.events.find(e => e.id === editingEventId) 688 + : null; 689 + 690 + const defaultColor = EVENT_COLORS[0] ?? '#4a90d9'; 691 + const evt = existing ?? { 692 + title: '', 693 + date: formatDate(state.currentDate), 694 + startTime: '09:00', 695 + endTime: '10:00', 696 + allDay: false, 697 + color: defaultColor, 698 + description: '', 699 + ...defaults, 700 + }; 701 + 702 + modalTitle.value = evt.title ?? ''; 703 + modalDate.value = evt.date ?? formatDate(state.currentDate); 704 + modalStartTime.value = evt.startTime ?? '09:00'; 705 + modalEndTime.value = evt.endTime ?? '10:00'; 706 + modalAllDay.checked = evt.allDay ?? false; 707 + modalDescription.value = evt.description ?? ''; 708 + 709 + // Set active color swatch 710 + const activeColor = evt.color ?? defaultColor; 711 + modalColorPicker.querySelectorAll('.event-color-swatch').forEach(swatch => { 712 + swatch.classList.toggle('active', (swatch as HTMLElement).dataset.color === activeColor); 713 + }); 714 + 715 + // Toggle time fields visibility based on all-day 716 + updateTimeFieldsVisibility(); 717 + 718 + // Show/hide delete button 719 + modalDelete.style.display = existing ? '' : 'none'; 720 + 721 + // Update modal title 722 + modalTitleEl.textContent = existing ? 'Edit Event' : 'New Event'; 723 + 724 + modalBackdrop.style.display = ''; 725 + modalTitle.focus(); 726 + } 727 + 728 + function closeModal(): void { 729 + modalBackdrop.style.display = 'none'; 730 + editingEventId = null; 731 + } 732 + 733 + function updateTimeFieldsVisibility(): void { 734 + const hidden = modalAllDay.checked; 735 + modalStartTime.parentElement!.style.display = hidden ? 'none' : ''; 736 + modalEndTime.parentElement!.style.display = hidden ? 'none' : ''; 737 + } 738 + 739 + function saveEvent(): void { 740 + const now = Date.now(); 741 + const event: CalendarEvent = { 742 + id: editingEventId ?? crypto.randomUUID(), 743 + title: modalTitle.value.trim() || 'Untitled', 744 + date: modalDate.value, 745 + startTime: modalAllDay.checked ? '' : modalStartTime.value, 746 + endTime: modalAllDay.checked ? '' : modalEndTime.value, 747 + allDay: modalAllDay.checked, 748 + color: (modalColorPicker.querySelector('.event-color-swatch.active') as HTMLElement)?.dataset.color || EVENT_COLORS[0] || '#4a90d9', 749 + description: modalDescription.value.trim(), 750 + createdAt: editingEventId 751 + ? (state.events.find(e => e.id === editingEventId)?.createdAt ?? now) 752 + : now, 753 + updatedAt: now, 754 + }; 755 + 756 + if (editingEventId) { 757 + updateEventInYjs(event); 758 + } else { 759 + addEventToYjs(event); 760 + } 761 + 762 + closeModal(); 763 + } 764 + 765 + function deleteEvent(): void { 766 + if (!editingEventId) return; 767 + if (!confirm('Delete this event?')) return; 768 + deleteEventFromYjs(editingEventId); 769 + closeModal(); 770 + } 771 + 772 + // Modal event wiring 773 + modalSave.addEventListener('click', saveEvent); 774 + modalDelete.addEventListener('click', deleteEvent); 775 + modalCancel.addEventListener('click', closeModal); 776 + modalAllDay.addEventListener('change', updateTimeFieldsVisibility); 777 + modalBackdrop.addEventListener('click', (e) => { 778 + if (e.target === modalBackdrop) closeModal(); 779 + }); 780 + 781 + // Color swatch selection 782 + modalColorPicker.addEventListener('click', (e) => { 783 + const swatch = (e.target as HTMLElement).closest('.event-color-swatch') as HTMLElement | null; 784 + if (!swatch) return; 785 + modalColorPicker.querySelectorAll('.event-color-swatch').forEach(s => s.classList.remove('active')); 786 + swatch.classList.add('active'); 787 + }); 788 + 789 + // --------------------------------------------------------------------------- 790 + // Event delegation on calendar grid 791 + // --------------------------------------------------------------------------- 792 + 793 + calendarGrid.addEventListener('click', (e) => { 794 + const target = e.target as HTMLElement; 795 + 796 + // Click on event pill or block -> edit 797 + const eventEl = target.closest('[data-event-id]') as HTMLElement | null; 798 + if (eventEl) { 799 + const eventId = eventEl.dataset.eventId!; 800 + const evt = state.events.find(ev => ev.id === eventId); 801 + if (evt) { 802 + openModal(evt); 803 + } 804 + return; 805 + } 806 + 807 + // Click on "+N more" link -> switch to day view 808 + const moreLink = target.closest('.cal-more-link') as HTMLElement | null; 809 + if (moreLink) { 810 + const dateStr = moreLink.dataset.date!; 811 + state.currentDate = parseEventDate(dateStr); 812 + setView('day'); 813 + return; 814 + } 815 + 816 + // Click on agenda "Create Event" button 817 + if (target.closest('.cal-new-event-btn')) { 818 + openModal({ date: formatDate(new Date()) }); 819 + return; 820 + } 821 + 822 + // Click on day cell (month view) -> new event on that date 823 + const dayCell = target.closest('.cal-day-cell') as HTMLElement | null; 824 + if (dayCell) { 825 + const dateStr = dayCell.dataset.date!; 826 + openModal({ date: dateStr }); 827 + return; 828 + } 829 + 830 + // Click on hour row (week/day view) -> new event at that time 831 + const hourRow = target.closest('.cal-hour-row') as HTMLElement | null; 832 + if (hourRow) { 833 + const dateStr = hourRow.dataset.date!; 834 + const hour = parseInt(hourRow.dataset.hour!, 10); 835 + openModal({ 836 + date: dateStr, 837 + startTime: minutesToTime(hour * 60), 838 + endTime: minutesToTime((hour + 1) * 60), 839 + allDay: false, 840 + }); 841 + return; 842 + } 843 + 844 + // Click on all-day cell (week/day view) -> new all-day event 845 + const alldayCell = target.closest('.cal-allday-cell') as HTMLElement | null; 846 + if (alldayCell) { 847 + const dateStr = alldayCell.dataset.date ?? formatDate(state.currentDate); 848 + openModal({ date: dateStr, allDay: true, startTime: '', endTime: '' }); 849 + return; 850 + } 851 + 852 + // Click on agenda item -> edit 853 + const agendaItem = target.closest('.cal-agenda-item') as HTMLElement | null; 854 + if (agendaItem) { 855 + const eventId = agendaItem.dataset.eventId!; 856 + const evt = state.events.find(ev => ev.id === eventId); 857 + if (evt) openModal(evt); 858 + return; 859 + } 860 + }); 861 + 862 + // --------------------------------------------------------------------------- 863 + // Navigation button wiring 864 + // --------------------------------------------------------------------------- 865 + 866 + document.getElementById('btn-today')?.addEventListener('click', goToday); 867 + document.getElementById('btn-prev')?.addEventListener('click', navigatePrev); 868 + document.getElementById('btn-next')?.addEventListener('click', navigateNext); 869 + 870 + document.querySelectorAll('[data-view]').forEach(btn => { 871 + btn.addEventListener('click', () => { 872 + const view = (btn as HTMLElement).dataset.view as CalendarView; 873 + if (view) setView(view); 874 + }); 875 + }); 876 + 877 + // --------------------------------------------------------------------------- 878 + // Keyboard shortcuts 879 + // --------------------------------------------------------------------------- 880 + 881 + function isModalOpen(): boolean { 882 + return modalBackdrop.style.display !== 'none'; 883 + } 884 + 885 + document.addEventListener('keydown', (e) => { 886 + // Don't capture when typing in input/textarea (unless Escape) 887 + const tag = (e.target as HTMLElement).tagName; 888 + const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; 889 + 890 + if (e.key === 'Escape') { 891 + if (isModalOpen()) { 892 + e.preventDefault(); 893 + closeModal(); 894 + } 895 + return; 896 + } 897 + 898 + // Don't intercept shortcuts when typing in form fields or modal 899 + if (isInput || isModalOpen()) return; 900 + 901 + // Don't intercept if Cmd/Ctrl is held (let command palette through) 902 + if (e.metaKey || e.ctrlKey) return; 903 + 904 + switch (e.key) { 905 + case 't': 906 + e.preventDefault(); 907 + goToday(); 908 + break; 909 + case 'm': 910 + e.preventDefault(); 911 + setView('month'); 912 + break; 913 + case 'w': 914 + e.preventDefault(); 915 + setView('week'); 916 + break; 917 + case 'd': 918 + e.preventDefault(); 919 + setView('day'); 920 + break; 921 + case 'a': 922 + e.preventDefault(); 923 + setView('agenda'); 924 + break; 925 + case 'n': 926 + e.preventDefault(); 927 + openModal({ date: formatDate(new Date()) }); 928 + break; 929 + case 'ArrowLeft': 930 + e.preventDefault(); 931 + navigatePrev(); 932 + break; 933 + case 'ArrowRight': 934 + e.preventDefault(); 935 + navigateNext(); 936 + break; 937 + } 938 + }); 939 + 940 + // --------------------------------------------------------------------------- 941 + // Title handling (same pattern as slides/forms) 942 + // --------------------------------------------------------------------------- 943 + 944 + async function loadTitle(): Promise<void> { 945 + try { 946 + const res = await fetch(`/api/documents/${docId}`); 947 + if (!res.ok) return; 948 + const doc = await res.json(); 949 + if (doc.name_encrypted && cryptoKey) { 950 + const bytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 951 + const { decrypt } = await import('../lib/crypto.js'); 952 + const plain = await decrypt(new Uint8Array(bytes.buffer), cryptoKey); 953 + titleInput.value = new TextDecoder().decode(plain); 954 + } 955 + } catch { /* ignore */ } 956 + } 957 + 958 + titleInput.addEventListener('change', async () => { 959 + if (!cryptoKey) return; 960 + const { encrypt } = await import('../lib/crypto.js'); 961 + const nameBytes = new TextEncoder().encode(titleInput.value); 962 + const encrypted = await encrypt(nameBytes, cryptoKey); 963 + const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 964 + fetch(`/api/documents/${docId}/name`, { 965 + method: 'PUT', 966 + headers: { 'Content-Type': 'application/json' }, 967 + body: JSON.stringify({ name_encrypted: b64 }), 968 + }); 969 + }); 970 + 971 + // --------------------------------------------------------------------------- 972 + // Command Palette 973 + // --------------------------------------------------------------------------- 974 + 975 + createCommandPalette({ 976 + actions: [ 977 + { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, 978 + { id: 'new-calendar', label: 'New Calendar', category: 'action', icon: '\u25A6', action: () => { window.open('/', '_blank'); } }, 979 + { id: 'today', label: 'Today', category: 'action', icon: '\u25C9', shortcut: 'T', action: goToday }, 980 + { id: 'month-view', label: 'Month View', category: 'action', icon: '\u2588', shortcut: 'M', action: () => setView('month') }, 981 + { id: 'week-view', label: 'Week View', category: 'action', icon: '\u2630', shortcut: 'W', action: () => setView('week') }, 982 + { id: 'day-view', label: 'Day View', category: 'action', icon: '\u25A1', shortcut: 'D', action: () => setView('day') }, 983 + { id: 'agenda-view', label: 'Agenda View', category: 'action', icon: '\u2261', shortcut: 'A', action: () => setView('agenda') }, 984 + { id: 'new-event', label: 'New Event', category: 'action', icon: '+', shortcut: 'N', action: () => openModal({ date: formatDate(new Date()) }) }, 985 + ], 986 + }); 987 + 988 + // --------------------------------------------------------------------------- 989 + // Responsive: switch to agenda on narrow screens 990 + // --------------------------------------------------------------------------- 991 + 992 + function handleResize(): void { 993 + if (window.innerWidth < 768 && state.view !== 'agenda') { 994 + setView('agenda'); 995 + } 996 + } 997 + 998 + window.addEventListener('resize', handleResize); 999 + 1000 + // --------------------------------------------------------------------------- 1001 + // Initialize 1002 + // --------------------------------------------------------------------------- 1003 + 1004 + async function init(): Promise<void> { 1005 + await initCrypto(); 1006 + state.cryptoKey = cryptoKey; 1007 + setupTooltips(); 1008 + 1009 + if (cryptoKey) { 1010 + const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 1011 + 1012 + provider.on('sync', () => { 1013 + loadEventsFromYjs(); 1014 + renderView(); 1015 + }); 1016 + 1017 + await loadTitle(); 1018 + } else { 1019 + renderView(); 1020 + } 1021 + 1022 + // Set initial active view button 1023 + document.querySelectorAll('[data-view]').forEach(btn => { 1024 + btn.classList.toggle('active', (btn as HTMLElement).dataset.view === state.view); 1025 + }); 1026 + 1027 + renderView(); 1028 + } 1029 + 1030 + init();
+1075
src/css/app.css
··· 9258 9258 color: var(--color-teal); 9259 9259 text-decoration: underline; 9260 9260 } 9261 + 9262 + /* ============================================================ 9263 + Calendar — E2EE Collaborative Calendar 9264 + ============================================================ */ 9265 + 9266 + /* --- Calendar tokens --- */ 9267 + :root { 9268 + --cal-cell-min: 120px; 9269 + --cal-cell-min-tablet: 90px; 9270 + --cal-cell-min-mobile: 60px; 9271 + --cal-hour-height: 60px; 9272 + --cal-gutter-width: 60px; 9273 + --cal-now-color: #bd413f; 9274 + --cal-now-color: oklch(0.55 0.16 25); 9275 + 9276 + /* Event palette — 8 hues keyed to oklch for both themes */ 9277 + --cal-ev-red: #c75450; 9278 + --cal-ev-red: oklch(0.56 0.14 25); 9279 + --cal-ev-orange: #c47525; 9280 + --cal-ev-orange: oklch(0.60 0.14 60); 9281 + --cal-ev-gold: #a88a1a; 9282 + --cal-ev-gold: oklch(0.62 0.12 90); 9283 + --cal-ev-green: #3a8a4e; 9284 + --cal-ev-green: oklch(0.55 0.12 150); 9285 + --cal-ev-teal: #0f7e7f; 9286 + --cal-ev-teal: oklch(0.52 0.10 195); 9287 + --cal-ev-blue: #3d6ea5; 9288 + --cal-ev-blue: oklch(0.52 0.10 250); 9289 + --cal-ev-purple: #7c549e; 9290 + --cal-ev-purple: oklch(0.50 0.12 310); 9291 + --cal-ev-slate: #6a6560; 9292 + --cal-ev-slate: oklch(0.50 0.01 75); 9293 + } 9294 + 9295 + [data-theme="dark"] { 9296 + --cal-now-color: #ce514d; 9297 + --cal-now-color: oklch(0.60 0.16 25); 9298 + 9299 + --cal-ev-red: #d06b67; 9300 + --cal-ev-red: oklch(0.62 0.14 25); 9301 + --cal-ev-orange: #d08e45; 9302 + --cal-ev-orange: oklch(0.66 0.12 60); 9303 + --cal-ev-gold: #c0a230; 9304 + --cal-ev-gold: oklch(0.70 0.12 90); 9305 + --cal-ev-green: #4faa65; 9306 + --cal-ev-green: oklch(0.65 0.12 150); 9307 + --cal-ev-teal: #2fa0a0; 9308 + --cal-ev-teal: oklch(0.62 0.10 195); 9309 + --cal-ev-blue: #5f92c4; 9310 + --cal-ev-blue: oklch(0.62 0.10 250); 9311 + --cal-ev-purple: #9e78be; 9312 + --cal-ev-purple: oklch(0.60 0.12 310); 9313 + --cal-ev-slate: #8a857f; 9314 + --cal-ev-slate: oklch(0.60 0.01 75); 9315 + } 9316 + 9317 + 9318 + /* ── App Layout ──────────────────────────────────────────────────────── */ 9319 + 9320 + .calendar-app { 9321 + display: flex; 9322 + flex-direction: column; 9323 + height: 100vh; 9324 + overflow: hidden; 9325 + } 9326 + 9327 + .calendar-main { 9328 + flex: 1; 9329 + display: flex; 9330 + flex-direction: column; 9331 + overflow: hidden; 9332 + } 9333 + 9334 + 9335 + /* ── Toolbar ─────────────────────────────────────────────────────────── */ 9336 + 9337 + .cal-toolbar { 9338 + display: flex; 9339 + align-items: center; 9340 + padding: var(--space-sm) var(--space-md); 9341 + padding-left: max(var(--space-md), env(safe-area-inset-left)); 9342 + padding-right: max(var(--space-md), env(safe-area-inset-right)); 9343 + gap: var(--space-md); 9344 + border-bottom: 1px solid var(--color-border); 9345 + background: var(--color-surface); 9346 + flex-shrink: 0; 9347 + } 9348 + 9349 + .cal-nav-group { 9350 + display: flex; 9351 + align-items: center; 9352 + gap: var(--space-sm); 9353 + } 9354 + 9355 + .cal-nav-btn { 9356 + display: inline-flex; 9357 + align-items: center; 9358 + justify-content: center; 9359 + padding: 0.3rem 0.65rem; 9360 + border: 1px solid var(--color-border); 9361 + border-radius: var(--radius-sm); 9362 + background: transparent; 9363 + color: var(--color-text-muted); 9364 + font-family: var(--font-body); 9365 + font-size: 0.8rem; 9366 + cursor: pointer; 9367 + transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); 9368 + line-height: 1.3; 9369 + min-height: 28px; 9370 + } 9371 + 9372 + .cal-nav-btn:hover { 9373 + background: var(--color-hover); 9374 + color: var(--color-text); 9375 + border-color: var(--color-border-strong); 9376 + } 9377 + 9378 + .cal-nav-btn:active { 9379 + transform: scale(0.98); 9380 + } 9381 + 9382 + .cal-nav-btn:focus-visible { 9383 + outline: 2px solid var(--color-accent); 9384 + outline-offset: 1px; 9385 + } 9386 + 9387 + .cal-date-label { 9388 + font-family: var(--font-display); 9389 + font-size: 1.2rem; 9390 + color: var(--color-text); 9391 + white-space: nowrap; 9392 + user-select: none; 9393 + margin-left: var(--space-xs); 9394 + } 9395 + 9396 + .cal-view-group { 9397 + display: flex; 9398 + align-items: center; 9399 + gap: 2px; 9400 + margin-left: auto; 9401 + border: 1px solid var(--color-border); 9402 + border-radius: var(--radius-sm); 9403 + overflow: hidden; 9404 + } 9405 + 9406 + .cal-view-btn { 9407 + display: inline-flex; 9408 + align-items: center; 9409 + justify-content: center; 9410 + padding: 0.3rem 0.7rem; 9411 + border: none; 9412 + background: transparent; 9413 + color: var(--color-text-muted); 9414 + font-family: var(--font-body); 9415 + font-size: 0.75rem; 9416 + font-weight: 500; 9417 + text-transform: uppercase; 9418 + letter-spacing: 0.04em; 9419 + cursor: pointer; 9420 + transition: background var(--transition-fast), color var(--transition-fast); 9421 + line-height: 1.3; 9422 + min-height: 28px; 9423 + } 9424 + 9425 + .cal-view-btn:hover { 9426 + background: var(--color-hover); 9427 + color: var(--color-text); 9428 + } 9429 + 9430 + .cal-view-btn.active { 9431 + background: var(--color-accent); 9432 + color: var(--color-btn-primary-text); 9433 + } 9434 + 9435 + .cal-view-btn:focus-visible { 9436 + outline: 2px solid var(--color-accent); 9437 + outline-offset: -2px; 9438 + } 9439 + 9440 + 9441 + /* ── Month View ──────────────────────────────────────────────────────── */ 9442 + 9443 + .cal-month-grid { 9444 + display: grid; 9445 + grid-template-columns: repeat(7, 1fr); 9446 + grid-auto-rows: minmax(var(--cal-cell-min), 1fr); 9447 + flex: 1; 9448 + overflow-y: auto; 9449 + } 9450 + 9451 + .cal-month-header { 9452 + display: contents; 9453 + } 9454 + 9455 + .cal-month-header-cell { 9456 + position: sticky; 9457 + top: 0; 9458 + z-index: var(--z-component); 9459 + padding: var(--space-xs) var(--space-sm); 9460 + font-family: var(--font-display); 9461 + font-size: 0.7rem; 9462 + font-weight: 600; 9463 + text-transform: uppercase; 9464 + letter-spacing: 0.06em; 9465 + color: var(--color-text-muted); 9466 + background: var(--color-surface); 9467 + border-bottom: 1px solid var(--color-border-strong); 9468 + text-align: center; 9469 + user-select: none; 9470 + } 9471 + 9472 + .cal-day-cell { 9473 + min-height: var(--cal-cell-min); 9474 + border-right: 1px solid var(--color-border); 9475 + border-bottom: 1px solid var(--color-border); 9476 + padding: var(--space-xs); 9477 + cursor: pointer; 9478 + transition: background var(--transition-fast); 9479 + overflow: hidden; 9480 + position: relative; 9481 + } 9482 + 9483 + .cal-day-cell:nth-child(7n) { 9484 + border-right: none; 9485 + } 9486 + 9487 + .cal-day-cell:hover { 9488 + background: var(--color-hover); 9489 + } 9490 + 9491 + .cal-day-number { 9492 + display: inline-flex; 9493 + align-items: center; 9494 + justify-content: center; 9495 + font-size: 0.78rem; 9496 + font-weight: 600; 9497 + color: var(--color-text); 9498 + width: 1.6em; 9499 + height: 1.6em; 9500 + line-height: 1; 9501 + margin-bottom: var(--space-xs); 9502 + border-radius: 50%; 9503 + user-select: none; 9504 + } 9505 + 9506 + .cal-day-today .cal-day-number { 9507 + background: var(--color-teal); 9508 + color: #f7f5f1; 9509 + color: oklch(0.97 0.005 75); 9510 + } 9511 + 9512 + .cal-day-other-month { 9513 + opacity: 0.38; 9514 + } 9515 + 9516 + .cal-day-other-month:hover { 9517 + opacity: 0.55; 9518 + } 9519 + 9520 + .cal-event-pill { 9521 + display: block; 9522 + padding: 1px 5px; 9523 + margin-bottom: 2px; 9524 + border-radius: var(--radius-sm); 9525 + font-size: 0.68rem; 9526 + line-height: 1.4; 9527 + font-family: var(--font-body); 9528 + color: #f7f5f1; 9529 + color: oklch(0.97 0.005 75); 9530 + background: var(--cal-ev-teal); 9531 + white-space: nowrap; 9532 + overflow: hidden; 9533 + text-overflow: ellipsis; 9534 + cursor: pointer; 9535 + transition: filter var(--transition-fast); 9536 + } 9537 + 9538 + .cal-event-pill:hover { 9539 + filter: brightness(1.1); 9540 + } 9541 + 9542 + [data-theme="dark"] .cal-event-pill { 9543 + color: #f7f5f1; 9544 + color: oklch(0.97 0.005 75); 9545 + } 9546 + 9547 + .cal-more-link { 9548 + display: block; 9549 + padding: 1px 5px; 9550 + font-size: 0.65rem; 9551 + color: var(--color-text-muted); 9552 + cursor: pointer; 9553 + transition: color var(--transition-fast); 9554 + user-select: none; 9555 + } 9556 + 9557 + .cal-more-link:hover { 9558 + color: var(--color-text); 9559 + } 9560 + 9561 + 9562 + /* ── Week View ───────────────────────────────────────────────────────── */ 9563 + 9564 + .cal-week-grid { 9565 + display: grid; 9566 + grid-template-columns: var(--cal-gutter-width) repeat(7, 1fr); 9567 + grid-template-rows: auto auto 1fr; 9568 + flex: 1; 9569 + overflow: hidden; 9570 + } 9571 + 9572 + .cal-week-header { 9573 + display: contents; 9574 + } 9575 + 9576 + .cal-week-header-cell { 9577 + padding: var(--space-xs) var(--space-sm); 9578 + font-family: var(--font-display); 9579 + font-size: 0.7rem; 9580 + font-weight: 600; 9581 + text-transform: uppercase; 9582 + letter-spacing: 0.06em; 9583 + color: var(--color-text-muted); 9584 + text-align: center; 9585 + border-bottom: 1px solid var(--color-border); 9586 + background: var(--color-surface); 9587 + user-select: none; 9588 + } 9589 + 9590 + .cal-week-header-cell.is-today { 9591 + color: var(--color-teal); 9592 + } 9593 + 9594 + .cal-week-header-gutter { 9595 + grid-row: 1; 9596 + grid-column: 1; 9597 + border-bottom: 1px solid var(--color-border); 9598 + background: var(--color-surface); 9599 + } 9600 + 9601 + .cal-week-header-date { 9602 + display: block; 9603 + font-size: 1.3rem; 9604 + font-family: var(--font-display); 9605 + font-weight: 400; 9606 + letter-spacing: normal; 9607 + text-transform: none; 9608 + line-height: 1.2; 9609 + } 9610 + 9611 + .cal-week-header-cell.is-today .cal-week-header-date { 9612 + display: inline-flex; 9613 + align-items: center; 9614 + justify-content: center; 9615 + width: 1.6em; 9616 + height: 1.6em; 9617 + border-radius: 50%; 9618 + background: var(--color-teal); 9619 + color: #f7f5f1; 9620 + color: oklch(0.97 0.005 75); 9621 + } 9622 + 9623 + /* All-day row */ 9624 + .cal-allday-bar { 9625 + display: contents; 9626 + } 9627 + 9628 + .cal-allday-gutter { 9629 + grid-column: 1; 9630 + padding: var(--space-xs); 9631 + font-size: 0.6rem; 9632 + color: var(--color-text-faint); 9633 + text-transform: uppercase; 9634 + letter-spacing: 0.04em; 9635 + border-bottom: 1px solid var(--color-border-strong); 9636 + background: var(--color-surface); 9637 + display: flex; 9638 + align-items: center; 9639 + justify-content: center; 9640 + } 9641 + 9642 + .cal-allday-cell { 9643 + border-right: 1px solid var(--color-border); 9644 + border-bottom: 1px solid var(--color-border-strong); 9645 + padding: var(--space-xs); 9646 + min-height: 2rem; 9647 + background: var(--color-bg); 9648 + } 9649 + 9650 + .cal-allday-cell:last-child { 9651 + border-right: none; 9652 + } 9653 + 9654 + /* Time body — scrollable region */ 9655 + .cal-week-body { 9656 + grid-column: 1 / -1; 9657 + display: grid; 9658 + grid-template-columns: var(--cal-gutter-width) repeat(7, 1fr); 9659 + overflow-y: auto; 9660 + } 9661 + 9662 + .cal-time-gutter { 9663 + width: var(--cal-gutter-width); 9664 + } 9665 + 9666 + .cal-hour-label { 9667 + height: var(--cal-hour-height); 9668 + display: flex; 9669 + align-items: flex-start; 9670 + justify-content: flex-end; 9671 + padding: 0 var(--space-sm) 0 0; 9672 + font-size: 0.65rem; 9673 + font-family: var(--font-mono); 9674 + color: var(--color-text-faint); 9675 + position: relative; 9676 + top: -0.45em; 9677 + user-select: none; 9678 + } 9679 + 9680 + .cal-day-column { 9681 + position: relative; 9682 + border-right: 1px solid var(--color-border); 9683 + } 9684 + 9685 + .cal-day-column:last-child { 9686 + border-right: none; 9687 + } 9688 + 9689 + .cal-hour-row { 9690 + height: var(--cal-hour-height); 9691 + border-bottom: 1px solid var(--color-border); 9692 + } 9693 + 9694 + .cal-hour-row-half { 9695 + height: calc(var(--cal-hour-height) / 2); 9696 + border-bottom: 1px dashed var(--color-border); 9697 + border-bottom-color: #d7d4cf66; 9698 + border-bottom-color: oklch(0.87 0.008 75 / 0.4); 9699 + } 9700 + 9701 + [data-theme="dark"] .cal-hour-row-half { 9702 + border-bottom-color: #2b282566; 9703 + border-bottom-color: oklch(0.28 0.008 75 / 0.4); 9704 + } 9705 + 9706 + /* Event blocks — absolute positioned in day columns */ 9707 + .cal-event-block { 9708 + position: absolute; 9709 + left: 2px; 9710 + right: 2px; 9711 + border-radius: var(--radius-sm); 9712 + padding: 3px 6px; 9713 + font-size: 0.7rem; 9714 + font-family: var(--font-body); 9715 + line-height: 1.3; 9716 + color: #f7f5f1; 9717 + color: oklch(0.97 0.005 75); 9718 + background: var(--cal-ev-teal); 9719 + overflow: hidden; 9720 + cursor: pointer; 9721 + z-index: 1; 9722 + transition: box-shadow var(--transition-fast), filter var(--transition-fast); 9723 + border-left: 3px solid rgba(0, 0, 0, 0.15); 9724 + } 9725 + 9726 + .cal-event-block:hover { 9727 + box-shadow: var(--shadow-md); 9728 + filter: brightness(1.08); 9729 + z-index: 2; 9730 + } 9731 + 9732 + .cal-event-block-title { 9733 + font-weight: 600; 9734 + white-space: nowrap; 9735 + overflow: hidden; 9736 + text-overflow: ellipsis; 9737 + } 9738 + 9739 + .cal-event-block-time { 9740 + font-size: 0.6rem; 9741 + opacity: 0.85; 9742 + white-space: nowrap; 9743 + } 9744 + 9745 + /* Current time indicator */ 9746 + .cal-now-line { 9747 + position: absolute; 9748 + left: 0; 9749 + right: 0; 9750 + height: 2px; 9751 + background: var(--cal-now-color); 9752 + z-index: 3; 9753 + pointer-events: none; 9754 + } 9755 + 9756 + .cal-now-line::before { 9757 + content: ''; 9758 + position: absolute; 9759 + left: -4px; 9760 + top: -3px; 9761 + width: 8px; 9762 + height: 8px; 9763 + border-radius: 50%; 9764 + background: var(--cal-now-color); 9765 + } 9766 + 9767 + 9768 + /* ── Day View ────────────────────────────────────────────────────────── */ 9769 + 9770 + .cal-day-grid { 9771 + display: grid; 9772 + grid-template-columns: var(--cal-gutter-width) 1fr; 9773 + grid-template-rows: auto auto 1fr; 9774 + flex: 1; 9775 + overflow: hidden; 9776 + } 9777 + 9778 + .cal-day-header { 9779 + grid-column: 1 / -1; 9780 + padding: var(--space-xs) var(--space-md); 9781 + font-family: var(--font-display); 9782 + font-size: 0.8rem; 9783 + font-weight: 600; 9784 + color: var(--color-text-muted); 9785 + text-transform: uppercase; 9786 + letter-spacing: 0.06em; 9787 + border-bottom: 1px solid var(--color-border); 9788 + background: var(--color-surface); 9789 + user-select: none; 9790 + } 9791 + 9792 + .cal-day-header-date { 9793 + font-size: 1.4rem; 9794 + font-weight: 400; 9795 + text-transform: none; 9796 + letter-spacing: normal; 9797 + color: var(--color-text); 9798 + margin-left: var(--space-sm); 9799 + } 9800 + 9801 + .cal-day-header.is-today .cal-day-header-date { 9802 + display: inline-flex; 9803 + align-items: center; 9804 + justify-content: center; 9805 + width: 1.6em; 9806 + height: 1.6em; 9807 + border-radius: 50%; 9808 + background: var(--color-teal); 9809 + color: #f7f5f1; 9810 + color: oklch(0.97 0.005 75); 9811 + } 9812 + 9813 + /* Day view all-day bar */ 9814 + .cal-day-allday-gutter { 9815 + grid-column: 1; 9816 + padding: var(--space-xs); 9817 + font-size: 0.6rem; 9818 + color: var(--color-text-faint); 9819 + text-transform: uppercase; 9820 + letter-spacing: 0.04em; 9821 + border-bottom: 1px solid var(--color-border-strong); 9822 + background: var(--color-surface); 9823 + display: flex; 9824 + align-items: center; 9825 + justify-content: center; 9826 + } 9827 + 9828 + .cal-day-allday-cell { 9829 + grid-column: 2; 9830 + border-bottom: 1px solid var(--color-border-strong); 9831 + padding: var(--space-xs); 9832 + min-height: 2rem; 9833 + background: var(--color-bg); 9834 + } 9835 + 9836 + /* Day view scrollable body */ 9837 + .cal-day-body { 9838 + grid-column: 1 / -1; 9839 + display: grid; 9840 + grid-template-columns: var(--cal-gutter-width) 1fr; 9841 + overflow-y: auto; 9842 + } 9843 + 9844 + /* Reuses .cal-time-gutter, .cal-hour-label, .cal-hour-row, 9845 + .cal-hour-row-half, .cal-day-column, .cal-event-block, 9846 + .cal-now-line from week view */ 9847 + 9848 + 9849 + /* ── Agenda View ─────────────────────────────────────────────────────── */ 9850 + 9851 + .cal-agenda { 9852 + flex: 1; 9853 + overflow-y: auto; 9854 + padding: var(--space-md) var(--space-lg); 9855 + } 9856 + 9857 + .cal-date-group { 9858 + margin-bottom: var(--space-md); 9859 + } 9860 + 9861 + .cal-date-header { 9862 + font-family: var(--font-display); 9863 + font-size: 0.85rem; 9864 + font-weight: 600; 9865 + color: var(--color-text); 9866 + padding-bottom: var(--space-xs); 9867 + margin-bottom: var(--space-xs); 9868 + margin-top: var(--space-md); 9869 + border-bottom: 1px solid var(--color-border); 9870 + user-select: none; 9871 + } 9872 + 9873 + .cal-date-group:first-child .cal-date-header { 9874 + margin-top: 0; 9875 + } 9876 + 9877 + .cal-date-header-today { 9878 + color: var(--color-teal); 9879 + } 9880 + 9881 + .cal-agenda-item { 9882 + display: flex; 9883 + align-items: flex-start; 9884 + gap: var(--space-sm); 9885 + padding: var(--space-sm) var(--space-sm); 9886 + border-radius: var(--radius-sm); 9887 + cursor: pointer; 9888 + transition: background var(--transition-fast); 9889 + } 9890 + 9891 + .cal-agenda-item:hover { 9892 + background: var(--color-hover); 9893 + } 9894 + 9895 + .cal-agenda-dot { 9896 + flex-shrink: 0; 9897 + width: 8px; 9898 + height: 8px; 9899 + border-radius: 50%; 9900 + background: var(--cal-ev-teal); 9901 + margin-top: 0.35em; 9902 + } 9903 + 9904 + .cal-agenda-time { 9905 + flex-shrink: 0; 9906 + width: 6.5em; 9907 + font-size: 0.78rem; 9908 + font-family: var(--font-mono); 9909 + color: var(--color-text-muted); 9910 + white-space: nowrap; 9911 + } 9912 + 9913 + .cal-agenda-content { 9914 + flex: 1; 9915 + min-width: 0; 9916 + } 9917 + 9918 + .cal-agenda-title { 9919 + font-size: 0.88rem; 9920 + font-weight: 500; 9921 + color: var(--color-text); 9922 + line-height: 1.3; 9923 + } 9924 + 9925 + .cal-agenda-desc { 9926 + font-size: 0.78rem; 9927 + color: var(--color-text-muted); 9928 + line-height: 1.4; 9929 + white-space: nowrap; 9930 + overflow: hidden; 9931 + text-overflow: ellipsis; 9932 + margin-top: 1px; 9933 + } 9934 + 9935 + .cal-agenda-empty { 9936 + display: flex; 9937 + flex-direction: column; 9938 + align-items: center; 9939 + justify-content: center; 9940 + padding: var(--space-2xl) var(--space-md); 9941 + color: var(--color-text-faint); 9942 + font-size: 0.9rem; 9943 + text-align: center; 9944 + user-select: none; 9945 + } 9946 + 9947 + .cal-agenda-empty-icon { 9948 + font-size: 2rem; 9949 + margin-bottom: var(--space-sm); 9950 + opacity: 0.5; 9951 + } 9952 + 9953 + 9954 + /* ── Event Modal ─────────────────────────────────────────────────────── */ 9955 + 9956 + .event-modal { 9957 + max-width: 480px; 9958 + width: 90%; 9959 + padding: var(--space-lg); 9960 + background: var(--color-bg); 9961 + border: 1px solid var(--color-border); 9962 + border-radius: var(--radius-lg); 9963 + box-shadow: var(--shadow-lg); 9964 + } 9965 + 9966 + .event-modal h3 { 9967 + font-family: var(--font-display); 9968 + font-size: 1.2rem; 9969 + margin-bottom: var(--space-md); 9970 + color: var(--color-text); 9971 + } 9972 + 9973 + .event-modal-field { 9974 + display: flex; 9975 + flex-direction: column; 9976 + gap: var(--space-xs); 9977 + margin-bottom: var(--space-md); 9978 + } 9979 + 9980 + .event-modal-field label { 9981 + font-size: 0.72rem; 9982 + font-weight: 600; 9983 + color: var(--color-text-muted); 9984 + text-transform: uppercase; 9985 + letter-spacing: 0.04em; 9986 + user-select: none; 9987 + } 9988 + 9989 + .event-modal-field input[type="text"], 9990 + .event-modal-field input[type="date"], 9991 + .event-modal-field input[type="time"], 9992 + .event-modal-field input[type="datetime-local"], 9993 + .event-modal-field textarea, 9994 + .event-modal-field select { 9995 + width: 100%; 9996 + padding: 0.5rem 0.7rem; 9997 + font-family: var(--font-body); 9998 + font-size: 0.85rem; 9999 + border: 1px solid var(--color-border); 10000 + border-radius: var(--radius-sm); 10001 + background: var(--color-surface); 10002 + color: var(--color-text); 10003 + outline: none; 10004 + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); 10005 + } 10006 + 10007 + .event-modal-field input:focus, 10008 + .event-modal-field textarea:focus, 10009 + .event-modal-field select:focus { 10010 + border-color: var(--color-teal); 10011 + box-shadow: 0 0 0 2px var(--color-focus); 10012 + } 10013 + 10014 + .event-modal-field textarea { 10015 + resize: vertical; 10016 + min-height: 3.5rem; 10017 + line-height: 1.5; 10018 + } 10019 + 10020 + .event-modal-row { 10021 + display: flex; 10022 + gap: var(--space-sm); 10023 + } 10024 + 10025 + .event-modal-row > .event-modal-field { 10026 + flex: 1; 10027 + min-width: 0; 10028 + } 10029 + 10030 + /* Color swatches */ 10031 + .event-modal-colors { 10032 + display: flex; 10033 + gap: var(--space-sm); 10034 + flex-wrap: wrap; 10035 + } 10036 + 10037 + .event-modal-color { 10038 + width: 24px; 10039 + height: 24px; 10040 + border-radius: 50%; 10041 + border: 2px solid transparent; 10042 + cursor: pointer; 10043 + transition: border-color var(--transition-fast), transform var(--transition-fast); 10044 + padding: 0; 10045 + background: none; 10046 + } 10047 + 10048 + .event-modal-color:hover { 10049 + transform: scale(1.15); 10050 + } 10051 + 10052 + .event-modal-color.selected { 10053 + border-color: var(--color-text); 10054 + box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-text-muted); 10055 + } 10056 + 10057 + .event-modal-color:focus-visible { 10058 + outline: 2px solid var(--color-accent); 10059 + outline-offset: 2px; 10060 + } 10061 + 10062 + /* Modal actions */ 10063 + .event-modal-actions { 10064 + display: flex; 10065 + gap: var(--space-sm); 10066 + justify-content: flex-end; 10067 + margin-top: var(--space-lg); 10068 + padding-top: var(--space-md); 10069 + border-top: 1px solid var(--color-border); 10070 + } 10071 + 10072 + 10073 + /* ── Dark Mode — calendar-specific adjustments ───────────────────────── */ 10074 + 10075 + [data-theme="dark"] .cal-event-block { 10076 + border-left-color: rgba(255, 255, 255, 0.1); 10077 + } 10078 + 10079 + [data-theme="dark"] .cal-event-pill, 10080 + [data-theme="dark"] .cal-event-block { 10081 + /* Slightly lower saturation in dark mode for comfort */ 10082 + filter: saturate(0.9); 10083 + } 10084 + 10085 + [data-theme="dark"] .cal-event-pill:hover { 10086 + filter: saturate(0.9) brightness(1.12); 10087 + } 10088 + 10089 + [data-theme="dark"] .cal-event-block:hover { 10090 + filter: saturate(0.9) brightness(1.1); 10091 + } 10092 + 10093 + 10094 + /* ── Print ───────────────────────────────────────────────────────────── */ 10095 + 10096 + @media print { 10097 + .cal-toolbar, 10098 + .cal-now-line { 10099 + display: none !important; 10100 + } 10101 + 10102 + .cal-day-cell { 10103 + min-height: 80px !important; 10104 + break-inside: avoid; 10105 + } 10106 + 10107 + .cal-event-pill, 10108 + .cal-event-block { 10109 + -webkit-print-color-adjust: exact; 10110 + print-color-adjust: exact; 10111 + } 10112 + } 10113 + 10114 + 10115 + /* ── Responsive: Tablet (768px) ──────────────────────────────────────── */ 10116 + 10117 + @media (max-width: 768px) { 10118 + .cal-toolbar { 10119 + flex-wrap: wrap; 10120 + padding: var(--space-xs) var(--space-sm); 10121 + gap: var(--space-sm); 10122 + } 10123 + 10124 + .cal-nav-group { 10125 + order: 1; 10126 + flex: 1 1 100%; 10127 + } 10128 + 10129 + .cal-view-group { 10130 + order: 2; 10131 + margin-left: 0; 10132 + flex: 1 1 100%; 10133 + justify-content: center; 10134 + } 10135 + 10136 + .cal-date-label { 10137 + font-size: 1.05rem; 10138 + } 10139 + 10140 + /* Month grid — smaller cells */ 10141 + .cal-month-grid { 10142 + grid-auto-rows: minmax(var(--cal-cell-min-tablet), 1fr); 10143 + } 10144 + 10145 + .cal-day-cell { 10146 + min-height: var(--cal-cell-min-tablet); 10147 + padding: 2px; 10148 + } 10149 + 10150 + .cal-day-number { 10151 + font-size: 0.72rem; 10152 + } 10153 + 10154 + .cal-event-pill { 10155 + font-size: 0.62rem; 10156 + padding: 0 3px; 10157 + } 10158 + 10159 + /* Week/day — narrower gutter */ 10160 + .cal-week-grid, 10161 + .cal-week-body { 10162 + grid-template-columns: 48px repeat(7, 1fr); 10163 + } 10164 + 10165 + .cal-day-grid, 10166 + .cal-day-body { 10167 + grid-template-columns: 48px 1fr; 10168 + } 10169 + 10170 + .cal-hour-label { 10171 + font-size: 0.6rem; 10172 + padding-right: var(--space-xs); 10173 + } 10174 + 10175 + /* Event modal full-width */ 10176 + .event-modal { 10177 + max-width: 100%; 10178 + width: 100%; 10179 + border-radius: var(--radius-md); 10180 + margin-bottom: var(--space-md); 10181 + } 10182 + 10183 + .event-modal-row { 10184 + flex-direction: column; 10185 + gap: 0; 10186 + } 10187 + 10188 + /* Agenda — less padding */ 10189 + .cal-agenda { 10190 + padding: var(--space-sm) var(--space-md); 10191 + } 10192 + } 10193 + 10194 + 10195 + /* ── Responsive: Mobile (480px) ──────────────────────────────────────── */ 10196 + 10197 + @media (max-width: 480px) { 10198 + .cal-toolbar { 10199 + padding: var(--space-xs); 10200 + gap: var(--space-xs); 10201 + } 10202 + 10203 + .cal-nav-btn { 10204 + padding: 0.25rem 0.5rem; 10205 + font-size: 0.72rem; 10206 + min-height: 32px; 10207 + min-width: 32px; 10208 + } 10209 + 10210 + .cal-view-btn { 10211 + padding: 0.25rem 0.5rem; 10212 + font-size: 0.68rem; 10213 + min-height: 32px; 10214 + } 10215 + 10216 + .cal-date-label { 10217 + font-size: 0.95rem; 10218 + } 10219 + 10220 + /* Month grid — compact: no pills, just dots */ 10221 + .cal-month-grid { 10222 + grid-auto-rows: minmax(var(--cal-cell-min-mobile), 1fr); 10223 + } 10224 + 10225 + .cal-day-cell { 10226 + min-height: var(--cal-cell-min-mobile); 10227 + padding: 1px; 10228 + } 10229 + 10230 + .cal-month-header-cell { 10231 + font-size: 0.6rem; 10232 + padding: var(--space-xs) 2px; 10233 + } 10234 + 10235 + /* Abbreviated day names handled by JS (S M T W T F S), 10236 + but ensure cells don't overflow */ 10237 + .cal-month-header-cell { 10238 + overflow: hidden; 10239 + text-overflow: ellipsis; 10240 + } 10241 + 10242 + .cal-day-number { 10243 + font-size: 0.68rem; 10244 + width: 1.4em; 10245 + height: 1.4em; 10246 + } 10247 + 10248 + /* Hide event pills on very small screens — show dot indicator */ 10249 + .cal-event-pill { 10250 + width: 5px; 10251 + height: 5px; 10252 + border-radius: 50%; 10253 + padding: 0; 10254 + font-size: 0; 10255 + line-height: 0; 10256 + display: inline-block; 10257 + margin: 0 1px 1px 0; 10258 + vertical-align: top; 10259 + } 10260 + 10261 + .cal-more-link { 10262 + font-size: 0; 10263 + width: 5px; 10264 + height: 5px; 10265 + border-radius: 50%; 10266 + background: var(--color-text-faint); 10267 + display: inline-block; 10268 + padding: 0; 10269 + margin: 0 1px 1px 0; 10270 + } 10271 + 10272 + /* Week view — horizontal scroll */ 10273 + .cal-week-grid { 10274 + overflow-x: auto; 10275 + -webkit-overflow-scrolling: touch; 10276 + min-width: 600px; 10277 + } 10278 + 10279 + /* Day view — full width, narrow gutter */ 10280 + .cal-day-grid, 10281 + .cal-day-body { 10282 + grid-template-columns: 40px 1fr; 10283 + } 10284 + 10285 + .cal-hour-label { 10286 + font-size: 0.55rem; 10287 + } 10288 + 10289 + /* Agenda — full width, most comfortable mobile view */ 10290 + .cal-agenda { 10291 + padding: var(--space-xs) var(--space-sm); 10292 + } 10293 + 10294 + .cal-agenda-time { 10295 + width: 5em; 10296 + font-size: 0.72rem; 10297 + } 10298 + 10299 + .cal-agenda-title { 10300 + font-size: 0.82rem; 10301 + } 10302 + 10303 + .cal-agenda-desc { 10304 + display: none; 10305 + } 10306 + 10307 + /* Modal — full width */ 10308 + .event-modal { 10309 + padding: var(--space-md); 10310 + border-radius: var(--radius-sm) var(--radius-sm) 0 0; 10311 + } 10312 + 10313 + .event-modal h3 { 10314 + font-size: 1.05rem; 10315 + } 10316 + 10317 + .event-modal-field input, 10318 + .event-modal-field textarea, 10319 + .event-modal-field select { 10320 + font-size: 16px; /* prevents iOS zoom on focus */ 10321 + } 10322 + } 10323 + 10324 + 10325 + /* ── Smooth theme transitions ────────────────────────────────────────── */ 10326 + 10327 + .cal-toolbar, 10328 + .cal-month-header-cell, 10329 + .cal-day-cell, 10330 + .cal-week-header-cell, 10331 + .cal-allday-cell, 10332 + .cal-day-header, 10333 + .event-modal { 10334 + transition: background-color var(--transition-med), border-color var(--transition-med), color var(--transition-med); 10335 + }
+5
src/index.html
··· 70 70 <span class="create-card-title">New Diagram</span> 71 71 <span class="create-card-desc">Freeform whiteboard with shapes, arrows, and freehand drawing</span> 72 72 </a> 73 + <a class="create-card" id="new-calendar" href="#"> 74 + <span class="create-card-icon">&#9776;</span> 75 + <span class="create-card-title">New Calendar</span> 76 + <span class="create-card-desc">Personal calendar with month, week, and day views</span> 77 + </a> 73 78 <a class="create-card create-card-accent" id="daily-note" href="#"> 74 79 <span class="create-card-icon">&#9830;</span> 75 80 <span class="create-card-title">Today's Note</span>
+3 -3
src/landing-create.ts
··· 16 16 import { showToast } from './landing-toast.js'; 17 17 18 18 // --- Document type routing --- 19 - const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 19 + const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams', calendar: '/calendar' }; 20 20 function docPathLocal(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; } 21 21 22 22 // ── Deps interface ─────────────────────────────────────────── ··· 32 32 33 33 // ── Create document ────────────────────────────────────────── 34 34 35 - export async function createDocument(deps: CreateDeps, type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'): Promise<void> { 35 + export async function createDocument(deps: CreateDeps, type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' | 'calendar'): Promise<void> { 36 36 const key = await generateKey(); 37 37 const keyStr = await exportKey(key); 38 38 39 - const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form', slide: 'Untitled Presentation', diagram: 'Untitled Diagram' }; 39 + const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form', slide: 'Untitled Presentation', diagram: 'Untitled Diagram', calendar: 'Untitled Calendar' }; 40 40 const defaultName = nameMap[type]; 41 41 const nameBytes = new TextEncoder().encode(defaultName); 42 42 const { encrypt } = await import('./lib/crypto.js');
+2 -2
src/landing-render.ts
··· 17 17 import { parseTags, collectAllTags, filterByTag } from './tags.js'; 18 18 19 19 // --- Document type routing --- 20 - const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 21 - const DOC_ICON_MAP: Record<string, string> = { doc: '&#9998;', sheet: '&#9638;', form: '&#9783;', slide: '&#9707;', diagram: '&#9683;' }; 20 + const DOC_PATH_MAP: Record<string, string> = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams', calendar: '/calendar' }; 21 + const DOC_ICON_MAP: Record<string, string> = { doc: '&#9998;', sheet: '&#9638;', form: '&#9783;', slide: '&#9707;', diagram: '&#9683;', calendar: '&#9776;' }; 22 22 23 23 export function docPath(type: string): string { return DOC_PATH_MAP[type] || '/sheets'; } 24 24 export function docIcon(type: string): string { return DOC_ICON_MAP[type] || '&#9638;'; }
+5 -1
src/landing.ts
··· 28 28 const newFormBtn = document.getElementById('new-form') as HTMLElement; 29 29 const newSlideBtn = document.getElementById('new-slide') as HTMLElement; 30 30 const newDiagramBtn = document.getElementById('new-diagram') as HTMLElement; 31 + const newCalendarBtn = document.getElementById('new-calendar') as HTMLElement; 31 32 const dailyNoteBtn = document.getElementById('daily-note') as HTMLElement; 32 33 const searchInput = document.getElementById('search-input') as HTMLInputElement; 33 34 const searchClear = document.getElementById('search-clear') as HTMLElement; ··· 174 175 newFormBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'form'); }); 175 176 newSlideBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'slide'); }); 176 177 newDiagramBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'diagram'); }); 178 + newCalendarBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'calendar'); }); 177 179 dailyNoteBtn.addEventListener('click', (e) => { e.preventDefault(); openDailyNote(createDeps); }); 178 180 179 181 // --- Drag-and-drop file import --- ··· 260 262 { id: 'new-form', label: 'New Form', category: 'action', icon: '\u2637', action: () => createDocument(createDeps, 'form') }, 261 263 { id: 'new-slide', label: 'New Presentation', category: 'action', icon: '\u25eb', action: () => createDocument(createDeps, 'slide') }, 262 264 { id: 'new-diagram', label: 'New Diagram', category: 'action', icon: '\u25d3', action: () => createDocument(createDeps, 'diagram') }, 265 + { id: 'new-calendar', label: 'New Calendar', category: 'action', icon: '\u2630', action: () => createDocument(createDeps, 'calendar') }, 263 266 { id: 'daily-note', label: "Today's Note", category: 'action', icon: '\u2666', action: () => openDailyNote(createDeps) }, 264 267 { id: 'backup-export', label: 'Export Backup', category: 'action', icon: '\u2913', action: () => backupExportBtn.click() }, 265 268 { id: 'backup-import', label: 'Restore Backup', category: 'action', icon: '\u2912', action: () => backupImportBtn.click() }, ··· 280 283 id: `doc-${doc.id}`, 281 284 label: doc._decryptedName || 'Encrypted Document', 282 285 category: 'document' as const, 283 - icon: { doc: '\u270e', sheet: '\u25a6', form: '\u2637', slide: '\u25eb', diagram: '\u25d3' }[doc.type] || '\u25a6', 286 + icon: { doc: '\u270e', sheet: '\u25a6', form: '\u2637', slide: '\u25eb', diagram: '\u25d3', calendar: '\u2630' }[doc.type] || '\u25a6', 284 287 action: () => { window.location.href = `${path}/${doc.id}#${doc._keyStr}`; }, 285 288 }; 286 289 }); ··· 320 323 'new-form': () => createDocument(createDeps, 'form'), 321 324 'new-slide': () => createDocument(createDeps, 'slide'), 322 325 'new-diagram': () => createDocument(createDeps, 'diagram'), 326 + 'new-calendar': () => createDocument(createDeps, 'calendar'), 323 327 }; 324 328 const handler = actionMap[urlAction]; 325 329 if (handler) {
+68 -14
src/lib/key-passphrase.ts
··· 1 1 /** 2 - * Key passphrase UI — prompts the user to set or enter their key-wrapping passphrase. 2 + * Key passphrase UI — opt-in passphrase protection for the key bundle. 3 3 * 4 - * The passphrase is used to derive a wrapping key (PBKDF2) that encrypts the 5 - * document key bundle before it is stored in localStorage or sent to the server. 4 + * By default, keys are stored as plaintext in localStorage (the Tailscale 5 + * identity layer provides the trust boundary). Users can opt-in to passphrase 6 + * wrapping via a future settings UI. 6 7 * 7 - * The derived key lives only in memory for the session — it is never persisted. 8 + * The ensureWrappingKey / ensureWrappingKeyForStore functions are no-ops 9 + * unless the user has explicitly enabled passphrase protection. All callers 10 + * can continue to call them unconditionally. 8 11 */ 9 12 10 13 import { ··· 13 16 isLegacyFormat, 14 17 migrateLegacyKeys, 15 18 getLocalKeys, 19 + isWrappedFormat, 20 + unwrapToPlaintext, 16 21 } from './key-sync.js'; 17 22 18 23 const PASSPHRASE_SET_KEY = 'tools-keys-passphrase-set'; 24 + const PASSPHRASE_OPT_IN_KEY = 'tools-keys-passphrase-enabled'; 19 25 20 26 /** Check if the user has previously set a passphrase (stored as a flag, not the passphrase). */ 21 27 export function hasPassphraseBeenSet(): boolean { 22 28 return localStorage.getItem(PASSPHRASE_SET_KEY) === '1'; 23 29 } 24 30 31 + /** Check if the user has opted in to passphrase protection. */ 32 + export function isPassphraseEnabled(): boolean { 33 + return localStorage.getItem(PASSPHRASE_OPT_IN_KEY) === '1'; 34 + } 35 + 25 36 /** Mark that a passphrase has been configured. */ 26 37 function markPassphraseSet(): void { 27 38 localStorage.setItem(PASSPHRASE_SET_KEY, '1'); 28 39 } 29 40 30 41 /** 31 - * Ensure the wrapping key is initialized. Shows a modal prompt if needed. 32 - * Returns true if the key is ready, false if the user cancelled. 42 + * Ensure the wrapping key is initialized. 43 + * 44 + * If passphrase protection is not enabled (default), returns true immediately. 45 + * If enabled, shows a modal prompt if needed. 33 46 */ 34 47 export async function ensureWrappingKey(): Promise<boolean> { 48 + // If passphrase protection is not opted in, skip entirely 49 + if (!isPassphraseEnabled()) { 50 + // If keys were previously wrapped (from before opt-in change), 51 + // prompt once to unwrap them back to plaintext 52 + if (isWrappedFormat()) { 53 + const passphrase = await showPassphraseModal('unlock'); 54 + if (passphrase === null) return true; // can't unwrap, but don't block 55 + await initWrappingKey(passphrase); 56 + try { 57 + await unwrapToPlaintext(); 58 + } catch { /* wrong passphrase — keys stay wrapped, user can try again */ } 59 + // Clear the passphrase flag since they're opting out 60 + localStorage.removeItem(PASSPHRASE_SET_KEY); 61 + localStorage.removeItem(PASSPHRASE_OPT_IN_KEY); 62 + } 63 + return true; 64 + } 65 + 35 66 if (hasWrappingKey()) return true; 36 67 37 68 const isLegacy = isLegacyFormat(); ··· 40 71 // If there are no stored keys at all and no passphrase set, skip — new user 41 72 const raw = localStorage.getItem('tools-keys'); 42 73 if (!raw && !hasPassphraseBeenSet()) { 43 - // Brand new user with no keys — set up passphrase silently on first key store 44 74 return true; 45 75 } 46 76 ··· 50 80 await initWrappingKey(passphrase); 51 81 52 82 if (isLegacy) { 53 - // Migrate plaintext keys to wrapped format 54 83 await migrateLegacyKeys(); 55 - } else if (isFirstTime) { 56 - // First-time setup — just mark it 57 - } else { 84 + } else if (!isFirstTime) { 58 85 // Verify the passphrase decrypts existing keys 59 86 try { 60 87 await getLocalKeys(); 61 88 } catch { 62 - // Wrong passphrase — decryption failed 63 89 throw new Error('incorrect-passphrase'); 64 90 } 65 91 } ··· 70 96 71 97 /** 72 98 * Ensure wrapping key is ready before storing a key. 73 - * For new users who haven't been prompted yet, prompts now. 99 + * 100 + * If passphrase protection is not enabled (default), returns true immediately. 74 101 */ 75 102 export async function ensureWrappingKeyForStore(): Promise<boolean> { 103 + if (!isPassphraseEnabled()) return true; 104 + 76 105 if (hasWrappingKey()) return true; 77 106 78 107 if (!hasPassphraseBeenSet()) { 79 - // New user storing their first key — prompt to set passphrase 80 108 const passphrase = await showPassphraseModal('setup'); 81 109 if (passphrase === null) return false; 82 110 await initWrappingKey(passphrase); ··· 85 113 } 86 114 87 115 return ensureWrappingKey(); 116 + } 117 + 118 + /** 119 + * Enable passphrase protection. Call from a settings UI. 120 + * Returns false if the user cancelled the passphrase prompt. 121 + */ 122 + export async function enablePassphraseProtection(): Promise<boolean> { 123 + const passphrase = await showPassphraseModal('setup'); 124 + if (passphrase === null) return false; 125 + 126 + localStorage.setItem(PASSPHRASE_OPT_IN_KEY, '1'); 127 + await initWrappingKey(passphrase); 128 + await migrateLegacyKeys(); 129 + markPassphraseSet(); 130 + return true; 131 + } 132 + 133 + /** 134 + * Disable passphrase protection. Unwraps keys back to plaintext. 135 + */ 136 + export async function disablePassphraseProtection(): Promise<void> { 137 + if (isWrappedFormat() && hasWrappingKey()) { 138 + await unwrapToPlaintext(); 139 + } 140 + localStorage.removeItem(PASSPHRASE_OPT_IN_KEY); 141 + localStorage.removeItem(PASSPHRASE_SET_KEY); 88 142 } 89 143 90 144 // --- Modal UI ---
+32
src/lib/key-sync.ts
··· 72 72 return null; 73 73 } 74 74 75 + /** Check if the store is in v2 wrapped (encrypted) format. */ 76 + export function isWrappedFormat(): boolean { 77 + try { 78 + const raw = localStorage.getItem(STORAGE_KEY); 79 + if (!raw) return false; 80 + const parsed = JSON.parse(raw); 81 + return parsed?.v === 2; 82 + } catch { 83 + return false; 84 + } 85 + } 86 + 87 + /** 88 + * Unwrap v2 encrypted keys back to plaintext format. 89 + * Requires the wrapping key to be initialized first. 90 + */ 91 + export async function unwrapToPlaintext(): Promise<void> { 92 + if (!_wrappingKey) throw new Error('No wrapping key'); 93 + const keys = await getLocalKeys(); 94 + if (Object.keys(keys).length === 0) return; 95 + // Store as plaintext (bypass wrapping by clearing the key temporarily) 96 + const savedKey = _wrappingKey; 97 + const savedSalt = _salt; 98 + _wrappingKey = null; 99 + _salt = null; 100 + await setLocalKeys(keys); 101 + // Push plaintext to server too 102 + await pushKeysToServer(keys); 103 + _wrappingKey = savedKey; 104 + _salt = savedSalt; 105 + } 106 + 75 107 /** Check if the store is still in legacy plaintext format. */ 76 108 export function isLegacyFormat(): boolean { 77 109 try {
+2 -1
vite.config.ts
··· 23 23 configureServer(server) { 24 24 server.middlewares.use((req, _res, next) => { 25 25 const url = req.url || ''; 26 - for (const app of ['docs', 'sheets', 'forms', 'slides', 'diagrams']) { 26 + for (const app of ['docs', 'sheets', 'forms', 'slides', 'diagrams', 'calendar']) { 27 27 if (url.startsWith(`/${app}/`) && !url.includes('.')) { 28 28 req.url = `/${app}/index.html`; 29 29 break; ··· 49 49 forms: resolve(__dirname, 'src/forms/index.html'), 50 50 slides: resolve(__dirname, 'src/slides/index.html'), 51 51 diagrams: resolve(__dirname, 'src/diagrams/index.html'), 52 + calendar: resolve(__dirname, 'src/calendar/index.html'), 52 53 }, 53 54 }, 54 55 },