Merge pull request #3 from ttlequals0/master

dark-album update
This commit is contained in:
Simon Adams
2025-08-27 11:23:54 +02:00
committed by GitHub
7 changed files with 215 additions and 55 deletions

View File

@@ -2,6 +2,10 @@
A tiny, zero-login web app for collecting photos/videos into your **Immich** server. A tiny, zero-login web app for collecting photos/videos into your **Immich** server.
![Immich Drop Uploader Dark Mode UI](./screenshot.png)
## Features
- **No accounts** — open the page, drop files, done - **No accounts** — open the page, drop files, done
- **Queue with progress** via WebSocket (success / duplicate / error) - **Queue with progress** via WebSocket (success / duplicate / error)
- **Duplicate prevention** (local SHA1 cache + optional Immich bulkcheck) - **Duplicate prevention** (local SHA1 cache + optional Immich bulkcheck)
@@ -9,16 +13,18 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** ser
- **Mobilefriendly** - **Mobilefriendly**
- **.envonly config** (clean deploys) + Docker/Compose - **.envonly config** (clean deploys) + Docker/Compose
- **Privacyfirst**: never lists server media; UI only shows the current session - **Privacyfirst**: never lists server media; UI only shows the current session
- **Dark mode support** — automatically detects system preference, with manual toggle
- **Album integration** — auto-adds uploads to a configured album (creates if needed)
--- ---
## Table of contents ## Table of contents
- [Quick start](#Quick_start) - [Quick start](#quick-start)
- [New Features](#new-features)
- [Architecture](#architecture) - [Architecture](#architecture)
- [Folder structure](#folder-structure) - [Folder structure](#folder-structure)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Configuration (.env)](#configuration-env) - [Configuration (.env)](#configuration-env)
- [Quick start (Docker/Compose)](#quick-start-dockercompose)
- [How it works](#how-it-works) - [How it works](#how-it-works)
- [Mobile notes](#mobile-notes) - [Mobile notes](#mobile-notes)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
@@ -30,17 +36,22 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** ser
## Quick start ## Quick start
Copy the docker-compose.yml and the .env file to a common folder, Copy the docker-compose.yml and the .env file to a common folder,
update the .env file before executing the CLI commands to quick start the container. update the .env file before executing the CLI commands to quick start the container.
### docker-compose.yml ### docker-compose.yml
``` ```yaml
version: "3.9" version: "3.9"
services: services:
immich-drop: immich-drop:
image: ghcr.io/nasogaa/immich-drop:latest image: ttlequals0/immich-drop:latest
pull_policy: always pull_policy: always
container_name: immich-drop container_name: immich-drop
restart: unless-stopped restart: unless-stopped
# Optional: Set album name for auto-adding uploads
environment:
IMMICH_ALBUM_NAME: dead-drop # Optional: uploads will be added to this album
# Load all variables from your repo's .env (PORT, IMMICH_BASE_URL, IMMICH_API_KEY, etc.) # Load all variables from your repo's .env (PORT, IMMICH_BASE_URL, IMMICH_API_KEY, etc.)
env_file: env_file:
- ./.env - ./.env
@@ -64,6 +75,7 @@ services:
volumes: volumes:
immich_drop_data: immich_drop_data:
``` ```
### .env ### .env
``` ```
@@ -72,18 +84,42 @@ PORT=8080
IMMICH_BASE_URL=http://REPLACE_ME:2283/api IMMICH_BASE_URL=http://REPLACE_ME:2283/api
IMMICH_API_KEY=REPLACE_ME IMMICH_API_KEY=REPLACE_ME
MAX_CONCURRENT=3 MAX_CONCURRENT=3
IMMICH_ALBUM_NAME=dead-drop # Optional: auto-add uploads to this album
STATE_DB=/data/state.db STATE_DB=/data/state.db
``` ```
### CLI ### CLI
``` ```bash
docker compose pull docker compose pull
docker compose up -d docker compose up -d
``` ```
--- ---
## New Features
### 🌙 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
### 📁 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
---
## Architecture ## Architecture
- **Frontend:** static HTML/JS (Tailwind). Drag & drop or Choose files, queue UI with progress and status chips. - **Frontend:** static HTML/JS (Tailwind). Drag & drop or "Choose files", queue UI with progress and status chips.
- **Backend:** FastAPI + Uvicorn. - **Backend:** FastAPI + Uvicorn.
- Proxies uploads to Immich `/assets` - Proxies uploads to Immich `/assets`
- Computes SHA1 and checks a local SQLite cache (`state.db`) - Computes SHA1 and checks a local SQLite cache (`state.db`)
@@ -132,6 +168,9 @@ IMMICH_BASE_URL=http://REPLACE_ME:2283/api
IMMICH_API_KEY=REPLACE_ME IMMICH_API_KEY=REPLACE_ME
MAX_CONCURRENT=3 MAX_CONCURRENT=3
# Optional: Album name for auto-adding uploads (creates if doesn't exist)
IMMICH_ALBUM_NAME=dead-drop
# Local dedupe cache # Local dedupe cache
STATE_DB=./data/state.db # local dev -> ./state.db (data folder is created in docker image) STATE_DB=./data/state.db # local dev -> ./state.db (data folder is created in docker image)
# In Docker this is overridden to /data/state.db by docker-compose.yml # In Docker this is overridden to /data/state.db by docker-compose.yml
@@ -142,23 +181,6 @@ You can keep a checkedin `/.env.example` with the keys above for onboarding.
--- ---
## Quick start (Docker/Compose)
1) Put your settings in **.env** at the repo root (see below).
2) Build & run:
```bash
docker compose build
docker compose up -d
# open http://localhost:8080
```
A named volume stores `/data/state.db` so duplicates are remembered across container restarts.
---
## How it works ## How it works
1. **Queue** Files selected in the browser are queued; each gets a clientside ID. 1. **Queue** Files selected in the browser are queued; each gets a clientside ID.
@@ -168,8 +190,9 @@ A named volume stores `/data/state.db` so duplicates are remembered across conta
- `assetData`, `deviceAssetId`, `deviceId`, - `assetData`, `deviceAssetId`, `deviceId`,
- `fileCreatedAt`, `fileModifiedAt` (from EXIF when available; else `lastModified`), - `fileCreatedAt`, `fileModifiedAt` (from EXIF when available; else `lastModified`),
- `isFavorite=false`, `filename`, and header `x-immich-checksum`. - `isFavorite=false`, `filename`, and header `x-immich-checksum`.
5. **Progress** Backend streams progress via WebSocket to the same session. 5. **Album** If `IMMICH_ALBUM_NAME` is configured, adds the uploaded asset to the album (creates album if it doesn't exist).
6. **Privacy** UI shows only the current sessions items. It never lists server media. 6. **Progress** Backend streams progress via WebSocket to the same session.
7. **Privacy** UI shows only the current session's items. It never lists server media.
--- ---
@@ -183,7 +206,7 @@ A named volume stores `/data/state.db` so duplicates are remembered across conta
## Troubleshooting ## Troubleshooting
**Uploads dont start on phones / picker reopens** **Uploads don't start on phones / picker reopens**
Hardrefresh; current UI suppresses ghost clicks and resets the input. Hardrefresh; current UI suppresses ghost clicks and resets the input.
If using a PWA/WebView, test in Safari/Chrome directly to rule out container quirks. If using a PWA/WebView, test in Safari/Chrome directly to rule out container quirks.

View File

@@ -53,6 +53,9 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
# Global settings (read-only at runtime) # Global settings (read-only at runtime)
SETTINGS: Settings = load_settings() SETTINGS: Settings = load_settings()
# Album cache
ALBUM_ID: Optional[str] = None
# ---------- DB (local dedupe cache) ---------- # ---------- DB (local dedupe cache) ----------
def db_init() -> None: def db_init() -> None:
@@ -143,12 +146,15 @@ class SessionHub:
async def disconnect(self, session_id: str, ws: WebSocket) -> None: async def disconnect(self, session_id: str, ws: WebSocket) -> None:
"""Remove a socket from the hub and close it (best-effort).""" """Remove a socket from the hub and close it (best-effort)."""
try: if session_id in self.sessions and ws in self.sessions[session_id]:
await ws.close() self.sessions[session_id].remove(ws)
finally: self._cleanup_closed(session_id)
if session_id in self.sessions and ws in self.sessions[session_id]: # Only try to close if the connection is still open
self.sessions[session_id].remove(ws) if ws.client_state == WebSocketState.CONNECTED:
self._cleanup_closed(session_id) try:
await ws.close()
except Exception:
pass
hub = SessionHub() hub = SessionHub()
@@ -190,6 +196,78 @@ def immich_headers() -> dict:
"""Headers for Immich API calls (keeps key server-side).""" """Headers for Immich API calls (keeps key server-side)."""
return {"Accept": "application/json", "x-api-key": SETTINGS.immich_api_key} return {"Accept": "application/json", "x-api-key": SETTINGS.immich_api_key}
def get_or_create_album() -> Optional[str]:
"""Get existing album by name or create a new one. Returns album ID or None."""
global ALBUM_ID
# Skip if no album name configured
if not SETTINGS.album_name:
return None
# Return cached album ID if already fetched
if ALBUM_ID:
return ALBUM_ID
try:
# First, try to find existing album
url = f"{SETTINGS.normalized_base_url}/albums"
r = requests.get(url, headers=immich_headers(), timeout=10)
if r.status_code == 200:
albums = r.json()
for album in albums:
if album.get("albumName") == SETTINGS.album_name:
ALBUM_ID = album.get("id")
print(f"Found existing album '{SETTINGS.album_name}' with ID: {ALBUM_ID}")
return ALBUM_ID
# Album doesn't exist, create it
create_url = f"{SETTINGS.normalized_base_url}/albums"
payload = {
"albumName": SETTINGS.album_name,
"description": "Auto-created album for Immich Drop uploads"
}
r = requests.post(create_url, headers={**immich_headers(), "Content-Type": "application/json"},
json=payload, timeout=10)
if r.status_code in (200, 201):
data = r.json()
ALBUM_ID = data.get("id")
print(f"Created new album '{SETTINGS.album_name}' with ID: {ALBUM_ID}")
return ALBUM_ID
else:
print(f"Failed to create album: {r.status_code} - {r.text}")
except Exception as e:
print(f"Error managing album: {e}")
return None
def add_asset_to_album(asset_id: str) -> bool:
"""Add an asset to the configured album. Returns True on success."""
album_id = get_or_create_album()
if not album_id or not asset_id:
return False
try:
url = f"{SETTINGS.normalized_base_url}/albums/{album_id}/assets"
payload = {"ids": [asset_id]}
r = requests.put(url, headers={**immich_headers(), "Content-Type": "application/json"},
json=payload, timeout=10)
if r.status_code == 200:
results = r.json()
# Check if any result indicates success
for result in results:
if result.get("success"):
return True
elif result.get("error") == "duplicate":
# Asset already in album, consider it success
return True
return False
except Exception as e:
print(f"Error adding asset to album: {e}")
return False
def immich_ping() -> bool: def immich_ping() -> bool:
"""Best-effort reachability check against a few Immich endpoints.""" """Best-effort reachability check against a few Immich endpoints."""
if not SETTINGS.immich_api_key: if not SETTINGS.immich_api_key:
@@ -236,7 +314,11 @@ async def index(_: Request) -> HTMLResponse:
@app.post("/api/ping") @app.post("/api/ping")
async def api_ping() -> dict: async def api_ping() -> dict:
"""Connectivity test endpoint used by the UI to display a temporary banner.""" """Connectivity test endpoint used by the UI to display a temporary banner."""
return {"ok": immich_ping(), "base_url": SETTINGS.normalized_base_url} return {
"ok": immich_ping(),
"base_url": SETTINGS.normalized_base_url,
"album_name": SETTINGS.album_name if SETTINGS.album_name else None
}
@app.websocket("/ws") @app.websocket("/ws")
async def ws_endpoint(ws: WebSocket) -> None: async def ws_endpoint(ws: WebSocket) -> None:
@@ -332,6 +414,12 @@ async def api_upload(
asset_id = data.get("id") asset_id = data.get("id")
db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso) db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso)
status = data.get("status", "created") status = data.get("status", "created")
# Add to album if configured
if SETTINGS.album_name and asset_id:
if add_asset_to_album(asset_id):
status += f" (added to album '{SETTINGS.album_name}')"
await send_progress(session_id, item_id, "duplicate" if status == "duplicate" else "done", 100, status, asset_id) await send_progress(session_id, item_id, "duplicate" if status == "duplicate" else "done", 100, status, asset_id)
return JSONResponse({"id": asset_id, "status": status}, status_code=200) return JSONResponse({"id": asset_id, "status": status}, status_code=200)
else: else:

View File

@@ -14,6 +14,7 @@ class Settings:
immich_base_url: str immich_base_url: str
immich_api_key: str immich_api_key: str
max_concurrent: int = 3 max_concurrent: int = 3
album_name: str = ""
@property @property
def normalized_base_url(self) -> str: def normalized_base_url(self) -> str:
@@ -24,8 +25,9 @@ def load_settings() -> Settings:
"""Load settings from .env, applying defaults when absent.""" """Load settings from .env, applying defaults when absent."""
base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api") base = os.getenv("IMMICH_BASE_URL", "http://127.0.0.1:2283/api")
api_key = os.getenv("IMMICH_API_KEY", "") api_key = os.getenv("IMMICH_API_KEY", "")
album_name = os.getenv("IMMICH_ALBUM_NAME", "")
try: try:
maxc = int(os.getenv("MAX_CONCURRENT", "3")) maxc = int(os.getenv("MAX_CONCURRENT", "3"))
except ValueError: except ValueError:
maxc = 3 maxc = 3
return Settings(immich_base_url=base, immich_api_key=api_key, max_concurrent=maxc) return Settings(immich_base_url=base, immich_api_key=api_key, max_concurrent=maxc, album_name=album_name)

View File

@@ -6,6 +6,9 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment:
IMMICH_ALBUM_NAME: dead-drop
env_file: env_file:
- ./.env - ./.env

View File

@@ -3,6 +3,29 @@ const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.ra
let items = []; let items = [];
let socket; let socket;
// --- Dark mode ---
function initDarkMode() {
const stored = localStorage.getItem('theme');
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
updateThemeIcon();
}
function toggleDarkMode() {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateThemeIcon();
}
function updateThemeIcon() {
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('iconLight').classList.toggle('hidden', !isDark);
document.getElementById('iconDark').classList.toggle('hidden', isDark);
}
initDarkMode();
// --- helpers --- // --- helpers ---
function human(bytes){ function human(bytes){
if (!bytes) return '0 B'; if (!bytes) return '0 B';
@@ -21,20 +44,20 @@ function addItem(file){
function render(){ function render(){
const itemsEl = document.getElementById('items'); const itemsEl = document.getElementById('items');
itemsEl.innerHTML = items.map(it => ` itemsEl.innerHTML = items.map(it => `
<div class="rounded-2xl border bg-white p-4 shadow-sm"> <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="flex items-center justify-between">
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate font-medium">${it.name} <span class="text-xs text-gray-500">(${human(it.size)})</span></div> <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="mt-1 text-xs text-gray-600"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
${it.message ? `<span>${it.message}</span>` : ''} ${it.message ? `<span>${it.message}</span>` : ''}
</div> </div>
</div> </div>
<div class="text-sm">${it.status}</div> <div class="text-sm">${it.status}</div>
</div> </div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-100"> <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==='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> </div>
<div class="mt-2 text-sm text-gray-600"> <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)} ${it.status==='uploading' ? `Uploading… ${it.progress}%` : it.status.charAt(0).toUpperCase()+it.status.slice(1)}
</div> </div>
</div> </div>
@@ -116,6 +139,7 @@ const btnClearAll = document.getElementById('btnClearAll');
const btnPing = document.getElementById('btnPing'); const btnPing = document.getElementById('btnPing');
const pingStatus = document.getElementById('pingStatus'); const pingStatus = document.getElementById('pingStatus');
const banner = document.getElementById('topBanner'); const banner = document.getElementById('topBanner');
const btnTheme = document.getElementById('btnTheme');
// --- Connection test with ephemeral banner --- // --- Connection test with ephemeral banner ---
btnPing.onclick = async () => { btnPing.onclick = async () => {
@@ -126,9 +150,13 @@ btnPing.onclick = async () => {
pingStatus.textContent = j.ok ? 'Connected' : 'No connection'; pingStatus.textContent = j.ok ? 'Connected' : 'No connection';
pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600'); pingStatus.className = 'ml-2 text-sm ' + (j.ok ? 'text-green-600' : 'text-red-600');
if(j.ok){ if(j.ok){
banner.textContent = `Connected to Immich at ${j.base_url}`; let bannerText = `Connected to Immich at ${j.base_url}`;
if(j.album_name) {
bannerText += ` | Uploading to album: "${j.album_name}"`;
}
banner.textContent = bannerText;
banner.classList.remove('hidden'); banner.classList.remove('hidden');
setTimeout(() => banner.classList.add('hidden'), 3000); setTimeout(() => banner.classList.add('hidden'), 4000);
} }
}catch{ }catch{
pingStatus.textContent = 'No connection'; pingStatus.textContent = 'No connection';
@@ -137,8 +165,8 @@ btnPing.onclick = async () => {
}; };
// --- Drag & drop (no click-to-open on touch) --- // --- 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'); })); ['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'); })); ['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', (e)=>{
e.preventDefault(); e.preventDefault();
const files = Array.from(e.dataTransfer.files || []); const files = Array.from(e.dataTransfer.files || []);
@@ -187,3 +215,6 @@ if (!isTouch) {
// --- Clear buttons --- // --- Clear buttons ---
btnClearFinished.onclick = ()=>{ items = items.filter(i => !['done','duplicate'].includes(i.status)); render(); }; btnClearFinished.onclick = ()=>{ items = items.filter(i => !['done','duplicate'].includes(i.status)); render(); };
btnClearAll.onclick = ()=>{ items = []; render(); }; btnClearAll.onclick = ()=>{ items = []; render(); };
// --- Dark mode toggle ---
btnTheme.onclick = toggleDarkMode;

View File

@@ -5,32 +5,45 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Immich Drop Uploader</title> <title>Immich Drop Uploader</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
</head> </head>
<body class="min-h-screen bg-gray-50 text-gray-900"> <body class="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-200">
<div class="mx-auto max-w-4xl p-6 space-y-6"> <div class="mx-auto max-w-4xl p-6 space-y-6">
<!-- Ephemeral top banner --> <!-- Ephemeral top banner -->
<div id="topBanner" class="hidden rounded-2xl border border-green-200 bg-green-50 p-3 text-green-700 text-center"></div> <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"> <header class="flex items-center justify-between">
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1> <h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm">Test connection</button> <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">
<span id="pingStatus" class="ml-2 text-sm text-gray-500"></span> <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"/>
</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 dark:hover:bg-gray-800 hover:bg-gray-100 transition-colors">Test connection</button>
<span id="pingStatus" class="ml-2 text-sm text-gray-500 dark:text-gray-400"></span>
</div> </div>
</header> </header>
<!-- Dropzone --> <!-- Dropzone -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-10 text-center bg-white"> <section id="dropzone" class="rounded-2xl border-2 border-dashed p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600 transition-colors">
<div class="mx-auto h-12 w-12 opacity-70"> <div class="mx-auto h-12 w-12 opacity-70">
<!-- upload icon --> <!-- 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> <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> </div>
<p class="mt-3 font-medium">Drop images or videos here</p> <p class="mt-3 font-medium">Drop images or videos here</p>
<p class="text-sm text-gray-600">...or</p> <p class="text-sm text-gray-600 dark:text-gray-400">...or</p>
<!-- Mobile-safe choose control: label wraps the hidden input --> <!-- Mobile-safe choose control: label wraps the hidden input -->
<div class="mt-3 relative inline-block"> <div class="mt-3 relative inline-block">
<label class="rounded-2xl bg-black text-white px-4 py-2 hover:opacity-90 cursor-pointer select-none"> <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 transition-colors">
Choose files Choose files
<input id="fileInput" <input id="fileInput"
type="file" type="file"
@@ -40,13 +53,13 @@
</label> </label>
</div> </div>
<div class="mt-4 text-sm text-gray-500"> <div class="mt-4 text-sm text-gray-500 dark:text-gray-400">
We never show uploaded media and keep everything session-local. No account required. We never show uploaded media and keep everything session-local. No account required.
</div> </div>
</section> </section>
<!-- Queue summary --> <!-- Queue summary -->
<section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm"> <section id="summary" class="rounded-2xl border bg-white p-4 shadow-sm dark:bg-gray-800 dark:border-gray-700 transition-colors">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<div class="flex gap-4"> <div class="flex gap-4">
<span>Queued/Processing: <b id="countQueued">0</b></span> <span>Queued/Processing: <b id="countQueued">0</b></span>
@@ -56,8 +69,8 @@
<span>Errors: <b id="countErr">0</b></span> <span>Errors: <b id="countErr">0</b></span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button id="btnClearFinished" class="rounded-xl border px-3 py-1 text-sm">Clear finished</button> <button id="btnClearFinished" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-700 hover:bg-gray-100 transition-colors">Clear finished</button>
<button id="btnClearAll" class="rounded-xl border px-3 py-1 text-sm">Clear all</button> <button id="btnClearAll" class="rounded-xl border px-3 py-1 text-sm dark:border-gray-600 dark:hover:bg-gray-700 hover:bg-gray-100 transition-colors">Clear all</button>
</div> </div>
</div> </div>
</section> </section>
@@ -65,7 +78,7 @@
<!-- Items --> <!-- Items -->
<section id="items" class="space-y-3"></section> <section id="items" class="space-y-3"></section>
<footer class="pt-4 pb-10 text-center text-xs text-gray-500"> <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 uploads to Immich. This page never lists media from the server and only shows your current session's items.
</footer> </footer>
</div> </div>

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB