cdn

Log | Files | Refs | Activity

commit 99b8f57201b8d9a7596f745c3e645b6b56f5f4fe
parent a68ba0edecfcb3b7dfec7b28b59b6d16f89b5c26
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Fri,  4 Sep 2026 07:24:12 +0530

Merge pull request #11 from notamitgamer/feat/ux-polish

UI/UX polish: loading state, upload validation, copy feedback, zip download, and more
Diffstat:
Mapp/main.py | 40+++++++++++++++++++++++++++++++++++++++-
Mapp/storage.py | 49+++++++++++++++++++++++++++++++++++++++++++++++--
Mapp/templates/index.html | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 235 insertions(+), 12 deletions(-)

diff --git a/app/main.py b/app/main.py @@ -2,13 +2,15 @@ import os import shutil import uuid import mimetypes +import io +import zipfile import httpx from pathlib import Path from fastapi import FastAPI, Request, File, UploadFile, HTTPException from fastapi.responses import StreamingResponse, HTMLResponse, PlainTextResponse, RedirectResponse, FileResponse from fastapi.templating import Jinja2Templates -from .storage import is_file, list_directory, upload_temp_file, HF_REPO_ID +from .storage import is_file, list_directory, list_files_recursive, upload_temp_file, HF_REPO_ID app = FastAPI() @@ -154,6 +156,42 @@ async def handle_upload(files: list[UploadFile] = File(...)): return {"files": results} +@app.get("/api/download-zip/{path:path}") +async def download_zip(path: str): + clean_path = path.strip("/") + + try: + files = list_files_recursive(clean_path) + except ValueError as e: + raise HTTPException(status_code=413, detail=str(e)) + + if not files: + raise HTTPException(status_code=404, detail="Folder is empty or not found") + + prefix_len = len(clean_path.rstrip("/")) + 1 if clean_path else 0 + + buffer = io.BytesIO() + async with httpx.AsyncClient(follow_redirects=True) as client: + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for f in files: + hf_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/{f['path']}" + r = await client.get(hf_url) + if r.status_code != 200: + continue # skip files that fail rather than aborting the whole zip + # arcname: path relative to the requested folder, so the zip + # doesn't contain the full repo path + arcname = f["path"][prefix_len:] if prefix_len else f["path"] + zf.writestr(arcname, r.content) + + buffer.seek(0) + zip_filename = (clean_path.rstrip("/").split("/")[-1] if clean_path else HF_REPO_ID.split("/")[-1]) + ".zip" + + return StreamingResponse( + buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'} + ) + @app.api_route("/ping", methods=["GET", "HEAD"], response_class=PlainTextResponse) async def ping(): return "Server is awake!" diff --git a/app/storage.py b/app/storage.py @@ -48,7 +48,9 @@ def list_directory(path: str): return cached try: - items = list(api.list_repo_tree(repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset")) + items = list(api.list_repo_tree( + repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset", expand_info=True + )) except Exception: return [] @@ -67,11 +69,22 @@ def list_directory(path: str): else: size_str = f"{size / (1024 * 1024):.1f} MB" + # last_commit is only populated when expand_info=True is passed to + # list_repo_tree; we fetch it lazily below only if available, so this + # degrades gracefully to "-" rather than failing the whole listing. + last_modified = getattr(item, "last_commit", None) + modified_str = "-" + if last_modified is not None: + date = getattr(last_modified, "date", None) + if date is not None: + modified_str = date.strftime("%Y-%m-%d") + files_and_folders.append({ "name": name, "path": item.path, "is_dir": is_dir, - "size_str": size_str + "size_str": size_str, + "modified_str": modified_str }) files_and_folders.sort(key=lambda x: (not x["is_dir"], x["name"].lower())) @@ -79,6 +92,38 @@ def list_directory(path: str): _set_cache(cache_key, files_and_folders) return files_and_folders +# Hard cap so a zip-download request can't be used to pull an unbounded +# amount of data through the server at once. +ZIP_MAX_FILES = 300 +ZIP_MAX_TOTAL_BYTES = 500 * 1024 * 1024 # 500 MB + +def list_files_recursive(path: str): + """ + Returns a flat list of every file (not folder) under `path`, for + building a zip archive. Raises ValueError if the folder is too big + to safely zip in one request. + """ + try: + items = list(api.list_repo_tree( + repo_id=HF_REPO_ID, path_in_repo=path, repo_type="dataset", recursive=True + )) + except Exception: + return [] + + files = [] + total_size = 0 + for item in items: + if hasattr(item, "size"): # files only, skip folder entries + size = getattr(item, "size", 0) or 0 + total_size += size + files.append({"path": item.path, "size": size}) + if len(files) > ZIP_MAX_FILES or total_size > ZIP_MAX_TOTAL_BYTES: + raise ValueError( + f"Folder too large to zip (limit: {ZIP_MAX_FILES} files / " + f"{ZIP_MAX_TOTAL_BYTES // (1024 * 1024)} MB)." + ) + return files + def upload_temp_file(temp_path: str, filename: str) -> str: """Uploads a local file to HF and returns the relative path.""" slug = str(uuid.uuid4())[:8] diff --git a/app/templates/index.html b/app/templates/index.html @@ -240,6 +240,58 @@ background-color: var(--accent-pink); } + /* Dim + slightly blur the content while Turbo is fetching the next + page, so navigation doesn't feel like nothing happened. */ + .container { + transition: opacity 0.15s ease; + } + body.turbo-loading .container { + opacity: 0.5; + pointer-events: none; + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 3rem 1.5rem; + color: var(--text-dim); + } + .empty-state svg { opacity: 0.6; } + + .breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .copy-path-btn { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 0.15rem; + transition: color 0.15s; + } + .copy-path-btn:hover { color: var(--accent-pink); } + + .listing-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.75rem; + } + + .modified-label { + min-width: 4.5rem; + text-align: right; + } + @media (max-width: 480px) { + .modified-label { display: none; } + } + footer { margin-top: 4rem; padding-top: 1rem; @@ -273,7 +325,7 @@ <div class="top-accent" style="margin-bottom: 1.5rem;"></div> {% endif %} - <div class="breadcrumb"> + <div class="breadcrumb" id="breadcrumb"> <a href="/">cdn</a> {% if path %} {% set parts = path.split('/') %} @@ -288,11 +340,23 @@ {% elif page == 'file' %} / {{ filename }} {% endif %} + {% if path or page == 'file' %} + <button type="button" class="copy-path-btn" title="Copy path" onclick="copyBreadcrumbPath(this)" data-path="{{ path }}"> + <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> + </button> + {% endif %} </div> {% if page == 'listing' %} + {% if items %} + <div class="listing-toolbar"> + <a href="/api/download-zip/{{ path }}" class="btn" data-turbo="false" title="Download everything in this folder as a .zip"> + Download all (.zip) + </a> + </div> + {% endif %} <div class="list-container"> - <input type="text" id="filter-input" class="filter-input" placeholder="Filter this folder..." autocomplete="off" spellcheck="false"> + <input type="text" id="filter-input" class="filter-input" placeholder="Filter this folder... (press / to focus)" autocomplete="off" spellcheck="false"> <div id="file-list"> {% if path %} @@ -318,6 +382,7 @@ </div> <div class="item-meta"> {% if not item.is_dir %} + <span class="modified-label">{{ item.modified_str }}</span> <span>{{ item.size_str }}</span> <!-- ADD data-turbo="false" to download links --> <a href="/api/download/{{ item.path }}" class="download-icon" title="Download" data-turbo="false"> @@ -329,8 +394,9 @@ {% endfor %} {% if not items %} - <div class="list-item" style="color: var(--text-dim); justify-content: center;"> - Empty directory + <div class="empty-state"> + <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> + <span>Empty directory</span> </div> {% endif %} </div> @@ -362,8 +428,8 @@ <!-- ADD data-turbo="false" to these action buttons --> <a href="/api/download/{{ path }}" class="btn" data-turbo="false">Download</a> <a href="{{ raw_base_url }}/{{ path }}" class="btn" data-turbo="false">Open Raw</a> - <button onclick="copyToClipboard(window.location.origin + '/' + '{{ path }}')" class="btn">Copy Link</button> - <button onclick="copyToClipboard('{{ raw_base_url }}/{{ path }}')" class="btn">Copy Raw Link</button> + <button onclick="copyToClipboard(window.location.origin + '/' + '{{ path }}', this)" class="btn">Copy Link</button> + <button onclick="copyToClipboard('{{ raw_base_url }}/{{ path }}', this)" class="btn">Copy Raw Link</button> </div> </div> @@ -395,6 +461,11 @@ const results = document.getElementById('results'); const errorBox = document.getElementById('error-box'); + // Client-side guardrail only — the real limit still needs to be + // enforced server-side; this just avoids a slow/failed upload + // and gives the user immediate feedback instead. + const MAX_FILE_SIZE_BYTES = 200 * 1024 * 1024; // 200 MB + dropzone.addEventListener('click', () => fileInput.click()); dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover')); @@ -411,10 +482,35 @@ errorBox.style.display = 'block'; } + function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + function handleFiles(fileList) { errorBox.style.display = 'none'; - const files = Array.from(fileList); + const allFiles = Array.from(fileList); + const files = []; + const rejected = []; + + allFiles.forEach(f => { + if (f.size > MAX_FILE_SIZE_BYTES) { + rejected.push(f.name); + } else { + files.push(f); + } + }); + + if (rejected.length) { + showError( + `Skipped ${rejected.length} file(s) over ${formatBytes(MAX_FILE_SIZE_BYTES)}: ${rejected.join(', ')}` + ); + } + + if (!files.length) return; + const rows = {}; files.forEach(f => { const row = document.createElement('div'); @@ -485,9 +581,27 @@ </div> <script> - function copyToClipboard(text) { + function copyToClipboard(text, btn) { navigator.clipboard.writeText(text).then(() => { - // visual feedback could go here + if (!btn) return; + const original = btn.textContent; + btn.textContent = 'Copied!'; + btn.disabled = true; + setTimeout(() => { + btn.textContent = original; + btn.disabled = false; + }, 1500); + }).catch(err => { + console.error('failed to copy: ', err); + }); + } + + function copyBreadcrumbPath(btn) { + const path = btn.getAttribute('data-path') || ''; + navigator.clipboard.writeText(path).then(() => { + const original = btn.innerHTML; + 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>'; + setTimeout(() => { btn.innerHTML = original; }, 1200); }).catch(err => { console.error('failed to copy: ', err); }); @@ -504,6 +618,32 @@ }); }); } + + // Dim the page immediately while Turbo fetches the next page, and + // make the built-in Turbo progress bar show up without its default + // delay, so navigation always gives some visual feedback. + document.addEventListener('turbo:load', () => { + if (window.Turbo && window.Turbo.setProgressBarDelay) { + window.Turbo.setProgressBarDelay(0); + } + }, { once: true }); + document.addEventListener('turbo:click', () => document.body.classList.add('turbo-loading')); + document.addEventListener('turbo:before-fetch-request', () => document.body.classList.add('turbo-loading')); + document.addEventListener('turbo:before-render', () => document.body.classList.remove('turbo-loading')); + document.addEventListener('turbo:render', () => document.body.classList.remove('turbo-loading')); + + // Press "/" anywhere (outside of a text field) to jump into the + // filter box on listing pages. + document.addEventListener('keydown', (e) => { + if (e.key !== '/') return; + const tag = (document.activeElement && document.activeElement.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + const filterInput = document.getElementById('filter-input'); + if (filterInput) { + e.preventDefault(); + filterInput.focus(); + } + }); </script> </body> </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