cdn

Log | Files | Refs | Activity

root / app / templates / index.html

index.html (34269B)


      1 <!DOCTYPE html>
      2 <html lang="en">
      3 <head>
      4     <meta charset="UTF-8">
      5     <meta name="viewport" content="width=device-width, initial-scale=1.0">
      6     <title>CDN | {{ page | capitalize }}</title>
      7     <meta name="description" content="Personal CDN for browsing, uploading, and linking hosted assets.">
      8 
      9     <link rel="icon" href="/favicon.ico" sizes="any">
     10     <link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32x32.png">
     11     <link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16x16.png">
     12     <link rel="icon" type="image/svg+xml" href="/static/icons/logo.svg">
     13     <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
     14 
     15     <link rel="manifest" href="/static/manifest.json">
     16     <meta name="theme-color" content="#050505">
     17     <meta name="color-scheme" content="dark">
     18     <meta name="apple-mobile-web-app-capable" content="yes">
     19     <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
     20     <meta name="apple-mobile-web-app-title" content="CDN">
     21 
     22     <meta property="og:title" content="CDN | {{ page | capitalize }}">
     23     <meta property="og:description" content="Personal CDN for browsing, uploading, and linking hosted assets.">
     24     <meta property="og:image" content="/static/icons/og-logo.png">
     25     <meta property="og:type" content="website">
     26     <meta name="twitter:card" content="summary">
     27 
     28     <script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.4/dist/turbo.es2017-esm.js"></script>
     29     
     30     <style>
     31         :root {
     32             --bg: #050505;
     33             --surface: #121212;
     34             --text-main: #c9d1d9;
     35             --text-dim: #8b949e;
     36             --accent-pink: #ffb4ab;
     37             --border: #21262d;
     38             --danger: #f85149;
     39         }
     40         
     41         * { box-sizing: border-box; }
     42         * { -webkit-tap-highlight-color: transparent; }
     43         
     44         body {
     45             font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
     46             background: var(--bg);
     47             color: var(--text-main);
     48             margin: 0;
     49             padding: 3rem 1.5rem;
     50             line-height: 1.6;
     51             font-size: 1rem;
     52         }
     53 
     54         @media (max-width: 480px) {
     55             body {
     56                 padding: 1.5rem 1rem;
     57                 font-size: 0.9rem;
     58             }
     59         }
     60 
     61         .container {
     62             max-width: 760px;
     63             margin: 0 auto;
     64         }
     65 
     66         .top-accent {
     67             width: 48px;
     68             height: 2px;
     69             background: linear-gradient(90deg, #5eead4 0%, #5eead4 33%, #fca5a5 33%, #fca5a5 66%, #ffffff 66%, #ffffff 100%);
     70             margin-bottom: 2rem;
     71         }
     72 
     73         h1 {
     74             font-size: 1.4rem;
     75             text-transform: uppercase;
     76             letter-spacing: 1px;
     77             color: #ffffff;
     78             font-weight: 600;
     79             margin: 0 0 1.5rem 0;
     80         }
     81 
     82         p.intro {
     83             color: var(--text-main);
     84             margin-bottom: 1.5rem;
     85         }
     86 
     87         blockquote {
     88             border-left: 2px solid var(--accent-pink);
     89             margin: 0 0 1.5rem 0;
     90             padding-left: 1rem;
     91             color: var(--text-dim);
     92         }
     93 
     94         .upload-notice {
     95             margin-bottom: 2.5rem;
     96             color: var(--text-dim);
     97         }
     98 
     99         a { color: var(--accent-pink); text-decoration: none; }
    100         a:hover { text-decoration: underline; }
    101 
    102         .breadcrumb {
    103             color: var(--accent-pink);
    104             margin-bottom: 1rem;
    105         }
    106         .breadcrumb a { color: inherit; }
    107 
    108         .list-container {
    109             border: 1px solid var(--border);
    110             border-radius: 4px;
    111             background: var(--bg);
    112             margin-bottom: 2rem;
    113         }
    114 
    115         .filter-input {
    116             width: 100%;
    117             padding: 0.85rem 1rem;
    118             background: transparent;
    119             border: none;
    120             border-bottom: 1px solid var(--border);
    121             color: var(--text-main);
    122             font-family: inherit;
    123             font-size: inherit;
    124         }
    125         .filter-input:focus {
    126             outline: none;
    127             background: var(--surface);
    128         }
    129         .filter-input::placeholder { color: var(--text-dim); }
    130 
    131         .list-item {
    132             display: flex;
    133             align-items: center;
    134             justify-content: space-between;
    135             padding: 0.75rem 1rem;
    136             border-bottom: 1px solid var(--border);
    137             color: var(--text-main);
    138             gap: 0.5rem;
    139         }
    140         .list-item:hover {
    141             background: var(--surface);
    142         }
    143         .list-item:last-child { border-bottom: none; }
    144         
    145         .item-name {
    146             display: flex;
    147             align-items: center;
    148             gap: 0.75rem;
    149             word-break: break-all;
    150             min-width: 0;
    151             flex: 1;
    152         }
    153         .item-name svg {
    154             color: var(--text-dim);
    155             flex-shrink: 0;
    156         }
    157         .item-name a {
    158             overflow: hidden;
    159             text-overflow: ellipsis;
    160             white-space: nowrap;
    161         }
    162         
    163         .item-meta {
    164             display: flex;
    165             align-items: center;
    166             gap: 1rem;
    167             color: var(--text-dim);
    168             font-size: 0.8rem;
    169             white-space: nowrap;
    170             flex-shrink: 0;
    171         }
    172 
    173         @media (max-width: 480px) {
    174             .item-meta .size-label {
    175                 display: none;
    176             }
    177         }
    178 
    179         .download-icon {
    180             color: var(--text-dim);
    181             transition: color 0.15s;
    182             display: flex;
    183             align-items: center;
    184             padding: 0.25rem;
    185         }
    186         .download-icon:hover {
    187             color: var(--accent-pink);
    188         }
    189 
    190         .btn {
    191             display: inline-flex;
    192             align-items: center;
    193             gap: 0.5rem;
    194             background: var(--surface);
    195             color: var(--text-main);
    196             padding: 0.4rem 0.8rem;
    197             border: 1px solid var(--border);
    198             border-radius: 4px;
    199             cursor: pointer;
    200             font-family: inherit;
    201             font-size: 0.85rem;
    202             transition: opacity 0.15s ease;
    203         }
    204         .btn:hover:not(:disabled) { background: #21262d; text-decoration: none; border-color: #30363d; }
    205         .btn:disabled { opacity: 0.6; cursor: not-allowed; }
    206 
    207         @media (max-width: 480px) {
    208             .btn {
    209                 padding: 0.6rem 1rem;
    210                 font-size: 0.8rem;
    211             }
    212         }
    213 
    214         #dropzone {
    215             border: 1px dashed var(--border);
    216             border-radius: 4px;
    217             padding: 3rem 1.5rem;
    218             text-align: center;
    219             cursor: pointer;
    220             color: var(--text-dim);
    221             margin-bottom: 1.5rem;
    222             transition: all 0.2s;
    223         }
    224         #dropzone:hover, #dropzone.dragover {
    225             border-color: var(--accent-pink);
    226             color: var(--accent-pink);
    227             background: rgba(255, 180, 171, 0.05);
    228         }
    229 
    230         .file-row {
    231             display: flex; justify-content: space-between; padding: 0.75rem;
    232             border: 1px solid var(--border); border-radius: 4px; margin-bottom: 0.5rem;
    233             align-items: center;
    234         }
    235         .file-row.done { border-color: #2ea043; }
    236         .file-row.error { border-color: var(--danger); }
    237 
    238         .turbo-progress-bar {
    239             height: 3px;
    240             background-color: var(--accent-pink);
    241         }
    242 
    243         .container {
    244             transition: opacity 0.15s ease;
    245         }
    246         body.turbo-loading .container {
    247             opacity: 0.5;
    248             pointer-events: none;
    249         }
    250 
    251         .empty-state {
    252             display: flex;
    253             flex-direction: column;
    254             align-items: center;
    255             gap: 0.75rem;
    256             padding: 3rem 1.5rem;
    257             color: var(--text-dim);
    258         }
    259         .empty-state svg { opacity: 0.6; }
    260 
    261         .breadcrumb {
    262             display: flex;
    263             align-items: center;
    264             gap: 0.5rem;
    265             flex-wrap: wrap;
    266         }
    267         .copy-path-btn {
    268             background: none;
    269             border: none;
    270             color: var(--text-dim);
    271             cursor: pointer;
    272             display: inline-flex;
    273             align-items: center;
    274             padding: 0.15rem;
    275             transition: color 0.15s;
    276         }
    277         .copy-path-btn:hover { color: var(--accent-pink); }
    278 
    279         .listing-toolbar {
    280             display: flex;
    281             justify-content: space-between;
    282             align-items: center;
    283             flex-wrap: wrap;
    284             gap: 0.5rem;
    285             margin-bottom: 0.75rem;
    286         }
    287 
    288         @keyframes spin {
    289             100% { transform: rotate(360deg); }
    290         }
    291         .spin-anim {
    292             animation: spin 1s linear infinite;
    293         }
    294 
    295         .modal-overlay {
    296             position: fixed; top: 0; left: 0; width: 100%; height: 100%;
    297             background: rgba(0, 0, 0, 0.75);
    298             display: flex; align-items: center; justify-content: center;
    299             z-index: 1000;
    300             backdrop-filter: blur(2px);
    301         }
    302         .modal {
    303             background: var(--bg);
    304             border: 1px solid var(--border);
    305             border-radius: 6px;
    306             padding: 1.5rem;
    307             max-width: 420px;
    308             width: 90%;
    309             box-shadow: 0 10px 30px rgba(0,0,0,0.5);
    310         }
    311         .modal h3 {
    312             margin-top: 0; margin-bottom: 1rem;
    313             color: #fff; font-size: 1.2rem;
    314             font-weight: 500;
    315         }
    316         .modal p { margin-bottom: 1.5rem; }
    317         .modal-actions {
    318             display: flex; justify-content: flex-end; gap: 0.75rem;
    319         }
    320         .btn-primary {
    321             background: var(--accent-pink);
    322             color: #000;
    323             border-color: var(--accent-pink);
    324             font-weight: 600;
    325         }
    326         .btn-primary:hover {
    327             background: #ffc9c2;
    328             border-color: #ffc9c2;
    329         }
    330 
    331         .upload-spinner {
    332             display: inline-block;
    333             width: 12px; height: 12px;
    334             border: 2px solid var(--accent-pink);
    335             border-top-color: transparent;
    336             border-radius: 50%;
    337             margin-right: 6px;
    338             vertical-align: middle;
    339         }
    340 
    341         footer {
    342             margin-top: 4rem;
    343             padding-top: 1rem;
    344             border-top: 1px solid var(--border);
    345             color: var(--text-dim);
    346             font-size: 0.8rem;
    347             display: flex;
    348             justify-content: space-between;
    349             flex-wrap: wrap;
    350             gap: 0.5rem;
    351         }
    352         footer a { color: var(--text-dim); }
    353         footer a:hover { color: var(--text-main); }
    354     </style>
    355 </head>
    356 <body>
    357     <div class="container">
    358         {% if page == 'listing' and not path %}
    359         <div class="top-accent"></div>
    360         <h1>HEY, THIS IS <a href="https://amit.is-a.dev">MY</a> CDN.</h1>
    361         <p class="intro">Assets for my other sites. Meant for me, but you're welcome to look around.</p>
    362         <blockquote>
    363             Please don't hotlink. I can change these URLs at any point without warning, so anything pointing here may break.
    364         </blockquote>
    365         <blockquote>
    366             If you want to upload anything, go to <a href="/upload">here</a> and upload and you will get a link.
    367         </blockquote>
    368         {% endif %}
    369 
    370         {% if page != 'listing' or path %}
    371         <div class="top-accent" style="margin-bottom: 1.5rem;"></div>
    372         {% endif %}
    373 
    374         <div class="breadcrumb" id="breadcrumb">
    375             <a href="/">cdn</a>
    376             {% if path %}
    377                 {% set parts = path.split('/') %}
    378                 {% set current = namespace(val='') %}
    379                 {% for part in parts %}
    380                     {% set current.val = current.val + part + '/' %}
    381                     / <a href="/{{ current.val[:-1] }}">{{ part }}</a>
    382                 {% endfor %}
    383             {% endif %}
    384             {% if page == 'upload' %}
    385                 / <a href="/upload">upload</a>
    386             {% elif page == 'file' %}
    387                 / {{ filename }}
    388             {% endif %}
    389             {% if path or page == 'file' %}
    390             <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}">
    391                 <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
    392             </button>
    393             {% endif %}
    394         </div>
    395 
    396         {% if page == 'listing' %}
    397         {% if items %}
    398         <div class="listing-toolbar">
    399             <div style="color: var(--text-dim); font-size: 0.9rem;">
    400                 {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
    401                 {% set file_count = visible_items | rejectattr("is_dir") | list | length %}
    402                 {% set dir_count = visible_items | selectattr("is_dir") | list | length %}
    403                 
    404                 {% if dir_count > 0 %}
    405                     {{ dir_count }} folder{{ 's' if dir_count != 1 else '' }}
    406                 {% endif %}
    407                 {% if dir_count > 0 and file_count > 0 %} &amp; {% endif %}
    408                 {% if file_count > 0 %}
    409                     {{ file_count }} file{{ 's' if file_count != 1 else '' }}
    410                 {% endif %}
    411                 {% if dir_count == 0 and file_count == 0 %}
    412                     0 items
    413                 {% endif %}
    414             </div>
    415             <button id="download-zip-btn" class="btn" data-path="{{ path }}" title="Download everything in this folder as a .zip">
    416                 <svg id="zip-spinner" class="spin-anim" style="display: none; width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    417                     <line x1="12" y1="2" x2="12" y2="6"></line>
    418                     <line x1="12" y1="18" x2="12" y2="22"></line>
    419                     <line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line>
    420                     <line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line>
    421                     <line x1="2" y1="12" x2="6" y2="12"></line>
    422                     <line x1="18" y1="12" x2="22" y2="12"></line>
    423                     <line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line>
    424                     <line x1="16.24" y1="4.93" x2="19.07" y2="7.76"></line>
    425                 </svg>
    426                 <span id="zip-btn-text">Download all (.zip)</span>
    427             </button>
    428         </div>
    429         {% endif %}
    430         <div class="list-container">
    431             <input type="text" id="filter-input" class="filter-input" placeholder="Filter this folder... (press / to focus)" autocomplete="off" spellcheck="false">
    432             
    433             <div id="file-list">
    434                 {% if path %}
    435                 <div class="list-item" data-name="..">
    436                     <div class="item-name">
    437                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14l-4-4 4-4"/><path d="M5 10h11a4 4 0 1 1 0 8h-1"/></svg>
    438                         {% set parent_path = '/' %}
    439                         {% set path_parts = path.split('/') %}
    440                         {% if path_parts|length > 1 %}
    441                             {% set parent_path = '/' + path_parts[:-1]|join('/') %}
    442                         {% endif %}
    443                         <a href="{{ parent_path }}">..</a>
    444                     </div>
    445                 </div>
    446                 {% endif %}
    447 
    448                 {% for item in items %}
    449                 {% if item.name != '.gitattributes' %}
    450                 <div class="list-item" data-name="{{ item.name }}">
    451                     <div class="item-name">
    452                         {% if item.is_dir %}
    453                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
    454                         <a href="/{{ item.path }}">{{ item.name }}/</a>
    455                         {% else %}
    456                         <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
    457                         <a href="{{ raw_base_url }}/{{ item.path }}" data-turbo="false">{{ item.name }}</a>
    458                         {% endif %}
    459                     </div>
    460                     <div class="item-meta">
    461                         {% if not item.is_dir %}
    462                         <span>{{ item.size_str }}</span>
    463                         <a href="/api/download/{{ item.path }}" class="download-icon" title="Download" data-turbo="false">
    464                             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></svg>
    465                         </a>
    466                         {% endif %}
    467                     </div>
    468                 </div>
    469                 {% endif %}
    470                 {% endfor %}
    471                 
    472                 {% set visible_items = items | rejectattr("name", "equalto", ".gitattributes") | list %}
    473                 {% if not visible_items %}
    474                 <div class="empty-state">
    475                     <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
    476                     <span>Empty directory</span>
    477                 </div>
    478                 {% endif %}
    479             </div>
    480         </div>
    481 
    482         {% elif page == 'file' %}
    483         <div class="list-container" style="padding: 3rem 1.5rem; text-align: center;">
    484             <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" style="margin-bottom: 1rem;"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
    485             <h2 style="margin: 0 0 0.5rem 0; font-weight: normal; color: var(--accent-pink);">{{ filename }}</h2>
    486             <p style="color: var(--text-dim); margin-bottom: 2rem; font-size: 0.85rem; word-break: break-all;">{{ path }}</p>
    487             
    488             <div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; padding: 0 0.5rem;">
    489                 <a href="/api/download/{{ path }}" class="btn" data-turbo="false">Download</a>
    490                 <a href="{{ raw_base_url }}/{{ path }}" class="btn" data-turbo="false">Open Raw</a>
    491                 <button onclick="copyToClipboard(window.location.origin + '/' + '{{ path }}', this)" class="btn">Copy Link</button>
    492                 <button onclick="copyToClipboard('{{ raw_base_url }}/{{ path }}', this)" class="btn">Copy Raw Link</button>
    493             </div>
    494         </div>
    495 
    496         {% elif page == '404' %}
    497         <div class="list-container" style="padding: 4rem 1.5rem; text-align: center;">
    498             <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="1.5" style="margin-bottom: 1rem;"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
    499             <h2 style="margin: 0 0 0.5rem 0; font-weight: normal; color: var(--danger);">404 - Not Found</h2>
    500             <p style="color: var(--text-dim); margin-bottom: 2rem;">The file or directory you're looking for doesn't exist.</p>
    501             <a href="/" class="btn">Go to Root</a>
    502         </div>
    503 
    504         {% elif page == 'upload' %}
    505         <blockquote>
    506             If you do not remember the link, go to the <a href="/uploads">uploads folder</a> to find your file.
    507         </blockquote>
    508 
    509         <div id="dropzone">
    510             <div>drag &amp; drop files here</div>
    511             <div style="font-size: 0.8rem; margin-top: 0.5rem;">or click to select</div>
    512             <input type="file" id="file-input" multiple style="display:none;">
    513         </div>
    514 
    515         <div id="error-box" style="display: none; color: var(--danger); margin-bottom: 1rem; padding: 0.75rem; border: 1px solid var(--danger); border-radius: 4px;"></div>
    516         <div id="results"></div>
    517 
    518         <script>
    519             const dropzone = document.getElementById('dropzone');
    520             const fileInput = document.getElementById('file-input');
    521             const results = document.getElementById('results');
    522             const errorBox = document.getElementById('error-box');
    523 
    524             const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024;
    525 
    526             dropzone.addEventListener('click', () => fileInput.click());
    527             dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
    528             dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
    529             dropzone.addEventListener('drop', (e) => {
    530                 e.preventDefault(); dropzone.classList.remove('dragover');
    531                 if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
    532             });
    533             fileInput.addEventListener('change', () => {
    534                 if (fileInput.files.length) handleFiles(fileInput.files);
    535             });
    536 
    537             function showError(msg) {
    538                 errorBox.textContent = msg;
    539                 errorBox.style.display = 'block';
    540             }
    541 
    542             function formatBytes(bytes) {
    543                 if (bytes < 1024) return bytes + ' B';
    544                 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    545                 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    546             }
    547 
    548             function handleFiles(fileList) {
    549                 errorBox.style.display = 'none';
    550 
    551                 const allFiles = Array.from(fileList);
    552                 const files = [];
    553                 const rejected = [];
    554 
    555                 allFiles.forEach(f => {
    556                     if (f.size > MAX_FILE_SIZE_BYTES) {
    557                         rejected.push(f.name);
    558                     } else {
    559                         files.push(f);
    560                     }
    561                 });
    562 
    563                 if (rejected.length) {
    564                     showError(
    565                         `Skipped ${rejected.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejected.join(', ')}`
    566                     );
    567                 }
    568 
    569                 if (!files.length) return;
    570 
    571                 const rows = {};
    572                 files.forEach(f => {
    573                     const row = document.createElement('div');
    574                     row.className = 'file-row';
    575                     row.innerHTML = `<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem;">${f.name}</div><div class="status" style="color:var(--text-dim); white-space:nowrap;">uploading...</div>`;
    576                     results.prepend(row);
    577                     rows[f.name] = row;
    578                 });
    579 
    580                 const formData = new FormData();
    581                 files.forEach(f => formData.append('files', f));
    582 
    583                 const xhr = new XMLHttpRequest();
    584                 xhr.open('POST', '/api/upload', true);
    585 
    586                 xhr.upload.onprogress = (e) => {
    587                     if (!e.lengthComputable) return;
    588                     const pct = Math.round((e.loaded / e.total) * 100);
    589                     files.forEach(f => { 
    590                         const statusNode = rows[f.name].querySelector('.status');
    591                         if (pct === 100) {
    592                             statusNode.innerHTML = `<span class="upload-spinner spin-anim"></span><span style="color: var(--accent-pink);">saving to HF...</span>`;
    593                         } else {
    594                             statusNode.textContent = pct + '%'; 
    595                         }
    596                     });
    597                 };
    598 
    599                 xhr.onload = () => {
    600                     if (xhr.status >= 200 && xhr.status < 300) {
    601                         try {
    602                             const data = JSON.parse(xhr.responseText);
    603                             data.files.forEach(item => {
    604                                 const row = rows[item.filename];
    605                                 if (!row) return;
    606                                 row.classList.add('done');
    607                                 const rawPath = item.raw_url;
    608                                 row.innerHTML = `
    609                                     <div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:1rem; color: #2ea043;">${item.filename}</div>
    610                                     <div style="white-space:nowrap; display:flex; gap:0.75rem;">
    611                                         <a href="${rawPath}" target="_blank">raw</a>
    612                                     </div>
    613                                 `;
    614                             });
    615                         } catch (err) { showError('invalid response from server.'); }
    616                     } else {
    617                         let msg = 'upload failed.';
    618                         try { msg = JSON.parse(xhr.responseText).detail || msg; } catch (e) {}
    619                         showError(msg);
    620                         files.forEach(f => {
    621                             rows[f.name].classList.add('error');
    622                             rows[f.name].querySelector('.status').textContent = 'failed';
    623                             rows[f.name].querySelector('.status').style.color = 'var(--danger)';
    624                         });
    625                     }
    626                     fileInput.value = '';
    627                 };
    628                 xhr.onerror = () => { showError('connection error.'); };
    629                 xhr.send(formData);
    630             }
    631         </script>
    632         {% endif %}
    633 
    634         <div id="zip-modal-overlay" class="modal-overlay" style="display: none;">
    635             <div class="modal">
    636                 <h3>Confirm Download</h3>
    637                 <p id="zip-modal-text" style="color: var(--text-main); font-size: 0.95rem;"></p>
    638                 <div class="modal-actions">
    639                     <button type="button" class="btn" id="zip-modal-cancel">Cancel</button>
    640                     <a href="#" class="btn btn-primary" id="zip-modal-confirm" data-turbo="false">Download (.zip)</a>
    641                 </div>
    642             </div>
    643         </div>
    644 
    645         <div id="error-modal-overlay" class="modal-overlay" style="display: none;">
    646             <div class="modal">
    647                 <h3 style="color: var(--danger); margin-top: 0; margin-bottom: 1rem; font-size: 1.2rem; font-weight: 500;">Notice</h3>
    648                 <p id="error-modal-text" style="color: var(--text-main); font-size: 0.95rem; margin-bottom: 1.5rem;"></p>
    649                 <div class="modal-actions">
    650                     <button type="button" class="btn" id="error-modal-close">Close</button>
    651                 </div>
    652             </div>
    653         </div>
    654 
    655         <footer>
    656             <div>
    657                 cdn · &copy; 2026 · assets may move without notice
    658             </div>
    659             {% if repo_file_count is not none %}
    660             <div title="Across the whole repo">
    661                 {{ repo_file_count }} file{{ '' if repo_file_count == 1 else 's' }} · {{ repo_size_str }} total
    662             </div>
    663             {% endif %}
    664         </footer>
    665     </div>
    666 
    667     <script>
    668         function copyToClipboard(text, btn) {
    669             navigator.clipboard.writeText(text).then(() => {
    670                 if (!btn) return;
    671                 const original = btn.textContent;
    672                 btn.textContent = 'Copied!';
    673                 btn.disabled = true;
    674                 setTimeout(() => {
    675                     btn.textContent = original;
    676                     btn.disabled = false;
    677                 }, 1500);
    678             }).catch(err => {
    679                 console.error('failed to copy: ', err);
    680             });
    681         }
    682 
    683         function copyBreadcrumbPath(btn) {
    684             const path = btn.getAttribute('data-path') || '';
    685             navigator.clipboard.writeText(path).then(() => {
    686                 const original = btn.innerHTML;
    687                 btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
    688                 setTimeout(() => { btn.innerHTML = original; }, 1200);
    689             }).catch(err => {
    690                 console.error('failed to copy: ', err);
    691             });
    692         }
    693 
    694         if (!window.__CDN_App_Initialized) {
    695             window.__CDN_App_Initialized = true;
    696 
    697             document.addEventListener('input', (e) => {
    698                 if (e.target.id === 'filter-input') {
    699                     const term = e.target.value.toLowerCase();
    700                     const fileList = document.querySelectorAll('#file-list .list-item[data-name]');
    701                     fileList.forEach(item => {
    702                         const name = item.getAttribute('data-name').toLowerCase();
    703                         if (name === '..') return;
    704                         item.style.display = name.includes(term) ? 'flex' : 'none';
    705                     });
    706                 }
    707             });
    708 
    709             document.addEventListener('click', async (e) => {
    710                 const zipBtn = e.target.closest('#download-zip-btn');
    711                 if (zipBtn) {
    712                     e.preventDefault();
    713                     if (zipBtn.disabled) return;
    714 
    715                     const path = zipBtn.getAttribute('data-path') || '';
    716                     const zipSpinner = document.getElementById('zip-spinner');
    717                     const zipBtnText = document.getElementById('zip-btn-text');
    718                     const zipModalOverlay = document.getElementById('zip-modal-overlay');
    719 
    720                     zipBtn.disabled = true;
    721                     if (zipSpinner) zipSpinner.style.display = 'block';
    722                     if (zipBtnText) zipBtnText.textContent = 'Calculating...';
    723 
    724                     try {
    725                         const res = await fetch('/api/zip-stats/' + path);
    726                         const data = await res.json();
    727                         
    728                         if (!res.ok) {
    729                             throw new Error(data.detail || 'Failed to get directory stats');
    730                         }
    731                         
    732                         const folderName = path ? path.split('/').pop() : 'root';
    733                         document.getElementById('zip-modal-text').innerHTML = 
    734                             `Are you sure you want to download <strong>${folderName}</strong>?<br><br>` +
    735                             `This will package <strong>${data.file_count} file(s)</strong> totaling <strong>${data.size_str}</strong> into a .zip.`;
    736                         
    737                         document.getElementById('zip-modal-confirm').href = '/api/download-zip/' + path;
    738                         zipModalOverlay.style.display = 'flex';
    739                     } catch (err) {
    740                         const errorModalOverlay = document.getElementById('error-modal-overlay');
    741                         if (errorModalOverlay) {
    742                             document.getElementById('error-modal-text').textContent = err.message;
    743                             errorModalOverlay.style.display = 'flex';
    744                         } else {
    745                             alert(err.message);
    746                         }
    747                     } finally {
    748                         zipBtn.disabled = false;
    749                         if (zipSpinner) zipSpinner.style.display = 'none';
    750                         if (zipBtnText) zipBtnText.textContent = 'Download all (.zip)';
    751                     }
    752                 }
    753 
    754                 if (e.target.closest('#zip-modal-cancel') || e.target.closest('#zip-modal-confirm')) {
    755                     const zipOverlay = document.getElementById('zip-modal-overlay');
    756                     if (zipOverlay) zipOverlay.style.display = 'none';
    757                 }
    758 
    759                 if (e.target.closest('#error-modal-close')) {
    760                     const errOverlay = document.getElementById('error-modal-overlay');
    761                     if (errOverlay) errOverlay.style.display = 'none';
    762                 }
    763             });
    764 
    765             if ('serviceWorker' in navigator) {
    766                 window.addEventListener('load', () => {
    767                     navigator.serviceWorker.register('/static/sw.js').catch(err => {
    768                         console.error('service worker registration failed:', err);
    769                     });
    770                 });
    771             }
    772 
    773             document.addEventListener('turbo:load', () => {
    774                 if (window.Turbo && window.Turbo.setProgressBarDelay) {
    775                     window.Turbo.setProgressBarDelay(0);
    776                 }
    777             }, { once: true });
    778             document.addEventListener('turbo:visit', () => document.body.classList.add('turbo-loading'));
    779             document.addEventListener('turbo:submit-start', () => document.body.classList.add('turbo-loading'));
    780             document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading'));
    781             document.addEventListener('turbo:render', () => document.body.classList.remove('turbo-loading'));
    782             document.addEventListener('turbo:load', () => document.body.classList.remove('turbo-loading'));
    783 
    784             document.addEventListener('keydown', (e) => {
    785                 if (e.key !== '/') return;
    786                 const tag = (document.activeElement && document.activeElement.tagName) || '';
    787                 if (tag === 'INPUT' || tag === 'TEXTAREA') return;
    788                 const filterInput = document.getElementById('filter-input');
    789                 if (filterInput) {
    790                     e.preventDefault();
    791                     filterInput.focus();
    792                 }
    793             });
    794         }
    795     </script>
    796 </body>
    797 </html>
© notamitgamer • Site Built: 2026-09-05 01:53:16 UTC • git-mirror commit: c170d72 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror