75
README.md
75
README.md
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 SHA‑1 cache + optional Immich bulk‑check)
|
- **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**
|
- **Mobile‑friendly**
|
||||||
- **.env‑only config** (clean deploys) + Docker/Compose
|
- **.env‑only config** (clean deploys) + Docker/Compose
|
||||||
- **Privacy‑first**: never lists server media; UI only shows the current session
|
- **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
|
## 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 SHA‑1 and checks a local SQLite cache (`state.db`)
|
- 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
|
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 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
|
## How it works
|
||||||
|
|
||||||
1. **Queue** – Files selected in the browser are queued; each gets a client‑side ID.
|
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`,
|
- `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 session’s 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 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.
|
– 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.
|
– If using a PWA/WebView, test in Safari/Chrome directly to rule out container quirks.
|
||||||
|
|
||||||
|
|||||||
96
app/app.py
96
app/app.py
@@ -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:
|
|
||||||
await ws.close()
|
|
||||||
finally:
|
|
||||||
if session_id in self.sessions and ws in self.sessions[session_id]:
|
if session_id in self.sessions and ws in self.sessions[session_id]:
|
||||||
self.sessions[session_id].remove(ws)
|
self.sessions[session_id].remove(ws)
|
||||||
self._cleanup_closed(session_id)
|
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()
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
IMMICH_ALBUM_NAME: dead-drop
|
||||||
|
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
BIN
screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Reference in New Issue
Block a user