diff --git a/.env.example b/.env.example index 7ec5314..1c8dc0a 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,14 @@ SHOW_PROGRESS=true # ALBUM_ID= # SHOW_FAVORITES_ONLY=false +# Admin Authentication (optional — leave ADMIN_PASSWORD blank to disable) +ADMIN_USERNAME=admin +ADMIN_PASSWORD= +# ADMIN_PASSWORD=changeme + +# API Token for external access (Home Assistant, scripts, etc.) +# When set, REST endpoints require this token via Bearer auth or x-api-token header +# FRAMBE_API_TOKEN=your-secret-token-here + # Server (internal port — Docker maps externally via docker-compose) PORT=3000 diff --git a/.gitignore b/.gitignore index b41d595..e7e1d23 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .env npm-debug.log .DS_Store +docker-compose.yml \ No newline at end of file diff --git a/README.md b/README.md index d8c5dfe..54e6af9 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,11 @@ A lightweight, self-contained Docker web application that connects to your [Immi ## ✨ Features - **Immich API Integration** — Connects securely via API key (kept server-side) -- **Album Browser** — Select any album, random photos, or favorites only +- **Album Browser** — Select any album (owned or shared), random photos, or favorites only - **Person / Face Support** — Display photos of a specific person via Immich's face recognition +- **Admin Dashboard** — Real-time WebSocket-based control panel for all connected frames +- **Admin Authentication** — Optional username/password login to protect the admin dashboard +- **REST API with Token Auth** — Control frames from Home Assistant, scripts, or external tools - **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters - **Auto-Refresh** — Periodically checks for new photos added to the source album/person - **Smooth Crossfade** — Double-buffered image transitions with configurable duration @@ -25,30 +28,49 @@ A lightweight, self-contained Docker web application that connects to your [Immi - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Docker Containerised** — Single container, minimal footprint -## 🚀 Quick Start +--- + +## 🚀 Deployment + +### Prerequisites + +- A running [Immich](https://immich.app/) server +- An Immich API key (see below) +- Docker and Docker Compose (recommended) — or Node.js 18+ for running from source ### 1. Get your Immich API Key 1. Open your Immich web interface 2. Click your profile picture → **Account Settings** → **API Keys** 3. Create a new key with `asset.read` and `album.read` permissions +4. Copy the key — you'll need it for the next step -### 2. Run with Docker Compose +### 2. Deploy with Docker Compose (recommended) ```bash git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git cd frambe ``` -Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: +Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`: + +```yaml +environment: + - IMMICH_URL=http://your-immich-server:2283 + - IMMICH_API_KEY=your-api-key-here +``` + +Then start the container: ```bash docker compose up -d ``` -Open `http://your-server:3030` in a browser on your tablet/screen. +Frambe is now running at `http://your-server:3030`. -### 3. Run with Docker directly +### 3. Deploy with Docker Run + +If you prefer not to use Compose: ```bash docker build -t frambe . @@ -61,6 +83,174 @@ docker run -d \ frambe ``` +### 4. Run from Source (development) + +```bash +git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git +cd frambe +npm install +``` + +Create a `.env` file from the example: + +```bash +cp .env.example .env +``` + +Edit `.env` with your Immich URL and API key, then start the server: + +```bash +npm start +``` + +Frambe will be available at `http://localhost:3000`. + +### Port Mapping + +The internal server runs on port **3000**. The Docker Compose config maps this to external port **3030** by default. You can change this in `docker-compose.yml`: + +```yaml +ports: + - "8080:3000" # Access Frambe on port 8080 instead +``` + +--- + +## 🔄 Upgrading + +### Docker Compose (recommended) + +```bash +cd frambe +git pull +docker compose build +docker compose up -d +``` + +Your configuration in `docker-compose.yml` is preserved — only the application code is rebuilt. + +### Docker Run + +```bash +cd frambe +git pull +docker stop frambe +docker rm frambe +docker build -t frambe . +docker run -d \ + --name frambe \ + -p 3030:3000 \ + -e IMMICH_URL=http://your-immich-server:2283 \ + -e IMMICH_API_KEY=your-api-key \ + --restart unless-stopped \ + frambe +``` + +### From Source + +```bash +cd frambe +git pull +npm install +npm start +``` + +### Switching to a Specific Version + +Frambe uses git tags for releases. To pin to a specific version: + +```bash +git fetch --tags +git checkout v1.4.1 # Replace with desired version +docker compose build && docker compose up -d +``` + +To switch back to the latest: + +```bash +git checkout main +git pull +docker compose build && docker compose up -d +``` + +### Upgrade Notes + +- **All upgrades are non-destructive** — Frambe stores no persistent data on disk. All configuration is via environment variables. +- **No database migrations** — there is no database. Session tokens are in-memory and will reset on restart (users simply log in again). +- **Check the changelog below** before upgrading major versions for any new required environment variables. + +--- + +## 🔑 Authentication + +### Admin Dashboard Login + +Protect the admin dashboard with a username and password: + +```yaml +environment: + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=your-secure-password +``` + +When `ADMIN_PASSWORD` is set, accessing `/admin` requires signing in. When not set, the dashboard is open (useful for trusted local networks). + +### API Token for External Access + +Enable token-authenticated REST API access for Home Assistant, scripts, or other external tools: + +```yaml +environment: + - FRAMBE_API_TOKEN=your-secret-token-here +``` + +--- + +## 🔌 REST API + +When `FRAMBE_API_TOKEN` is configured, the following endpoints are available: + +### List Connected Frames + +``` +GET /api/clients +Authorization: Bearer your-secret-token-here +``` + +Returns all connected frame clients with their status, IP, name, and config. + +### Send Command to a Frame + +``` +POST /api/clients/:id/command +Authorization: Bearer your-secret-token-here +Content-Type: application/json + +{ + "action": "next", + "payload": {} +} +``` + +Available actions: `start`, `stop`, `next`, `prev`, `sleep`, `wake`, `refresh`, `setSource`, `setConfig` + +### Home Assistant Example + +```yaml +rest_command: + frambe_next_photo: + url: "http://frambe-server:3030/api/clients/{{ client_id }}/command" + method: POST + headers: + Authorization: "Bearer your-secret-token-here" + Content-Type: "application/json" + payload: '{"action": "next"}' +``` + +Authentication can also be provided via `x-api-token` header or `?token=` query parameter. + +--- + ## 🔗 Zero-Touch URL Parameters Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet: @@ -74,9 +264,11 @@ Skip the setup screen entirely by passing query parameters. This is ideal for de You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person. +--- + ## ⚙️ Configuration -All settings are via environment variables: +All settings are via environment variables. Set them in `docker-compose.yml`, pass with `docker run -e`, or put them in a `.env` file when running from source. | Variable | Default | Description | |---|---|---| @@ -91,11 +283,17 @@ All settings are via environment variables: | `SHOW_DATE` | `true` | Display date overlay | | `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_PROGRESS` | `true` | Display progress bar | +| `INCLUDE_VIDEOS` | `true` | Include video assets in slideshow | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) | +| `ADMIN_USERNAME` | `admin` | Admin dashboard login username | +| `ADMIN_PASSWORD` | *(empty)* | Admin dashboard password (leave empty to disable auth) | +| `FRAMBE_API_TOKEN` | *(empty)* | API token for REST endpoint access (leave empty for open access) | | `PORT` | `3000` | Internal server port (Docker maps externally via compose) | +--- + ## 🎮 Controls ### Touch / Mouse @@ -110,6 +308,8 @@ All settings are via environment variables: - `I` — Toggle info overlay - `Esc` — Exit to album selection +--- + ## 📱 Tablet Setup Tips 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch) @@ -117,23 +317,100 @@ All settings are via environment variables: 3. Enable kiosk mode or guided access to lock to the app 4. Disable screen timeout in your device settings +--- + ## 🏗️ Architecture ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Browser │ HTTP │ Frambe │ API │ Immich │ -│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ +│ (Tablet) │◄────────►│ (Node.js) │◄────────►│ Server │ └──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘ + ▲ + ┌─────────┴─────────┐ + │ REST API / WS │ + │ (Home Assistant) │ + └───────────────────┘ ``` The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting. -## 📋 Version History +--- -- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow -- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030 -- **1.1.0** — Rebrand to Frambe -- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment +## 🏷️ Versioning + +Frambe follows [Semantic Versioning](https://semver.org/): + +- **Major** (`X.0.0`) — Large feature overhauls, breaking changes, or major UI redesigns +- **Minor** (`0.X.0`) — New features, functionality additions, or significant improvements +- **Patch** (`0.0.X`) — Bug fixes, small tweaks, and minor corrections + +### Branches + +| Branch | Purpose | +|---|---| +| `main` | Stable releases — production-ready code | +| `dev` | Development — latest features, may be unstable | + +### Changelog + +#### v1.4.1 — Shared Albums +- ✅ Album picker now shows **shared albums** alongside owned albums +- ✅ Shared albums are visually marked with a "Shared" badge in the picker +- ✅ Shared album icons use 🔗 to distinguish from owned 📁 albums +- ✅ Deduplication ensures albums shared with yourself don't appear twice + +#### v1.4.0 — Admin Auth & REST API +- ✅ Admin dashboard login with username/password authentication (env-based) +- ✅ Session management with HttpOnly cookies (24-hour expiry, automatic cleanup) +- ✅ API token authentication for external access (Home Assistant, scripts, curl) +- ✅ REST endpoint: `GET /api/clients` — list all connected frames +- ✅ REST endpoint: `POST /api/clients/:id/command` — send commands to frames +- ✅ Multiple auth methods: Bearer token, `x-api-token` header, `?token=` query param +- ✅ Auth status endpoint: `GET /api/auth/status` +- ✅ Backwards compatible — auth is opt-in, disabled by default +- 🆕 New env vars: `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `FRAMBE_API_TOKEN` + +#### v1.3.0 — Admin Dashboard & Video Support +- ✅ Real-time admin dashboard at `/admin` with WebSocket communication +- ✅ Live frame management: start, stop, next, prev, sleep, wake, refresh +- ✅ Remote source switching: change album, person, random, or favorites per frame +- ✅ Remote config: adjust slideshow interval, toggle clock/date/EXIF/progress per frame +- ✅ Frame naming and rename support (persists by IP) +- ✅ Video playback support in slideshow (with `INCLUDE_VIDEOS` toggle) +- ✅ Person / face recognition photo source via Immich's people API +- ✅ Connection status indicators and auto-reconnect + +#### v1.2.1 — Bug Fixes +- 🐛 Fixed port mapping (3030:3000 external:internal) +- 🐛 Fixed URL parameter auto-launch not starting the slideshow + +#### v1.2.0 — Zero-Touch Launch & Auto-Refresh +- ✅ URL query parameters for zero-touch launch (`?album=`, `?person=`, `?favorites`, `?random`) +- ✅ Person / face support — display photos of a specific person +- ✅ Periodic auto-refresh — new photos appear without restarting +- ✅ App icon for home screen bookmarks +- ✅ Default external port changed to 3030 +- 🆕 New env var: `REFRESH_INTERVAL` + +#### v1.1.0 — Rebrand +- ✅ Rebranded to Frambe + +#### v1.0.0 — Initial Release +- ✅ Album browser with Immich API integration +- ✅ Full-screen slideshow with smooth crossfade transitions +- ✅ Double-buffered image loading for seamless display +- ✅ Background blur behind non-covering images +- ✅ Clock, date, and EXIF metadata overlays +- ✅ Progress bar showing time until next photo +- ✅ Touch controls (left/centre/right tap zones) +- ✅ Keyboard controls (arrows, space, F, I, Esc) +- ✅ Screen wake lock to prevent display sleep +- ✅ Configurable via environment variables +- ✅ Docker containerised deployment +- ✅ Vanilla HTML/CSS/JS frontend — no frameworks, works on older devices + +--- ## 📄 License diff --git a/docker-compose.yml b/docker-compose.yml index 457c2d8..b38d33e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: - IMAGE_FIT=contain - SHUFFLE=true - BACKGROUND_BLUR=true - - REFRESH_INTERVAL=300 # Seconds between album/person refresh checks + - REFRESH_INTERVAL=300 # Seconds between album/person refresh checks # Overlays - SHOW_CLOCK=true @@ -26,6 +26,13 @@ services: - SHOW_EXIF=true - SHOW_PROGRESS=true + # Admin Authentication (leave ADMIN_PASSWORD blank to disable login) + - ADMIN_USERNAME=admin + # - ADMIN_PASSWORD=changeme + + # API Token for external access (Home Assistant, scripts, etc.) + # - FRAMBE_API_TOKEN=your-secret-token-here + # Auto-start (optional — or use URL params instead) # - ALBUM_ID= # - SHOW_FAVORITES_ONLY=false diff --git a/package.json b/package.json index abbeb9e..2ee2b36 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frambe", - "version": "1.3.0", - "description": "Frambe — a lightweight digital photo frame web app for Immich", + "version": "1.4.0", + "description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard", "main": "server.js", "scripts": { "start": "node server.js" @@ -9,7 +9,9 @@ "dependencies": { "express": "^4.21.0", "node-fetch": "^2.7.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "ws": "^8.18.0", + "sharp": "^0.33.0" }, "engines": { "node": ">=18" diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..e34bf42 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,192 @@ + + + + + + Frambe Admin + + + +
+ Frambe +

