Compare commits

..

20 Commits

Author SHA1 Message Date
0dc7fa8f9e Add example Telegram bot settings 2026-01-21 13:50:27 -07:00
5efd4788b4 chore: Disable verbose httpx request logging
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 13:36:12 -07:00
506d658073 feat: Display date-based directory name for public uploads
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 12:30:38 -07:00
675080ae71 feat: Include destination and source in upload notifications
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 12:22:14 -07:00
e51bd24db9 feat: Suggest /help command in new file notification 2026-01-21 12:22:11 -07:00
d77c1a1d1a feat: Add Telegram commands to control public uploads and show help
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 12:12:51 -07:00
3c7dd1c0e7 fix: Use Markdown for Telegram messages 2026-01-21 12:12:49 -07:00
e4aae22835 feat: Add markdown parsing to Telegram messages
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 11:59:14 -07:00
3cefce9cfc feat: Enhance batch notification formatting and reduce delay 2026-01-21 11:59:12 -07:00
6322163b10 feat: Add Telegram bot notification for batch upload completion
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 11:49:48 -07:00
9c70e47232 feat: Integrate Telegram bot with polling for owner commands
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-21 11:41:13 -07:00
ccaf5869bf fix: Remove green success banners for uploads and invites
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:31:51 -07:00
233c96dcf8 feat: Remove 'Choose a folder' button from upload UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:28:24 -07:00
2c4969ae21 style: Increase vertical gap in mobile choose controls 2026-01-20 22:28:21 -07:00
a3881b8e03 feat: Add folder upload and enhance dropzone UI on invite page
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:23:37 -07:00
e163e4dd45 fix: Add vertical spacing to buttons on mobile
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:17:43 -07:00
95a25796f9 style: Clarify drag and drop prompt for multiple items 2026-01-20 22:17:41 -07:00
fb8567a4a9 feat: Add multiple file/folder upload and enhanced drag-and-drop support
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:11:53 -07:00
cc95608364 feat: Add directory upload support with path preservation
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 22:05:32 -07:00
5749597408 fix: Set container user to match host for file ownership
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-01-20 21:58:33 -07:00
7 changed files with 308 additions and 39 deletions

View File

@@ -26,3 +26,10 @@ CHUNK_SIZE_MB=50
# Custom session secrets
# By default, a random one is generated
#SESSION_SECRET=SET-A-STRONG-RANDOM-VALUE
# Optional Telegram bot for upload alerts and control
# create a bot using @BotFather then copy the API key here
# get your account's ID by messaging https://t.me/userinfobot
# Leave these blank to disable
TELEGRAM_BOT_API_KEY=
TELEGRAM_BOT_OWNER_ID=

View File

