upload unique links + default upload page + login

This commit is contained in:
MEGASOL\simon.adams
2025-09-01 18:48:01 +02:00
parent 6a4e7fdb65
commit 43d5e0c0ff
11 changed files with 1005 additions and 63 deletions

View File

@@ -1,5 +1,13 @@
// Frontend logic (mobile-safe picker; no settings UI)
const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
// Detect invite token from URL path /invite/{token}
let INVITE_TOKEN = null;
try {
const parts = (window.location.pathname || '').split('/').filter(Boolean);
if (parts[0] === 'invite' && parts[1]) {
INVITE_TOKEN = parts[1];
}
} catch {}
let items = [];
let socket;
@@ -24,8 +32,10 @@ function toggleDarkMode() {
function updateThemeIcon() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('iconLight').classList.toggle('hidden', !isDark);
document.getElementById('iconDark').classList.toggle('hidden', isDark);
const light = document.getElementById('iconLight');
const dark = document.getElementById('iconDark');
if (light && light.classList) light.classList.toggle('hidden', !isDark);
if (dark && dark.classList) dark.classList.toggle('hidden', isDark);
}
initDarkMode();
@@ -131,6 +141,7 @@ async function runQueue(){
form.append('item_id', next.id);
form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || '');
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
const res = await fetch('/api/upload', { method:'POST', body: form });
const body = await res.json().catch(()=>({}));
if(!res.ok && next.status!=='error'){
@@ -184,7 +195,7 @@ function showBanner(text, kind='ok'){
}
// --- Connection test with ephemeral banner ---
btnPing.onclick = async () => {
if (btnPing) btnPing.onclick = async () => {
pingStatus.textContent = 'checking…';
try{
const r = await fetch('/api/ping', { method:'POST' });
@@ -204,6 +215,21 @@ btnPing.onclick = async () => {
}
};
// If on invite page, fetch invite info and show context banner
(async function initInviteBanner(){
if (!INVITE_TOKEN) return;
try {
const r = await fetch(`/api/invite/${INVITE_TOKEN}`);
if (!r.ok) return;
const j = await r.json();
const parts = [];
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'); }));
@@ -267,4 +293,4 @@ btnClearAll.onclick = ()=>{
};
// --- Dark mode toggle ---
btnTheme.onclick = toggleDarkMode;
if (btnTheme) btnTheme.onclick = toggleDarkMode;

94
frontend/header.js Normal file
View File

@@ -0,0 +1,94 @@
// Shared header utilities: theme + ping + ephemeral banner
(function(){
const doc = document;
const root = doc.documentElement;
const banner = doc.getElementById('topBanner');
function updateThemeIcon(){
const isDark = root.classList.contains('dark');
const light = doc.getElementById('iconLight');
const dark = doc.getElementById('iconDark');
if (light && light.classList) light.classList.toggle('hidden', !isDark);
if (dark && dark.classList) dark.classList.toggle('hidden', isDark);
}
function initDarkMode(){
try{
const stored = localStorage.getItem('theme');
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}catch{}
updateThemeIcon();
}
function toggleDarkMode(){
const isDark = root.classList.toggle('dark');
try{ localStorage.setItem('theme', isDark ? 'dark' : 'light'); }catch{}
updateThemeIcon();
}
function showBanner(text, kind='ok'){
if(!banner) return;
banner.textContent = text;
banner.className = 'rounded-2xl p-3 text-center transition-colors ' + (
kind==='ok' ? 'border border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300'
: kind==='warn' ? 'border border-amber-200 bg-amber-50 text-amber-700 dark:bg-amber-900 dark:border-amber-700 dark:text-amber-300'
: 'border border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300'
);
banner.classList.remove('hidden');
setTimeout(() => banner.classList.add('hidden'), 3000);
}
function wire(){
const btnTheme = doc.getElementById('btnTheme');
const btnPing = doc.getElementById('btnPing');
const pingStatus = doc.getElementById('pingStatus');
const linkPublic = doc.getElementById('linkPublicUploader');
const linkHome = doc.getElementById('linkHome');
if (btnTheme) btnTheme.onclick = toggleDarkMode;
if (btnPing) btnPing.onclick = async () => {
if (pingStatus) pingStatus.textContent = 'checking…';
try{
const r = await fetch('/api/ping', { method:'POST' });
const j = await r.json();
if (pingStatus) {
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{
if (pingStatus) {
pingStatus.textContent = 'No connection';
pingStatus.className='ml-2 text-sm text-red-600';
}
}
};
// Hide public uploader links unless enabled
(async ()=>{
try{
const r = await fetch('/api/config');
const j = await r.json();
const enabled = !!(j && j.public_upload_page_enabled);
if (linkPublic) linkPublic.classList.toggle('hidden', !enabled);
if (linkHome) linkHome.classList.toggle('hidden', !enabled);
}catch{
if (linkPublic) linkPublic.classList.add('hidden');
if (linkHome) linkHome.classList.add('hidden');
}
})();
}
initDarkMode();
wire();
// Expose for other scripts if needed
window.__header = { toggleDarkMode, showBanner };
})();

View File

@@ -19,6 +19,7 @@
<header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<div class="flex items-center gap-2">
<a href="/login" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors">Login</a>
<button id="btnTheme" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors" 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.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>

131
frontend/invite.html Normal file
View File

@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en" xml:lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Immich Drop Uploader (Invite)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
<style> body { transition: background-color .2s ease, color .2s ease; } </style>
<meta name="robots" content="noindex, nofollow" />
<meta http-equiv="Cache-Control" content="no-store" />
<meta http-equiv="Pragma" content="no-cache" />
</head>
<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-4xl 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 tracking-tight">Immich 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">
<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>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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>
<!-- Link information -->
<section id="linkInfo" class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 text-sm">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div><b>Status:</b> <span id="liStatus">Loading…</span></div>
<div><b>Album:</b> <span id="liAlbum"></span></div>
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-1">
<div><b>Type:</b> <span id="liType"></span></div>
<div><b>Uses left:</b> <span id="liUses"></span></div>
<div><b>Expires:</b> <span id="liExpires"></span></div>
<div><b>Claimed:</b> <span id="liClaimed"></span></div>
</div>
</div>
</section>
<!-- Dropzone and queue copied from index.html -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600">
<div class="mx-auto h-12 w-12 opacity-70">
<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">Drop images or videos here</p>
<p class="text-sm text-gray-600 dark:text-gray-400">...or</p>
<div class="mt-3 relative inline-block">
<label class="rounded-2xl bg-black text-white dark:bg-white dark:text-black px-4 py-2 hover:opacity-90 cursor-pointer select-none">
Choose files
<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">
Files will be uploaded to the selected album for this invite.
</div>
</section>
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700">
<div class="flex items-center justify-between text-sm">
<div class="flex gap-4">
<span>Queued/Processing: <b id="countQueued">0</b></span>
<span>Uploading: <b id="countUploading">0</b></span>
<span>Done: <b id="countDone">0</b></span>
<span>Duplicates: <b id="countDup">0</b></span>
<span>Errors: <b id="countErr">0</b></span>
</div>
</div>
</section>
<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">
Invite upload page
</footer>
</div>
<script src="/static/header.js"></script>
<script src="/static/app.js"></script>
</body>
<script>
// Hide ping and theme controls if referenced by app.js
// Standardized header includes ping + theme; no-op guards in app.js
// Populate link info section
(async function(){
try {
const parts = (location.pathname || '').split('/').filter(Boolean);
const token = parts[1];
const r = await fetch(`/api/invite/${token}`);
const j = await r.json();
const fmt = (s)=> s ? new Date(s).toLocaleString() : '—';
document.getElementById('liAlbum').textContent = j.albumName || j.albumId || '—';
document.getElementById('liType').textContent = j.oneTime ? 'One-time' : (j.maxUses < 0 ? 'Indefinite' : `Up to ${j.maxUses} uses`);
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';
if (!j.active) {
// Disable dropzone
const dz = document.getElementById('dropzone');
const fi = document.getElementById('fileInput');
dz.classList.add('opacity-50');
fi.disabled = true;
const items = document.getElementById('items');
items.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
}
} catch {}
})();
// Hook theme/ping
(function(){
const btnTheme = document.getElementById('btnTheme');
const btnPing = document.getElementById('btnPing');
const pingStatus = document.getElementById('pingStatus');
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'; }
};
})();
</script>
</html>

74
frontend/login.html Normal file
View File

@@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Login Immich Drop</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<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">Login to Immich</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">
<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>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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>
<div class="max-w-md">
<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">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" />
</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" />
</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>
<a href="/" class="text-sm text-gray-500">Back to uploader</a>
</div>
</form>
</div>
</div>
<script src="/static/header.js"></script>
<script>
const form = document.getElementById('loginForm');
const msg = document.getElementById('msg');
function show(kind, text){
msg.textContent = text;
msg.className = 'mb-3 rounded-lg border p-2 text-sm ' + (kind==='ok' ? 'border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300');
msg.classList.remove('hidden');
}
form.onsubmit = async (e)=>{
e.preventDefault();
const email = document.getElementById('email').value.trim();
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}) });
const j = await r.json().catch(()=>({}));
if(!r.ok){ show('err', j.error || 'Login failed'); return; }
show('ok', 'Login successful. Redirecting…');
setTimeout(()=>{ location.href = '/menu'; }, 500);
}catch(err){ show('err', String(err)); }
};
// header.js wires theme + ping and provides consistent banner behavior
</script>
</body>
</html>

168
frontend/menu.html Normal file
View File

@@ -0,0 +1,168 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Menu Immich Drop</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: 'class' };
</script>
</head>
<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">
<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>
<svg id="iconDark" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<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>
<section class="rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
<div>
<div class="text-sm font-medium mb-1">Target album</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-2 bg-white dark:bg-gray-900 dark:border-gray-700"></select>
</div>
<div id="albumInputWrap" class="hidden">
<input id="albumInput" placeholder="Album name" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700" />
<button id="btnCreateAlbum" class="mt-2 rounded-xl bg-black text-white px-3 py-1 dark:bg-white dark:text-black">Create album</button>
</div>
<div id="albumHint" class="text-sm text-gray-500"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<div class="text-sm font-medium mb-1">Usage</div>
<select id="usage" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700">
<option value="1">One-time</option>
<option value="-1">Indefinite</option>
</select>
</div>
<div>
<div class="text-sm font-medium mb-1">Expires in days</div>
<input id="days" type="number" min="0" placeholder="Leave empty for no expiry" class="w-full rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700" />
</div>
</div>
<div>
<button id="btnCreate" class="rounded-xl bg-black text-white px-4 py-2 dark:bg-white dark:text-black">Create link</button>
</div>
<div id="result" class="hidden rounded-xl border p-3 text-sm space-y-2">
<div id="linkRow" class="flex items-center gap-2">
<input id="linkOut" class="flex-1 rounded-lg border px-3 py-2 bg-white dark:bg-gray-900 dark:border-gray-700" readonly />
<button id="btnCopy" class="rounded-xl border px-3 py-2 text-sm dark:border-gray-600">Copy</button>
</div>
<div>
<img id="qrImg" alt="QR" class="h-40 w-40" />
</div>
</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.
</section>
</div>
<script src="/static/header.js"></script>
<script>
const albumSelectWrap = document.getElementById('albumSelectWrap');
const albumSelect = document.getElementById('albumSelect');
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');
const result = document.getElementById('result');
const btnLogout = document.getElementById('btnLogout');
const btnTheme = document.getElementById('btnTheme');
const btnPing = document.getElementById('btnPing');
const pingStatus = document.getElementById('pingStatus');
const linkOut = document.getElementById('linkOut');
const btnCopy = document.getElementById('btnCopy');
const qrImg = document.getElementById('qrImg');
function showResult(kind, text){
result.className = 'rounded-xl border p-3 text-sm space-y-2 ' + (kind==='ok' ? 'border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300');
result.classList.remove('hidden');
}
async function loadAlbums(){
try {
const r = await fetch('/api/albums');
if (r.status === 403) {
albumHint.textContent = 'Listing albums is forbidden with current credentials. Using .env IMMICH_ALBUM_NAME if set.';
albumInputWrap.classList.remove('hidden');
return;
}
const list = await r.json();
if (Array.isArray(list)){
albumSelect.innerHTML = list.map(a => `<option value="${a.id}">${a.albumName || a.title || a.id}</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.';
}
} 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) {
albumId = albumSelect.value;
} else if (albumInput.value.trim()) {
albumName = albumInput.value.trim();
}
const payload = { maxUses: parseInt(usage.value, 10) };
const d = days.value.trim();
if (d) payload.expiresDays = parseInt(d, 10);
if (albumId) payload.albumId = albumId; else if (albumName) payload.albumName = albumName;
try{
const r = await fetch('/api/invites', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json().catch(()=>({}));
if(!r.ok){ showResult('err', j.error || 'Failed to create link'); return; }
const link = j.absoluteUrl || (location.origin + j.url);
showResult('ok', '');
linkOut.value = link;
// Build QR via backend PNG generator (no external libs)
qrImg.src = `/api/qr?text=${encodeURIComponent(link)}`;
}catch(err){ showResult('err', String(err)); }
};
// Logout handled via plain link to /logout (clears session + redirects)
btnCopy.onclick = async ()=>{
try { await navigator.clipboard.writeText(linkOut.value); btnCopy.textContent = 'Copied'; setTimeout(()=>btnCopy.textContent='Copy', 1200); } catch {}
};
// header.js wires theme + ping and shows banner consistently
loadAlbums();
</script>
</body>
</html>