editor.html (16091B)
1 $def with (storage, text, text2, markdown, safe_filename, soundlink, public, logged, user, combine, remix) 2 3 <link rel="stylesheet" href="/static/splash.css?v=116" type="text/css" rel="stylesheet"/> 4 <meta name="viewport" content="width=device-width, initial-scale=1"> 5 <meta http-equiv="cache-control" content="no-cache"> 6 <html> 7 <head> 8 <title>HEART RANKED EDITOR</title> 9 <style> 10 #editor { width: 100%; height: 100px; } 11 #editor2 { width: 100%; height: 300px; } 12 #status { margin-top: 10px; font-style: italic; } 13 #drop-zone { 14 width: 450px; 15 height: auto; 16 border: 1px dashed #ccc; 17 border-radius: 12px; 18 text-align: center; 19 cursor: pointer; 20 transition: all 0.3s; 21 } 22 #drop-zone.dragover { 23 border-color: #007bff; 24 background: #e6f3ff; 25 } 26 #drop-zone p { 27 margin: 20px 0; 28 font-size: 18px; 29 color: #666; 30 } 31 #file-list { 32 margin-top: 20px; 33 max-height: 300px; 34 overflow-y: auto; 35 } 36 .file-item { 37 padding: 12px; 38 margin: 8px 0; 39 border-radius: 6px; 40 border: 1px solid #ddd; 41 display: flex; 42 align-items: center; 43 gap: 12px; 44 45 } 46 .file-info { 47 flex: 1; 48 } 49 .progress-container { 50 height: 10px; 51 background: #eee; 52 border-radius: 4px; 53 overflow: hidden; 54 margin-top: 6px; 55 } 56 .progress-bar { 57 height: 100%; 58 background: linear-gradient(90deg, #007bff, #00c6ff); 59 width: 0%; 60 transition: width 0.3s ease; 61 } 62 .status { 63 font-size: 14px; 64 min-width: 90px; 65 text-align: right; 66 } 67 .success { color: green; } 68 .error { color: red; } 69 70 .page-wrapper { 71 min-height: 100vh; /* Optional: full height */ 72 display: flex; 73 justify-content: center; /* Centers horizontally */ 74 padding: 20px 10px; 75 } 76 77 .wrapper { 78 display: flex; 79 flex-wrap: wrap; 80 gap: 24px; 81 width: 100%; 82 max-width: 1200px; /* Adjust this to control total width */ 83 align-items: stretch; /* Both columns same height */ 84 } 85 86 .main { 87 flex: 1 1 50%; /* Takes most of the space */ 88 min-width: 400px; /* Prevents it from becoming too narrow */ 89 max-width: 450px; /* Prevents it from becoming too narrow */ 90 padding:10px; 91 } 92 93 #rendered { 94 flex: 0 0 50%px; /* Fixed width when there's room */ 95 min-width: 400px; /* Prevents it from becoming too narrow */ 96 max-width: 450px; /* Minimum width before it wraps */ 97 padding:10px; 98 } 99 100 /* Optional: Make it full-width on very small screens */ 101 @media (max-width: 800px) { 102 .rendered { 103 flex: 1 1 100%; /* Takes full width on mobile */ 104 } 105 } 106 107 </style> 108 </head> 109 <body> 110 111 <div class="page-wrapper"> 112 <div class="wrapper"> 113 <div class="main"> 114 <div id="drop-zone"> 115 <p>Drag & drop images jpg, gif, png <br>or documents pdf, md, txt, readme <br>files here or click to browse</p> 116 <input type="file" id="file-input" multiple style="display:none;"> 117 </div> 118 119 <div id="file-list"></div> 120 121 <script> 122 /** 123 * Convert a filename into a web-safe version. 124 * 125 * @param {string} name 126 * @param {number} max_length 127 * @param {string} replacement 128 * @returns {string} 129 */ 130 function safeFilename(name, maxLength = 100, replacement = "-") { 131 if (!name || typeof name !== "string") { 132 return "file"; 133 } 134 135 // Normalize unicode (é → e, etc.) 136 let normalized = name.normalize("NFKD"); 137 138 // Remove non-ASCII characters (equivalent to Python's encode('ascii', 'ignore')) 139 normalized = normalized.replace(/[^\x00-\x7F]/g, ""); 140 141 // Replace spaces and underscores with replacement char 142 normalized = normalized.replace(/[\s_]+/g, replacement); 143 144 // Keep only alphanumeric, hyphen, underscore, and dot 145 normalized = normalized.replace(/[^a-zA-Z0-9.\-_]/g, ""); 146 147 // Replace multiple replacement chars with single one 148 const esc = replacement.replace(/[.*+?^$${}()|[\]\\]/g, "\\$&"); 149 normalized = normalized.replace(new RegExp(esc + "+", "g"), replacement); 150 151 // Remove leading/trailing replacement chars and dots 152 normalized = normalized.replace(new RegExp(`^[$${esc}.]+|[$${esc}.]+$$`, "g"), ""); 153 154 // Prevent empty or hidden files 155 if (!normalized || normalized.startsWith(".")) { 156 normalized = "file" + normalized; 157 } 158 159 // Enforce max length (leave room for extension) 160 if (normalized.length > maxLength) { 161 normalized = normalized.substring(0, maxLength); 162 } 163 164 return normalized.toLowerCase(); 165 } 166 167 168 const dropZone = document.getElementById('drop-zone'); 169 const fileInput = document.getElementById('file-input'); 170 const fileListEl = document.getElementById('file-list'); 171 172 dropZone.addEventListener('click', () => fileInput.click()); 173 174 dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); }); 175 dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); 176 dropZone.addEventListener('drop', e => { 177 e.preventDefault(); 178 dropZone.classList.remove('dragover'); 179 handleFiles(e.dataTransfer.files); 180 }); 181 182 fileInput.addEventListener('change', e => handleFiles(e.target.files)); 183 184 function handleFiles(files) { 185 fileListEl.innerHTML = '<code>Files to upload:</code>'; 186 187 Array.from(files).forEach((file, index) => { 188 const id = 'file-' + index; 189 const item = document.createElement('div'); 190 const filenamez = safeFilename(file.name); 191 item.className = 'file-item'; 192 item.id = id; 193 item.innerHTML = ` 194 <div class="file-info"> 195 <strong>$${filenamez}</strong> 196 <small>($${(file.size / 1024 / 1024).toFixed(2)} MB)</small><br> 197 <a style="color: white;" class="insert-link" onclick="insertAtCursor('$${filenamez}')">insert</a> 198 <a style="color: white;" class="insert-link" onclick="rotateright('$${filenamez}')">↻</a> 199 <a style="color: white;" class="insert-link" onclick="rotateleft('$${filenamez}')">↺</a> 200 <div class="progress-container"> 201 <div class="progress-bar" id="prog-$${id}"></div> 202 </div> 203 </div> 204 <span class="status" id="status-$${id}">Waiting...</span> 205 `; 206 fileListEl.appendChild(item); 207 }); 208 209 uploadSequentially(Array.from(files)); // One by one with individual progress 210 } 211 212 async function uploadSequentially(files) { 213 for (let i = 0; i < files.length; i++) { 214 const file = files[i]; 215 const id = 'file-' + i; 216 const progressBar = document.getElementById(`prog-$${id}`); 217 const statusEl = document.getElementById(`status-$${id}`); 218 219 statusEl.textContent = 'Uploading...'; 220 221 await uploadSingleFile(file, progressBar, statusEl); 222 } 223 } 224 225 function uploadSingleFile(file, progressBar, statusEl) { 226 return new Promise((resolve) => { 227 const formData = new FormData(); 228 formData.append('files', file); // same key as backend 229 230 const xhr = new XMLHttpRequest(); 231 xhr.open('POST', '/upload', true); 232 233 xhr.upload.onprogress = (e) => { 234 if (e.lengthComputable) { 235 const percent = Math.round((e.loaded / e.total) * 100); 236 progressBar.style.width = percent + '%'; 237 statusEl.textContent = percent + '%'; 238 } 239 }; 240 241 xhr.onload = () => { 242 if (xhr.status === 200) { 243 progressBar.style.width = '100%'; 244 statusEl.textContent = '✅ Done'; 245 statusEl.classList.add('success'); 246 } else { 247 statusEl.textContent = '❌ Failed'; 248 statusEl.classList.add('error'); 249 } 250 resolve(); 251 }; 252 253 xhr.onerror = () => { 254 statusEl.textContent = '❌ Error'; 255 statusEl.classList.add('error'); 256 resolve(); 257 }; 258 259 xhr.send(formData); 260 }); 261 }</script> 262 263 264 <small><span style="color: yellow">#Heading1 ##Heading2 ###Heading3</span><span style="color: green"> **bold text**</span> <span style="color: magenta"> *italicized text*</span><span style="color: red"> > blockquote</span><span style="color: lime"> 1. First item 2. Second item 3. Third item</span><span style="color: purple"> - First item - Second item - Third item</span><span style="color: cyan"> `code`</span><span style="color: orange"> line break --- </span><span style="color: silver"> link [title](https://robinbackman.com)</span></small><br> 265 <br> 266 <textarea id="editor">$text</textarea> 267 <small id="count">255 characters remaining</small> 268 <textarea id="editor2">$text2</textarea> 269 $if soundlink: 270 <a href="https://robinbackman.com/editor?edit=$soundlink"><code>https://robinbackman.com/editor?edit=$soundlink</code></a></br> 271 <div id="status"></div> 272 <a id='back' href='/heartranked'>go back</a> 273 $if logged: 274 or <a id='publish' href='/editor?publish=yes'>publish</a> 275 or <a id='new' href='/editor?new=yes'>new</a> 276 <small>logged in as $user 277 $if combine: 278 this is a <a href="/heartranked?show=$combine">combined post</a> 279 $if remix: 280 this is a <a href="/heartranked?show=$remix">remix post</a></small> 281 </div> 282 <div style="text-align: left; width: 455px;" id="rendered"> 283 </div> 284 <small>made with LOVE by King Robin. sorry only registered users can publish for now.</small> 285 </div> 286 287 <script> 288 $if public == 'yes': 289 const link = document.getElementById('publish'); 290 link.style.visibility = 'hidden'; 291 292 let lastSaved = document.getElementById("editor").value; 293 294 // Wait until the page is fully loaded 295 document.addEventListener('DOMContentLoaded', () => { 296 297 const texteditor = document.getElementById('editor'); 298 const count = document.getElementById('count'); 299 300 // Safety check 301 if (!texteditor || !count) { 302 console.error('Element not found! Check your IDs.'); 303 return; 304 } 305 306 texteditor.addEventListener('input', () => { 307 const remaining = 255 - texteditor.value.length; 308 if (remaining < 0) { 309 count.textContent = `$${remaining} TOO MANY CHARACTERS. CAN'T PUBLISH! KEEP IT SHORTER.`; 310 const link = document.getElementById('publish'); 311 link.style.visibility = 'hidden';} 312 else { 313 count.textContent = `$${remaining} characters remaining`; 314 const link = document.getElementById('publish'); 315 link.style.visibility = 'visible';} 316 317 // Optional: Change color when getting low 318 if (remaining < 30) { 319 count.style.color = 'red'; 320 } else if (remaining < 80) { 321 count.style.color = 'orange'; 322 } else { 323 count.style.color = 'gray'; 324 } 325 }); 326 327 // Initial count 328 count.textContent = '255 characters remaining'; 329 }); 330 331 lastSaved = ''; 332 lastSaved2 = ''; 333 334 // Auto-save every 60 seconds 335 async function autoSave() { 336 const textarea = document.getElementById("editor"); 337 const currentText = textarea.value; 338 const textarea2 = document.getElementById("editor2"); 339 const currentText2 = textarea2.value; 340 if (currentText === lastSaved && currentText2 === lastSaved2) return; 341 342 try { 343 const res = await fetch('/save', { 344 method: 'POST', 345 headers: { 'Content-Type': 'application/json' }, 346 body: JSON.stringify({ text: currentText , text2: currentText2 }) 347 }); 348 349 if (res.ok) { 350 lastSaved = currentText; 351 lastSaved2 = currentText2; 352 const link = document.getElementById('publish'); 353 link.style.visibility = 'visible'; 354 document.getElementById("status").innerHTML = 355 `Saved at $${new Date().toLocaleTimeString()}`; 356 } 357 } catch (e) { 358 document.getElementById("status").innerHTML = "Save failed"; 359 } 360 361 if (currentText === lastSaved2 && currentText2 === lastSaved2) return; 362 363 } 364 365 // Fetch rendered content from server 366 async function updateRendered() { 367 const textarea = document.getElementById("editor"); 368 const currentText = textarea.value; 369 const textarea2 = document.getElementById("editor2"); 370 const currentText2 = textarea2.value; 371 if (currentText === lastSaved && currentText2 === lastSaved2) return; 372 try { 373 const res = await fetch('/rendered'); 374 const text = await res.text(); 375 if (text != 'None') { 376 document.getElementById("rendered").innerHTML = text;} 377 } catch (e) { 378 console.error("Failed to fetch rendered content"); 379 } 380 } 381 382 // Set up intervals 383 setInterval(autoSave, 5000); // Auto-save every 60s 384 setInterval(updateRendered, 5000); // Update rendered every 5s 385 386 // Manual save triggers 387 document.getElementById("editor").addEventListener("blur", autoSave); 388 document.addEventListener("keydown", e => { 389 if (e.ctrlKey && e.key === "s") { 390 e.preventDefault(); 391 autoSave(); 392 } 393 }); 394 395 async function rotateright(text) { 396 try { 397 const res = await fetch('/imageapi', { 398 method: 'POST', 399 headers: { 'Content-Type': 'application/json' }, 400 body: JSON.stringify({ image: text , action: 'rotateright'}) 401 }); 402 } catch (e) { 403 console.error(e); 404 status.innerHTML = '<span style="color:red">❌ Failed to send</span>'; 405 } 406 } 407 408 async function rotateleft(text) { 409 try { 410 const res = await fetch('/imageapi', { 411 method: 'POST', 412 headers: { 'Content-Type': 'application/json' }, 413 body: JSON.stringify({ image: text , action: 'rotateleft'}) 414 }); 415 } catch (e) { 416 console.error(e); 417 status.innerHTML = '<span style="color:red">❌ Failed to send</span>'; 418 } 419 } 420 421 let lastActiveTextarea = null; 422 423 // Track which textarea was last focused 424 function trackActive() { 425 const text1 = document.getElementById('editor'); 426 const text2 = document.getElementById('editor2'); 427 428 text1.addEventListener('focus', () => lastActiveTextarea = text1); 429 text2.addEventListener('focus', () => lastActiveTextarea = text2); 430 } 431 432 function isdocs(filename) { 433 const lower = filename.toLowerCase(); 434 return lower.endsWith('.txt') || 435 lower.endsWith('.pdf') || 436 lower.endsWith('.md') || 437 lower.endsWith('.readme'); 438 } 439 440 function isfilm(filename) { 441 const lower = filename.toLowerCase(); 442 return lower.endsWith('.mpeg') || 443 lower.endsWith('.mp4'); 444 } 445 446 function insertAtCursor(text) { 447 if (!lastActiveTextarea) { 448 alert("Please click inside one of the textareas first!"); 449 return; 450 } 451 const start = lastActiveTextarea.selectionStart; 452 const end = lastActiveTextarea.selectionEnd; 453 if (isdocs(text)) { 454 const insertlen = "["+text+"](/static/users/$user/docs/"+text+" '"+text+"')\n"; 455 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 456 + insertlen 457 + lastActiveTextarea.value.substring(end); 458 } 459 if (isfilm(text)) { 460 const insertlen = "<video controls='controls'><source src=/static/users/$user/films/"+text+"></video>\n"; 461 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 462 + insertlen 463 + lastActiveTextarea.value.substring(end); 464 } else { 465 const insertlen = "\n"; 466 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 467 + insertlen 468 + lastActiveTextarea.value.substring(end); 469 } 470 // Move cursor after inserted text 471 lastActiveTextarea.selectionStart = lastActiveTextarea.selectionEnd = start + insertlen.length; 472 473 // Focus it again so you can keep typing 474 lastActiveTextarea.focus(); 475 } 476 // Initialize tracking 477 window.onload = trackActive; 478 479 // Initial load 480 updateRendered(); 481 </script> 482 </body> 483 </html> 484 485 486