Compare commits

..

10 Commits

Author SHA1 Message Date
0080b21bb0 Remove more Immich references 2025-11-22 21:03:40 -07:00
fc86d6cc11 Remove immich references from the UI 2025-11-22 20:56:24 -07:00
89542476b0 Ignore aider 2025-11-22 20:56:16 -07:00
ebd120b2cd feat: Implement hashed admin password support and centralize password logic
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-22 20:50:19 -07:00
7b1e3da8b0 refactor: Use relative paths for data directories 2025-11-22 20:50:13 -07:00
6b68a84684 refactor: Shift album management from Immich API to local directories
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-22 20:17:14 -07:00
78452b93ef Refactor: Replace Immich integration with local file storage and admin auth
Co-authored-by: aider (gemini/gemini-2.5-pro) <aider@aider.chat>
2025-11-22 20:15:12 -07:00
2275774a46 Save photos locally if Immich API not configured 2025-11-22 19:41:49 -07:00
MEGASOL\simon.adams
d1852e9cd9 add manag link menue 2025-10-03 16:58:26 +02:00
MEGASOL\simon.adams
519abb8f3a buffix apple 2025-09-21 10:03:15 +02:00
10 changed files with 1017 additions and 496 deletions

11
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.env
*.env
agent.md
docker-compose.yml
@@ -20,7 +21,15 @@ dist/
.vscode/
.DS_Store
# Local data/dbs
# Local data/dbs (do not commit local state)
data/
/data/
data/*.db
data/*.sqlite
data/*.sqlite3
data/chunks/
state.db
# Logs
*.log
.aider*

View File

@@ -7,23 +7,16 @@ Admin users log in to create public invite links; invite links are always public
## Features
- **Invite links (public)** — create upload links you can share with anyone
- **Onetime link claim** — first browser session claims a onetime link; it can upload multiple files, others are blocked
- **Optional public uploader** — disabled by default; can be enabled via `.env`
- **Queue with progress** via WebSocket (success / duplicate / error)
- **Duplicate prevention** (local SHA1 cache + optional Immich bulkcheck)
- **Original dates preserved** (EXIF → `fileCreatedAt` / `fileModifiedAt`)
- **Mobilefriendly**
- **.envonly config** (clean deploys) + Docker/Compose
- **Privacyfirst**: never lists server media; UI only shows the current session
- **Dark mode** — detects system preference; manual toggle persists across pages
- **Albums** — add uploads to a configured album (creates if needed)
- **Copy + QR** — copy invite link and display QR for easy sharing
- **Chunked uploads (optional)** — split large files to bypass proxy limits; configurable size
- **Invite passwords (optional)** — protect invite links with a password prompt
- **Deviceflexible HMI** — responsive layout, mobilesafe picker, sticky mobile bar
- **Retry failed uploads** — oneclick retry for any errored item
- **Invites without album** — create links that dont add uploads to any album
- **Invite Links:** public-by-URL links for uploads; one-time or multi-use
- **Manage Links:** search/sort, enable/disable, delete, edit name/expiry
- **Row Actions:** icon-only actions with tooltips (Open, Copy, Details, QR, Save)
- **Passwords (optional):** protect invites with a password gate
- **Albums (optional):** upload into a specific album (auto-create supported)
- **Duplicate Prevention:** local SHA1 cache (+ optional Immich bulk-check)
- **Progress Queue:** WebSocket updates; retry failed items
- **Chunked Uploads (optional):** large-file support with configurable chunk size
- **Privacy-first:** never lists server media; session-local uploads only
- **Mobile + Dark Mode:** responsive UI, safe-area padding, persistent theme
---
@@ -105,27 +98,17 @@ docker compose up -d
```
---
## New Features
## What's New
### 🔐 Login + Menu
- Login with your Immich credentials to access the menu.
- The menu lets you list/create albums and create invite links.
- The menu is always behind login; logout clears the session.
### v0.5.0 Manage Links overhaul
- In-panel bulk actions footer (Delete/Enable/Disable stay inside the box)
- Per-row icon actions with tooltips; Save button lights up only on changes
- Per-row QR modal; Details modal close fixed and reliable
- Auto-refresh after creating a link; new row is highlighted and scrolled into view
- Expiry save fix: stores end-of-day to avoid off-by-one date issues
### 🔗 Invite Links
- Links are always public by URL (no login required to use).
- You can make links onetime (claimed by the first browser session) or indefinite / limited uses.
- Set link expiry (e.g., 1, 2, 7 days). Expired links are inactive.
- Copy link and view a QR code for easy sharing.
### 🔑 Invite Passwords (New)
- When creating an invite, you can optionally set a password.
- Recipients must enter the password before they can upload through the link.
- The app stores only a salted hash serverside; sessions that pass the check are marked authorized.
### 🧩 Chunked Uploads (New)
- Optin support for splitting large files into chunks to bypass proxy limits (e.g., Cloudflare 100MB).
- Enable with `CHUNKED_UPLOADS_ENABLED=true`; tune `CHUNK_SIZE_MB` (default 95MB).
Roadmap highlight
- Wed like to add a per-user UI and remove reliance on a fixed API key by allowing users to authenticate and provide their own Immich API tokens. This is not in scope for the initial versions but aligns with future direction.
- The frontend automatically switches to chunked mode only for files larger than the configured chunk size.
### 📱 DeviceFlexible HMI (New)
@@ -147,22 +130,10 @@ docker compose up -d
- Support for invites with no album association.
### 🌙 Dark Mode
- Automatically detects system dark/light preference on first visit
- Manual toggle button in the header (sun/moon icon)
- Preference saved in browser localStorage
- Smooth color transitions for better UX
- All UI elements properly themed for both modes
- Automatic or manual toggle; persisted preference
### 📁 Album Integration
- Configure `IMMICH_ALBUM_NAME` environment variable to auto-add uploads to a specific album
- Album is automatically created if it doesn't exist
- Efficient caching of album ID to minimize API calls
- Visual feedback showing which album uploads are being added to
- Works seamlessly with existing duplicate detection
### 🐛 Bug Fixes
- Fixed WebSocket disconnection error that occurred when clients closed connections
- Improved error handling for edge cases
- Auto-create + assign album if configured; optional invites without album
---

1027
app/app.py

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,15 @@ import os
from dataclasses import dataclass
import secrets
from dotenv import load_dotenv
import hashlib
import binascii
@dataclass
class Settings:
"""App settings loaded from environment variables (.env)."""
immich_base_url: str
immich_api_key: str
admin_password: str
max_concurrent: int
album_name: str = ""
public_upload_page_enabled: bool = False
public_base_url: str = ""
state_db: str = ""
@@ -25,10 +25,17 @@ class Settings:
chunked_uploads_enabled: bool = False
chunk_size_mb: int = 95
@property
def normalized_base_url(self) -> str:
"""Return the base URL without a trailing slash for clean joining and display."""
return self.immich_base_url.rstrip("/")
def _hash_password(pw: str) -> str:
"""Return PBKDF2-SHA256 hash of a password."""
try:
if not pw:
return ""
salt = os.urandom(16)
iterations = 200_000
dk = hashlib.pbkdf2_hmac('sha256', pw.encode('utf-8'), salt, iterations)
return f"pbkdf2_sha256${iterations}${binascii.hexlify(salt).decode()}${binascii.hexlify(dk).decode()}"
except Exception:
return ""
def load_settings() -> Settings:
"""Load settings from .env, applying defaults when absent."""
@@ -37,9 +44,15 @@ def load_settings() -> Settings:
load_dotenv()
except Exception:
pass
base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api")
api_key = os.getenv("IMMICH_API_KEY", "")
album_name = os.getenv("IMMICH_ALBUM_NAME", "")
admin_password = os.getenv("ADMIN_PASSWORD", "admin") # Default for convenience, should be changed
if not admin_password.startswith("pbkdf2_sha256$"):
print("="*60)
print("WARNING: ADMIN_PASSWORD is in plaintext.")
print("For better security, use the hashed password below in your .env file:")
hashed_pw = _hash_password(admin_password)
if hashed_pw:
print(f"ADMIN_PASSWORD={hashed_pw}")
print("="*60)
# Safe defaults: disable public uploader and invites unless explicitly enabled
def as_bool(v: str, default: bool = False) -> bool:
if v is None:
@@ -59,10 +72,8 @@ def load_settings() -> Settings:
except ValueError:
chunk_size_mb = 95
return Settings(
immich_base_url=base,
immich_api_key=api_key,
admin_password=admin_password,
max_concurrent=maxc,
album_name=album_name,
public_upload_page_enabled=public_upload,
public_base_url=os.getenv("PUBLIC_BASE_URL", ""),
state_db=state_db,

View File

@@ -1,5 +1,28 @@
// Frontend logic (mobile-safe picker; no settings UI)
const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
// Simple device fingerprint: stable per-browser using stored id + UA/screen/timezone
function getDeviceId(){
try{
let id = localStorage.getItem('immich_drop_device_id');
if (!id) { id = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2)); localStorage.setItem('immich_drop_device_id', id); }
return id;
}catch{ return 'anon'; }
}
function computeFingerprint(){
try{
const id = getDeviceId();
const ua = navigator.userAgent || '';
const lang = navigator.language || '';
const plat = navigator.platform || '';
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
const scr = (screen && (screen.width+'x'+screen.height+'x'+screen.colorDepth)) || '';
const raw = [id, ua, lang, plat, tz, scr].join('|');
// tiny hash
let h = 0; for (let i=0;i<raw.length;i++){ h = (h<<5) - h + raw.charCodeAt(i); h |= 0; }
return `${id}:${Math.abs(h)}`;
}catch{ return getDeviceId(); }
}
const FINGERPRINT = computeFingerprint();
let CFG = { chunked_uploads_enabled: false, chunk_size_mb: 95 };
// Detect invite token from URL path /invite/{token}
let INVITE_TOKEN = null;
@@ -196,6 +219,7 @@ async function uploadWhole(next){
form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || '');
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
form.append('fingerprint', FINGERPRINT);
const res = await fetch('/api/upload', { method:'POST', body: form });
const body = await res.json().catch(()=>({}));
if(!res.ok && next.status!=='error'){
@@ -225,7 +249,8 @@ async function uploadChunked(next){
size: next.file.size,
last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '',
content_type: next.file.type || 'application/octet-stream'
content_type: next.file.type || 'application/octet-stream',
fingerprint: FINGERPRINT
}) });
} catch {}
// upload parts
@@ -240,6 +265,7 @@ async function uploadChunked(next){
fd.append('chunk_index', String(i));
fd.append('total_chunks', String(total));
if (INVITE_TOKEN) fd.append('invite_token', INVITE_TOKEN);
fd.append('fingerprint', FINGERPRINT);
fd.append('chunk', blob, `${next.file.name}.part${i}`);
const r = await fetch('/api/upload/chunk', { method:'POST', body: fd });
if (!r.ok) {
@@ -260,6 +286,7 @@ async function uploadChunked(next){
last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '',
content_type: next.file.type || 'application/octet-stream',
fingerprint: FINGERPRINT,
total_chunks: total
}) });
const body = await rc.json().catch(()=>({}));
@@ -312,7 +339,7 @@ if (btnPing) btnPing.onclick = async () => {
pingStatus.textContent = j.ok ? 'Connected' : 'No connection';
pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600');
if(j.ok){
let bannerText = `Connected to Immich at ${j.base_url}`;
let bannerText = `Connected to server.`;
if(j.album_name) {
bannerText += ` | Uploading to album: "${j.album_name}"`;
}
@@ -353,6 +380,20 @@ dz.addEventListener('drop', (e)=>{
// --- Mobile-safe file input change handler ---
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
// On iOS Safari, the `capture` attribute forces camera-only and hides Photo Library.
// Keep camera default on Android, but remove capture elsewhere to allow picking from Photos/Files.
try {
const ua = (navigator.userAgent || navigator.vendor || window.opera || '');
const isAndroid = /Android/i.test(ua);
if (fi) {
if (isAndroid) {
fi.setAttribute('capture', 'environment');
} else {
fi.removeAttribute('capture');
}
}
} catch {}
let suppressClicksUntil = 0;
if (isTouch && dropHint) {
try { dropHint.classList.add('hidden'); } catch {}

View File

@@ -59,7 +59,7 @@
pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600');
}
if(j.ok){
let text = `Connected to Immich at ${j.base_url}`;
let text = `Connected server.`;
if (j.album_name) text += ` | Uploading to album: "${j.album_name}"`;
showBanner(text, 'ok');
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Immich Drop Uploader</title>
<title>Image Drop Uploader</title>
<link rel="icon" type="image/png" href="/static/favicon.png" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
@@ -17,7 +17,7 @@
<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 flex-wrap gap-2">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
<div class="flex items-center gap-2">
<a href="/login" class="rounded-xl border px-4 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" aria-label="Login">Login</a>
<button id="btnTheme" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" title="Toggle dark mode" aria-label="Toggle theme">
@@ -50,7 +50,6 @@
type="file"
multiple
accept="image/*,video/*"
capture="environment"
class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
@@ -83,7 +82,7 @@
<section id="items" class="space-y-3"></section>
<footer class="pt-4 pb-10 text-center text-xs text-gray-500 dark:text-gray-400">
Built for simple, account-less uploads to Immich. This page never lists media from the server and only shows your current session's items.
Built for simple, account-less image uploads. This page never lists media from the server and only shows your current session's items.
</footer>
</div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Immich Drop Uploader (Invite)</title>
<title>Image Drop Uploader (Invite)</title>
<link rel="icon" type="image/png" href="/static/favicon.png" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
@@ -21,7 +21,7 @@
<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 flex-wrap gap-2">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<h1 class="text-2xl font-semibold tracking-tight">Image Drop Uploader</h1>
<div class="flex items-center gap-2">
<a id="linkHome" href="/" class="hidden rounded-xl border px-3 py-1 text-sm dark:border-gray-600">Home</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600" title="Toggle dark mode">
@@ -73,7 +73,7 @@
<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">
Choose files
<input id="fileInput" type="file" multiple accept="image/*,video/*" capture="environment" class="absolute inset-0 opacity-0 cursor-pointer" />
<input id="fileInput" type="file" multiple accept="image/*,video/*" class="absolute inset-0 opacity-0 cursor-pointer" />
</label>
</div>
<div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
@@ -129,7 +129,7 @@
document.getElementById('liUses').textContent = (typeof j.remaining==='number') ? String(j.remaining) : '—';
document.getElementById('liExpires').textContent = j.expiresAt ? fmt(j.expiresAt) : 'No expiry';
document.getElementById('liClaimed').textContent = j.claimed ? 'Yes' : 'No';
document.getElementById('liStatus').textContent = j.active ? 'Active' : 'Inactive';
document.getElementById('liStatus').textContent = j.active ? 'Active' : ('Inactive' + (j.inactiveReason ? (' ('+j.inactiveReason+')') : ''));
const dz = document.getElementById('dropzone');
const fi = document.getElementById('fileInput');
const itemsEl = document.getElementById('items');
@@ -169,7 +169,7 @@
// Disable dropzone
dz.classList.add('opacity-50');
fi.disabled = true;
itemsEl.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
itemsEl.innerHTML = `<div class="text-sm text-gray-500">This link is not active${j.inactiveReason?` (${j.inactiveReason})`:''}.</div>`;
}
} catch {}
})();
@@ -181,7 +181,7 @@
if (btnTheme) btnTheme.onclick = ()=>{ const isDark = document.documentElement.classList.toggle('dark'); try{ localStorage.setItem('theme', isDark ? 'dark':'light'); }catch{}; const isDarkNow = document.documentElement.classList.contains('dark'); try{ document.getElementById('iconLight').classList.toggle('hidden', !isDarkNow); document.getElementById('iconDark').classList.toggle('hidden', isDarkNow);}catch{} };
if (btnPing) btnPing.onclick = async ()=>{
pingStatus.textContent = 'checking…';
try { const r = await fetch('/api/ping', { method:'POST' }); const j = await r.json(); pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); if(j.ok){ let text = `Connected to Immich at ${j.base_url}`; if(j.album_name) text += ` | Uploading to album: "${j.album_name}"`; showBanner(text, 'ok'); } } catch { pingStatus.textContent = 'No connection'; pingStatus.className='ml-2 text-sm text-red-600'; }
try { const r = await fetch('/api/ping', { method:'POST' }); const j = await r.json(); pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); if(j.ok){ let text = `Connected to server.`; if(j.album_name) text += ` | Uploading to album: "${j.album_name}"`; showBanner(text, 'ok'); } } catch { pingStatus.textContent = 'No connection'; pingStatus.className='ml-2 text-sm text-red-600'; }
};
})();
</script>

View File

@@ -34,8 +34,8 @@
<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">Email</label>
<input id="email" type="email" required class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-800 dark:border-gray-700" />
<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>

View File

@@ -33,14 +33,13 @@
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-4">
<div>
<div class="text-sm font-medium mb-1">Target album (optional)</div>
<div class="text-sm font-medium mb-1">Target folder (optional)</div>
<div id="albumControls" class="space-y-2">
<div id="albumSelectWrap" class="hidden">
<select id="albumSelect" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700"></select>
</div>
<div id="albumInputWrap" class="hidden">
<input id="albumInput" placeholder="Album name (leave blank for none)" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
<button id="btnCreateAlbum" class="mt-2 w-full sm:w-auto rounded-xl bg-black text-white px-4 py-3 dark:bg-white dark:text-black">Create album</button>
<input id="albumInput" placeholder="New folder name (leave blank for public)" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
</div>
<div id="albumHint" class="text-sm text-gray-500"></div>
</div>
@@ -77,8 +76,49 @@
</div>
</section>
<!-- Manage links -->
<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">
<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>
<option value="created">Oldest</option>
<option value="-expires">Expiry desc</option>
<option value="expires">Expiry asc</option>
<option value="name">Name AZ</option>
<option value="-name">Name ZA</option>
</select>
<button id="btnRefresh" 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"><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">Actions</th>
</tr>
</thead>
<tbody id="invitesTBody"></tbody>
</table>
</div>
<!-- Footer inside the panel to keep bulk actions within the box -->
<div class="pt-3 mt-2 border-t dark:border-gray-700 flex items-center justify-end gap-2 flex-wrap">
<button id="btnDisableSel" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Disable selected</button>
<button id="btnEnableSel" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Enable selected</button>
<button id="btnDeleteSel" class="rounded-xl border px-3 py-2 text-sm border-red-400 text-red-600 dark:border-red-600">Delete selected</button>
</div>
</section>
<section class="text-xs text-gray-500">
If album listing or creation is forbidden by your token, specify a fixed album in the .env file as IMMICH_ALBUM_NAME.
Admin link page
</section>
</div>
@@ -89,7 +129,6 @@
const albumInputWrap = document.getElementById('albumInputWrap');
const albumInput = document.getElementById('albumInput');
const albumHint = document.getElementById('albumHint');
const btnCreateAlbum = document.getElementById('btnCreateAlbum');
const btnCreate = document.getElementById('btnCreate');
const usage = document.getElementById('usage');
const days = document.getElementById('days');
@@ -118,29 +157,17 @@
}
const list = await r.json();
if (Array.isArray(list)){
const opts = [{id:'', name:'— No album —'}].concat(list.map(a => ({id:a.id, name:(a.albumName || a.title || a.id)})));
const opts = [{id:'', name:'Select existing...'}].concat(list.map(a => ({id:a.id, name:(a.albumName || a.title || a.id)})));
albumSelect.innerHTML = opts.map(a => `<option value="${a.id}">${a.name}</option>`).join('');
albumSelectWrap.classList.remove('hidden');
albumInputWrap.classList.remove('hidden');
albumHint.textContent = 'Pick an existing album, or type a new name and click Create album. Select “— No album —” or leave the field blank to skip album association.';
albumHint.textContent = 'Pick an existing folder, or type a new folder name.';
}
} catch (e) {
albumHint.textContent = 'Failed to load albums.';
}
}
btnCreateAlbum.onclick = async () => {
const name = albumInput.value.trim();
if (!name) return;
try{
const r = await fetch('/api/albums', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ name }) });
const j = await r.json().catch(()=>({}));
if(!r.ok){ showResult('err', j.error || 'Album create failed'); return; }
showResult('ok', `Album created: ${j.albumName || j.id || name}`);
try { await loadAlbums(); } catch {}
}catch(err){ showResult('err', String(err)); }
};
btnCreate.onclick = async () => {
let albumId = null, albumName = null;
if (!albumSelectWrap.classList.contains('hidden') && albumSelect.value) {
@@ -163,6 +190,9 @@
linkOut.value = link;
// Build QR via backend PNG generator (no external libs)
qrImg.src = `/api/qr?text=${encodeURIComponent(link)}`;
// Also refresh the managed links list and highlight the new entry
if (j && j.token) { try { LAST_CREATED_TOKEN = j.token; } catch {} }
try { await loadInvites(); } catch {}
}catch(err){ showResult('err', String(err)); }
};
@@ -182,6 +212,235 @@
// header.js wires theme + ping and shows banner consistently
loadAlbums();
// --- Manage UI logic ---
const searchQ = document.getElementById('searchQ');
const sortSel = document.getElementById('sortSel');
const btnRefresh = document.getElementById('btnRefresh');
const invitesTBody = document.getElementById('invitesTBody');
const chkAll = document.getElementById('chkAll');
const btnDisableSel = document.getElementById('btnDisableSel');
const btnEnableSel = document.getElementById('btnEnableSel');
const btnDeleteSel = document.getElementById('btnDeleteSel');
let INVITES = [];
let LAST_CREATED_TOKEN = null;
async function loadInvites(){
const params = new URLSearchParams();
const q = (searchQ.value||'').trim(); if (q) params.set('q', q);
const sort = (sortSel.value||'').trim(); if (sort) params.set('sort', sort);
try{
const r = await fetch('/api/invites?'+params.toString());
const j = await r.json();
INVITES = (j && j.items) ? j.items : [];
}catch{ INVITES = []; }
renderInvites();
}
function fmtDayMonth(iso){ try{ const d = new Date(iso); return d.toLocaleDateString(undefined,{ day:'2-digit', month:'short' }); }catch{return '—';} }
function statusBadge(row){
// Red reasons override disabled (yellow)
const inactive = String(row.inactiveReason||'');
if (/expired|claimed|exhausted/i.test(inactive)){
const label = inactive.charAt(0).toUpperCase()+inactive.slice(1);
return `<span class="inline-flex items-center rounded-full bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300 px-2 py-0.5">${label}</span>`;
}
if (row.active){
return `<span class="inline-flex items-center rounded-full bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-0.5">Active</span>`;
}
if (/disabled/i.test(inactive)){
return `<span class="inline-flex items-center rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 px-2 py-0.5">Disabled</span>`;
}
return `<span>${row.active?'Active':'Inactive'}</span>`;
}
function renderInvites(){
invitesTBody.innerHTML = INVITES.map(row => {
const status = statusBadge(row);
const uses = (typeof row.remaining==='number') ? `${row.used||0}/${(row.maxUses<0)?'∞':row.maxUses}` : `${row.used||0}/${(row.maxUses<0)?'∞':row.maxUses??'?'}`;
const exp = row.expiresAt ? `<span title="${new Date(row.expiresAt).toLocaleString()}">${fmtDayMonth(row.expiresAt)}</span>` : '—';
const url = location.origin + '/invite/' + row.token;
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%;">
<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">
<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">
<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">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 3a9 9 0 1 1 0 18A9 9 0 0 1 12 3zm0 4a1.25 1.25 0 1 0 0 2.5A1.25 1.25 0 0 0 12 7zm-1.5 4.5h3v6h-3v-6z"/></svg>
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Details</span>
</button>
<button class="btnQR group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-url="${url}" aria-label="Show QR code">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h8v8H3V3zm2 2v4h4V5H5zm8-2h8v8h-8V3zm2 2v4h4V5h-4zM3 13h8v8H3v-8zm2 2v4h4v-4H5zm12 0h-2v2h2v2h-4v2h6v-6h-2v0zm-4-2h2v2h-2v-2zm6-2h2v2h-2v-2z"/></svg>
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Show QR</span>
</button>
<a class="group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" target="_blank" href="${url}" aria-label="Open link">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M14 3h7v7h-2V6.414l-9.293 9.293-1.414-1.414L17.586 5H14V3z"/><path d="M5 5h6v2H7v10h10v-4h2v6H5V5z"/></svg>
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Open</span>
</a>
<button class="btnCopyLink group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center" data-url="${url}" aria-label="Copy link">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14h13a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg>
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Copy</span>
</button>
<button class="btnSave group relative rounded-xl border px-2 py-1 text-xs dark:border-gray-600 inline-flex items-center opacity-50 cursor-not-allowed" data-token="${row.token}" aria-label="Save changes" disabled>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M17 3H7a2 2 0 0 0-2 2v14l7-3 7 3V5a2 2 0 0 0-2-2zM7 5h10v10l-5-2-5 2V5z"/></svg>
<span class="pointer-events-none absolute -top-8 left-1/2 -translate-x-1/2 rounded bg-black text-white text-xs px-2 py-1 opacity-0 group-hover:opacity-100 dark:bg-gray-700">Save</span>
</button>
</div>
</td>
</tr>`;
}).join('');
// wire row actions
// helper to compute change state and toggle Save
function updateSaveState(token){
const inName = invitesTBody.querySelector(`.inName[data-token="${token}"]`);
const inExp = invitesTBody.querySelector(`.inExpires[data-token="${token}"]`);
const btn = invitesTBody.querySelector(`.btnSave[data-token="${token}"]`);
if (!inName || !inExp || !btn) return;
const origName = inName.getAttribute('data-original') || '';
const origExp = inExp.getAttribute('data-original') || '';
const curName = (inName.value||'').trim();
const curExp = inExp.value || '';
const changed = (curName !== origName) || (curExp !== origExp);
btn.disabled = !changed;
btn.classList.toggle('opacity-50', !changed);
btn.classList.toggle('cursor-not-allowed', !changed);
}
// set original values as data attributes and wire change listeners
INVITES.forEach(row => {
const token = row.token;
const inName = invitesTBody.querySelector(`.inName[data-token="${token}"]`);
const inExp = invitesTBody.querySelector(`.inExpires[data-token="${token}"]`);
if (inName) inName.setAttribute('data-original', (row.name||'').trim());
const dStr = row.expiresAt? new Date(row.expiresAt).toISOString().slice(0,10) : '';
if (inExp) inExp.setAttribute('data-original', dStr);
if (inName) inName.addEventListener('input', ()=>updateSaveState(token));
if (inExp) inExp.addEventListener('change', ()=>updateSaveState(token));
updateSaveState(token);
});
invitesTBody.querySelectorAll('.btnSave').forEach(btn => btn.onclick = async ()=>{
const token = btn.getAttribute('data-token');
if (btn.disabled) return;
const name = invitesTBody.querySelector(`.inName[data-token="${token}"]`).value.trim();
const expVal = invitesTBody.querySelector(`.inExpires[data-token="${token}"]`).value;
const payload = { name };
if (expVal) {
// Set to end-of-day local to avoid off-by-one day on save
const dt = new Date(expVal);
dt.setHours(23,59,59,999);
payload.expiresAt = dt.toISOString().slice(0,19);
} else { payload.expiresAt = null; }
try{
const r = await fetch(`/api/invite/${token}`, { method:'PATCH', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify(payload) });
if (!r.ok) throw new Error('Update failed');
await loadInvites();
}catch(e){ showResult('err', String(e.message||e)); }
});
invitesTBody.querySelectorAll('.btnDetails').forEach(btn => btn.onclick = async ()=>{
const token = btn.getAttribute('data-token');
try{
const r = await fetch(`/api/invite/${token}/uploads`);
const j = await r.json();
const items = (j && j.items) ? j.items : [];
const html = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="max-w-3xl w-full rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-medium">Uploads</div>
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
</div>
<div class="max-h-[60vh] overflow-auto">
${items.length? `<table class="w-full text-sm"><thead><tr class="text-left border-b dark:border-gray-700"><th class="py-1">When</th><th class="py-1">IP</th><th class="py-1">Filename</th><th class="py-1">Size</th><th class="py-1">Fingerprint</th></tr></thead><tbody>` + items.map(it=>`<tr class="border-b dark:border-gray-800"><td class="py-1">${new Date(it.uploadedAt).toLocaleString()}</td><td class="py-1">${it.ip||''}</td><td class="py-1">${it.filename||''}</td><td class="py-1">${(it.size||0).toLocaleString()}</td><td class="py-1">${(it.fingerprint||'').slice(0,16)}</td></tr>`).join('') + `</tbody></table>` : '<div class="text-sm text-gray-500">No uploads yet.</div>'}
</div>
</div>
</div>`;
const wrap = document.createElement('div');
wrap.innerHTML = html;
const dlg = wrap.firstElementChild;
document.body.appendChild(dlg);
dlg.querySelectorAll('.dlgClose').forEach(b=> b.onclick = ()=>{ try{ dlg.remove(); }catch{} });
}catch(e){ showResult('err', 'Failed to load uploads'); }
});
invitesTBody.querySelectorAll('.btnQR').forEach(btn => btn.onclick = ()=>{
const url = btn.getAttribute('data-url');
const html = `
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div class="max-w-md w-full rounded-2xl bg-white dark:bg-gray-900 border dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-2">
<div class="text-lg font-medium">QR Code</div>
<button class="dlgClose rounded-lg border px-2 py-1 text-xs dark:border-gray-600">Close</button>
</div>
<div class="flex items-center gap-4">
<img src="/api/qr?text=${encodeURIComponent(url)}" alt="QR" class="h-40 w-40"/>
<div class="text-sm break-all">${url}</div>
</div>
</div>
</div>`;
const wrap = document.createElement('div');
wrap.innerHTML = html;
const dlg = wrap.firstElementChild;
document.body.appendChild(dlg);
dlg.querySelectorAll('.dlgClose').forEach(b=> b.onclick = ()=>{ try{ dlg.remove(); }catch{} });
});
invitesTBody.querySelectorAll('.btnCopyLink').forEach(btn => btn.onclick = ()=>{
const url = btn.getAttribute('data-url');
const flash = ()=>{ btn.setAttribute('data-old', btn.innerHTML); btn.innerHTML='Copied'; setTimeout(()=>{ const old=btn.getAttribute('data-old'); if(old) btn.innerHTML=old; }, 1000); };
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(flash).catch(()=>{}); }
});
if (chkAll) chkAll.checked = false;
// Highlight and scroll to newly created invite if present
if (LAST_CREATED_TOKEN) {
const tr = invitesTBody.querySelector(`tr[data-token="${LAST_CREATED_TOKEN}"]`);
if (tr) {
tr.classList.add('bg-yellow-50','dark:bg-yellow-900/30');
try { tr.scrollIntoView({ behavior:'smooth', block:'center' }); } catch {}
setTimeout(()=>{ try{ tr.classList.remove('bg-yellow-50','dark:bg-yellow-900/30'); }catch{} }, 1600);
}
LAST_CREATED_TOKEN = null;
}
}
btnRefresh.onclick = loadInvites;
searchQ.oninput = ()=>{ clearTimeout(searchQ._t); searchQ._t = setTimeout(loadInvites, 300); };
sortSel.onchange = loadInvites;
chkAll.onchange = ()=>{ invitesTBody.querySelectorAll('.chkRow').forEach(c=>{ c.checked = chkAll.checked; }); };
btnDisableSel.onclick = async ()=>{
const toks = Array.from(invitesTBody.querySelectorAll('.chkRow:checked')).map(x=>x.getAttribute('data-token'));
if (!toks.length) return;
try{
const r = await fetch('/api/invites/bulk', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ tokens: toks, action:'disable' }) });
if (!r.ok) throw new Error('Bulk disable failed');
await loadInvites();
}catch(e){ showResult('err', String(e.message||e)); }
};
btnEnableSel.onclick = async ()=>{
const toks = Array.from(invitesTBody.querySelectorAll('.chkRow:checked')).map(x=>x.getAttribute('data-token'));
if (!toks.length) return;
try{
const r = await fetch('/api/invites/bulk', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ tokens: toks, action:'enable' }) });
if (!r.ok) throw new Error('Bulk enable failed');
await loadInvites();
}catch(e){ showResult('err', String(e.message||e)); }
};
btnDeleteSel.onclick = async ()=>{
const toks = Array.from(invitesTBody.querySelectorAll('.chkRow:checked')).map(x=>x.getAttribute('data-token'));
if (!toks.length) return;
if (!confirm('Are you sure? This cannot be undone.')) return;
try{
const r = await fetch('/api/invites/delete', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ tokens: toks }) });
if (!r.ok) throw new Error('Delete failed');
await loadInvites();
}catch(e){ showResult('err', String(e.message||e)); }
};
// Initial load
loadInvites();
</script>
</body>
</html>