heartranked

git clone https://git.tarina.org/heartranked
Log | Files | Refs

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 = "!["+text+"](/static/users/$user/images/web/"+text+" '"+text+"')\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