heartranked

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

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