uploads

Log | Files | Refs

commit 8cc054e718b63d379ed821da9055765c1e27f276
parent 125176a486b7d5aa7ae17dc1597010ea9bd7d325
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Tue, 21 Jul 2026 08:18:19 +0530

Merge pull request #1 from notamitgamer/update

Refactor file upload handling and add metadata support
Diffstat:
Mapi/index.py | 252++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mindex.html | 563++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mrequirements.txt | 1+
Mvercel.json | 28++++++++++++++--------------
4 files changed, 574 insertions(+), 270 deletions(-)

diff --git a/api/index.py b/api/index.py @@ -1,63 +1,237 @@ import os +import re +import json import random import string -from datetime import datetime +from datetime import datetime, timedelta, timezone +from typing import List, Optional + +import httpx from fastapi import FastAPI, File, UploadFile, Form, HTTPException -from huggingface_hub import HfApi +from fastapi.responses import RedirectResponse, JSONResponse +from huggingface_hub import HfApi, hf_hub_download +from huggingface_hub.utils import EntryNotFoundError # Fix for Vercel's read-only file system (os error 30) os.environ["HF_HOME"] = "/tmp" os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0" -# Create FastAPI app for Vercel app = FastAPI(docs_url=None, redoc_url=None) -# Settings -DATASET_REPO = "notamitgamer/uploads" +DATASET_REPO = "notamitgamer/uploads" HF_TOKEN = os.getenv("HF_TOKEN") +MAX_UPLOAD_BYTES = int(4.4 * 1024 * 1024) # stay under Vercel's 4.5MB body limit -@app.post("/api/upload") -async def upload_file(file: UploadFile = File(...), custom_id: str = Form(None)): +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SAFE_ID_RE = re.compile(r"[^a-zA-Z0-9._-]+") + + +def sanitize_id(raw: str, ext: str) -> str: + """Turn a user-supplied custom_id into a safe repo path segment. + + - strips anything that isn't alnum, dot, dash, underscore + - removes leading dots/dashes (no path traversal, no dotfiles) + - enforces a sane max length + - re-appends the original extension if missing + """ + raw = raw.strip() + raw = raw.replace("/", "-").replace("\\", "-") + raw = _SAFE_ID_RE.sub("", raw) + raw = raw.lstrip(".-") + if not raw: + raise HTTPException(status_code=400, detail="custom_id is invalid after sanitization") + raw = raw[:80] + if ext and not raw.endswith(ext): + raw += ext + return raw + + +def random_id(ext: str) -> str: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + random_str = "".join(random.choices(string.ascii_letters + string.digits, k=6)) + return f"{timestamp}-{random_str}{ext}" + + +def get_api() -> HfApi: if not HF_TOKEN: raise HTTPException(status_code=500, detail="HF_TOKEN environment variable is not set") + return HfApi() + + +def ensure_repo(api: HfApi): + api.create_repo( + repo_id=DATASET_REPO, + repo_type="dataset", + exist_ok=True, + token=HF_TOKEN, + private=False, + ) + + +def upload_bytes(api: HfApi, content: bytes, path_in_repo: str): + api.upload_file( + path_or_fileobj=content, + path_in_repo=path_in_repo, + repo_id=DATASET_REPO, + repo_type="dataset", + token=HF_TOKEN, + ) + + +def write_meta(api: HfApi, item_id: str, expires_at: Optional[str]): + if not expires_at: + return + meta = {"expires_at": expires_at} + upload_bytes(api, json.dumps(meta).encode(), f"{item_id}.meta.json") + + +def raw_url(item_id: str) -> str: + return f"https://huggingface.co/datasets/{DATASET_REPO}/resolve/main/{item_id}" + + +# --------------------------------------------------------------------------- +# Upload a single file (used by /api/upload for each item) +# --------------------------------------------------------------------------- + +def handle_one_upload( + api: HfApi, + filename: str, + content: bytes, + custom_id: Optional[str], + expires_in_minutes: Optional[int], +) -> dict: + ext = os.path.splitext(filename)[1] + item_id = sanitize_id(custom_id, ext) if custom_id else random_id(ext) + + expires_at = None + if expires_in_minutes and expires_in_minutes > 0: + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=expires_in_minutes)).isoformat() + + upload_bytes(api, content, item_id) + write_meta(api, item_id, expires_at) + + link_path = f"/api/file/{item_id}" if expires_at else f"/{item_id}" + return { + "item_id": item_id, + "link_path": link_path, + "expires_at": expires_at, + "size": len(content), + } + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.post("/api/upload") +async def upload_files( + files: List[UploadFile] = File(...), + custom_id: Optional[str] = Form(None), + expires_in_minutes: Optional[int] = Form(None), +): + api = get_api() + ensure_repo(api) + + # custom_id only makes sense for a single file + if custom_id and len(files) > 1: + raise HTTPException(status_code=400, detail="custom_id can only be used when uploading a single file") + + results = [] + for f in files: + content = await f.read() + if len(content) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"'{f.filename}' exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)}MB request limit", + ) + try: + result = handle_one_upload(api, f.filename or "file", content, custom_id, expires_in_minutes) + results.append(result) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to upload '{f.filename}': {e}") + + return {"files": results} + + +@app.post("/api/upload-url") +async def upload_from_url( + url: str = Form(...), + custom_id: Optional[str] = Form(None), + expires_in_minutes: Optional[int] = Form(None), +): + if not url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="Only http/https URLs are supported") + + api = get_api() + ensure_repo(api) try: - ext = os.path.splitext(file.filename)[1] - - # Generate unique code if not provided - if not custom_id: - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - random_str = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) - item_id = f"{timestamp}-{random_str}{ext}" - else: - item_id = custom_id - if ext and not item_id.endswith(ext): - item_id += ext - - # Read the file directly into server RAM (bytes) - content = await file.read() - - api = HfApi() - - # NEW: Automatically create the dataset on Hugging Face if it doesn't exist - api.create_repo( - repo_id=DATASET_REPO, - repo_type="dataset", - exist_ok=True, - token=HF_TOKEN, - private=False # Ensure it is public so your links work instantly + async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client: + resp = await client.get(url) + resp.raise_for_status() + content = resp.content + except httpx.HTTPError as e: + raise HTTPException(status_code=400, detail=f"Could not fetch URL: {e}") + + if len(content) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"Remote file exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)}MB limit", ) - # Push directly from RAM to Hugging Face Dataset (Bypassing disk entirely) - api.upload_file( - path_or_fileobj=content, - path_in_repo=item_id, + filename = url.split("/")[-1].split("?")[0] or "file" + + try: + result = handle_one_upload(api, filename, content, custom_id, expires_in_minutes) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + return {"files": [result]} + + +@app.get("/api/file/{item_id:path}") +async def serve_expiring_file(item_id: str): + """Dynamic redirect for links that were uploaded with an expiry.""" + try: + meta_path = hf_hub_download( repo_id=DATASET_REPO, repo_type="dataset", - token=HF_TOKEN + filename=f"{item_id}.meta.json", + token=HF_TOKEN, ) - - return {"item_id": item_id} - + with open(meta_path) as fh: + meta = json.load(fh) + expires_at = meta.get("expires_at") + if expires_at and datetime.fromisoformat(expires_at) < datetime.now(timezone.utc): + return JSONResponse(status_code=410, content={"detail": "This link has expired."}) + except EntryNotFoundError: + pass # no meta file -> treat as non-expiring + except Exception: + pass # if metadata can't be checked, fail open rather than block a valid file + + return RedirectResponse(url=raw_url(item_id), status_code=302) + + +@app.get("/api/files") +async def list_files(): + api = get_api() + try: + entries = api.list_repo_files(repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + files = [] + for name in entries: + if name.endswith(".meta.json") or name == ".gitattributes": + continue + files.append({"item_id": name, "url": raw_url(name)}) + + files.sort(key=lambda x: x["item_id"], reverse=True) + return {"files": files} diff --git a/index.html b/index.html @@ -5,52 +5,35 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Upload Hub</title> - <!-- Fonts: Lexend & Material Symbols --> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,1,0" rel="stylesheet" /> <script src="https://cdn.tailwindcss.com"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> <script> tailwind.config = { theme: { extend: { - fontFamily: { - sans: ['Lexend', 'sans-serif'], - }, + fontFamily: { sans: ['Lexend', 'sans-serif'] }, colors: { m3: { - background: '#FDFCFB', - backgroundDark: '#1A1C19', - surface: '#FDFCFB', - surfaceDark: '#1A1C19', - surfaceContainer: '#F1F1EA', - surfaceContainerDark: '#2D2F2B', - surfaceContainerHigh: '#EBEBE4', - surfaceContainerHighDark: '#333531', - surfaceContainerHighest: '#E5E6DE', - surfaceContainerHighestDark: '#434541', - onSurface: '#1A1C19', - onSurfaceDark: '#E3E3DC', - onSurfaceVariant: '#43483E', - onSurfaceVariantDark: '#C3C8BC', - primary: '#386A20', - primaryDark: '#9CD67D', - onPrimary: '#FFFFFF', - onPrimaryDark: '#0C3900', - secondaryContainer: '#D7E8CD', - secondaryContainerDark: '#374B2E', - onSecondaryContainer: '#111F0F', - onSecondaryContainerDark: '#D7E8CD', - error: '#BA1A1A', - errorDark: '#FFB4AB', - errorContainer: '#FFDAD6', - errorContainerDark: '#93000A', - onErrorContainer: '#410002', - onErrorContainerDark: '#FFDAD6', - outline: '#74796D', - outlineDark: '#8D9286', + background: '#FDFCFB', backgroundDark: '#1A1C19', + surface: '#FDFCFB', surfaceDark: '#1A1C19', + surfaceContainer: '#F1F1EA', surfaceContainerDark: '#2D2F2B', + surfaceContainerHigh: '#EBEBE4', surfaceContainerHighDark: '#333531', + surfaceContainerHighest: '#E5E6DE', surfaceContainerHighestDark: '#434541', + onSurface: '#1A1C19', onSurfaceDark: '#E3E3DC', + onSurfaceVariant: '#43483E', onSurfaceVariantDark: '#C3C8BC', + primary: '#386A20', primaryDark: '#9CD67D', + onPrimary: '#FFFFFF', onPrimaryDark: '#0C3900', + secondaryContainer: '#D7E8CD', secondaryContainerDark: '#374B2E', + onSecondaryContainer: '#111F0F', onSecondaryContainerDark: '#D7E8CD', + error: '#BA1A1A', errorDark: '#FFB4AB', + errorContainer: '#FFDAD6', errorContainerDark: '#93000A', + onErrorContainer: '#410002', onErrorContainerDark: '#FFDAD6', + outline: '#74796D', outlineDark: '#8D9286', } } } @@ -59,99 +42,143 @@ </script> <style> - body { - background-color: #FDFCFB; - margin: 0; - -webkit-font-smoothing: antialiased; - } - @media (prefers-color-scheme: dark) { - body { background-color: #1A1C19; } - } - input:focus { - outline: none; - border-bottom-color: #386A20; - } - @media (prefers-color-scheme: dark) { - input:focus { border-bottom-color: #9CD67D; } - } + body { background-color: #FDFCFB; margin: 0; -webkit-font-smoothing: antialiased; } + @media (prefers-color-scheme: dark) { body { background-color: #1A1C19; } } + input:focus, select:focus { outline: none; border-bottom-color: #386A20; } + @media (prefers-color-scheme: dark) { input:focus, select:focus { border-bottom-color: #9CD67D; } } .show { display: block !important; } .show-flex { display: flex !important; } .hide { display: none !important; } + .tab-active { border-bottom: 2px solid #386A20; color: #386A20; } + @media (prefers-color-scheme: dark) { .tab-active { border-bottom-color: #9CD67D; color: #9CD67D; } } + @keyframes indeterminate { 0% { transform: translateX(-100%); } 100% { transform: translateX(250%); } } + .indeterminate-bar { animation: indeterminate 1.2s ease-in-out infinite; } </style> </head> -<body class="bg-m3-background dark:bg-m3-backgroundDark text-m3-onSurface dark:text-m3-onSurfaceDark flex flex-col items-center justify-center min-h-screen p-4 font-sans"> +<body class="bg-m3-background dark:bg-m3-backgroundDark text-m3-onSurface dark:text-m3-onSurfaceDark flex flex-col items-center min-h-screen p-4 font-sans"> - <div id="ui-container" class="w-full max-w-[480px] show"> - - <div class="mb-8"> - <h1 class="text-2xl font-bold tracking-tight">Upload</h1> - <p class="text-sm text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark mt-1">Store files permanently on Hugging Face</p> - </div> + <div id="ui-container" class="w-full max-w-[520px] mt-8"> - <div class="flex flex-col gap-1.5 mb-6"> - <label for="custom-id" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Custom ID (Optional)</label> - <input type="text" id="custom-id" placeholder="e.g., source-code" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark text-m3-onSurface dark:text-m3-onSurfaceDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> + <div class="mb-6 flex items-end justify-between"> + <div> + <h1 class="text-2xl font-bold tracking-tight">Upload</h1> + <p class="text-sm text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark mt-1">Store files permanently on Hugging Face</p> + </div> </div> - <div id="dropzone" class="bg-m3-surfaceContainer dark:bg-m3-surfaceContainerDark hover:bg-m3-surfaceContainerHigh dark:hover:bg-m3-surfaceContainerHighDark rounded-[24px] p-10 flex flex-col items-center justify-center cursor-pointer mb-6 border border-transparent transition-colors"> - <span class="material-symbols-outlined text-[48px] text-m3-primary dark:text-m3-primaryDark mb-4">upload</span> - <span class="text-base font-semibold text-m3-onSurface dark:text-m3-onSurfaceDark">Click or drag file</span> - <span class="text-sm mt-1 text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Max 4.5MB per file</span> - <input type="file" id="file-input" class="hidden"> + <!-- Tabs --> + <div class="flex gap-6 mb-6 border-b border-m3-surfaceContainerHighest dark:border-m3-surfaceContainerHighestDark"> + <button data-tab="upload-tab" class="tab-btn tab-active pb-2 text-sm font-semibold">Upload</button> + <button data-tab="url-tab" class="tab-btn pb-2 text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">From URL</button> + <button data-tab="browse-tab" class="tab-btn pb-2 text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Browse</button> </div> - <div id="progress-container" class="hide mb-6"> - <div class="flex justify-between items-center mb-2"> - <span class="text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Uploading...</span> - <span id="progress-text" class="text-sm font-bold text-m3-primary dark:text-m3-primaryDark">0%</span> + <!-- ==================== UPLOAD TAB ==================== --> + <div id="upload-tab" class="tab-panel"> + <div class="flex flex-col gap-1.5 mb-4"> + <label for="custom-id" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Custom ID (single file only, optional)</label> + <input type="text" id="custom-id" placeholder="e.g., source-code" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> </div> - <div class="w-full bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark h-2 rounded-none"> - <div id="progress-bar" class="bg-m3-primary dark:bg-m3-primaryDark h-2 rounded-none" style="width: 0%;"></div> + + <div class="flex flex-col gap-1.5 mb-6"> + <label for="expiry-select" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Link expiry (optional)</label> + <select id="expiry-select" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark"> + <option value="">Never (permanent link)</option> + <option value="60">1 hour</option> + <option value="1440">1 day</option> + <option value="10080">7 days</option> + <option value="43200">30 days</option> + </select> </div> - </div> - <div id="skeleton-container" class="hide animate-pulse bg-m3-surfaceContainer dark:bg-m3-surfaceContainerDark p-4 rounded-[16px] mb-6"> - <div class="flex items-center gap-2 mb-3"> - <div class="w-5 h-5 rounded-full bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> - <div class="w-32 h-4 rounded bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> + <div id="dropzone" class="bg-m3-surfaceContainer dark:bg-m3-surfaceContainerDark hover:bg-m3-surfaceContainerHigh dark:hover:bg-m3-surfaceContainerHighDark rounded-[24px] p-10 flex flex-col items-center justify-center cursor-pointer mb-6 border border-transparent transition-colors"> + <span class="material-symbols-outlined text-[48px] text-m3-primary dark:text-m3-primaryDark mb-4">upload</span> + <span class="text-base font-semibold">Click, drag, or paste (Ctrl+V) files</span> + <span class="text-sm mt-1 text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Multiple files supported &middot; any type</span> + <input type="file" id="file-input" class="hidden" multiple> </div> - <div class="bg-m3-background dark:bg-m3-backgroundDark p-3 rounded-[8px] flex flex-col gap-3"> - <div class="w-full h-4 rounded bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> - <div class="w-3/4 h-4 rounded bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> - <div class="flex gap-2 w-full mt-1"> - <div class="flex-1 h-9 rounded-[8px] bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> - <div class="flex-1 h-9 rounded-[8px] bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark"></div> + + <div id="progress-container" class="hide mb-6"> + <div class="flex justify-between items-center mb-2"> + <span id="progress-label" class="text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Uploading to browser...</span> + <span id="progress-text" class="text-sm font-bold text-m3-primary dark:text-m3-primaryDark">0%</span> + </div> + <div class="w-full bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark h-2 rounded-none overflow-hidden relative"> + <div id="progress-bar" class="bg-m3-primary dark:bg-m3-primaryDark h-2 rounded-none" style="width: 0%;"></div> </div> </div> - </div> - <div id="result-container" class="hide bg-m3-secondaryContainer dark:bg-m3-secondaryContainerDark p-4 rounded-[16px] mb-6 border border-transparent"> - <div class="flex items-center gap-2 mb-3"> - <span class="material-symbols-outlined text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark">check_circle</span> - <span class="font-bold text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark text-sm">Upload Successful</span> + <div id="processing-container" class="hide mb-6"> + <div class="flex justify-between items-center mb-2"> + <span class="text-sm font-semibold text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Saving to Hugging Face&hellip;</span> + <span id="processing-timer" class="text-sm font-bold text-m3-primary dark:text-m3-primaryDark">0s</span> + </div> + <div class="w-full bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark h-2 rounded-none overflow-hidden relative"> + <div class="indeterminate-bar bg-m3-primary dark:bg-m3-primaryDark h-2 w-1/3 absolute"></div> + </div> </div> - <div class="flex flex-col gap-3 bg-m3-background dark:bg-m3-backgroundDark p-3 rounded-[8px]"> - <div id="final-link-text" class="text-sm text-m3-onSurface dark:text-m3-onSurfaceDark break-all select-all font-medium"></div> - <div class="flex gap-2 w-full"> - <button id="copy-btn" class="flex-1 bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-3 py-2 rounded-[8px] text-sm font-medium hover:bg-opacity-90 active:bg-opacity-100 flex items-center justify-center gap-1.5 border border-transparent"> - <span class="material-symbols-outlined text-[18px]" id="copy-icon">content_copy</span> - <span>Copy</span> - </button> - <a id="open-btn" href="#" target="_blank" class="flex-1 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark text-m3-onSurface dark:text-m3-onSurfaceDark px-3 py-2 rounded-[8px] text-sm font-medium hover:bg-opacity-90 active:bg-opacity-100 flex items-center justify-center gap-1.5 no-underline border border-transparent"> - <span class="material-symbols-outlined text-[18px]">open_in_new</span> - <span>Open</span> - </a> + + <div id="results-list" class="flex flex-col gap-3 mb-6"></div> + + <div id="error-container" class="hide bg-m3-errorContainer dark:bg-m3-errorContainerDark text-m3-onErrorContainer dark:text-m3-onErrorContainerDark p-4 rounded-[16px] mb-6"> + <div class="flex items-start gap-3"> + <span class="material-symbols-outlined">error</span> + <span id="error-text" class="text-sm font-medium mt-0.5"></span> </div> </div> </div> - <div id="error-container" class="hide bg-m3-errorContainer dark:bg-m3-errorContainerDark text-m3-onErrorContainer dark:text-m3-onErrorContainerDark p-4 rounded-[16px] mb-6"> - <div class="flex items-start gap-3"> - <span class="material-symbols-outlined">error</span> - <span id="error-text" class="text-sm font-medium mt-0.5"></span> + <!-- ==================== URL TAB ==================== --> + <div id="url-tab" class="tab-panel hide"> + <div class="flex flex-col gap-1.5 mb-4"> + <label for="url-input" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">File URL</label> + <input type="text" id="url-input" placeholder="https://example.com/file.png" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> + </div> + <div class="flex flex-col gap-1.5 mb-4"> + <label for="url-custom-id" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Custom ID (optional)</label> + <input type="text" id="url-custom-id" placeholder="e.g., logo" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark placeholder-gray-500"> + </div> + <div class="flex flex-col gap-1.5 mb-6"> + <label for="url-expiry-select" class="text-sm font-medium text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Link expiry (optional)</label> + <select id="url-expiry-select" class="px-4 py-3 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark rounded-t-md border-b-2 border-m3-outline dark:border-m3-outlineDark"> + <option value="">Never (permanent link)</option> + <option value="60">1 hour</option> + <option value="1440">1 day</option> + <option value="10080">7 days</option> + <option value="43200">30 days</option> + </select> + </div> + <button id="url-upload-btn" class="w-full bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-4 py-3 rounded-[8px] text-sm font-semibold mb-6">Fetch &amp; Upload</button> + + <div id="url-results-list" class="flex flex-col gap-3 mb-6"></div> + <div id="url-error-container" class="hide bg-m3-errorContainer dark:bg-m3-errorContainerDark text-m3-onErrorContainer dark:text-m3-onErrorContainerDark p-4 rounded-[16px] mb-6"> + <div class="flex items-start gap-3"> + <span class="material-symbols-outlined">error</span> + <span id="url-error-text" class="text-sm font-medium mt-0.5"></span> + </div> </div> </div> + <!-- ==================== BROWSE TAB ==================== --> + <div id="browse-tab" class="tab-panel hide"> + <button id="refresh-files-btn" class="flex items-center gap-2 text-sm font-semibold text-m3-primary dark:text-m3-primaryDark mb-4"> + <span class="material-symbols-outlined text-[18px]">refresh</span> Refresh + </button> + <div id="browse-list" class="flex flex-col gap-2"></div> + <div id="browse-empty" class="hide text-sm text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">No files yet.</div> + </div> + + </div> + + <!-- QR modal --> + <div id="qr-modal" class="hide fixed inset-0 bg-black/50 items-center justify-center z-50 p-4"> + <div class="bg-m3-surface dark:bg-m3-surfaceDark rounded-[24px] p-6 max-w-[320px] w-full flex flex-col items-center gap-4"> + <div id="qr-code-box" class="bg-white p-3 rounded-[8px]"></div> + <div class="flex gap-2 w-full"> + <button id="qr-download-btn" class="flex-1 bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-3 py-2 rounded-[8px] text-sm font-medium">Download PNG</button> + <button id="qr-close-btn" class="flex-1 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark px-3 py-2 rounded-[8px] text-sm font-medium">Close</button> + </div> + </div> </div> <div class="fixed bottom-4 left-0 w-full text-center text-xs font-medium text-m3-outline dark:text-m3-outlineDark z-10 pointer-events-none"> @@ -159,174 +186,276 @@ </div> <script> + // ---------- Tabs ---------- + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => { + b.classList.remove('tab-active'); + b.classList.add('text-m3-onSurfaceVariant', 'dark:text-m3-onSurfaceVariantDark'); + }); + btn.classList.add('tab-active'); + btn.classList.remove('text-m3-onSurfaceVariant', 'dark:text-m3-onSurfaceVariantDark'); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hide')); + document.getElementById(btn.dataset.tab).classList.remove('hide'); + if (btn.dataset.tab === 'browse-tab') loadFileList(); + }); + }); + + // ---------- Upload tab elements ---------- const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('file-input'); const customIdInput = document.getElementById('custom-id'); + const expirySelect = document.getElementById('expiry-select'); const progressContainer = document.getElementById('progress-container'); const progressBar = document.getElementById('progress-bar'); const progressText = document.getElementById('progress-text'); - const skeletonContainer = document.getElementById('skeleton-container'); - const resultContainer = document.getElementById('result-container'); - const finalLinkText = document.getElementById('final-link-text'); - const copyBtn = document.getElementById('copy-btn'); - const copyIcon = document.getElementById('copy-icon'); - const openBtn = document.getElementById('open-btn'); + const processingContainer = document.getElementById('processing-container'); + const processingTimer = document.getElementById('processing-timer'); + const resultsList = document.getElementById('results-list'); const errorContainer = document.getElementById('error-container'); const errorText = document.getElementById('error-text'); dropzone.addEventListener('click', () => fileInput.click()); - - dropzone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropzone.style.borderColor = 'var(--tw-colors-m3-primary)'; - }); - - dropzone.addEventListener('dragleave', () => { - dropzone.style.borderColor = 'transparent'; - }); - + dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.style.borderColor = '#386A20'; }); + dropzone.addEventListener('dragleave', () => { dropzone.style.borderColor = 'transparent'; }); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.style.borderColor = 'transparent'; - if (e.dataTransfer.files.length) { - uploadFile(e.dataTransfer.files[0]); - } + if (e.dataTransfer.files.length) uploadFiles(Array.from(e.dataTransfer.files)); }); - fileInput.addEventListener('change', () => { - if (fileInput.files.length) { - uploadFile(fileInput.files[0]); - } + if (fileInput.files.length) uploadFiles(Array.from(fileInput.files)); }); - copyBtn.addEventListener('click', () => { - const linkToCopy = openBtn.href; - navigator.clipboard.writeText(linkToCopy).then(() => { - copyIcon.textContent = 'check'; - copyBtn.querySelector('span:last-child').textContent = 'Copied'; - - setTimeout(() => { - copyIcon.textContent = 'content_copy'; - copyBtn.querySelector('span:last-child').textContent = 'Copy'; - }, 2000); - }).catch(err => { - const tempTextArea = document.createElement("textarea"); - tempTextArea.value = linkToCopy; - tempTextArea.style.position = "fixed"; - tempTextArea.style.opacity = "0"; - document.body.appendChild(tempTextArea); - tempTextArea.select(); - document.execCommand("copy"); - document.body.removeChild(tempTextArea); - - copyIcon.textContent = 'check'; - copyBtn.querySelector('span:last-child').textContent = 'Copied'; - setTimeout(() => { - copyIcon.textContent = 'content_copy'; - copyBtn.querySelector('span:last-child').textContent = 'Copy'; - }, 2000); - }); + // Paste-to-upload (only when the Upload tab is visible) + document.addEventListener('paste', (e) => { + if (document.getElementById('upload-tab').classList.contains('hide')) return; + const items = e.clipboardData && e.clipboardData.files; + if (items && items.length) uploadFiles(Array.from(items)); }); - function resetUI() { - progressContainer.classList.remove('show'); - progressContainer.classList.add('hide'); - skeletonContainer.classList.remove('show'); - skeletonContainer.classList.add('hide'); - resultContainer.classList.remove('show'); - resultContainer.classList.add('hide'); - errorContainer.classList.remove('show-flex'); - errorContainer.classList.add('hide'); + function resetUploadUI() { + progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); + processingContainer.classList.remove('show'); processingContainer.classList.add('hide'); + errorContainer.classList.remove('show-flex'); errorContainer.classList.add('hide'); progressBar.style.width = '0%'; progressText.innerText = '0%'; } - function uploadFile(file) { - resetUI(); + function showError(container, textEl, msg) { + textEl.innerText = msg; + container.classList.remove('hide'); + container.classList.add('show-flex'); + } - if (file.size > 4.5 * 1024 * 1024) { - errorText.innerText = "File exceeds 4.5MB serverless limit."; - errorContainer.classList.remove('hide'); - errorContainer.classList.add('show-flex'); - return; - } + function renderResultCard(container, item) { + const origin = window.location.origin; + const finalUrl = origin + item.link_path; + const card = document.createElement('div'); + card.className = "bg-m3-secondaryContainer dark:bg-m3-secondaryContainerDark p-4 rounded-[16px] border border-transparent"; + const expiryNote = item.expires_at + ? `<div class="text-xs text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark opacity-80 mb-2">Expires: ${new Date(item.expires_at).toLocaleString()}</div>` + : ''; + card.innerHTML = ` + <div class="flex items-center gap-2 mb-2"> + <span class="material-symbols-outlined text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark">check_circle</span> + <span class="font-bold text-m3-onSecondaryContainer dark:text-m3-onSecondaryContainerDark text-sm">${item.item_id}</span> + </div> + ${expiryNote} + <div class="flex flex-col gap-3 bg-m3-background dark:bg-m3-backgroundDark p-3 rounded-[8px]"> + <div class="text-sm break-all select-all font-medium link-text">${finalUrl}</div> + <div class="flex gap-2 w-full"> + <button class="copy-btn flex-1 bg-m3-primary dark:bg-m3-primaryDark text-m3-onPrimary dark:text-m3-onPrimaryDark px-3 py-2 rounded-[8px] text-sm font-medium flex items-center justify-center gap-1.5"> + <span class="material-symbols-outlined text-[18px]">content_copy</span><span>Copy</span> + </button> + <a href="${finalUrl}" target="_blank" class="open-btn flex-1 bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark px-3 py-2 rounded-[8px] text-sm font-medium flex items-center justify-center gap-1.5 no-underline"> + <span class="material-symbols-outlined text-[18px]">open_in_new</span><span>Open</span> + </a> + <button class="qr-btn bg-m3-surfaceContainerHighest dark:bg-m3-surfaceContainerHighestDark px-3 py-2 rounded-[8px] text-sm font-medium flex items-center justify-center"> + <span class="material-symbols-outlined text-[18px]">qr_code_2</span> + </button> + </div> + </div>`; + card.querySelector('.copy-btn').addEventListener('click', function () { + navigator.clipboard.writeText(finalUrl).catch(() => {}); + const span = this.querySelector('span:last-child'); + span.textContent = 'Copied'; setTimeout(() => span.textContent = 'Copy', 1500); + }); + card.querySelector('.qr-btn').addEventListener('click', () => openQrModal(finalUrl, item.item_id)); + container.prepend(card); + } - progressContainer.classList.remove('hide'); - progressContainer.classList.add('show'); + function uploadFiles(files) { + resetUploadUI(); const formData = new FormData(); - formData.append('file', file); - + files.forEach(f => formData.append('files', f)); const customId = customIdInput.value.trim(); - if (customId) formData.append('custom_id', customId); + if (customId) { + if (files.length > 1) { + showError(errorContainer, errorText, "Custom ID can only be used with a single file."); + return; + } + formData.append('custom_id', customId); + } + const expiry = expirySelect.value; + if (expiry) formData.append('expires_in_minutes', expiry); + + progressContainer.classList.remove('hide'); progressContainer.classList.add('show'); const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/upload', true); - xhr.upload.onprogress = function(e) { + xhr.upload.onprogress = function (e) { if (e.lengthComputable) { - const percentComplete = Math.round((e.loaded / e.total) * 100); - progressBar.style.width = percentComplete + '%'; - progressText.innerText = percentComplete + '%'; - - if (percentComplete === 100) { - progressContainer.classList.remove('show'); - progressContainer.classList.add('hide'); - skeletonContainer.classList.remove('hide'); - skeletonContainer.classList.add('show'); + const pct = Math.round((e.loaded / e.total) * 100); + progressBar.style.width = pct + '%'; + progressText.innerText = pct + '%'; + if (pct === 100) { + progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); + processingContainer.classList.remove('hide'); processingContainer.classList.add('show'); + startProcessingTimer(); } } }; - xhr.onload = function() { - progressContainer.classList.remove('show'); - progressContainer.classList.add('hide'); - skeletonContainer.classList.remove('show'); - skeletonContainer.classList.add('hide'); - + xhr.onload = function () { + stopProcessingTimer(); + progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); + processingContainer.classList.remove('show'); processingContainer.classList.add('hide'); + if (xhr.status >= 200 && xhr.status < 300) { try { const data = JSON.parse(xhr.responseText); - // Changed routing to native path instead of query parameter - const finalUrl = `${window.location.origin}/${data.item_id}`; - - finalLinkText.innerText = finalUrl; - openBtn.href = finalUrl; - - resultContainer.classList.remove('hide'); - resultContainer.classList.add('show'); - - fileInput.value = ''; - customIdInput.value = ''; - } catch(err) { - errorText.innerText = "Invalid response from server."; - errorContainer.classList.remove('hide'); - errorContainer.classList.add('show-flex'); + data.files.forEach(item => renderResultCard(resultsList, item)); + fileInput.value = ''; customIdInput.value = ''; + } catch (err) { + showError(errorContainer, errorText, "Invalid response from server."); } } else { try { const errData = JSON.parse(xhr.responseText); - errorText.innerText = errData.detail || 'Upload failed.'; - } catch(err) { - errorText.innerText = "Upload failed."; + showError(errorContainer, errorText, errData.detail || 'Upload failed.'); + } catch (err) { + showError(errorContainer, errorText, "Upload failed."); } - errorContainer.classList.remove('hide'); - errorContainer.classList.add('show-flex'); } }; - xhr.onerror = function() { - progressContainer.classList.remove('show'); - progressContainer.classList.add('hide'); - skeletonContainer.classList.remove('show'); - skeletonContainer.classList.add('hide'); - errorText.innerText = "System error: Could not connect to server."; - errorContainer.classList.remove('hide'); - errorContainer.classList.add('show-flex'); + xhr.onerror = function () { + stopProcessingTimer(); + progressContainer.classList.remove('show'); progressContainer.classList.add('hide'); + processingContainer.classList.remove('show'); processingContainer.classList.add('hide'); + showError(errorContainer, errorText, "System error: Could not connect to server."); }; xhr.send(formData); } + + let processingInterval = null; + function startProcessingTimer() { + let seconds = 0; + processingTimer.innerText = '0s'; + processingInterval = setInterval(() => { + seconds += 1; + processingTimer.innerText = seconds + 's'; + }, 1000); + } + function stopProcessingTimer() { + if (processingInterval) { clearInterval(processingInterval); processingInterval = null; } + } + + // ---------- URL tab ---------- + const urlInput = document.getElementById('url-input'); + const urlCustomId = document.getElementById('url-custom-id'); + const urlExpirySelect = document.getElementById('url-expiry-select'); + const urlUploadBtn = document.getElementById('url-upload-btn'); + const urlResultsList = document.getElementById('url-results-list'); + const urlErrorContainer = document.getElementById('url-error-container'); + const urlErrorText = document.getElementById('url-error-text'); + + urlUploadBtn.addEventListener('click', async () => { + urlErrorContainer.classList.remove('show-flex'); urlErrorContainer.classList.add('hide'); + const url = urlInput.value.trim(); + if (!url) { showError(urlErrorContainer, urlErrorText, "Enter a URL first."); return; } + + const formData = new FormData(); + formData.append('url', url); + const cid = urlCustomId.value.trim(); + if (cid) formData.append('custom_id', cid); + const exp = urlExpirySelect.value; + if (exp) formData.append('expires_in_minutes', exp); + + urlUploadBtn.disabled = true; + urlUploadBtn.innerText = 'Fetching & uploading...'; + + try { + const res = await fetch('/api/upload-url', { method: 'POST', body: formData }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Upload failed.'); + data.files.forEach(item => renderResultCard(urlResultsList, item)); + urlInput.value = ''; urlCustomId.value = ''; + } catch (err) { + showError(urlErrorContainer, urlErrorText, err.message); + } finally { + urlUploadBtn.disabled = false; + urlUploadBtn.innerText = 'Fetch & Upload'; + } + }); + + // ---------- Browse tab ---------- + const browseList = document.getElementById('browse-list'); + const browseEmpty = document.getElementById('browse-empty'); + document.getElementById('refresh-files-btn').addEventListener('click', loadFileList); + + async function loadFileList() { + browseList.innerHTML = '<div class="text-sm text-m3-onSurfaceVariant dark:text-m3-onSurfaceVariantDark">Loading...</div>'; + browseEmpty.classList.add('hide'); + try { + const res = await fetch('/api/files'); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Could not load files.'); + browseList.innerHTML = ''; + if (!data.files.length) { browseEmpty.classList.remove('hide'); return; } + data.files.forEach(f => { + const row = document.createElement('div'); + row.className = "flex items-center justify-between gap-2 bg-m3-surfaceContainer dark:bg-m3-surfaceContainerDark px-4 py-3 rounded-[12px]"; + row.innerHTML = ` + <span class="text-sm truncate flex-1">${f.item_id}</span> + <a href="${f.url}" target="_blank" class="text-m3-primary dark:text-m3-primaryDark flex items-center"> + <span class="material-symbols-outlined text-[20px]">open_in_new</span> + </a>`; + browseList.appendChild(row); + }); + } catch (err) { + browseList.innerHTML = ''; + showError(errorContainer, errorText, err.message); + } + } + + // ---------- QR modal ---------- + const qrModal = document.getElementById('qr-modal'); + const qrCodeBox = document.getElementById('qr-code-box'); + const qrDownloadBtn = document.getElementById('qr-download-btn'); + let qrFilename = 'qrcode'; + + function openQrModal(url, name) { + qrCodeBox.innerHTML = ''; + qrFilename = name || 'qrcode'; + new QRCode(qrCodeBox, { text: url, width: 220, height: 220 }); + qrModal.classList.remove('hide'); qrModal.classList.add('show-flex'); + } + document.getElementById('qr-close-btn').addEventListener('click', () => { + qrModal.classList.remove('show-flex'); qrModal.classList.add('hide'); + }); + qrDownloadBtn.addEventListener('click', () => { + const img = qrCodeBox.querySelector('img') || qrCodeBox.querySelector('canvas'); + const src = img.tagName === 'CANVAS' ? img.toDataURL('image/png') : img.src; + const a = document.createElement('a'); + a.href = src; + a.download = qrFilename + '-qr.png'; + a.click(); + }); </script> </body> </html> diff --git a/requirements.txt b/requirements.txt @@ -1,3 +1,4 @@ fastapi python-multipart huggingface_hub +httpx diff --git a/vercel.json b/vercel.json @@ -1,16 +1,16 @@ { -"$schema": "https://openapi.vercel.sh/vercel.json", -"redirects": [ -{ -"source": "/:filename", -"destination": "https://huggingface.co/datasets/notamitgamer/uploads/resolve/main/:filename", -"permanent": false -} -], -"rewrites": [ -{ -"source": "/api/(.*)", -"destination": "/api/index" -} -] + "$schema": "https://openapi.vercel.sh/vercel.json", + "redirects": [ + { + "source": "/:filename", + "destination": "https://huggingface.co/datasets/notamitgamer/uploads/resolve/main/:filename", + "permanent": false + } + ], + "rewrites": [ + { + "source": "/api/(.*)", + "destination": "/api/index" + } + ] }
© notamitgamer • Site Built: 2026-07-21 13:58:23 UTC • git-mirror commit: 1037f62 [view raw info]
Originally created with stagit • modified by notamitgamer
Forked from github.com/notamitgamer/git-mirror