-7
fly.staging.toml
-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
+1
fly.toml
+157
server/main.go
+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
server/server
This is a binary file and will not be displayed.
+309
server/templates/library.html
+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
+
© <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) => ({'&':'&','<':'<','>':'>','"':'"'}[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
+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();