Tap is a proof-of-concept editor for screenplays formatted in Fountain markup. It stores all data in AT Protocol records.

Added Library page, local dev mode

Changed files
+475 -12
server
web
components
-7
fly.staging.toml
··· 7 7 [build] 8 8 dockerfile = "Dockerfile" 9 9 10 - [[restart]] 11 - policy = "on-failure" 12 - retries = 10 13 - processes = ["app"] 14 - 15 10 [env] 16 11 PORT = "8088" 17 - # Public base URL for OAuth client metadata & redirect URI 18 - # If you use a custom staging domain, change this accordingly. 19 12 CLIENT_URI = "https://staging.tapapp.lol" 20 13 21 14 [http_service]
+1
fly.toml
··· 9 9 10 10 [env] 11 11 PORT = "8088" 12 + CLIENT_URI = "https://tapapp.lol" 12 13 13 14 [http_service] 14 15 internal_port = 8088
+157
server/main.go
··· 29 29 userSessions = map[string]Session{} 30 30 ) 31 31 32 + // DEV_OFFLINE enables local, in-memory docs and a stub session for development 33 + var devOffline = getEnv("DEV_OFFLINE", "") == "1" 34 + 35 + // In-memory docs store for DEV_OFFLINE, keyed by our legacy session ID 36 + type devDoc struct { 37 + ID string 38 + Name string 39 + Text string 40 + UpdatedAt string 41 + } 42 + var devDocsMu sync.Mutex 43 + var devDocs = map[string]map[string]*devDoc{} 44 + func devGetStore(sid string) map[string]*devDoc { 45 + devDocsMu.Lock() 46 + defer devDocsMu.Unlock() 47 + m, ok := devDocs[sid] 48 + if !ok { m = map[string]*devDoc{}; devDocs[sid] = m } 49 + return m 50 + } 51 + 32 52 // handleDocs lists and creates documents in lol.tapapp.tap.doc 33 53 func handleDocs(w http.ResponseWriter, r *http.Request) { 34 54 w.Header().Set("Content-Type", "application/json; charset=utf-8") 55 + if devOffline { 56 + // Local in-memory implementation for development 57 + sid := getOrCreateSessionID(w, r) 58 + store := devGetStore(sid) 59 + switch r.Method { 60 + case http.MethodGet: 61 + type item struct { 62 + ID string `json:"id"` 63 + Name string `json:"name"` 64 + UpdatedAt string `json:"updatedAt"` 65 + } 66 + out := make([]item, 0, len(store)) 67 + for _, d := range store { 68 + out = append(out, item{ID: d.ID, Name: d.Name, UpdatedAt: d.UpdatedAt}) 69 + } 70 + _ = json.NewEncoder(w).Encode(out) 71 + return 72 + case http.MethodPost: 73 + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 74 + var body struct{ Name, Text string } 75 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 76 + if body.Name == "" { body.Name = "Untitled" } 77 + rb := make([]byte, 8); _, _ = rand.Read(rb); id := "d-" + hex.EncodeToString(rb) 78 + now := time.Now().UTC().Format(time.RFC3339) 79 + store[id] = &devDoc{ID: id, Name: body.Name, Text: body.Text, UpdatedAt: now} 80 + w.WriteHeader(http.StatusCreated) 81 + _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) 82 + return 83 + default: 84 + w.WriteHeader(http.StatusMethodNotAllowed); return 85 + } 86 + } 35 87 did, _, ok := getDIDAndHandle(r) 36 88 if !ok { 37 89 w.WriteHeader(http.StatusNoContent) ··· 116 168 117 169 // handleDocByID gets/updates/deletes a document by rkey 118 170 func handleDocByID(w http.ResponseWriter, r *http.Request) { 171 + if devOffline { 172 + w.Header().Set("Content-Type", "application/json; charset=utf-8") 173 + id := strings.TrimPrefix(r.URL.Path, "/docs/") 174 + if id == "" { w.WriteHeader(http.StatusBadRequest); return } 175 + sid := getOrCreateSessionID(w, r) 176 + store := devGetStore(sid) 177 + switch r.Method { 178 + case http.MethodGet: 179 + // PDF export support in offline mode 180 + if strings.HasSuffix(id, ".pdf") { 181 + baseID := strings.TrimSuffix(id, ".pdf") 182 + d, ok := store[baseID]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 183 + name := d.Name; if name == "" { name = "Untitled" } 184 + blocks := fountain.Parse(d.Text) 185 + pdfBytes, err := renderPDF(blocks, name) 186 + if err != nil { log.Printf("pdf render error: %v", err); http.Error(w, "PDF render failed", http.StatusInternalServerError); return } 187 + safeName := sanitizeFilename(name) 188 + // Override content-type for PDF 189 + w.Header().Del("Content-Type") 190 + w.Header().Set("Content-Type", "application/pdf") 191 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pdf\"", safeName)) 192 + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") 193 + w.Header().Set("Pragma", "no-cache"); w.Header().Set("Expires", "0") 194 + _, _ = w.Write(pdfBytes) 195 + return 196 + } 197 + // Plain text export (.fountain) 198 + if strings.HasSuffix(id, ".fountain") { 199 + baseID := strings.TrimSuffix(id, ".fountain") 200 + d, ok := store[baseID]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 201 + w.Header().Del("Content-Type") 202 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 203 + name := d.Name; if name == "" { name = "screenplay" } 204 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 205 + _, _ = w.Write([]byte(d.Text)) 206 + return 207 + } 208 + // Delete via action query (for simple UI link) 209 + if r.URL.Query().Get("action") == "delete" { 210 + if _, ok := store[id]; !ok { http.Error(w, "not found", http.StatusNotFound); return } 211 + delete(store, id) 212 + http.Redirect(w, r, "/library", http.StatusSeeOther) 213 + return 214 + } 215 + d, ok := store[id]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 216 + _ = json.NewEncoder(w).Encode(map[string]any{"id": d.ID, "name": d.Name, "text": d.Text, "updatedAt": d.UpdatedAt}) 217 + return 218 + case http.MethodPut: 219 + var body struct{ Name *string `json:"name"`; Text *string `json:"text"` } 220 + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) 221 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest); return } 222 + d, ok := store[id]; if !ok { http.Error(w, "not found", http.StatusNotFound); return } 223 + if body.Name != nil { n := strings.TrimSpace(*body.Name); if n == "" { n = "Untitled" }; d.Name = n } 224 + if body.Text != nil { d.Text = *body.Text } 225 + d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) 226 + w.WriteHeader(http.StatusNoContent) 227 + return 228 + case http.MethodDelete: 229 + if _, ok := store[id]; !ok { http.Error(w, "not found", http.StatusNotFound); return } 230 + delete(store, id) 231 + w.WriteHeader(http.StatusNoContent) 232 + return 233 + default: 234 + w.WriteHeader(http.StatusMethodNotAllowed); return 235 + } 236 + } 119 237 did, handle, ok := getDIDAndHandle(r) 120 238 if !ok { 121 239 w.Header().Set("Content-Type", "application/json; charset=utf-8") ··· 145 263 switch r.Method { 146 264 case http.MethodGet: 147 265 s2 := Session{DID: did, Handle: handle} 266 + // Plain text export 267 + if strings.HasSuffix(id, ".fountain") { 268 + baseID := strings.TrimSuffix(id, ".fountain") 269 + name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, baseID) 270 + if err != nil { w.WriteHeader(status); return } 271 + w.Header().Del("Content-Type") 272 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 273 + if name == "" { name = "screenplay" } 274 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.fountain\"", sanitizeFilename(name))) 275 + _, _ = w.Write([]byte(text)) 276 + return 277 + } 278 + // Delete via action query 279 + if r.URL.Query().Get("action") == "delete" { 280 + // Delete record on PDS 281 + delPayload := map[string]any{"repo": did, "collection": "lol.tapapp.tap.doc", "rkey": id} 282 + dbuf, _ := json.Marshal(delPayload) 283 + delURL := pdsBaseFromUser(r) + "/xrpc/com.atproto.repo.deleteRecord" 284 + dRes, err := pdsRequest(w, r, http.MethodPost, delURL, "application/json", dbuf) 285 + if err != nil { http.Error(w, "delete failed", http.StatusBadGateway); return } 286 + defer dRes.Body.Close() 287 + if dRes.StatusCode < 200 || dRes.StatusCode >= 300 { w.WriteHeader(dRes.StatusCode); return } 288 + http.Redirect(w, r, "/library", http.StatusSeeOther) 289 + return 290 + } 148 291 name, text, status, err := getDocNameAndText(w, r, r.Context(), s2, id) 149 292 if err != nil { w.WriteHeader(status); return } 150 293 _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name, "text": text, "updatedAt": ""}) ··· 276 419 s, ok := userSessions[sid] 277 420 sessionsMu.Unlock() 278 421 if !ok || s.DID == "" { 422 + if devOffline { 423 + // Provide a stub session in offline mode so UI treats user as logged in 424 + stub := Session{DID: "did:example:dev", Handle: "dev.local"} 425 + sessionsMu.Lock(); userSessions[sid] = stub; sessionsMu.Unlock() 426 + _ = json.NewEncoder(w).Encode(stub) 427 + return 428 + } 279 429 w.WriteHeader(http.StatusNoContent) 280 430 return 281 431 } ··· 1068 1218 mux.HandleFunc("/about", handleAbout) 1069 1219 mux.HandleFunc("/privacy", handlePrivacy) 1070 1220 mux.HandleFunc("/terms", handleTerms) 1221 + mux.HandleFunc("/library", handleLibrary) 1071 1222 mux.HandleFunc("/health", handleHealth) 1072 1223 mux.HandleFunc("/preview", handlePreview) 1073 1224 // Multi-doc (ATProto-backed) ··· 1152 1303 Title: "Terms of Service", 1153 1304 } 1154 1305 render(w, "terms.html", data) 1306 + } 1307 + 1308 + func handleLibrary(w http.ResponseWriter, r *http.Request) { 1309 + w.Header().Set("Cache-Control", "no-cache") 1310 + data := struct{ Title string }{Title: "Library - Tap"} 1311 + render(w, "library.html", data) 1155 1312 } 1156 1313 1157 1314 func handleHealth(w http.ResponseWriter, r *http.Request) {
server/server

