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:
| M | api/index.py | | | 252 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- |
| M | index.html | | | 563 | ++++++++++++++++++++++++++++++++++++++++++++++++------------------------------- |
| M | requirements.txt | | | 1 | + |
| M | vercel.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 · 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…</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 & 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"
+ }
+ ]
}