feat: Add dark mode and album integration
Features: - Dark mode with system preference detection and manual toggle - Album integration via IMMICH_ALBUM_NAME environment variable - Auto-creates album if it doesn't exist - Adds uploaded assets to configured album - Shows album name in connection test - Fixed WebSocket disconnection error Updates: - Enhanced UI with dark mode support for all components - Updated README with new features and screenshot - Added configuration for album name in docker-compose.yml
This commit is contained in:
75
README.md
75
README.md
@@ -2,6 +2,10 @@
|
||||
|
||||
A tiny, zero-login web app for collecting photos/videos into your **Immich** server.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **No accounts** — open the page, drop files, done
|
||||
- **Queue with progress** via WebSocket (success / duplicate / error)
|
||||
- **Duplicate prevention** (local SHA‑1 cache + optional Immich bulk‑check)
|
||||
@@ -9,16 +13,18 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** ser
|
||||
- **Mobile‑friendly**
|
||||
- **.env‑only config** (clean deploys) + Docker/Compose
|
||||
- **Privacy‑first**: 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
|
||||
- [Quick start](#Quick_start)
|
||||
- [Quick start](#quick-start)
|
||||
- [New Features](#new-features)
|
||||
- [Architecture](#architecture)
|
||||
- [Folder structure](#folder-structure)
|
||||
- [Requirements](#requirements)
|
||||
- [Configuration (.env)](#configuration-env)
|
||||
- [Quick start (Docker/Compose)](#quick-start-dockercompose)
|
||||
- [How it works](#how-it-works)
|
||||
- [Mobile notes](#mobile-notes)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
@@ -30,8 +36,9 @@ A tiny, zero-login web app for collecting photos/videos into your **Immich** ser
|
||||
## Quick start
|
||||
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.
|
||||
|
||||
### docker-compose.yml
|
||||
```
|
||||
```yaml
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
@@ -41,6 +48,10 @@ services:
|
||||
container_name: immich-drop
|
||||
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.)
|
||||
env_file:
|
||||
- ./.env
|
||||
@@ -64,6 +75,7 @@ services:
|
||||
volumes:
|
||||
immich_drop_data:
|
||||
```
|
||||
|
||||
### .env
|
||||
|
||||
```
|
||||
@@ -72,18 +84,42 @@ PORT=8080
|
||||
IMMICH_BASE_URL=http://REPLACE_ME:2283/api
|
||||
IMMICH_API_KEY=REPLACE_ME
|
||||
MAX_CONCURRENT=3
|
||||
IMMICH_ALBUM_NAME=dead-drop # Optional: auto-add uploads to this album
|
||||
STATE_DB=/data/state.db
|
||||
```
|
||||
|
||||
### CLI
|
||||
```
|
||||
```bash
|
||||
docker compose pull
|
||||
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
|
||||
|
||||
- **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.
|
||||
- Proxies uploads to Immich `/assets`
|
||||
- Computes SHA‑1 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
|
||||
MAX_CONCURRENT=3
|
||||
|
||||
# Optional: Album name for auto-adding uploads (creates if doesn't exist)
|
||||
IMMICH_ALBUM_NAME=dead-drop
|
||||
|
||||
# Local dedupe cache
|
||||
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
|
||||
@@ -142,23 +181,6 @@ You can keep a checked‑in `/.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
|
||||
|
||||
1. **Queue** – Files selected in the browser are queued; each gets a client‑side ID.
|
||||
@@ -168,8 +190,9 @@ A named volume stores `/data/state.db` so duplicates are remembered across conta
|
||||
- `assetData`, `deviceAssetId`, `deviceId`,
|
||||
- `fileCreatedAt`, `fileModifiedAt` (from EXIF when available; else `lastModified`),
|
||||
- `isFavorite=false`, `filename`, and header `x-immich-checksum`.
|
||||
5. **Progress** – Backend streams progress via WebSocket to the same session.
|
||||
6. **Privacy** – UI shows only the current session’s items. It never lists server media.
|
||||
5. **Album** – If `IMMICH_ALBUM_NAME` is configured, adds the uploaded asset to the album (creates album if it doesn't exist).
|
||||
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
|
||||
|
||||
**Uploads don’t start on phones / picker re‑opens**
|
||||
**Uploads don't start on phones / picker re‑opens**
|
||||
– Hard‑refresh; current UI suppresses ghost clicks and resets the input.
|
||||
– If using a PWA/WebView, test in Safari/Chrome directly to rule out container quirks.
|
||||
|
||||
@@ -224,4 +247,4 @@ The backend contains docstrings so you can generate docs later if desired.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
MIT.
|
||||
102
app/app.py
102
app/app.py
@@ -53,6 +53,9 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
# Global settings (read-only at runtime)
|
||||
SETTINGS: Settings = load_settings()
|
||||
|
||||
# Album cache
|
||||
ALBUM_ID: Optional[str] = None
|
||||
|
||||
# ---------- DB (local dedupe cache) ----------
|
||||
|
||||
def db_init() -> None:
|
||||
@@ -143,12 +146,15 @@ class SessionHub:
|
||||
|
||||
async def disconnect(self, session_id: str, ws: WebSocket) -> None:
|
||||
"""Remove a socket from the hub and close it (best-effort)."""
|
||||
try:
|
||||
await ws.close()
|
||||
finally:
|
||||
if session_id in self.sessions and ws in self.sessions[session_id]:
|
||||
self.sessions[session_id].remove(ws)
|
||||
self._cleanup_closed(session_id)
|
||||
if session_id in self.sessions and ws in self.sessions[session_id]:
|
||||
self.sessions[session_id].remove(ws)
|
||||
self._cleanup_closed(session_id)
|
||||
# Only try to close if the connection is still open
|
||||
if ws.client_state == WebSocketState.CONNECTED:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
hub = SessionHub()
|
||||
|
||||
@@ -190,6 +196,78 @@ def immich_headers() -> dict:
|
||||
"""Headers for Immich API calls (keeps key server-side)."""
|
||||
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:
|
||||
"""Best-effort reachability check against a few Immich endpoints."""
|
||||
if not SETTINGS.immich_api_key:
|
||||
@@ -236,7 +314,11 @@ async def index(_: Request) -> HTMLResponse:
|
||||
@app.post("/api/ping")
|
||||
async def api_ping() -> dict:
|
||||
"""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")
|
||||
async def ws_endpoint(ws: WebSocket) -> None:
|
||||
@@ -332,6 +414,12 @@ async def api_upload(
|
||||
asset_id = data.get("id")
|
||||
db_insert_upload(checksum, file.filename, size, device_asset_id, asset_id, created_iso)
|
||||
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)
|
||||
return JSONResponse({"id": asset_id, "status": status}, status_code=200)
|
||||
else:
|
||||
|
||||
@@ -14,6 +14,7 @@ class Settings:
|
||||
immich_base_url: str
|
||||
immich_api_key: str
|
||||
max_concurrent: int = 3
|
||||
album_name: str = ""
|
||||
|
||||
@property
|
||||
def normalized_base_url(self) -> str:
|
||||
@@ -24,8 +25,9 @@ def load_settings() -> Settings:
|
||||
"""Load settings from .env, applying defaults when absent."""
|
||||
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", "")
|
||||
try:
|
||||
maxc = int(os.getenv("MAX_CONCURRENT", "3"))
|
||||
except ValueError:
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,9 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
environment:
|
||||
IMMICH_ALBUM_NAME: dead-drop
|
||||
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
|
||||
@@ -3,6 +3,29 @@ const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.ra
|
||||
let items = [];
|
||||
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 ---
|
||||
function human(bytes){
|
||||
if (!bytes) return '0 B';
|
||||
@@ -21,20 +44,20 @@ function addItem(file){
|
||||
function render(){
|
||||
const itemsEl = document.getElementById('items');
|
||||
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="min-w-0">
|
||||
<div class="truncate font-medium">${it.name} <span class="text-xs text-gray-500">(${human(it.size)})</span></div>
|
||||
<div class="mt-1 text-xs text-gray-600">
|
||||
<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 dark:text-gray-400">
|
||||
${it.message ? `<span>${it.message}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">${it.status}</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>
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +139,7 @@ const btnClearAll = document.getElementById('btnClearAll');
|
||||
const btnPing = document.getElementById('btnPing');
|
||||
const pingStatus = document.getElementById('pingStatus');
|
||||
const banner = document.getElementById('topBanner');
|
||||
const btnTheme = document.getElementById('btnTheme');
|
||||
|
||||
// --- Connection test with ephemeral banner ---
|
||||
btnPing.onclick = async () => {
|
||||
@@ -126,9 +150,13 @@ 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){
|
||||
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');
|
||||
setTimeout(() => banner.classList.add('hidden'), 3000);
|
||||
setTimeout(() => banner.classList.add('hidden'), 4000);
|
||||
}
|
||||
}catch{
|
||||
pingStatus.textContent = 'No connection';
|
||||
@@ -137,8 +165,8 @@ btnPing.onclick = async () => {
|
||||
};
|
||||
|
||||
// --- 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'); }));
|
||||
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e=>{ e.preventDefault(); dz.classList.remove('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','dark:bg-blue-900','dark:bg-opacity-20'); }));
|
||||
dz.addEventListener('drop', (e)=>{
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
@@ -187,3 +215,6 @@ if (!isTouch) {
|
||||
// --- Clear buttons ---
|
||||
btnClearFinished.onclick = ()=>{ items = items.filter(i => !['done','duplicate'].includes(i.status)); render(); };
|
||||
btnClearAll.onclick = ()=>{ items = []; render(); };
|
||||
|
||||
// --- Dark mode toggle ---
|
||||
btnTheme.onclick = toggleDarkMode;
|
||||
|
||||
@@ -5,32 +5,45 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Immich Drop Uploader</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">
|
||||
<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">
|
||||
<!-- 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">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Immich Drop Uploader</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="btnPing" class="rounded-xl border px-3 py-1 text-sm">Test connection</button>
|
||||
<span id="pingStatus" class="ml-2 text-sm text-gray-500"></span>
|
||||
<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"/>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<!-- 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">
|
||||
<!-- upload icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 16V8m0 0l-3 3m3-3 3 3M4 16a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-1a1 1 0 1 0-2 0v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-1a1 1 0 1 0-2 0v1z"/></svg>
|
||||
</div>
|
||||
<p class="mt-3 font-medium">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 -->
|
||||
<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
|
||||
<input id="fileInput"
|
||||
type="file"
|
||||
@@ -40,13 +53,13 @@
|
||||
</label>
|
||||
</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.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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 gap-4">
|
||||
<span>Queued/Processing: <b id="countQueued">0</b></span>
|
||||
@@ -56,8 +69,8 @@
|
||||
<span>Errors: <b id="countErr">0</b></span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="btnClearFinished" class="rounded-xl border px-3 py-1 text-sm">Clear finished</button>
|
||||
<button id="btnClearAll" class="rounded-xl border px-3 py-1 text-sm">Clear all</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 dark:border-gray-600 dark:hover:bg-gray-700 hover:bg-gray-100 transition-colors">Clear all</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -65,7 +78,7 @@
|
||||
<!-- Items -->
|
||||
<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.
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
BIN
screenshot.png
Normal file
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Reference in New Issue
Block a user