Compare commits

..

11 Commits

Author SHA1 Message Date
f45f60ef31 Login page tweaks 2026-05-19 20:49:39 +00:00
b0f6c1b6f9 fix: Improve mobile responsiveness of admin UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:36:15 +00:00
09994feebc refactor: Update admin page title to Admin 2026-05-19 19:36:10 +00:00
dae793e40c feat: Allow back button to close files modal
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:25:22 +00:00
3a881bb560 Add SESSION_SECRET env var to .env 2026-05-19 19:23:19 +00:00
4222763603 feat: Set login session cookie expiry to 365 days
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:10:32 +00:00
e8011e3f68 feat: Redirect to /menu if admin is already logged in on login page
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 19:07:37 +00:00
2f18b1ba6b feat: Hardcode 'admin' username for login and autofocus password 2026-05-19 19:07:36 +00:00
bdaa8d9049 Remove references to duplicate checking from readme 2026-05-19 18:56:46 +00:00
afcbc66e7f chore: Remove 'Duplicates' counter from upload status UI
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 17:07:06 +00:00
004930f60d feat: Remove duplicate checking functionality
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2026-05-19 00:36:08 +00:00
8 changed files with 75 additions and 171 deletions

View File

@@ -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

View File

@@ -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 SHA1 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 peritem progress via WebSocket.
- Intended to bypass upstream proxy limits (e.g., 100MB) while preserving EXIF timestamps, album add, and peritem 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 SHA1 and checks a local SQLite cache (`state.db`) to prevent duplicates.
- WebSocket `/ws` pushes peritem progress to the current browser session only.
- **Persistence:** A local SQLite database (`state.db`) prevents reuploads 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 **SHA1** 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.

View File

@@ -3,7 +3,6 @@ File Drop Uploader Backend (FastAPI, simplified)
----------------------------------------------------
- Serves static frontend (no settings UI)
- Uploads to file system
- Duplicate checks (local SHA-1 DB)
- WebSocket progress per session
- Ephemeral "Connected" banner via /api/ping
"""
@@ -34,7 +33,7 @@ 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:
@@ -67,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")
@@ -80,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 ----------
@@ -411,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,
@@ -606,26 +523,11 @@ async def api_upload(
fingerprint: Optional[str] = Form(None),
public_folder_name: Optional[str] = Form(None),
):
"""Receive a file, check duplicates; 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
@@ -734,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()
@@ -955,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
@@ -1070,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)}"

View File

@@ -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(()=>{});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}) });

View File

@@ -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>
@@ -121,7 +119,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 Files</h2>
<div class="flex items-center gap-2">
<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>
@@ -151,8 +149,12 @@
</div>
</section>
<section class="text-xs text-gray-500">
Admin link page
<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>
@@ -295,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('"','&quot;')}" title="${(row.name||'').replaceAll('"','&quot;')}"/>
</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">
@@ -544,7 +546,7 @@
}
const html = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<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>
@@ -574,16 +576,36 @@
wrap.innerHTML = html;
const dlg = wrap.firstElementChild;
document.body.appendChild(dlg);
document.body.style.overflow = 'hidden';
const close = () => { try { dlg.remove(); } catch {} };
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>