editor.html (15938B)
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=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', 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 soundlink: 270 <a href="/editor?edit=$soundlink"><code>/editor?edit=$soundlink</code></a></br> 271 <div id="status"></div> 272 <a id='back' href='/'>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="/?show=$combine">combined post</a> 279 $if remix: 280 this is a <a href="/?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 // Fetch rendered content from server 335 async function updateRendered() { 336 const textarea = document.getElementById("editor"); 337 const currentText = textarea.value; 338 const textarea2 = document.getElementById("editor2"); 339 const currentText2 = textarea2.value; 340 try { 341 const res = await fetch('/rendered'); 342 const text = await res.text(); 343 if (text != 'None') { 344 document.getElementById("rendered").innerHTML = text;} 345 } catch (e) { 346 console.error("Failed to fetch rendered content"); 347 } 348 } 349 350 // Auto-save every 60 seconds 351 async function autoSave() { 352 const textarea = document.getElementById("editor"); 353 const currentText = textarea.value; 354 const textarea2 = document.getElementById("editor2"); 355 const currentText2 = textarea2.value; 356 if ((currentText === lastSaved) && (currentText2 === lastSaved2)) return; 357 lastSaved = currentText; 358 lastSaved2 = currentText2; 359 try { 360 const res = await fetch('/save', { 361 method: 'POST', 362 headers: { 'Content-Type': 'application/json' }, 363 body: JSON.stringify({ text: currentText , text2: currentText2 }) 364 }); 365 366 if (res.ok) { 367 updateRendered(); 368 const link = document.getElementById('publish'); 369 link.style.visibility = 'visible'; 370 document.getElementById("status").innerHTML = 371 `Saved at $${new Date().toLocaleTimeString()}`; 372 } 373 } catch (e) { 374 document.getElementById("status").innerHTML = "Save failed"; 375 } 376 377 if (currentText === lastSaved2 && currentText2 === lastSaved2) return; 378 379 } 380 381 382 383 // Set up intervals 384 setInterval(autoSave, 5000); // Auto-save every 60s 385 // setInterval(updateRendered, 5000); // Update rendered every 5s 386 387 // Manual save triggers 388 document.getElementById("editor").addEventListener("blur", autoSave); 389 document.addEventListener("keydown", e => { 390 if (e.ctrlKey && e.key === "s") { 391 e.preventDefault(); 392 autoSave(); 393 } 394 }); 395 396 async function rotateright(text) { 397 try { 398 const res = await fetch('/imageapi', { 399 method: 'POST', 400 headers: { 'Content-Type': 'application/json' }, 401 body: JSON.stringify({ image: text , action: 'rotateright'}) 402 }); 403 } catch (e) { 404 console.error(e); 405 status.innerHTML = '<span style="color:red">❌ Failed to send</span>'; 406 } 407 } 408 409 async function rotateleft(text) { 410 try { 411 const res = await fetch('/imageapi', { 412 method: 'POST', 413 headers: { 'Content-Type': 'application/json' }, 414 body: JSON.stringify({ image: text , action: 'rotateleft'}) 415 }); 416 } catch (e) { 417 console.error(e); 418 status.innerHTML = '<span style="color:red">❌ Failed to send</span>'; 419 } 420 } 421 422 let lastActiveTextarea = null; 423 424 // Track which textarea was last focused 425 function trackActive() { 426 const text1 = document.getElementById('editor'); 427 const text2 = document.getElementById('editor2'); 428 429 text1.addEventListener('focus', () => lastActiveTextarea = text1); 430 text2.addEventListener('focus', () => lastActiveTextarea = text2); 431 } 432 433 function isdocs(filename) { 434 const lower = filename.toLowerCase(); 435 return lower.endsWith('.txt') || 436 lower.endsWith('.pdf') || 437 lower.endsWith('.md') || 438 lower.endsWith('.readme'); 439 } 440 441 function isfilm(filename) { 442 const lower = filename.toLowerCase(); 443 return lower.endsWith('.mpeg') || 444 lower.endsWith('.mp4'); 445 } 446 447 function insertAtCursor(text) { 448 if (!lastActiveTextarea) { 449 alert("Please click inside one of the textareas first!"); 450 return; 451 } 452 const start = lastActiveTextarea.selectionStart; 453 const end = lastActiveTextarea.selectionEnd; 454 if (isdocs(text)) { 455 const insertlen = "["+text+"](/static/users/$user/docs/"+text+" '"+text+"')\n"; 456 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 457 + insertlen 458 + lastActiveTextarea.value.substring(end); 459 } 460 if (isfilm(text)) { 461 const insertlen = "<video controls='controls'><source src=/static/users/$user/films/"+text+"></video>\n"; 462 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 463 + insertlen 464 + lastActiveTextarea.value.substring(end); 465 } else { 466 const insertlen = "\n"; 467 lastActiveTextarea.value = lastActiveTextarea.value.substring(0, start) 468 + insertlen 469 + lastActiveTextarea.value.substring(end); 470 } 471 // Move cursor after inserted text 472 lastActiveTextarea.selectionStart = lastActiveTextarea.selectionEnd = start + insertlen.length; 473 474 // Focus it again so you can keep typing 475 lastActiveTextarea.focus(); 476 } 477 // Initialize tracking 478 window.onload = trackActive; 479 480 // Initial load 481 </script> 482 </body> 483 </html> 484 485 486