comparison mrjunejune/src/public/editor.js @ 231:09a96dcb2b4c hg-web

[merge] Join existing hg-web branch head
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 16:50:48 -0700
parents e5aed6c36672
children
comparison
equal deleted inserted replaced
217:7ef4c9d2a72d 231:09a96dcb2b4c
1 let editor = null;
2 let currentNoteId = 'index';
3
4 function getAuthToken() {
5 return localStorage.getItem('notes-auth-token');
6 }
7
8 function requireAuth() {
9 if (!getAuthToken()) {
10 const returnUrl = encodeURIComponent(window.location.pathname);
11 window.location.href = '/notes/login?return=' + returnUrl;
12 return false;
13 }
14 return true;
15 }
16
17 function goHome() {
18 window.location.href = '/notes';
19 }
20
21 function logout() {
22 localStorage.removeItem('notes-auth-token');
23 window.location.href = '/notes/login';
24 }
25
26 function getNoteIdFromPath() {
27 const path = window.location.pathname;
28 const match = path.match(/^\/notes\/(.+)$/);
29 if (match && match[1] && match[1] !== 'login') {
30 return decodeURIComponent(match[1]);
31 }
32 return 'index';
33 }
34
35 function showNewNoteDialog() {
36 document.getElementById('new-note-dialog').classList.add('show');
37 document.getElementById('new-note-id').focus();
38 }
39
40 function hideNewNoteDialog() {
41 document.getElementById('new-note-dialog').classList.remove('show');
42 document.getElementById('new-note-id').value = '';
43 }
44
45 function createNewNote() {
46 let noteId = document.getElementById('new-note-id').value.trim();
47 if (!noteId) return;
48
49 // Sanitize note ID
50 noteId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
51
52 hideNewNoteDialog();
53 window.location.href = '/notes/' + encodeURIComponent(noteId);
54 }
55
56 // Handle Enter key in new note dialog
57 document.getElementById('new-note-id').addEventListener('keydown', function(e) {
58 if (e.key === 'Enter') {
59 e.preventDefault();
60 createNewNote();
61 }
62 if (e.key === 'Escape') {
63 hideNewNoteDialog();
64 }
65 });
66
67 // Close dialog on backdrop click
68 document.getElementById('new-note-dialog').addEventListener('click', function(e) {
69 if (e.target === this) {
70 hideNewNoteDialog();
71 }
72 });
73
74 async function uploadFile(file) {
75 const token = getAuthToken();
76 if (!token) {
77 throw new Error('Not authenticated');
78 }
79
80 // 1. Create media record
81 const createResponse = await fetch('/api/media/create', {
82 method: 'POST',
83 headers: {
84 'Authorization': 'Bearer ' + token,
85 'Content-Type': 'application/json'
86 },
87 body: JSON.stringify({
88 filename: file.name,
89 content_type: file.type
90 })
91 });
92
93 if (!createResponse.ok) {
94 const error = await createResponse.json().catch(() => ({}));
95 throw new Error(error.error || 'Failed to create media record');
96 }
97
98 const data = await createResponse.json();
99
100 // 2. Upload file directly to S3
101 const uploadResponse = await fetch(data.upload_url, {
102 method: 'PUT',
103 headers: {
104 'Content-Type': file.type
105 },
106 body: file
107 });
108
109 if (!uploadResponse.ok) {
110 throw new Error('Failed to upload file to S3');
111 }
112
113 // 3. Mark as uploaded (triggers processing for images)
114 await fetch(`/api/media/${data.media_id}/uploaded`, {
115 method: 'POST',
116 headers: {
117 'Authorization': 'Bearer ' + token
118 }
119 });
120
121 // 4. Poll for images, return immediately for non-images
122 if (file.type.startsWith('image/')) {
123 return await pollForProcessedImage(data.media_id, token);
124 } else {
125 // For non-images, use the public URL from the server
126 return { url: data.public_url };
127 }
128 }
129
130 async function pollForProcessedImage(mediaId, token) {
131 const maxAttempts = 60; // 2 minutes max (60 * 2 seconds)
132
133 for (let i = 0; i < maxAttempts; i++) {
134 await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second interval
135
136 const statusResponse = await fetch(`/api/media/${mediaId}/status`, {
137 headers: {
138 'Authorization': 'Bearer ' + token
139 }
140 });
141
142 if (!statusResponse.ok) {
143 console.warn('Status check failed, retrying...');
144 continue;
145 }
146
147 const statusData = await statusResponse.json();
148
149 if (statusData.status === 'finished') {
150 return { url: statusData.processed_url };
151 } else if (statusData.status === 'error') {
152 throw new Error(statusData.error_message || 'Processing failed');
153 }
154 // Status is 'uploaded' or 'processing', continue polling
155 }
156
157 throw new Error('Processing timeout after 2 minutes');
158 }
159
160 async function saveContent(content) {
161 const token = getAuthToken();
162 if (!token) return;
163
164 const response = await fetch('/api/editor/save', {
165 method: 'POST',
166 headers: {
167 'Authorization': 'Bearer ' + token,
168 'Content-Type': 'application/json'
169 },
170 body: JSON.stringify({
171 doc_id: currentNoteId,
172 content: content
173 })
174 });
175
176 if (!response.ok) {
177 throw new Error('Failed to save');
178 }
179 }
180
181 async function loadNote(noteId) {
182 const token = getAuthToken();
183 if (!token) return;
184
185 try {
186 const response = await fetch('/api/editor/load/' + encodeURIComponent(noteId), {
187 headers: { 'Authorization': 'Bearer ' + token }
188 });
189
190 if (response.ok) {
191 const data = await response.json();
192 editor.setContent(data.content || '');
193 }
194 } catch (error) {
195 console.error('Failed to load note:', error);
196 }
197 }
198
199 // Initialize
200 document.addEventListener('DOMContentLoaded', function() {
201 if (!requireAuth()) return;
202
203 currentNoteId = getNoteIdFromPath();
204 document.getElementById('note-id-display').textContent = currentNoteId;
205
206 // Update page title
207 document.title = currentNoteId + ' | Notes';
208
209 editor = RichEditor.init('editor-container', {
210 uploadCallback: uploadFile,
211 saveCallback: saveContent,
212 debounceMs: 1500,
213 placeholder: 'Start writing... (paste images, drag files, or use /upload)\n\nTip: Click "+ New Note" to create linked notes.'
214 });
215
216 loadNote(currentNoteId);
217 });
218