Compare commits
18 Commits
6c8e42f1ef
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f45f60ef31 | |||
| b0f6c1b6f9 | |||
| 09994feebc | |||
| dae793e40c | |||
| 3a881bb560 | |||
| 4222763603 | |||
| e8011e3f68 | |||
| 2f18b1ba6b | |||
| bdaa8d9049 | |||
| afcbc66e7f | |||
| 004930f60d | |||
| 3037d4078c | |||
| ce9a8fe2c4 | |||
| d2af59b754 | |||
| 2523410c84 | |||
| d00e1dceeb | |||
| ca5131f497 | |||
| 89b7b1bd23 |
@@ -8,8 +8,13 @@ TIMEZONE=America/Edmonton
|
||||
# Public uploader page (optional)
|
||||
PUBLIC_UPLOAD_PAGE_ENABLED=true
|
||||
|
||||
# Local dedupe cache (SQLite)
|
||||
#STATE_DB=./data/state.db
|
||||
# Login cookie session secret
|
||||
# Set this to something random if you want login sessions
|
||||
# to persist between container or app restarts.
|
||||
#SESSION_SECRET=
|
||||
|
||||
# Custom DB location
|
||||
#STATE_DB=
|
||||
|
||||
# Base URL for generating absolute invite links
|
||||
# Recommended for production, also sets CORS headers
|
||||
|
||||
@@ -15,7 +15,6 @@ Admin user can create invite links with optional limits and password protection.
|
||||
- **Manage Links:** Search/sort, enable/disable, delete, edit name/expiry.
|
||||
- **Passwords (optional):** Protect invite links with a password.
|
||||
- **Albums:** Upload into a specific folder (auto-create supported). Preserves client-side folder structure on upload.
|
||||
- **Duplicate Prevention:** Local SHA‑1 cache prevents re-uploading the same file.
|
||||
- **Telegram Notifications (optional):** Get notified via Telegram when upload batches are complete.
|
||||
- **Progress Queue:** WebSocket updates; see upload progress in real-time.
|
||||
- **Chunked Uploads (optional):** Large-file support with configurable chunk size.
|
||||
@@ -130,7 +129,7 @@ Then message the bot you just created "/start" so that it's able to interact wit
|
||||
|
||||
- Chunked uploads are enabled by default. Uses setting `CHUNKED_UPLOADS_ENABLED=true`.
|
||||
- Configure chunk size with `CHUNK_SIZE_MB` (default: `50`). The client only uses chunked mode for files larger than this.
|
||||
- Intended to bypass upstream proxy limits (e.g., 100MB) while preserving duplicate checks, EXIF timestamps, album add, and per‑item progress via WebSocket.
|
||||
- Intended to bypass upstream proxy limits (e.g., 100MB) while preserving EXIF timestamps, album add, and per‑item progress via WebSocket.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -139,7 +138,6 @@ Then message the bot you just created "/start" so that it's able to interact wit
|
||||
- **Frontend:** static HTML/JS (Tailwind). Drag & drop or "Choose files", queue UI with progress and status chips.
|
||||
- **Backend:** FastAPI + Uvicorn.
|
||||
- Saves uploaded files to the local filesystem.
|
||||
- Computes SHA‑1 and checks a local SQLite cache (`state.db`) to prevent duplicates.
|
||||
- WebSocket `/ws` pushes per‑item progress to the current browser session only.
|
||||
- **Persistence:** A local SQLite database (`state.db`) prevents re‑uploads across sessions. Uploaded files are stored in `/data/uploads`.
|
||||
|
||||
@@ -169,7 +167,6 @@ python main.py
|
||||
### How it works
|
||||
|
||||
1. **Queue** - Files selected in the browser are queued; each gets a client-side ID.
|
||||
2. **De-dupe (local)** - Server computes **SHA‑1** and checks `state.db`. If seen, marks as **duplicate**.
|
||||
3. **Save** - The file is saved to the local filesystem under `./data/uploads`.
|
||||
4. **Album** - If an album is specified via an invite link, or a folder name is provided on the public page, the file is saved into a corresponding subdirectory. Client-side folder structure is also preserved.
|
||||
5. **Progress** - Backend streams progress via WebSocket to the same session.
|
||||
|
||||
329
app/app.py
329
app/app.py
@@ -1,9 +1,8 @@
|
||||
"""
|
||||
Immich Drop Uploader – Backend (FastAPI, simplified)
|
||||
File Drop Uploader – Backend (FastAPI, simplified)
|
||||
----------------------------------------------------
|
||||
- Serves static frontend (no settings UI)
|
||||
- Uploads to Immich using values from .env ONLY
|
||||
- Duplicate checks (local SHA-1 DB + optional Immich bulk-check)
|
||||
- Uploads to file system
|
||||
- WebSocket progress per session
|
||||
- Ephemeral "Connected" banner via /api/ping
|
||||
"""
|
||||
@@ -18,6 +17,9 @@ import hashlib
|
||||
import os
|
||||
import sqlite3
|
||||
import binascii
|
||||
import base64
|
||||
import mimetypes
|
||||
import zipfile
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -26,12 +28,12 @@ import math
|
||||
import logging
|
||||
import httpx
|
||||
from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect, Request, Form
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, RedirectResponse, Response, StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.websockets import WebSocketState
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from PIL import Image, ExifTags
|
||||
from PIL import Image
|
||||
try:
|
||||
import qrcode
|
||||
except Exception:
|
||||
@@ -40,7 +42,7 @@ except Exception:
|
||||
from app.config import Settings, load_settings
|
||||
|
||||
# ---- App & static ----
|
||||
app = FastAPI(title="Immich Drop Uploader (Python)")
|
||||
app = FastAPI(title="File Drop Uploader (Python)")
|
||||
# Global settings (read-only at runtime)
|
||||
SETTINGS: Settings = load_settings()
|
||||
|
||||
@@ -64,7 +66,7 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("immich_drop")
|
||||
|
||||
# Cookie-based session for short-lived auth token storage (no persistence)
|
||||
app.add_middleware(SessionMiddleware, secret_key=SETTINGS.session_secret, same_site="lax")
|
||||
app.add_middleware(SessionMiddleware, secret_key=SETTINGS.session_secret, same_site="lax", max_age=365 * 24 * 60 * 60)
|
||||
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend")
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
@@ -77,61 +79,6 @@ except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- DB (local dedupe cache) ----------
|
||||
|
||||
def db_init() -> None:
|
||||
"""Create the local SQLite table used for duplicate checks (idempotent)."""
|
||||
conn = sqlite3.connect(SETTINGS.state_db)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
checksum TEXT UNIQUE,
|
||||
filename TEXT,
|
||||
size INTEGER,
|
||||
device_asset_id TEXT,
|
||||
immich_asset_id TEXT,
|
||||
created_at TEXT,
|
||||
inserted_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def db_lookup_checksum(checksum: str) -> Optional[dict]:
|
||||
"""Return a record for the given checksum if seen before (None if not)."""
|
||||
conn = sqlite3.connect(SETTINGS.state_db)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT checksum, immich_asset_id FROM uploads WHERE checksum = ?", (checksum,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
return {"checksum": row[0], "immich_asset_id": row[1]}
|
||||
return None
|
||||
|
||||
def db_lookup_device_asset(device_asset_id: str) -> bool:
|
||||
"""True if a deviceAssetId has been uploaded by this service previously."""
|
||||
conn = sqlite3.connect(SETTINGS.state_db)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM uploads WHERE device_asset_id = ?", (device_asset_id,))
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return bool(row)
|
||||
|
||||
def db_insert_upload(checksum: str, filename: str, size: int, device_asset_id: str, immich_asset_id: Optional[str], created_at: str) -> None:
|
||||
"""Insert a newly-uploaded asset into the local cache (ignore on duplicates)."""
|
||||
conn = sqlite3.connect(SETTINGS.state_db)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT OR IGNORE INTO uploads (checksum, filename, size, device_asset_id, immich_asset_id, created_at) VALUES (?,?,?,?,?,?)",
|
||||
(checksum, filename, size, device_asset_id, immich_asset_id, created_at)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
db_init()
|
||||
|
||||
# ---------- WebSocket hub ----------
|
||||
|
||||
@@ -408,33 +355,6 @@ def get_safe_subpath(relative_path: Optional[str]) -> str:
|
||||
return os.path.join(*safe_parts)
|
||||
|
||||
|
||||
def read_exif_datetimes(file_bytes: bytes):
|
||||
"""
|
||||
Extract EXIF DateTimeOriginal / ModifyDate values when possible.
|
||||
Returns (created, modified) as datetime or (None, None) on failure.
|
||||
"""
|
||||
created = modified = None
|
||||
try:
|
||||
with Image.open(io.BytesIO(file_bytes)) as im:
|
||||
exif = getattr(im, "_getexif", lambda: None)() or {}
|
||||
if exif:
|
||||
tags = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()}
|
||||
dt_original = tags.get("DateTimeOriginal") or tags.get("CreateDate")
|
||||
dt_modified = tags.get("ModifyDate") or dt_original
|
||||
def parse_dt(s: str):
|
||||
try:
|
||||
return datetime.strptime(s, "%Y:%m:%d %H:%M:%S")
|
||||
except Exception:
|
||||
return None, None
|
||||
if isinstance(dt_original, str):
|
||||
created = parse_dt(dt_original)
|
||||
if isinstance(dt_modified, str):
|
||||
modified = parse_dt(dt_modified)
|
||||
except Exception:
|
||||
pass
|
||||
return created, modified
|
||||
|
||||
|
||||
def slugify(value: Optional[str]) -> str:
|
||||
"""
|
||||
Normalizes string, converts to lowercase, removes non-alpha characters,
|
||||
@@ -603,26 +523,11 @@ async def api_upload(
|
||||
fingerprint: Optional[str] = Form(None),
|
||||
public_folder_name: Optional[str] = Form(None),
|
||||
):
|
||||
"""Receive a file, check duplicates, forward to Immich; stream progress via WS."""
|
||||
"""Receive a file and stream progress via WS."""
|
||||
raw = await file.read()
|
||||
size = len(raw)
|
||||
checksum = sha1_hex(raw)
|
||||
|
||||
exif_created, exif_modified = read_exif_datetimes(raw)
|
||||
created_at = exif_created or (datetime.fromtimestamp(last_modified / 1000) if last_modified else datetime.utcnow())
|
||||
modified_at = exif_modified or created_at
|
||||
created_iso = created_at.isoformat()
|
||||
modified_iso = modified_at.isoformat()
|
||||
|
||||
device_asset_id = f"{file.filename}-{last_modified or 0}-{size}"
|
||||
|
||||
if db_lookup_checksum(checksum):
|
||||
await send_progress(session_id, item_id, "duplicate", 100, "Duplicate (by checksum - local cache)")
|
||||
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
|
||||
if db_lookup_device_asset(device_asset_id):
|
||||
await send_progress(session_id, item_id, "duplicate", 100, "Already uploaded from this device (local cache)")
|
||||
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
|
||||
|
||||
|
||||
# Invite token validation (if provided)
|
||||
target_album_name: Optional[str] = None
|
||||
@@ -731,7 +636,6 @@ async def api_upload(
|
||||
i += 1
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(raw)
|
||||
db_insert_upload(checksum, file.filename, size, device_asset_id, None, created_iso)
|
||||
await add_file_to_batch(file.filename, size, display_album_name, bool(invite_token))
|
||||
await reset_telegram_debounce()
|
||||
|
||||
@@ -880,7 +784,7 @@ async def api_upload_chunk(
|
||||
|
||||
@app.post("/api/upload/chunk/complete")
|
||||
async def api_upload_chunk_complete(request: Request) -> JSONResponse:
|
||||
"""Assemble all parts and run the regular upload flow to Immich."""
|
||||
"""Assemble all parts and run the regular upload flow."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -952,21 +856,6 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
|
||||
file_like_name = name
|
||||
file_size = len(raw)
|
||||
checksum = sha1_hex(raw)
|
||||
exif_created, exif_modified = read_exif_datetimes(raw)
|
||||
created_at = exif_created or (datetime.fromtimestamp(last_modified / 1000) if last_modified else datetime.utcnow())
|
||||
modified_at = exif_modified or created_at
|
||||
created_iso = created_at.isoformat()
|
||||
modified_iso = modified_at.isoformat()
|
||||
device_asset_id = f"{file_like_name}-{last_modified or 0}-{file_size}"
|
||||
|
||||
# Local duplicate checks
|
||||
if db_lookup_checksum(checksum):
|
||||
await send_progress(session_id_local, item_id_local, "duplicate", 100, "Duplicate (by checksum - local cache)")
|
||||
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
|
||||
if db_lookup_device_asset(device_asset_id):
|
||||
await send_progress(session_id_local, item_id_local, "duplicate", 100, "Already uploaded from this device (local cache)")
|
||||
return JSONResponse({"status": "duplicate", "id": None}, status_code=200)
|
||||
|
||||
|
||||
# Invite validation/gating mirrors api_upload
|
||||
target_album_name: Optional[str] = None
|
||||
@@ -1067,7 +956,6 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
|
||||
i += 1
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(raw)
|
||||
db_insert_upload(checksum, file_like_name, file_size, device_asset_id, None, created_iso)
|
||||
await add_file_to_batch(file_like_name, file_size, display_album_name, bool(invite_token))
|
||||
|
||||
msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
|
||||
@@ -1228,6 +1116,201 @@ async def api_albums_create(request: Request) -> JSONResponse:
|
||||
logger.exception("Create album directory failed: %s", e)
|
||||
return JSONResponse({"error": "create_album_failed"}, status_code=500)
|
||||
|
||||
|
||||
# ---------- File Viewer APIs ----------
|
||||
UPLOAD_ROOT = "./data/uploads"
|
||||
|
||||
def _is_safe_path(base, path):
|
||||
"""Check that resolved path is under base path."""
|
||||
try:
|
||||
# After resolving symlinks, the path must be inside the base.
|
||||
return os.path.realpath(path).startswith(os.path.realpath(base))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@app.get("/api/files/dirs")
|
||||
async def api_files_dirs(request: Request):
|
||||
if not request.session.get("accessToken"):
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
|
||||
q = (request.query_params.get("q") or "").strip().lower()
|
||||
sort = (request.query_params.get("sort") or "-modified").strip()
|
||||
|
||||
dirs = []
|
||||
try:
|
||||
os.makedirs(UPLOAD_ROOT, exist_ok=True)
|
||||
for root, _, filenames in os.walk(UPLOAD_ROOT):
|
||||
if not filenames:
|
||||
continue
|
||||
|
||||
dir_path = root
|
||||
rel_path = os.path.relpath(dir_path, UPLOAD_ROOT)
|
||||
|
||||
if rel_path == '.':
|
||||
continue
|
||||
|
||||
if q:
|
||||
# Search in directory path and filenames
|
||||
path_match = q in rel_path.lower()
|
||||
file_match = any(q in fn.lower() for fn in filenames)
|
||||
if not path_match and not file_match:
|
||||
continue
|
||||
|
||||
try:
|
||||
total_size = sum(os.path.getsize(os.path.join(dir_path, f)) for f in filenames)
|
||||
file_mtimes = [os.path.getmtime(os.path.join(dir_path, f)) for f in filenames]
|
||||
last_modified = max(file_mtimes) if file_mtimes else os.path.getmtime(dir_path)
|
||||
|
||||
dirs.append({
|
||||
"path": rel_path,
|
||||
"path_b64": base64.urlsafe_b64encode(rel_path.encode()).decode(),
|
||||
"name": os.path.basename(dir_path),
|
||||
"file_count": len(filenames),
|
||||
"total_size": total_size,
|
||||
"modified": last_modified
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.exception("Failed to list directories: %s", e)
|
||||
return JSONResponse({"error": "server_error"}, status_code=500)
|
||||
|
||||
# Sort
|
||||
reverse = sort.startswith('-')
|
||||
sort_field = sort.lstrip('-+')
|
||||
if sort_field not in ('name', 'file_count', 'total_size', 'modified'):
|
||||
sort_field = 'modified'
|
||||
|
||||
dirs.sort(key=lambda x: x.get(sort_field, 0), reverse=reverse)
|
||||
|
||||
for d in dirs:
|
||||
d['modified'] = datetime.fromtimestamp(d['modified']).isoformat()
|
||||
|
||||
return JSONResponse({"items": dirs})
|
||||
|
||||
|
||||
@app.get("/api/files/list/{path_b64}")
|
||||
async def api_files_list(path_b64: str, request: Request):
|
||||
if not request.session.get("accessToken"):
|
||||
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||
dir_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||
except Exception:
|
||||
return JSONResponse({"error": "invalid_path"}, status_code=400)
|
||||
|
||||
if not _is_safe_path(UPLOAD_ROOT, dir_path) or not os.path.isdir(dir_path):
|
||||
return JSONResponse({"error": "not_found"}, status_code=404)
|
||||
|
||||
files = []
|
||||
try:
|
||||
for filename in sorted(os.listdir(dir_path)):
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
if os.path.isfile(file_path):
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
is_image = bool(mime_type and mime_type.startswith('image/'))
|
||||
rel_file_path = os.path.relpath(file_path, UPLOAD_ROOT)
|
||||
files.append({
|
||||
"name": filename,
|
||||
"path_b64": base64.urlsafe_b64encode(rel_file_path.encode()).decode(),
|
||||
"size": os.path.getsize(file_path),
|
||||
"modified": datetime.fromtimestamp(os.path.getmtime(file_path)).isoformat(),
|
||||
"is_image": is_image,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Failed to list files: %s", e)
|
||||
return JSONResponse({"error": "server_error"}, status_code=500)
|
||||
|
||||
return JSONResponse({"items": files})
|
||||
|
||||
|
||||
@app.get("/api/files/thumb/{path_b64}")
|
||||
async def api_files_thumb(path_b64: str, request: Request):
|
||||
if not request.session.get("accessToken"):
|
||||
return Response(status_code=401)
|
||||
|
||||
try:
|
||||
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||
file_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||
except Exception:
|
||||
return Response(status_code=400)
|
||||
|
||||
if not _is_safe_path(UPLOAD_ROOT, file_path) or not os.path.isfile(file_path):
|
||||
return Response(status_code=404)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(os.path.basename(file_path))
|
||||
if not mime_type or not mime_type.startswith('image/'):
|
||||
return Response(status_code=404)
|
||||
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
img.thumbnail((256, 256))
|
||||
buf = io.BytesIO()
|
||||
# Convert to RGB to avoid issues with saving palette-based images (e.g. some GIFs) as JPEG
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
buf.seek(0)
|
||||
return Response(content=buf.read(), media_type="image/jpeg")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to generate thumbnail for %s: %s", file_path, e)
|
||||
return Response(status_code=500)
|
||||
|
||||
|
||||
@app.get("/api/files/full/{path_b64}")
|
||||
async def api_files_full(path_b64: str, request: Request):
|
||||
if not request.session.get("accessToken"):
|
||||
return Response(status_code=401)
|
||||
|
||||
try:
|
||||
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||
file_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||
except Exception:
|
||||
return Response(status_code=400)
|
||||
|
||||
if not _is_safe_path(UPLOAD_ROOT, file_path) or not os.path.isfile(file_path):
|
||||
return Response(status_code=404)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
disposition = "attachment"
|
||||
if mime_type and (mime_type.startswith(('image/', 'video/', 'audio/', 'text/')) or mime_type == 'application/pdf'):
|
||||
disposition = "inline"
|
||||
|
||||
return FileResponse(file_path, content_disposition_type=disposition, filename=os.path.basename(rel_path))
|
||||
|
||||
|
||||
@app.get("/api/files/zip/{path_b64}")
|
||||
async def api_files_zip(path_b64: str, request: Request):
|
||||
if not request.session.get("accessToken"):
|
||||
return Response(status_code=401)
|
||||
|
||||
try:
|
||||
rel_path = base64.urlsafe_b64decode(path_b64).decode()
|
||||
dir_path = os.path.join(UPLOAD_ROOT, rel_path)
|
||||
except Exception:
|
||||
return Response(status_code=400)
|
||||
|
||||
if not _is_safe_path(UPLOAD_ROOT, dir_path) or not os.path.isdir(dir_path):
|
||||
return Response(status_code=404)
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for filename in sorted(os.listdir(dir_path)):
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
if os.path.isfile(file_path):
|
||||
zip_file.write(file_path, filename)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
zip_filename = f"{os.path.basename(rel_path) or 'download'}.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([zip_buffer.getvalue()]),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f"attachment; filename=\"{zip_filename}\""}
|
||||
)
|
||||
|
||||
|
||||
# ---------- Invites (one-time/expiring links) ----------
|
||||
|
||||
def ensure_invites_table() -> None:
|
||||
|
||||
@@ -37,8 +37,8 @@ let socket;
|
||||
let allCompleteBannerShown = false;
|
||||
|
||||
// Status precedence: never regress (e.g., uploading -> done shouldn't go back to uploading)
|
||||
const STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, duplicate: 3, done: 3, error: 4 };
|
||||
const FINAL_STATES = new Set(['done','duplicate','error']);
|
||||
const STATUS_ORDER = { queued: 0, checking: 1, uploading: 2, done: 3, error: 4 };
|
||||
const FINAL_STATES = new Set(['done','error']);
|
||||
|
||||
// --- Dark mode ---
|
||||
function initDarkMode() {
|
||||
@@ -119,7 +119,7 @@ function render(){
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div class="h-full ${it.status==='done'?'bg-green-500':it.status==='duplicate'?'bg-amber-500':it.status==='error'?'bg-red-500':'bg-blue-500'}" style="width:${Math.max(it.progress, (it.status==='done'||it.status==='duplicate'||it.status==='error')?100:it.progress)}%"></div>
|
||||
<div class="h-full ${it.status==='done'?'bg-green-500':it.status==='error'?'bg-red-500':'bg-blue-500'}" style="width:${Math.max(it.progress, (it.status==='done'||it.status==='error')?100:it.progress)}%"></div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)}
|
||||
@@ -144,18 +144,16 @@ function render(){
|
||||
});
|
||||
} catch {}
|
||||
|
||||
const c = {queued:0,uploading:0,done:0,dup:0,err:0};
|
||||
const c = {queued:0,uploading:0,done:0,err:0};
|
||||
for(const it of items){
|
||||
if(['queued','checking'].includes(it.status)) c.queued++;
|
||||
if(it.status==='uploading') c.uploading++;
|
||||
if(it.status==='done') c.done++;
|
||||
if(it.status==='duplicate') c.dup++;
|
||||
if(it.status==='error') c.err++;
|
||||
}
|
||||
document.getElementById('countQueued').textContent=c.queued;
|
||||
document.getElementById('countUploading').textContent=c.uploading;
|
||||
document.getElementById('countDone').textContent=c.done;
|
||||
document.getElementById('countDup').textContent=c.dup;
|
||||
document.getElementById('countErr').textContent=c.err;
|
||||
|
||||
if (!allCompleteBannerShown && items.length > 0) {
|
||||
@@ -252,12 +250,10 @@ async function uploadWhole(next){
|
||||
render();
|
||||
} else if (res.ok) {
|
||||
const statusText = (body && body.status) ? String(body.status) : '';
|
||||
const isDuplicate = /duplicate/i.test(statusText);
|
||||
next.status = isDuplicate ? 'duplicate' : 'done';
|
||||
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
|
||||
next.status = 'done';
|
||||
next.message = statusText || 'Uploaded';
|
||||
next.progress = 100;
|
||||
render();
|
||||
try { if (isDuplicate) showBanner(`Duplicate: ${next.name}`, 'warn'); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +319,8 @@ async function uploadChunked(next){
|
||||
render();
|
||||
} else if (rc.ok) {
|
||||
const statusText = (body && body.status) ? String(body.status) : '';
|
||||
const isDuplicate = /duplicate/i.test(statusText);
|
||||
next.status = isDuplicate ? 'duplicate' : 'done';
|
||||
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
|
||||
next.status = 'done';
|
||||
next.message = statusText || 'Uploaded';
|
||||
next.progress = 100;
|
||||
render();
|
||||
}
|
||||
@@ -491,7 +486,7 @@ if (btnMobilePick) {
|
||||
|
||||
// --- Clear buttons ---
|
||||
btnClearFinished.onclick = ()=>{
|
||||
items = items.filter(i => !['done','duplicate'].includes(i.status));
|
||||
items = items.filter(i => !['done'].includes(i.status));
|
||||
render();
|
||||
// also tell server to refresh album cache so a renamed album triggers a new one
|
||||
fetch('/api/album/reset', { method: 'POST' }).catch(()=>{});
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
|
||||
<span class="whitespace-nowrap">Uploading: <b id="countUploading">0</b></span>
|
||||
<span class="whitespace-nowrap">Done: <b id="countDone">0</b></span>
|
||||
<span class="whitespace-nowrap">Duplicates: <b id="countDup">0</b></span>
|
||||
<span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,6 @@
|
||||
<span class="whitespace-nowrap">Queued/Processing: <b id="countQueued">0</b></span>
|
||||
<span class="whitespace-nowrap">Uploading: <b id="countUploading">0</b></span>
|
||||
<span class="whitespace-nowrap">Done: <b id="countDone">0</b></span>
|
||||
<span class="whitespace-nowrap">Duplicates: <b id="countDup">0</b></span>
|
||||
<span class="whitespace-nowrap">Errors: <b id="countErr">0</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="mx-auto max-w-2xl p-6 space-y-6">
|
||||
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Login to Image Drop</h1>
|
||||
<h1 class="text-2xl font-semibold">Login</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
|
||||
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
|
||||
@@ -25,7 +25,6 @@
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
|
||||
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -33,13 +32,9 @@
|
||||
<h2 class="text-lg font-medium mb-2">Enter your credentials</h2>
|
||||
<div id="msg" class="hidden mb-3 rounded-lg border p-2 text-sm"></div>
|
||||
<form id="loginForm" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Username</label>
|
||||
<input id="email" type="username" value="admin" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Password</label>
|
||||
<input id="password" type="password" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
|
||||
<input id="password" type="password" required autofocus class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="rounded-xl bg-black text-white px-4 py-2 dark:bg-white dark:text-black" type="submit">Login</button>
|
||||
@@ -50,6 +45,13 @@
|
||||
</div>
|
||||
<script src="/static/header.js"></script>
|
||||
<script>
|
||||
(async function() {
|
||||
try {
|
||||
const r = await fetch('/api/albums');
|
||||
if (r.ok) location.href = '/menu';
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
const form = document.getElementById('loginForm');
|
||||
const msg = document.getElementById('msg');
|
||||
function show(kind, text){
|
||||
@@ -59,7 +61,7 @@
|
||||
}
|
||||
form.onsubmit = async (e)=>{
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const email = 'admin';
|
||||
const password = document.getElementById('password').value;
|
||||
try{
|
||||
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({email, password}) });
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
<body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto max-w-2xl p-6 space-y-6">
|
||||
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center dark:bg-green-900 dark:border-green-700 dark:text-green-300"></div>
|
||||
<header class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Create Upload Link</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Public uploader</a>
|
||||
<a href="/logout" id="btnLogout" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Logout</a>
|
||||
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
|
||||
<header class="flex items-center justify-between flex-wrap gap-y-2">
|
||||
<h1 class="text-2xl font-semibold">Admin</h1>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<a id="linkPublicUploader" href="/" class="hidden rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Public uploader</a>
|
||||
<a href="/logout" id="btnLogout" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Logout</a>
|
||||
<button id="btnTheme" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600" title="Toggle dark mode">
|
||||
<svg id="iconLight" class="w-4 h-4 hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
@@ -26,8 +26,6 @@
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Test connection</button>
|
||||
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -80,7 +78,7 @@
|
||||
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h2 class="text-lg font-medium">Manage Links</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<input id="searchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
|
||||
<select id="sortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
|
||||
<option value="-created">Newest</option>
|
||||
@@ -98,11 +96,11 @@
|
||||
<thead>
|
||||
<tr class="text-left border-b dark:border-gray-700">
|
||||
<th class="py-2"><input id="chkAll" type="checkbox"/></th>
|
||||
<th class="py-2" style="width: 45%;">Name</th>
|
||||
<th class="py-2" style="width: 18%;">Status</th>
|
||||
<th class="py-2">Uses</th>
|
||||
<th class="py-2">Expires</th>
|
||||
<th class="py-2">Folder</th>
|
||||
<th class="py-2">Name</th>
|
||||
<th class="py-2">Status</th>
|
||||
<th class="py-2 hidden md:table-cell">Uses</th>
|
||||
<th class="py-2 hidden md:table-cell">Expires</th>
|
||||
<th class="py-2 hidden md:table-cell">Folder</th>
|
||||
<th class="py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -117,8 +115,46 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="text-xs text-gray-500">
|
||||
Admin link page
|
||||
<!-- Manage Files -->
|
||||
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<h2 class="text-lg font-medium">Manage Files</h2>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<input id="filesSearchQ" placeholder="Search" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700"/>
|
||||
<select id="filesSortSel" class="rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
|
||||
<option value="-modified">Newest</option>
|
||||
<option value="modified">Oldest</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="-name">Name Z–A</option>
|
||||
<option value="-file_count">File count desc</option>
|
||||
<option value="file_count">File count asc</option>
|
||||
<option value="-total_size">Size desc</option>
|
||||
<option value="total_size">Size asc</option>
|
||||
</select>
|
||||
<button id="btnFilesRefresh" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left border-b dark:border-gray-700">
|
||||
<th class="py-2" style="width: 50%;">Folder</th>
|
||||
<th class="py-2">Files</th>
|
||||
<th class="py-2">Size</th>
|
||||
<th class="py-2">Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="filesTBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="text-xs text-gray-500 space-y-2">
|
||||
<p>Admin link page</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="btnPing" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Test connection</button>
|
||||
<span id="pingStatus" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -261,15 +297,15 @@
|
||||
return `
|
||||
<tr class="border-b dark:border-gray-800" data-token="${row.token}">
|
||||
<td class="py-2"><input class="chkRow" type="checkbox" data-token="${row.token}"/></td>
|
||||
<td class="py-2" style="width:45%;">
|
||||
<td class="py-2">
|
||||
<input class="inName w-full rounded border px-2 py-1 bg-white dark:bg-gray-900 dark:border-gray-700" data-token="${row.token}" value="${(row.name||'').replaceAll('"','"')}" title="${(row.name||'').replaceAll('"','"')}"/>
|
||||
</td>
|
||||
<td class="py-2">${status}</td>
|
||||
<td class="py-2">${uses}</td>
|
||||
<td class="py-2">
|
||||
<td class="py-2 hidden md:table-cell">${uses}</td>
|
||||
<td class="py-2 hidden md:table-cell">
|
||||
<input class="inExpires w-36 rounded border px-2 py-1 bg-white dark:bg-gray-900 dark:border-gray-700" type="date" data-token="${row.token}" value="${row.expiresAt? new Date(row.expiresAt).toISOString().slice(0,10):''}" title="${row.expiresAt? new Date(row.expiresAt).toLocaleString():''}"/>
|
||||
</td>
|
||||
<td class="py-2">${row.albumName || '—'}</td>
|
||||
<td class="py-2 hidden md:table-cell">${row.albumName || '—'}</td>
|
||||
<td class="py-2">
|
||||
<div class="flex items-center gap-1 whitespace-nowrap">
|
||||
<button class="btnDetails group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-token="${row.token}" aria-label="Show details">
|
||||
@@ -441,6 +477,136 @@
|
||||
|
||||
// Initial load
|
||||
loadInvites();
|
||||
|
||||
// --- Manage Files UI logic ---
|
||||
const filesSearchQ = document.getElementById('filesSearchQ');
|
||||
const filesSortSel = document.getElementById('filesSortSel');
|
||||
const btnFilesRefresh = document.getElementById('btnFilesRefresh');
|
||||
const filesTBody = document.getElementById('filesTBody');
|
||||
let DIRS = [];
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function humanSize(bytes){
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024, sizes = ['B','KB','MB','GB','TB'];
|
||||
const i = Math.floor(Math.log(bytes)/Math.log(k));
|
||||
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
|
||||
}
|
||||
function fmtDayMonthForFiles(iso){ try{ const d = new Date(iso); return d.toLocaleDateString(undefined,{ day:'2-digit', month:'short' }); }catch{return '—';} }
|
||||
|
||||
|
||||
async function loadDirs(){
|
||||
const params = new URLSearchParams();
|
||||
const q = (filesSearchQ.value||'').trim(); if (q) params.set('q', q);
|
||||
const sort = (filesSortSel.value||'').trim(); if (sort) params.set('sort', sort);
|
||||
try{
|
||||
const r = await fetch('/api/files/dirs?'+params.toString());
|
||||
const j = await r.json();
|
||||
DIRS = (j && j.items) ? j.items : [];
|
||||
} catch { DIRS = []; }
|
||||
renderDirs();
|
||||
}
|
||||
|
||||
function renderDirs(){
|
||||
filesTBody.innerHTML = DIRS.map(dir => {
|
||||
const modified = `<span title="${new Date(dir.modified).toLocaleString()}">${fmtDayMonthForFiles(dir.modified)}</span>`;
|
||||
return `
|
||||
<tr class="border-b dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer" data-path-b64="${dir.path_b64}">
|
||||
<td class="py-2">${escapeHtml(dir.path)}</td>
|
||||
<td class="py-2">${dir.file_count}</td>
|
||||
<td class="py-2">${humanSize(dir.total_size)}</td>
|
||||
<td class="py-2">${modified}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
filesTBody.querySelectorAll('tr').forEach(tr => {
|
||||
tr.onclick = () => showGallery(tr.dataset.pathB64);
|
||||
});
|
||||
}
|
||||
|
||||
async function showGallery(path_b64) {
|
||||
if (!path_b64) return;
|
||||
let files = [];
|
||||
try {
|
||||
const r = await fetch(`/api/files/list/${path_b64}`);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const j = await r.json();
|
||||
files = j.items || [];
|
||||
} catch (e) {
|
||||
showResult('err', 'Failed to load files: ' + String(e));
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div id="gallery-modal" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div class="max-w-6xl w-full h-[90vh] rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4 flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-lg font-medium">Files</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/api/files/zip/${path_b64}" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Download All</a>
|
||||
<button class="dlgClose rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
${files.length ? `<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4">` + files.map(it => {
|
||||
const thumbUrl = it.is_image ? `/api/files/thumb/${it.path_b64}` : '';
|
||||
const fullUrl = `/api/files/full/${it.path_b64}`;
|
||||
const safeName = escapeHtml(it.name);
|
||||
return `<a href="${fullUrl}" target="_blank" title="${safeName}\n${humanSize(it.size)}" class="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||
${it.is_image ?
|
||||
`<img src="${thumbUrl}" class="w-full h-full object-cover" loading="lazy"/>` :
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" class="w-12 h-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>`
|
||||
}
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 truncate pointer-events-none">${safeName}</div>
|
||||
</a>`
|
||||
}).join('') + `</div>` : '<div class="text-sm text-gray-500">No files in this directory.</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.innerHTML = html;
|
||||
const dlg = wrap.firstElementChild;
|
||||
document.body.appendChild(dlg);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const close = () => {
|
||||
document.body.style.overflow = '';
|
||||
if (location.hash === '#gallery') {
|
||||
history.back();
|
||||
} else {
|
||||
try { dlg.remove(); } catch {}
|
||||
}
|
||||
};
|
||||
dlg.onclick = (e) => { if (e.target === e.currentTarget) close(); };
|
||||
dlg.querySelectorAll('.dlgClose').forEach(b => b.onclick = close);
|
||||
|
||||
if (location.hash !== '#gallery') {
|
||||
history.pushState(null, '', '#gallery');
|
||||
}
|
||||
}
|
||||
|
||||
btnFilesRefresh.onclick = loadDirs;
|
||||
filesSearchQ.oninput = () => { clearTimeout(filesSearchQ._t); filesSearchQ._t = setTimeout(loadDirs, 300); };
|
||||
filesSortSel.onchange = loadDirs;
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
const galleryModal = document.querySelector('#gallery-modal');
|
||||
if (galleryModal) {
|
||||
document.body.style.overflow = '';
|
||||
galleryModal.remove();
|
||||
}
|
||||
});
|
||||
|
||||
loadDirs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user