@@ -20,9 +20,11 @@ import sqlite3
import binascii
import pytz
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
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.middleware.cors import CORSMiddleware
@@ -49,9 +51,12 @@ app.add_middleware(
# Global settings (read-only at runtime)
SETTINGS: Settings = load_settings()
_public_uploads_enabled_runtime = SETTINGS.public_upload_page_enabled
# Basic logging setup using settings
logging.basicConfig(level=SETTINGS.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
logger = logging.getLogger("immich_drop")
# Cookie-based session for short-lived auth token storage (no persistence)
@@ -170,6 +175,165 @@ class SessionHub:
hub = SessionHub()
# ---------- Telegram Bot ----------
# Batch upload notifications
_upload_batch: List[Tuple[str, int, str, bool]] = [] # filename, size, album_name, is_invite
_batch_complete_timer: Optional[asyncio.TimerHandle] = None
_batch_lock = asyncio.Lock()
def human_size(bytes_val: int) -> str:
"""Return a human-readable size string."""
if not bytes_val:
return "0 B"
k = 1024
sizes = ['B', 'KB', 'MB', 'GB', 'TB']
i = 0
if bytes_val > 0:
i = int(math.floor(math.log(bytes_val) / math.log(k)))
if i >= len(sizes):
i = len(sizes) - 1
return f"{(bytes_val / (k**i)):.1f} {sizes[i]}"
async def send_batch_notification():
"""Format and send a summary of the recently completed upload batch."""
async with _batch_lock:
if not _upload_batch:
return
batch_copy = list(_upload_batch)
_upload_batch.clear()
global _batch_complete_timer
if _batch_complete_timer:
_batch_complete_timer.cancel()
_batch_complete_timer = None
num_files = len(batch_copy)
total_size = sum(size for _, size, _, _ in batch_copy)
# All items in a batch should have the same destination album and source type
album_name = batch_copy[0][2] if batch_copy else "Unknown"
is_invite = batch_copy[0][3] if batch_copy else False
source = "Invite" if is_invite else "Public"
file_list_str = ""
if num_files > 0:
filenames = [name or "file" for name, _, _, _ in batch_copy]
if num_files > 15:
file_list_str = "\n".join(f"{name}" for name in filenames[:15])
file_list_str += f"\n... and {num_files - 15} more."
else:
file_list_str = "\n".join(f"{name}" for name in filenames)
msg = f"New files uploaded:\n\n- Destination: `{album_name}`\n- Source: {source}\n- Files: {num_files}\n- Total size: {human_size(total_size)}\n\n```\n{file_list_str}\n```List commands: /help".strip()
await send_telegram_message(TELEGRAM_OWNER_ID, msg, markdown=True)
def _schedule_batch_notification():
# Helper to run async func from sync context of call_later
asyncio.create_task(send_batch_notification())
async def reset_telegram_debounce():
"""Resets the 30s timer for batch completion notification."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
return
global _batch_complete_timer
async with _batch_lock:
if _batch_complete_timer:
_batch_complete_timer.cancel()
loop = asyncio.get_event_loop()
_batch_complete_timer = loop.call_later(10, _schedule_batch_notification)
async def add_file_to_batch(filename: str, size: int, album_name: str, is_invite: bool):
"""Adds a completed file to the batch list."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
return
async with _batch_lock:
_upload_batch.append((filename, size, album_name, is_invite))
TELEGRAM_API_URL = f"https://api.telegram.org/bot{SETTINGS.telegram_bot_api_key}"
TELEGRAM_OWNER_ID = SETTINGS.telegram_bot_owner_id
async def send_telegram_message(chat_id: str, text: str, markdown: bool = False):
"""Send a message via Telegram bot."""
if not SETTINGS.telegram_bot_api_key:
return
payload = {"chat_id": chat_id, "text": text}
if markdown:
payload["parse_mode"] = "Markdown"
async with httpx.AsyncClient() as client:
try:
await client.post(f"{TELEGRAM_API_URL}/sendMessage", json=payload)
logger.info("Sent Telegram message to %s", chat_id)
except Exception as e:
logger.error("Failed to send Telegram message: %s", e)
async def handle_telegram_update(update: dict):
"""Process a single Telegram update."""
if "message" not in update:
return
message = update["message"]
chat_id = message.get("chat", {}).get("id")
from_id = message.get("from", {}).get("id")
text = message.get("text", "")
if str(from_id) != TELEGRAM_OWNER_ID:
logger.warning("Ignoring Telegram message from non-owner: %s", from_id)
return
global _public_uploads_enabled_runtime
if text == "/start":
await send_telegram_message(str(chat_id), "File Drop Bot active.")
elif text == "/help":
help_text = (
"/help - Show this help message\n"
"/start - Check if bot is active\n"
"/disable_public - Temporarily disable public uploads\n"
"/enable_public - Temporarily enable public uploads"
)
await send_telegram_message(str(chat_id), help_text)
elif text == "/disable_public":
_public_uploads_enabled_runtime = False
logger.info("Public uploads disabled by Telegram owner.")
await send_telegram_message(str(chat_id), "Public uploads have been disabled.")
elif text == "/enable_public":
_public_uploads_enabled_runtime = True
logger.info("Public uploads enabled by Telegram owner.")
await send_telegram_message(str(chat_id), "Public uploads have been enabled.")
async def poll_telegram_updates():
"""Poll for Telegram updates and process them."""
if not SETTINGS.telegram_bot_api_key or not TELEGRAM_OWNER_ID:
logger.info("Telegram bot not configured, skipping polling.")
return
update_offset = 0
async with httpx.AsyncClient(timeout=35) as client:
while True:
try:
response = await client.get(
f"{TELEGRAM_API_URL}/getUpdates",
params={"offset": update_offset, "timeout": 30}
)
updates = response.json().get("result", [])
for update in updates:
await handle_telegram_update(update)
update_offset = update["update_id"] + 1
except Exception as e:
logger.error("Error polling Telegram updates: %s", e)
await asyncio.sleep(10) # wait before retrying on error
@app.on_event("startup")
async def startup_event():
"""On app startup, send boot message and start polling."""
if SETTINGS.telegram_bot_api_key and TELEGRAM_OWNER_ID:
await send_telegram_message(TELEGRAM_OWNER_ID, "File Drop Bot booted up.")
asyncio.create_task(poll_telegram_updates())
# ---------- Helpers ----------
def sha1_hex(file_bytes: bytes) -> str:
@@ -200,6 +364,46 @@ def sanitize_filename(name: Optional[str]) -> str:
cleaned = ''.join(cleaned_chars).strip()
return cleaned or "file"
def get_safe_subpath(relative_path: Optional[str]) -> str:
"""
From a relative path from the client, return a safe subpath for the filesystem.
This removes the filename, and sanitizes each directory component.
e.g., 'foo/bar/baz.jpg' -> 'foo/bar'
e.g., '../../foo/bar.jpg' -> 'foo'
"""
if not relative_path:
return ""
# We only want the directory part.
directory_path = os.path.dirname(relative_path)
if not directory_path or directory_path == '.':
return ""
# Normalize path, especially for windows clients sending '\'
normalized_path = os.path.normpath(directory_path.replace('\\', '/'))
# Split into components
parts = normalized_path.split('/')
# Sanitize and filter components
safe_parts = []
for part in parts:
# No empty parts, no current/parent dir references
if not part or part == '.' or part == '..':
continue
# Remove potentially harmful characters from each part of the path
safe_part = re.sub(r'[<>:"|?*]', '', part).strip().replace('/', '_')
if safe_part:
safe_parts.append(safe_part)
if not safe_parts:
return ""
# Using os.path.join to be OS-agnostic for the server's filesystem
return os.path.join(*safe_parts)
def read_exif_datetimes(file_bytes: bytes):
"""
Extract EXIF DateTimeOriginal / ModifyDate values when possible.
@@ -268,11 +472,15 @@ def _verify_password(stored: str, pw: Optional[str]) -> bool:
except Exception:
return False
def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None) -> str:
"""Get or create a directory for an album. Returns the path."""
def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = None, relative_path: Optional[str] = None) -> Tuple[str, str]:
"""
Get or create a directory for an album, including subdirectories.
Returns a tuple of (full_save_path, display_album_name).
"""
if not album_name or not isinstance(album_name, str):
album_name = "public"
display_album_name = album_name
if album_name == "public":
try:
tz = pytz.timezone(SETTINGS.timezone)
@@ -281,16 +489,22 @@ def get_or_create_album_dir(album_name: str, public_subfolder: Optional[str] = N
slug = slugify(public_subfolder)
if slug:
today = f"{today}-{slug}"
save_dir = os.path.join("./data/uploads", "public", today)
display_album_name = today
base_save_dir = os.path.join("./data/uploads", "public", today)
except Exception as e:
logger.warning("Timezone logic failed, falling back to 'public' album. Timezone: %s. Error: %s", SETTINGS.timezone, e)
save_dir = os.path.join("./data/uploads", "public")
base_save_dir = os.path.join("./data/uploads", "public")
display_album_name = "public"
else:
safe_album_name = sanitize_filename(album_name)
save_dir = os.path.join("./data/uploads", "albums", safe_album_name)
display_album_name = safe_album_name
base_save_dir = os.path.join("./data/uploads", "albums", safe_album_name)
safe_subpath = get_safe_subpath(relative_path)
save_dir = os.path.join(base_save_dir, safe_subpath) if safe_subpath else base_save_dir
os.makedirs(save_dir, exist_ok=True)
return save_dir
return save_dir, display_album_name
async def send_progress(session_id: str, item_id: str, status: str, progress: int = 0, message: str = "", response_id: Optional[str] = None) -> None:
"""Push a progress update over WebSocket for one queue item."""
@@ -307,7 +521,7 @@ async def send_progress(session_id: str, item_id: str, status: str, progress: in
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""Serve the SPA (frontend/index.html) or redirect to login if disabled."""
if not SETTINGS.public_upload_page_enabled:
if not _public_uploads_enabled_runtime:
return RedirectResponse(url="/login")
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
@@ -341,7 +555,7 @@ async def api_ping() -> dict:
async def api_config() -> dict:
"""Expose minimal public configuration flags for the frontend."""
return {
"public_upload_page_enabled": SETTINGS.public_upload_page_enabled,
"public_upload_page_enabled": _public_uploads_enabled_runtime,
"chunked_uploads_enabled": SETTINGS.chunked_uploads_enabled,
"chunk_size_mb": SETTINGS.chunk_size_mb,
}
@@ -380,6 +594,7 @@ async def api_upload(
item_id: str = Form(...),
session_id: str = Form(...),
last_modified: Optional[int] = Form(None),
relative_path: Optional[str] = Form(None),
invite_token: Optional[str] = Form(None),
fingerprint: Optional[str] = Form(None),
public_folder_name: Optional[str] = Form(None),
@@ -496,12 +711,12 @@ async def api_upload(
target_album_name = album_name
album_for_saving = target_album_name if invite_token else "public"
if not invite_token and not SETTINGS.public_upload_page_enabled:
if not invite_token and not _public_uploads_enabled_runtime:
await send_progress(session_id, item_id, "error", 100, "Public uploads disabled")
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
try:
save_dir = get_or_create_album_dir(album_for_saving, public_folder_name)
safe_name = sanitize_filename(file.filename)
save_dir, display_album_name = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path)
safe_name = sanitize_filename(os.path.basename(file.filename or "file"))
save_path = os.path.join(save_dir, safe_name)
# Avoid overwriting
if os.path.exists(save_path):
@@ -513,8 +728,10 @@ async def api_upload(
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()
msg = f"Saved to {album_for_saving}/{os.path.basename(save_path)}"
msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
await send_progress(session_id, item_id, "done", 100, msg)
# Increment invite usage on success
@@ -604,6 +821,7 @@ async def api_upload_chunk_init(request: Request) -> JSONResponse:
meta = {
"name": (data or {}).get("name"),
"size": (data or {}).get("size"),
"relative_path": (data or {}).get("relative_path"),
"last_modified": (data or {}).get("last_modified"),
"invite_token": (data or {}).get("invite_token"),
"content_type": (data or {}).get("content_type") or "application/octet-stream",
@@ -653,6 +871,7 @@ async def api_upload_chunk(
except Exception as e:
logger.exception("Chunk write failed: %s", e)
return JSONResponse({"error": "chunk_write_failed"}, status_code=500)
await reset_telegram_debounce()
return JSONResponse({"ok": True})
@app.post("/api/upload/chunk/complete")
@@ -680,6 +899,7 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
except Exception:
meta = {}
public_folder_name = meta.get("public_folder_name")
relative_path = meta.get("relative_path")
total_chunks = int(meta.get("total_chunks") or (data or {}).get("total_chunks") or 0)
if total_chunks <= 0:
return JSONResponse({"error": "missing_total"}, status_code=400)
@@ -827,13 +1047,13 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
target_album_name = album_name
album_for_saving = target_album_name if invite_token else "public"
if not invite_token and not SETTINGS.public_upload_page_enabled:
if not invite_token and not _public_uploads_enabled_runtime:
await send_progress(session_id_local, item_id_local, "error", 100, "Public uploads disabled")
return JSONResponse({"error": "public_upload_disabled"}, status_code=403)
try:
save_dir = get_or_create_album_dir(album_for_saving, public_folder_name)
safe_name = sanitize_filename(file_like_name)
save_dir, display_album_name = get_or_create_album_dir(album_for_saving, public_folder_name, relative_path)
safe_name = sanitize_filename(os.path.basename(file_like_name or "file"))
save_path = os.path.join(save_dir, safe_name)
if os.path.exists(save_path):
base, ext = os.path.splitext(safe_name)
@@ -844,8 +1064,9 @@ async def api_upload_chunk_complete(request: Request) -> JSONResponse:
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 {album_for_saving}/{os.path.basename(save_path)}"
msg = f"Saved to {display_album_name}/{os.path.basename(save_path)}"
await send_progress(session_id_local, item_id_local, "done", 100, msg)
if invite_token:
@@ -986,7 +1207,7 @@ async def api_albums_create(request: Request) -> JSONResponse:
if not name:
return JSONResponse({"error": "missing_name"}, status_code=400)
try:
get_or_create_album_dir(name)
_save_dir, _display_name = get_or_create_album_dir(name)
return JSONResponse({"id": name, "albumName": name}, status_code=201)
except Exception as e:
logger.exception("Create album directory failed: %s", e)
@@ -1082,7 +1303,7 @@ async def api_invites_create(request: Request) -> JSONResponse:
album_name = "public"
# Ensure album directory exists
get_or_create_album_dir(album_name)
_save_dir, _display_name = get_or_create_album_dir(album_name)
resolved_album_id = None # not used
# Compute expiry
expires_at = None

View File

@@ -25,6 +25,8 @@ class Settings:
chunked_uploads_enabled: bool = False
chunk_size_mb: int = 95
timezone: str = "UTC"
telegram_bot_api_key: str = ""
telegram_bot_owner_id: str = ""
def _hash_password(pw: str) -> str:
"""Return PBKDF2-SHA256 hash of a password."""
@@ -74,6 +76,8 @@ def load_settings() -> Settings:
except ValueError:
chunk_size_mb = 95
timezone = os.getenv("TIMEZONE", "UTC")
telegram_bot_api_key = os.getenv("TELEGRAM_BOT_API_KEY", "")
telegram_bot_owner_id = os.getenv("TELEGRAM_BOT_OWNER_ID", "")
return Settings(
admin_password=admin_password,
max_concurrent=maxc,
@@ -85,4 +89,6 @@ def load_settings() -> Settings:
chunked_uploads_enabled=chunked_uploads_enabled,
chunk_size_mb=chunk_size_mb,
timezone=timezone,
telegram_bot_api_key=telegram_bot_api_key,
telegram_bot_owner_id=telegram_bot_owner_id,
)

View File

@@ -9,6 +9,7 @@ services:
- .env
volumes:
- ./data:/image_drop/data
user: "${UID:-1000}:${GID:-1000}"
healthcheck:
test: ["CMD-SHELL", "python - <<'PY'\nimport urllib.request,sys\ntry:\n urllib.request.urlopen('http://localhost:8080/').read(); sys.exit(0)\nexcept Exception:\n sys.exit(1)\nPY"]
interval: 30s

View File

@@ -87,9 +87,10 @@ function human(bytes){
return (bytes/Math.pow(k,i)).toFixed(1)+' '+sizes[i];
}
function addItem(file){
function addItem(file, relativePath){
const id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0 };
const resolvedPath = (relativePath !== undefined) ? relativePath : (file.webkitRelativePath || '');
const it = { id, file, name: file.name, size: file.size, status: 'queued', progress: 0, relativePath: resolvedPath };
items.unshift(it);
render();
}
@@ -105,7 +106,7 @@ function render(){
<div class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 shadow-sm transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate font-medium">${it.name} <span class="text-xs text-gray-500 dark:text-gray-400">(${human(it.size)})</span></div>
<div class="truncate font-medium">${it.relativePath || it.name} <span class="text-xs text-gray-500 dark:text-gray-400">(${human(it.size)})</span></div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
${it.message ? `<span>${it.message}</span>` : ''}
</div>
@@ -223,6 +224,7 @@ async function uploadWhole(next){
form.append('item_id', next.id);
form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || '');
if (next.relativePath) form.append('relative_path', next.relativePath);
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
const publicFolderName = document.getElementById('publicFolderName')?.value?.trim();
if (publicFolderName) form.append('public_folder_name', publicFolderName);
@@ -240,7 +242,7 @@ async function uploadWhole(next){
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100;
render();
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {}
try { if (isDuplicate) showBanner(`Duplicate: ${next.name}`, 'warn'); } catch {}
}
}
@@ -254,6 +256,7 @@ async function uploadChunked(next){
item_id: next.id,
session_id: sessionId,
name: next.file.name,
relative_path: next.relativePath || '',
size: next.file.size,
last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '',
@@ -352,7 +355,6 @@ if (btnPing) btnPing.onclick = async () => {
if(j.album_name) {
bannerText += ` | Uploading to album: "${j.album_name}"`;
}
showBanner(bannerText, 'ok');
}
}catch{
pingStatus.textContent = 'No connection';
@@ -371,17 +373,43 @@ if (btnPing) btnPing.onclick = async () => {
if (j.albumName) parts.push(`Uploading to album: "${j.albumName}"`);
if (j.expiresAt) parts.push(`Expires: ${new Date(j.expiresAt).toLocaleString()}`);
if (typeof j.remaining === 'number') parts.push(`Uses left: ${j.remaining}`);
if (parts.length) showBanner(parts.join(' | '), 'ok');
} catch {}
})();
// --- Drag & drop (no click-to-open on touch) ---
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.add('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); }));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('border-blue-500','bg-blue-50','dark:bg-blue-900','dark:bg-opacity-20'); }));
dz.addEventListener('drop', (e)=>{
dz.addEventListener('drop', async (e)=>{
e.preventDefault();
const files = Array.from(e.dataTransfer.files || []);
files.forEach(addItem);
const filesAndPaths = [];
const traverseFileTree = async (entry) => {
if (!entry) return;
if (entry.isFile) {
return new Promise(resolve => {
entry.file(file => {
filesAndPaths.push({ file, path: entry.fullPath ? entry.fullPath.substring(1) : file.name });
resolve();
});
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
const entries = await new Promise(resolve => reader.readEntries(resolve));
for (const subEntry of entries) {
await traverseFileTree(subEntry);
}
}
};
if (e.dataTransfer.items && e.dataTransfer.items.length > 0 && e.dataTransfer.items[0].webkitGetAsEntry) {
const promises = Array.from(e.dataTransfer.items).map(item => traverseFileTree(item.webkitGetAsEntry()));
await Promise.all(promises);
filesAndPaths.forEach(fp => addItem(fp.file, fp.path));
} else {
// Fallback for browsers without directory drop support
Array.from(e.dataTransfer.files).forEach(file => addItem(file));
}
render();
runQueue();
});
@@ -412,19 +440,22 @@ fi.addEventListener('click', (e) => {
e.stopPropagation();
});
fi.onchange = () => {
const onFilesSelected = (inputEl) => {
if (!inputEl) return;
// Suppress any stray clicks for a short window after the picker closes
suppressClicksUntil = Date.now() + 800;
const files = Array.from(fi.files || []);
files.forEach(addItem);
const files = Array.from(inputEl.files || []);
files.forEach(file => addItem(file));
render();
runQueue();
// Reset a bit later so selecting the same items again still triggers 'change'
setTimeout(() => { try { fi.value = ''; } catch {} }, 500);
setTimeout(() => { try { inputEl.value = ''; } catch {} }, 500);
};
fi.onchange = () => onFilesSelected(fi);
// If you want the whole dropzone clickable on desktop only, enable this:
if (!isTouch) {
dz.addEventListener('click', () => {

View File

@@ -34,7 +34,7 @@
<!-- upload icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
</div>
<p class="mt-3 font-medium hidden md:block">Drop files here</p>
<p class="mt-3 font-medium hidden md:block">Drop multiple files or folders here</p>
<p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
<!-- Mobile-safe choose control: label wraps the hidden input -->

View File

@@ -58,12 +58,16 @@
<div id="dropHint" class="mx-auto -mt-6 h-12 w-12 opacity-70 hidden md:block">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
</div>
<p class="mt-3 font-medium hidden md:block">Drop files here</p>
<p class="mb-2 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
<div class="mt-3 relative inline-block">
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none" aria-label="Choose files">
<p class="mt-3 font-medium hidden md:block">Drop multiple files or folders here</p>
<p class="mb-5 text-sm text-gray-600 dark:text-gray-400 hidden md:block">...or</p>
<!-- Mobile-safe choose control: label wraps the hidden input -->
<div class="relative inline-block">
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-5 py-3 hover:opacity-90 cursor-pointer select-none transition-colors" aria-label="Choose files">
Choose files
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer" />
<input id="fileInput"
type="file"
multiple
class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
@@ -140,7 +144,6 @@
dz.classList.remove('opacity-50');
if (fi) fi.disabled = false;
itemsEl.innerHTML = '';
try { showBanner('Password accepted. You can upload now.', 'ok'); } catch {}
} catch (e) {
pwError.textContent = 'Error verifying password.';
pwError.classList.remove('hidden');