editor.html (16038B)
1 $def with (storage, text, text2, markdown, safe_filename, soundlink, public, logged, user, combine, remix, saveto) 2 3 <link rel="stylesheet" href="static/splash.css?v=133" 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/drop', 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://heartranked.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 saveto != '': 270 saving to file $saveto 271 $if soundlink: 272 <a href="/editor?edit=$soundlink"><code>/editor?edit=$soundlink</code></a></br> 273 <div id="status"></div> 274 <a id='back' href='/'>go back</a> 275 $if logged: 276 or <a id='publish' href='/editor?publish=yes'>publish</a> 277 or <a id='new' href='/editor?new=yes'>new</a> 278 <small>logged in as $user 279 $if combine: 280 this is a <a href="/?show=$combine">combined post</a> 281 $if remix: 282 this is a <a href="/?show=$remix">remix post</a></small> 283 </div> 284 <div style="text-align: left; width: 455px;" id="rendered"> 285 </div> 286 <small>made with LOVE by King Robin. sorry only registered users can publish for now.</small> 287 </div> 288 289 <script> 290 $if public == 'yes': 291 const link = document.getElementById('publish'); 292 link.style.visibility = 'hidden'; 293 294 let lastSaved = document.getElementById("editor").value; 295 296 // Wait until the page is fully loaded 297 document.addEventListener('DOMContentLoaded', () => { 298 299 const texteditor = document.getElementById('editor'); 300 const count = document.getElementById('count'); 301 302 // Safety check 303 if (!texteditor || !count) { 304 console.error('Element not found! Check your IDs.'); 305 return; 306 } 307 308 texteditor.addEventListener('input', () => { 309 const remaining = 255 - texteditor.value.length; 310 if (remaining < 0) { 311 count.textContent = `$${remaining} TOO MANY CHARACTERS. CAN'T PUBLISH! KEEP IT SHORTER.`; 312 const link = document.getElementById('publish'); 313 link.style.visibility = 'hidden';} 314 else { 315 count.textContent = `$${remaining} characters remaining`; 316 const link = document.getElementById('publish'); 317 link.style.visibility = 'visible';} 318 319 // Optional: Change color when getting low 320 if (remaining < 30) { 321 count.style.color = 'red'; 322 } else if (remaining < 80) { 323 count.style.color = 'orange'; 324 } else { 325 count.style.color = 'gray'; 326 } 327 }); 328 329 // Initial count 330 count.textContent = '255 characters remaining'; 331 }); 332 333 lastSaved = ''; 334 lastSaved2 = ''; 335 336 // Fetch rendered content from server 337 async function updateRendered() { 338 const textarea = document.getElementById("editor"); 339 const currentText = textarea.value; 340 const textarea2 = document.getElementById("editor2"); 341 const currentText2 = textarea2.value; 342 try { 343 const res = await fetch('/rendered'); 344 const text = await res.text(); 345 if (text != 'None') { 346 document.getElementById("rendered").innerHTML = text;} 347 } catch (e) { 348 console.error("Failed to fetch rendered content"); 349 } 350 } 351 352 // Auto-save every 60 seconds 353 async function autoSave() { 354 const textarea = document.getElementById("editor"); 355 const currentText = textarea.value; 356 const textarea2 = document.getElementById("editor2"); 357 const currentText2 = textarea2.value; 358 if ((currentText === lastSaved) && (currentText2 === lastSaved2)) return; 359 lastSaved = currentText; 360 lastSaved2 = currentText2; 361 try { 362 const res = await fetch('/save', { 363 method: 'POST', 364 headers: { 'Content-Type': 'application/json' }, 365 body: JSON.stringify({ text: currentText , text2: currentText2 }) 366 }); 367 368 if (res.ok) { 369 updateRendered(); 370 const link = document.getElementById('publish'); 371 link.style.visibility = 'visible'; 372 document.getElementById("status").innerHTML = 373 `Saved at $${new Date().toLocaleTimeString()}`; 374 } 375 } catch (e) { 376 document.getElementById("status").innerHTML = "Save failed"; 377 } 378 379 if (currentText === lastSaved2 && currentText2 === lastSaved2) return; 380 381 } 382 383 384 385 // Set up intervals 386 setInterval(autoSave, 5000); // Auto-save every 60s 387 // setInterval(updateRendered, 5000); // Update rendered every 5s 388 389 // Manual save triggers 390 document.getElementById("editor").addEventListener("blur", autoSave); 391 document.addEventListener("keydown", e => { 392 if (e.ctrlKey && e.key === "s") { 393 e.preventDefault(); 394 autoSave(); 395 } 396 }); 397 398 async function rotateright(text) { 399 try { 400 const res = await fetch('/imageapi', { 401 method: 'POST', 402 headers: { 'Content-Type': 'application/json' }, 403 body: JSON.stringify({ image: text , action: 'rotateright'}) 404 }); 405 } catch (e) { 406 console.error(e); 407 status.innerHTML = '<span style="color:red">❌ Failed to send</span>'; 408 } 409 } 410 411 async function rotateleft(text) { 412 try { 413 const res = await fetch('/imageapi', { 414 method: 'POST', 415 headers: { 'Content-Type': 'application/json' }, 416 body: JSON.stringify({ image: text , action: 'rotateleft'}) 417 }); 418 } catch (e) { 419 console.error(e); 420 status.innerHTML = '<span style="color:red">❌ Failed to send</span>'; 421 } 422 } 423 424 let lastActiveTextarea = null; 425 426 // Track which textarea was last focused 427 function trackActive() { 428 const text1 = document.getElementById('editor'); 429 const text2 = document.getElementById('editor2'); 430 431 text1.addEventListener('focus', () => lastActiveTextarea = text1); 432 text2.addEventListener('focus', () => lastActiveTextarea = text2); 433 } 434 435 function isdocs(filename) { 436 const lower = filename.toLowerCase(); 437 return lower.endsWith('.txt') || 438 lower.endsWith('.pdf') || 439 lower.endsWith('.zip') || 440 lower.endsWith('.md') || 441 lower.endsWith('.readme'); 442 } 443 444 function isfilm(filename) { 445 const lower = filename.toLowerCase(); 446 return lower.endsWith('.mpeg') || 447 lower.endsWith('.mp4'); 448 } 449 450 function insertAtCursor(text) { 451 if (!lastActiveTextarea) { 452 alert("Please click inside one of the textareas first!"); 453 return; 454 } 455 const start = lastActiveTextarea.selectionStart; 456 const end = lastActiveTextarea.selectionEnd; 457 if (isdocs(text)) { 458 const insertlen = "["+text+"](/static/users/$user/docs/"+text+" '"+text+"')\n"; 459 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 460 + insertlen 461 + lastActiveTextarea.value.substring(end); 462 } 463 else if (isfilm(text)) { 464 const insertlen = "<video controls='controls'><source src=/static/users/$user/films/"+text+"></video>\n"; 465 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 466 + insertlen 467 + lastActiveTextarea.value.substring(end); 468 } else { 469 const insertlen = "\n"; 470 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 471 + insertlen 472 + lastActiveTextarea.value.substring(end); 473 } 474 // Move cursor after inserted text 475 lastActiveTextarea.selectionStart = lastActiveTextarea.selectionEnd = start + insertlen.length; 476 477 // Focus it again so you can keep typing 478 lastActiveTextarea.focus(); 479 } 480 // Initialize tracking 481 window.onload = trackActive; 482 483 // Initial load 484 </script> 485 </body> 486 </html> 487 488 489