This is a binary file and will not be displayed.

+309
server/templates/library.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"/> 6 + <title>{{ .Title }}</title> 7 + <link rel="stylesheet" href="/static/styles.css"/> 8 + </head> 9 + <body> 10 + <header class="container header"> 11 + <h1 class="brand"> 12 + <a href="/"> 13 + <picture> 14 + <source media="(prefers-color-scheme: dark)" srcset="/static/images/tap-logo-dark.svg"> 15 + <source media="(prefers-color-scheme: light)" srcset="/static/images/tap-logo-light.svg"> 16 + <img class="brand-logo" src="/static/images/tap-og.png" alt="" aria-hidden="true" width="120" height="120"> 17 + </picture> 18 + <span class="brand-text">Tap</span> 19 + </a> 20 + </h1> 21 + <nav> 22 + <a href="/library" class="sp">Library</a> 23 + <a href="/" class="sp">Editor</a> 24 + <span id="header-user" class="sp" style="margin-left:12px; opacity:.85; display:none"></span> 25 + <button id="header-logout" class="sp" style="display:none">Logout</button> 26 + </nav> 27 + </header> 28 + 29 + <main class="container"> 30 + <div style="display:flex; justify-content:space-between; align-items:center; gap:12px; margin: 12px 0 8px"> 31 + <h2 style="margin:0">Library</h2> 32 + <div class="actions"> 33 + <button id="lib-new" type="button">New</button> 34 + </div> 35 + </div> 36 + <div id="list" class="list" style="display:grid; gap:6px"></div> 37 + </main> 38 + 39 + <footer class="container footer"> 40 + <p> 41 + <span class="footer-left"> 42 + <a href="/about">About</a> 43 + • <a href="/privacy">Privacy Policy</a> 44 + • <a href="/terms">Terms of Service</a> 45 + </span> 46 + <span class="footer-right"> 47 + &copy; <a href="https://limeleaf.coop" target="_blank" rel="noreferrer">Limeleaf Worker Collective</a> 48 + </span> 49 + </p> 50 + </footer> 51 + 52 + <script> 53 + document.addEventListener('DOMContentLoaded', () => { 54 + console.info('Library page script loaded'); 55 + const list = document.getElementById('list'); 56 + const btnNew = document.getElementById('lib-new'); 57 + const userEl = document.getElementById('header-user'); 58 + const btnLogout = document.getElementById('header-logout'); 59 + 60 + async function refreshHeader() { 61 + try { 62 + const res = await fetch('/atp/session'); 63 + if (res.ok && res.status !== 204) { 64 + const s = await res.json(); 65 + userEl.textContent = s?.handle || ''; 66 + userEl.style.display = s?.handle ? 'inline' : 'none'; 67 + btnLogout.style.display = s?.handle ? 'inline-block' : 'none'; 68 + } else { 69 + userEl.textContent = ''; 70 + userEl.style.display = 'none'; 71 + btnLogout.style.display = 'none'; 72 + } 73 + } catch { 74 + userEl.style.display = 'none'; 75 + btnLogout.style.display = 'none'; 76 + } 77 + } 78 + 79 + // Disable delegated capture handlers to avoid interfering with inline/button handlers 80 + // (Open/Export/PDF/Delete use anchors; Rename uses inline onclick + direct handler.) 81 + 82 + async function loadList() { 83 + list.innerHTML = '<div style="opacity:.75">Loading…</div>'; 84 + try { 85 + const res = await fetch('/docs'); 86 + if (!res.ok) { list.innerHTML = '<div style="opacity:.75">Failed to load</div>'; return; } 87 + const arr = await res.json(); 88 + const docs = Array.isArray(arr) ? arr : []; 89 + if (!docs.length) { 90 + list.innerHTML = '<div style="opacity:.75">No documents yet</div>'; 91 + return; 92 + } 93 + const html = docs 94 + .sort((a,b)=> new Date(b.updatedAt).getTime()-new Date(a.updatedAt).getTime()) 95 + .map(d => ` 96 + <div class="doc" data-id="${escapeHtml(d.id)}" data-name="${escapeHtml(d.name||'Untitled')}" style="display:flex; align-items:center; justify-content: space-between; gap:12px; padding:10px 12px; border:1px solid #e5e7eb; border-radius:10px; cursor:pointer"> 97 + <div> 98 + <div class="name"><a href="/?id=${encodeURIComponent(d.id)}" style="text-decoration:none; color:inherit">${escapeHtml(d.name||'Untitled')}</a></div> 99 + <div class="meta" style="font-size:12px; opacity:.75">Updated ${new Date(d.updatedAt).toLocaleString()}</div> 100 + </div> 101 + <div class="actions" style="display:flex; gap:8px"> 102 + <a href="/?id=${encodeURIComponent(d.id)}" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">Open</a> 103 + <button data-rename type="button" onclick="return window.__tapRename ? window.__tapRename(this) : false;">Rename</button> 104 + <a href="/docs/${encodeURIComponent(d.id)}.fountain" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">Export</a> 105 + <a href="/docs/${encodeURIComponent(d.id)}.pdf" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none">PDF</a> 106 + <a href="/docs/${encodeURIComponent(d.id)}?action=delete" class="btn" style="padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; text-decoration:none" onclick="return confirm('Delete this document? This cannot be undone.');">Delete</a> 107 + </div> 108 + </div> 109 + `).join(''); 110 + list.innerHTML = html; 111 + // Fallback: also bind per-button handlers in case delegation is bypassed 112 + list.querySelectorAll('[data-open]').forEach(btn => btn.addEventListener('click', (e) => { 113 + e.preventDefault(); e.stopPropagation(); 114 + const card = (e.currentTarget).closest('.doc'); 115 + const id = card?.getAttribute('data-id') || ''; 116 + if (!id) return; 117 + console.debug('Library: open (direct)', id); 118 + window.location.href = '/?id=' + encodeURIComponent(id); 119 + })); 120 + list.querySelectorAll('[data-rename]').forEach(btn => btn.addEventListener('click', async (e) => { 121 + e.preventDefault(); e.stopPropagation(); 122 + const card = (e.currentTarget).closest('.doc'); 123 + if (card) startInlineRename(card); 124 + })); 125 + list.querySelectorAll('[data-export]').forEach(btn => btn.addEventListener('click', async (e) => { 126 + e.preventDefault(); e.stopPropagation(); 127 + const card = (e.currentTarget).closest('.doc'); 128 + const id = card?.getAttribute('data-id') || ''; 129 + const name = (card?.getAttribute('data-name') || 'screenplay').replace(/[\\/:*?\"<>|]+/g, '-'); 130 + if (!id) return; 131 + try { 132 + const res = await fetch('/docs/' + encodeURIComponent(id)); 133 + if (!res.ok) { alert('Export failed'); return; } 134 + const d = await res.json(); 135 + const text = (d?.text || '').toString(); 136 + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); 137 + const url = URL.createObjectURL(blob); 138 + const a = document.createElement('a'); 139 + a.href = url; a.download = name + '.fountain'; a.click(); 140 + URL.revokeObjectURL(url); 141 + } catch { alert('Export failed'); } 142 + })); 143 + list.querySelectorAll('[data-export-pdf]').forEach(btn => btn.addEventListener('click', (e) => { 144 + e.preventDefault(); e.stopPropagation(); 145 + const card = (e.currentTarget).closest('.doc'); 146 + const id = card?.getAttribute('data-id') || ''; 147 + if (!id) return; 148 + window.location.href = '/docs/' + encodeURIComponent(id) + '.pdf'; 149 + })); 150 + list.querySelectorAll('[data-delete]').forEach(btn => btn.addEventListener('click', async (e) => { 151 + e.preventDefault(); e.stopPropagation(); 152 + const card = (e.currentTarget).closest('.doc'); 153 + const id = card?.getAttribute('data-id') || ''; 154 + if (!id) return; 155 + const cur = card?.getAttribute('data-name') || 'Untitled'; 156 + if (!window.confirm(`Delete "${cur}"? This cannot be undone.`)) return; 157 + try { const res = await fetch('/docs/' + encodeURIComponent(id), { method: 'DELETE' }); if (!res.ok) { alert('Delete failed'); return; } await loadList(); } 158 + catch { alert('Delete failed'); } 159 + })); 160 + } catch (e) { 161 + list.innerHTML = '<div style="opacity:.75">Failed to load</div>'; 162 + } 163 + } 164 + 165 + function onListClick(e) { 166 + // Normalize target to an Element (text nodes don't have closest) 167 + var target = e.target; 168 + if (target && target.nodeType === 3 /* TEXT_NODE */) { 169 + target = target.parentNode; 170 + } 171 + if (!target || !target.closest) return; 172 + // Use composedPath to improve reliability across shadow DOMs 173 + var path = typeof e.composedPath === 'function' ? e.composedPath() : []; 174 + // Find the card element 175 + var card = target.closest('.doc'); 176 + if (!card && Array.isArray(path)) { 177 + for (var i = 0; i < path.length; i++) { 178 + var n = path[i]; 179 + if (n && n.closest) { var c = n.closest('.doc'); if (c) { card = c; break; } } 180 + } 181 + } 182 + if (!card) return; 183 + const id = card.getAttribute('data-id') || ''; 184 + if (!id) return; 185 + var actionEl = target.closest('[data-open],[data-rename],[data-export],[data-export-pdf],[data-delete]'); 186 + if (!actionEl && Array.isArray(path)) { 187 + for (var j = 0; j < path.length; j++) { 188 + var m = path[j]; 189 + if (m && m.matches && m.matches('[data-open],[data-rename],[data-export],[data-export-pdf],[data-delete]')) { actionEl = m; break; } 190 + } 191 + } 192 + console.info('Library: onListClick target=', target, 'actionEl=', actionEl); 193 + if (actionEl && actionEl.hasAttribute('data-open')) { 194 + e.preventDefault(); e.stopPropagation(); 195 + console.debug('Library: open', id); 196 + window.location.href = '/?id=' + encodeURIComponent(id); 197 + return; 198 + } 199 + if (actionEl && actionEl.hasAttribute('data-rename')) { 200 + // Let the inline onclick handler run (do not prevent/stop here) 201 + console.debug('Library: rename (delegated pass-through)', id); 202 + return; 203 + } 204 + if (actionEl && actionEl.hasAttribute('data-export')) { 205 + e.preventDefault(); e.stopPropagation(); 206 + const name = (card.getAttribute('data-name') || 'screenplay').replace(/[\\/:*?\"<>|]+/g, '-'); 207 + fetch('/docs/' + encodeURIComponent(id)) 208 + .then(res => { if (!res.ok) throw new Error('bad'); return res.json(); }) 209 + .then(d => { 210 + const text = (d?.text || '').toString(); 211 + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); 212 + const url = URL.createObjectURL(blob); 213 + const a = document.createElement('a'); 214 + a.href = url; a.download = name + '.fountain'; a.click(); 215 + URL.revokeObjectURL(url); 216 + }) 217 + .catch(() => alert('Export failed')); 218 + return; 219 + } 220 + if (actionEl && actionEl.hasAttribute('data-export-pdf')) { 221 + e.preventDefault(); e.stopPropagation(); 222 + window.location.href = '/docs/' + encodeURIComponent(id) + '.pdf'; 223 + return; 224 + } 225 + if (actionEl && actionEl.hasAttribute('data-delete')) { 226 + e.preventDefault(); e.stopPropagation(); 227 + const cur = card.getAttribute('data-name') || 'Untitled'; 228 + if (!window.confirm(`Delete "${cur}"? This cannot be undone.`)) return; 229 + fetch('/docs/' + encodeURIComponent(id), { method: 'DELETE' }) 230 + .then(res => { if (!res.ok) throw new Error('bad'); return; }) 231 + .then(() => loadList()) 232 + .catch(() => alert('Delete failed')); 233 + return; 234 + } 235 + // Clicked the card background: open (but allow anchor default behavior) 236 + if (!actionEl && !(target && target.closest && target.closest('a'))) { 237 + e.preventDefault(); 238 + console.debug('Library: open (card)', id); 239 + window.location.href = '/?id=' + encodeURIComponent(id); 240 + } 241 + } 242 + 243 + // (Removed separate startInlineRename; use global __tapRename below) 244 + 245 + function escapeHtml(s) { 246 + const str = s == null ? '' : String(s); 247 + return str.replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); 248 + } 249 + 250 + btnLogout.addEventListener('click', async () => { 251 + try { await fetch('/atp/session', { method: 'DELETE' }); } catch {} 252 + window.location.href = '/'; 253 + }); 254 + 255 + btnNew.addEventListener('click', async () => { 256 + try { 257 + const res = await fetch('/docs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Untitled', text: '' }) }); 258 + if (!res.ok) { alert('Create failed'); return; } 259 + const d = await res.json(); 260 + const id = d?.id || d?.rkey; 261 + if (id) window.location.href = '/?id=' + encodeURIComponent(id); 262 + } catch { alert('Create failed'); } 263 + }); 264 + 265 + (async () => { await refreshHeader(); await loadList(); })(); 266 + // Expose a global rename helper for inline onclick wiring (self-contained) 267 + window.__tapRename = function(el){ 268 + try { 269 + var card = el && el.closest ? el.closest('.doc') : null; 270 + if (!card) return false; 271 + var nameEl = card.querySelector('.name'); 272 + if (!nameEl) return false; 273 + var id = card.getAttribute('data-id') || ''; 274 + if (!id) return false; 275 + var cur = card.getAttribute('data-name') || 'Untitled'; 276 + // Avoid creating multiple inputs 277 + if (card.querySelector('input[data-rename-input]')) return false; 278 + var input = document.createElement('input'); 279 + input.type = 'text'; 280 + input.value = cur; 281 + input.setAttribute('data-rename-input', ''); 282 + input.style.fontSize = '16px'; 283 + input.style.padding = '4px 6px'; 284 + input.style.border = '1px solid #d1d5db'; 285 + input.style.borderRadius = '6px'; 286 + var old = nameEl.innerHTML; 287 + nameEl.innerHTML = ''; 288 + nameEl.appendChild(input); 289 + input.focus(); input.select(); 290 + var committed = false; 291 + var commit = function(){ 292 + if (committed) return; committed = true; 293 + var next = (input.value || '').trim(); 294 + if (!next || next === cur) { nameEl.innerHTML = old; return; } 295 + fetch('/docs/' + encodeURIComponent(id), { 296 + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: next }) 297 + }).then(function(res){ if (!res.ok && res.status !== 204) throw new Error('bad'); }) 298 + .then(function(){ return loadList(); }) 299 + .catch(function(err){ console.warn('rename failed', err); alert('Rename failed'); nameEl.innerHTML = old; }); 300 + }; 301 + input.addEventListener('keydown', function(ke){ if (ke.key === 'Enter') { ke.preventDefault(); commit(); } if (ke.key === 'Escape') { ke.preventDefault(); nameEl.innerHTML = old; committed = true; } }); 302 + input.addEventListener('blur', function(){ commit(); }); 303 + } catch (e) { console.warn('global rename error', e); } 304 + return false; 305 + }; 306 + }); 307 + </script> 308 + </body> 309 + </html>
+8 -5
web/components/tap-app.ts
··· 283 283 // Load docs list and open latest, else fallback to legacy single-doc, else create new 284 284 try { 285 285 await this.loadDocsList(); 286 - if (this.docs.length > 0) { 287 - // open most recent by updatedAt 286 + // If URL includes ?id=..., open that doc explicitly; otherwise, open most recent 287 + const url = new URL(window.location.href); 288 + const qid = (url.searchParams.get('id') || '').trim(); 289 + if (qid) { 290 + await this.openDoc(qid); 291 + } else if (this.docs.length > 0) { 288 292 const sorted = [...this.docs].sort((a,b)=> new Date(b.updatedAt).getTime()-new Date(a.updatedAt).getTime()); 289 293 await this.openDoc(sorted[0].id); 290 294 } else { ··· 339 343 340 344 // Library and multi-doc handlers 341 345 this.root.addEventListener('open-library', async () => { 342 - await this.loadDocsList(); 343 - this.showLibrary = true; this.setAttribute('show-library',''); 344 - this.renderLibrary(); 346 + // Navigate to standalone Library page 347 + window.location.href = '/library'; 345 348 }); 346 349 this.root.addEventListener('new-doc', async () => { 347 350 await this.createDoc();