Frambe Admin

Connecting...
+
+ Disconnected + +
+
+

No frames seen yet

Open Frambe on a tablet or screen to see it here

+
REST API Reference
+ + + + + diff --git a/public/admin/login.html b/public/admin/login.html new file mode 100644 index 0000000..a78398a --- /dev/null +++ b/public/admin/login.html @@ -0,0 +1,65 @@ + + + + + + Frambe Admin — Login + + + +
+
+ Frambe +

Frambe Admin

+

Sign in to manage your frames

+
+
+
+ + +
+
+ + +
+ +
+ + + diff --git a/public/css/style.css b/public/css/style.css index 3683f10..ed54e5b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,147 +1,59 @@ -/* === Reset === */ -*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } -html, body { width: 100%; height: 100%; overflow: hidden; background: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; } -body.setup-mode { cursor: default; } -.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; color: #e8e6f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -/* === SETUP === */ -#setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; } -.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; } -.setup-header { text-align: center; margin-bottom: 2rem; } -.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; } -.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; } -.subtitle { font-size: 0.95rem; color: #888; } -.subtitle.connected { color: #4ade80; } -.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; } -.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; } -.source-btn { flex: 1; padding: 1rem; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.1); border-radius: 12px; color: #fff; font-size: 0.95rem; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s ease; } -.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); } -.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; } -.source-icon { font-size: 1.5rem; } -.albums-list { max-height: 300px; overflow-y: auto; margin-bottom: 1.5rem; } -.loading-text { text-align: center; color: #666; padding: 1rem; } +/* === SETUP SCREEN === */ +.setup-screen { display: flex; flex-direction: column; height: 100vh; overflow: hidden; } +.setup-header { padding: 1.5rem 2rem 1rem; flex-shrink: 0; text-align: center; } +.setup-logo { width: 64px; height: 64px; margin-bottom: 0.5rem; } +.setup-header h1 { font-size: 1.8rem; font-weight: 700; letter-spacing: -0.02em; color: #fff; } +.setup-header h1 span { color: #6366f1; } +#connection-status { font-size: 0.85rem; color: #666; margin-top: 0.3rem; transition: color 0.3s; } +#connection-status.connected { color: #4ade80; } +.setup-body { flex: 1; overflow-y: auto; padding: 0 2rem 1rem; display: flex; flex-direction: column; gap: 1.5rem; min-height: 0; } +.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: #555; margin-bottom: 0.6rem; } +.source-buttons { display: flex; gap: 0.5rem; } +.btn-source { flex: 1; padding: 0.6rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 8px; color: #ccc; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; } +.btn-source:hover { background: rgba(255,255,255,0.08); color: #fff; } +.btn-source.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; color: #fff; } .album-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 10px; margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s ease; animation: fadeIn 0.3s ease forwards; } .album-item:hover { background: rgba(255,255,255,0.08); } .album-item.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; } -.album-thumb { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; background: #222; } -.album-info { flex: 1; } -.album-name { font-size: 1rem; font-weight: 500; } +.album-thumb { width: 56px; height: 56px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.06); } +.album-info { flex: 1; min-width: 0; } +.album-name { font-size: 1rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .album-count { font-size: 0.8rem; color: #888; margin-top: 2px; } -.start-btn { display: block; width: 100%; padding: 1rem; background: #6366f1; border: none; border-radius: 12px; color: #fff; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } -.start-btn:hover:not(:disabled) { background: #4f46e5; transform: translateY(-1px); } -.start-btn:disabled { opacity: 0.3; cursor: not-allowed; } -.setup-error { text-align: center; padding: 2rem; } -.setup-error p { margin-bottom: 0.75rem; } -.setup-error .error-detail { color: #888; font-size: 0.85rem; } -.setup-error button { margin-top: 1rem; padding: 0.75rem 2rem; background: #6366f1; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; } -.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.5rem; vertical-align: middle; } -@keyframes spin { to { transform: rotate(360deg); } } -@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +.album-shared-badge { display: inline-block; font-size: 0.65rem; font-weight: 600; background: rgba(99,102,241,0.25); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.4); border-radius: 4px; padding: 1px 5px; margin-left: 6px; vertical-align: middle; text-transform: uppercase; letter-spacing: 0.04em; } +.setup-error { padding: 0 2rem; } +.error-box { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 1rem; color: #fca5a5; } +.loading-text { color: #555; font-size: 0.9rem; text-align: center; padding: 2rem; } +.setup-footer { padding: 1rem 2rem 1.5rem; flex-shrink: 0; } +.btn-start { width: 100%; padding: 0.9rem; background: #6366f1; border: none; border-radius: 10px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; } +.btn-start:hover:not(:disabled) { background: #4f46e5; } +.btn-start:disabled { opacity: 0.5; cursor: not-allowed; } +.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; } -/* ============================================= - SLIDESHOW - VINTAGE POLAROID PILE - ============================================= */ -#slideshow-screen { background: #1e1a14; overflow: hidden; } - -/* Background — near-full sepia */ -.bg-blur { - position: absolute; top: -30px; left: -30px; - width: calc(100% + 60px); height: calc(100% + 60px); - background-size: cover; background-position: center; - filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0); - opacity: 0; transition: opacity 3s ease; z-index: 1; -} +/* === SLIDESHOW SCREEN === */ +.slideshow-screen { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; } +#pile-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } +.bg-blur { position: absolute; top: -20px; left: -20px; right: -20px; bottom: -20px; background-size: cover; background-position: center; filter: blur(20px) brightness(0.35) saturate(0.8); opacity: 0; transition: opacity 1.5s ease; } .bg-blur.visible { opacity: 1; } - -/* Canvas pile */ -#pile-canvas { - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - z-index: 2; pointer-events: none; - transition: opacity 2s ease; -} - -/* Vignette */ -.bg-vignette { - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%); - z-index: 3; pointer-events: none; -} - -/* --- Centering wrapper (flexbox — works on all resolutions) --- */ -.main-frame-wrapper { - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - display: flex; align-items: center; justify-content: center; - z-index: 5; pointer-events: none; overflow: visible; -} - -/* --- Main frame — no transform centering, flexbox does it --- */ -.main-frame { - opacity: 0; - transition: opacity 1.2s ease; - animation: float 90s linear infinite; - pointer-events: auto; -} +.main-frame-wrap { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; } +.main-frame { background: #ede8df; padding: 12px 12px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.8s ease; animation: floatFrame 6s ease-in-out infinite; } .main-frame.visible { opacity: 1; } - -/* Slow drift + slight constant rotation */ -@keyframes float { - 0% { transform: translate(0, 0) rotate(1.5deg); } - 100% { transform: translate(8px, -5px) rotate(1.5deg); } -} - -/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */ -.main-frame .frame-border { - background: #ede8df; - padding: 1.2vmin 1.2vmin 4vmin 1.2vmin; - box-shadow: - 0 6px 40px rgba(0,0,0,0.6), - 0 2px 6px rgba(0,0,0,0.3), - inset 0 0 0 1px rgba(0,0,0,0.05); - border-radius: 2px; - position: relative; -} - -/* Large main image — allowed to overhang slightly */ -.main-frame .frame-media { - display: block; - max-width: 93vw; - max-height: 85vh; - width: auto; height: auto; - object-fit: contain; - background: #2a2520; -} - -/* === OVERLAY === */ -.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; } +#main-photo { display: block; max-width: 80vw; max-height: 75vh; width: auto; height: auto; } +#main-video { display: none; max-width: 80vw; max-height: 75vh; width: auto; height: auto; } +.touch-zones { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; } +.touch-zones > div { flex: 1; cursor: pointer; } +.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s; } .overlay.hidden { opacity: 0; } -.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.6); } -.clock { font-size: 2.5rem; font-weight: 200; letter-spacing: 0.05em; line-height: 1.2; } -.date-display { font-size: 0.95rem; font-weight: 300; color: rgba(255,255,255,0.8); margin-top: 0.25rem; } -.overlay-bottom { position: absolute; bottom: 0; left: 0; width: 100%; padding: 1.5rem 2rem 1rem; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); } -.exif-info { font-size: 0.85rem; font-weight: 300; color: rgba(255,255,255,0.75); margin-bottom: 0.75rem; text-shadow: 0 1px 4px rgba(0,0,0,0.8); } -.progress-bar { width: 100%; height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; } -.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.5); border-radius: 2px; transition: width 0.3s linear; } +.clock { position: absolute; top: 1.5rem; right: 2rem; font-size: 3rem; font-weight: 200; color: rgba(255,255,255,0.9); text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.02em; } +.date-display { position: absolute; top: 6rem; right: 2rem; font-size: 0.9rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.5); text-align: right; } +.exif-info { position: absolute; bottom: 3rem; left: 50%; transform: translateX(-50%); font-size: 0.8rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.6); white-space: nowrap; } +.progress-bar { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: rgba(255,255,255,0.1); } +.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.4); } +.btn-settings { position: absolute; bottom: 1.5rem; right: 1.5rem; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; width: 44px; height: 44px; color: rgba(255,255,255,0.8); font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s; pointer-events: all; } +.btn-settings.visible { opacity: 1; } -/* === CONTROLS === */ -.touch-zone { position: absolute; top: 0; height: 100%; z-index: 20; cursor: pointer; } -.touch-left { left: 0; width: 20%; } -.touch-center { left: 20%; width: 60%; } -.touch-right { right: 0; width: 20%; } -.settings-btn { position: absolute; top: 1rem; left: 1rem; width: 44px; height: 44px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; color: #fff; font-size: 1.2rem; cursor: pointer; z-index: 30; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; } -.settings-btn.visible { opacity: 1; pointer-events: auto; } - -/* === RESPONSIVE === */ -@media (max-width: 600px) { - .setup-container { padding: 1.25rem; } - .setup-header h1 { font-size: 1.6rem; } - .clock { font-size: 1.8rem; } - .overlay-top-right { top: 1rem; right: 1rem; } - .overlay-bottom { padding: 1rem 1rem 0.5rem; } - .source-buttons { flex-direction: column; } - .main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; } - .main-frame .frame-media { max-width: 96vw; max-height: 88vh; } -} - -.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; } -.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; } -.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } +@keyframes floatFrame { 0%, 100% { transform: translateY(0px) rotate(-0.3deg); } 50% { transform: translateY(-8px) rotate(0.3deg); } } diff --git a/public/index.html b/public/index.html index 13856a3..d6a1d65 100644 --- a/public/index.html +++ b/public/index.html @@ -6,70 +6,76 @@ - + Frambe -
-
-
- -

Frambe

-

Connecting to Immich…

-
-
-
-

Select Photo Source

-
- - -
-

Loading albums…

+ + +
+
+ +

Frambe

+

Connecting to Immich…

+
+
+
+
Photo Source
+
+ +
-
- + +
-