Rework Photography admin dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 30s

This commit is contained in:
Kroonk
2026-05-20 19:13:51 +02:00
parent 64fe667240
commit 3ad76e841d
6 changed files with 395 additions and 216 deletions

View File

@@ -24,8 +24,6 @@ RUN npm ci --omit=dev && npm cache clean --force
# === Serve-Phase: node.js backend + Runtime-Tools === # === Serve-Phase: node.js backend + Runtime-Tools ===
FROM node:20-alpine FROM node:20-alpine
# Getrennte RUNs halten einzelne Registry-Layer klein genug fuer Proxy-Limits.
RUN apk add --no-cache docker-cli
RUN apk add --no-cache imagemagick libjpeg-turbo libpng libwebp RUN apk add --no-cache imagemagick libjpeg-turbo libpng libwebp
RUN apk add --no-cache sqlite-libs RUN apk add --no-cache sqlite-libs

View File

@@ -7,7 +7,7 @@ The project features a fast static frontend (based on HTML5 UP Multiverse) power
- **Nextcloud Integration:** Photos placed into the Nextcloud directory are automatically detected. - **Nextcloud Integration:** Photos placed into the Nextcloud directory are automatically detected.
- **Client Separation:** Guests see the public portfolio, while logged-in clients see their designated, private sub-folders. - **Client Separation:** Guests see the public portfolio, while logged-in clients see their designated, private sub-folders.
- **Auto-Thumbnails:** Automatic resizing and EXIF extraction (shutter speed, ISO, aperture) for fast loading times. - **Auto-Thumbnails:** Automatic resizing and EXIF extraction (shutter speed, ISO, aperture) for fast loading times.
- **Admin Dashboard:** Manage users, assign private folders, and trigger One-Click container updates directly from the browser. - **Admin Dashboard:** Manage users, roles, private folders, access rights, account security, and deployment status.
- **Light/Dark Mode:** Dynamic theme toggle saving preferences in the browser. - **Light/Dark Mode:** Dynamic theme toggle saving preferences in the browser.
## Deployment Setup (Docker) ## Deployment Setup (Docker)
@@ -26,14 +26,10 @@ services:
- JWT_SECRET=replace-with-a-long-random-secret - JWT_SECRET=replace-with-a-long-random-secret
- FULLS_DIR=/app/public/images/fulls - FULLS_DIR=/app/public/images/fulls
- THUMBS_DIR=/app/public/images/thumbs - THUMBS_DIR=/app/public/images/thumbs
- SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
- SYNC_CONTAINER=photography-website
volumes: volumes:
- /path/to/nextcloud/data:/app/public/images/fulls:ro - /path/to/nextcloud/data:/app/public/images/fulls:ro
- thumbs:/app/public/images/thumbs - thumbs:/app/public/images/thumbs
- database:/app/data - database:/app/data
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/root/.docker/config.json:ro
``` ```
Run `docker compose up -d` to launch the site. Run `docker compose up -d` to launch the site.
@@ -46,7 +42,7 @@ node -e "const bcrypt = require('bcryptjs'); console.log(bcrypt.hashSync('your-a
Then start once with `INITIAL_ADMIN_USERNAME=MrDiderot` and `INITIAL_ADMIN_PASSWORD_HASH=<hash>`. Existing installations keep their SQLite users in the persistent `database` volume. Then start once with `INITIAL_ADMIN_USERNAME=MrDiderot` and `INITIAL_ADMIN_PASSWORD_HASH=<hash>`. Existing installations keep their SQLite users in the persistent `database` volume.
The admin dashboard includes a sync button that pulls `SYNC_IMAGE` and restarts `SYNC_CONTAINER`. This requires the Docker socket mount shown above. Container updates are handled by Watchtower, which pulls from `git.mischlabs.de/mrdiderot/photography:latest`.
## Credits & License ## Credits & License
- Originally based on a Jekyll template. - Originally based on a Jekyll template.

View File

@@ -6,55 +6,88 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/assets/css/main.min.css" /> <link rel="stylesheet" href="/assets/css/main.min.css" />
<style> <style>
body { background: #111418; } body { background: #101316; color: rgba(255,255,255,.86); }
.dashboard-shell { display:block; padding: 1.25em; max-width: 1320px; margin: 0 auto; width: 100%; } #wrapper { min-height: 100vh; }
.admin-grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1em; align-items: start; } #header { z-index: 20; }
.admin-section { background: rgba(20,23,27,0.92); border: 1px solid rgba(255,255,255,0.08); padding: 1em; border-radius: 8px; min-width: 0; box-shadow: 0 12px 30px rgba(0,0,0,0.18); } .admin-shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: calc(100vh - 3.25em); padding-top: 3.25em; }
.admin-section h2 { margin: 0 0 .75em 0; font-size: .95em; letter-spacing: .08em; text-transform: uppercase; color: #fff; } .admin-sidebar { position: sticky; top: 3.25em; height: calc(100vh - 3.25em); padding: 1em; border-right: 1px solid rgba(255,255,255,.08); background: rgba(15,18,21,.96); display: flex; flex-direction: column; gap: 1em; }
.admin-section p { margin: 0 0 .75em 0; color: rgba(255,255,255,.62); font-size: .9em; } .admin-brand { padding: .65em .45em; }
.admin-section.wide { grid-column: 1 / -1; } .admin-brand strong { display: block; color: #fff; font-size: 1em; letter-spacing: .02em; }
.toolbar-row, .compact-form { display:flex; gap:.55em; flex-wrap:wrap; align-items:flex-end; } .admin-brand span { display: block; color: rgba(255,255,255,.48); font-size: .8em; margin-top: .2em; }
.compact-form .field-inline { min-width: 150px; flex: 1 1 150px; } .admin-nav { display: grid; gap: .35em; }
.compact-form label { display:block; margin: 0 0 .25em 0; font-size:.72em; text-transform:uppercase; letter-spacing:.08em; color:rgba(255,255,255,.55); } .nav-item { width: 100%; height: auto; min-height: 2.75em; padding: .65em .75em; border: 1px solid transparent; border-radius: 7px; background: transparent; color: rgba(255,255,255,.68) !important; display: flex; align-items: center; gap: .75em; text-align: left; box-shadow: none; line-height: 1.2; }
.compact-form input, .compact-form select { height: 2.45em; } .nav-item i { width: 1.15em; text-align: center; color: rgba(255,255,255,.42); }
.nav-item:hover, .nav-item.active { background: rgba(255,255,255,.06); border-color: rgba(255,255,255,.09); color: #fff !important; }
.nav-item.active i { color: #8ee6d2; }
.sidebar-footer { margin-top: auto; display: grid; gap: .5em; }
.sidebar-footer a { border-bottom: 0; color: rgba(255,255,255,.64); }
.admin-content { min-width: 0; padding: 1.1em; }
.admin-view { display: none; max-width: 1220px; margin: 0 auto; }
.admin-view.active { display: block; }
.view-head { display: flex; justify-content: space-between; gap: 1em; align-items: flex-end; margin-bottom: 1em; }
.view-head h2 { margin: 0; color: #fff; font-size: 1.35em; letter-spacing: .01em; }
.view-head p { margin: .25em 0 0; color: rgba(255,255,255,.54); font-size: .92em; }
.view-actions { display: flex; gap: .5em; flex-wrap: wrap; justify-content: flex-end; }
.admin-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: .85em; align-items: start; }
.panel { background: rgba(22,25,29,.94); border: 1px solid rgba(255,255,255,.08); border-radius: 8px; padding: 1em; min-width: 0; box-shadow: 0 12px 30px rgba(0,0,0,.18); }
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-6 { grid-column: span 6; }
.span-7 { grid-column: span 7; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: 1 / -1; }
.panel h3 { margin: 0 0 .75em; color: #fff; font-size: .92em; letter-spacing: .08em; text-transform: uppercase; }
.panel p { margin: 0 0 .75em; color: rgba(255,255,255,.58); font-size: .9em; }
.stat-card { min-height: 106px; display: flex; flex-direction: column; justify-content: space-between; }
.stat-label { color: rgba(255,255,255,.52); font-size: .74em; letter-spacing: .09em; text-transform: uppercase; }
.stat-value { color: #fff; font-size: 2.05em; line-height: 1; margin-top: .25em; }
.stat-sub { color: rgba(255,255,255,.48); font-size: .82em; margin-top: .45em; }
.compact-form { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); gap: .65em; align-items: end; }
.compact-form .field-inline { min-width: 0; }
.compact-form label { display:block; margin: 0 0 .25em; font-size:.72em; text-transform:uppercase; letter-spacing:.08em; color:rgba(255,255,255,.55); }
.compact-form input, .compact-form select, .panel select { height: 2.45em; }
table { width: 100%; border-collapse: collapse; font-size: .9em; } table { width: 100%; border-collapse: collapse; font-size: .9em; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.09); vertical-align: middle; } th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,.09); vertical-align: middle; }
th { color: rgba(255,255,255,.58); font-size: .72em; text-transform: uppercase; letter-spacing: .08em; font-weight: 400; } th { color: rgba(255,255,255,.58); font-size: .72em; text-transform: uppercase; letter-spacing: .08em; font-weight: 400; }
.badge { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 0.76em; line-height: 1.6; } .table-wrap { overflow-x: auto; }
.badge-admin { background: #e44d26; } .badge { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: .76em; line-height: 1.6; white-space: nowrap; }
.badge-user { background: #2196F3; } .badge-admin { background: #d76345; }
.badge-client { background: #4CAF50; } .badge-user { background: #2d7fb8; }
.badge-client { background: #3f9f79; }
.badge-expired { background: #666; } .badge-expired { background: #666; }
.folder-badge { background: rgba(52,165,142,.16); color:#8ee6d2; margin: 2px 3px 2px 0; border: 1px solid rgba(52,165,142,.28); } .folder-badge { background: rgba(52,165,142,.16); color:#8ee6d2; margin: 2px 3px 2px 0; border: 1px solid rgba(52,165,142,.28); }
.muted { color: rgba(255,255,255,.48); } .muted { color: rgba(255,255,255,.48); }
.btn-small { padding: 3px 9px; font-size: 0.78em; margin: 2px; cursor: pointer; height: auto; line-height: 1.6; border-radius: 5px; } .btn-small { padding: 3px 9px; font-size: .78em; margin: 2px; cursor: pointer; height: auto; line-height: 1.6; border-radius: 5px; }
.btn-danger { border-color: rgba(228,77,38,.55) !important; color: #ffad9c !important; } .btn-danger { border-color: rgba(228,77,38,.55) !important; color: #ffad9c !important; }
.folder-row { cursor: pointer; transition: background 0.2s; } .folder-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap:.75em; }
.folder-row:hover { background: rgba(255,255,255,0.1); }
.folder-row.assigned { background: rgba(76,175,80,0.25); }
.folder-row.assigned:hover { background: rgba(76,175,80,0.35); }
.folder-status { font-weight: bold; }
.folder-status.yes { color: #4CAF50; }
.folder-status.no { color: #888; }
#folder-table-wrap { display: none; margin-top: 1em; }
.folder-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:.75em; }
.folder-card { border: 1px solid rgba(255,255,255,.09); border-radius: 8px; padding:.85em; background: rgba(255,255,255,.035); } .folder-card { border: 1px solid rgba(255,255,255,.09); border-radius: 8px; padding:.85em; background: rgba(255,255,255,.035); }
.folder-card h3 { margin:0 0 .35em 0; font-size:1em; color:#fff; } .folder-card h3 { margin:0 0 .35em; font-size:1em; color:#fff; text-transform:none; letter-spacing:0; }
.folder-meta { display:flex; gap:.45em; flex-wrap:wrap; margin:.5em 0; } .folder-meta { display:flex; gap:.45em; flex-wrap:wrap; margin:.5em 0; }
.folder-pill { border:1px solid rgba(255,255,255,.12); border-radius:999px; padding:.15em .55em; font-size:.78em; color:rgba(255,255,255,.68); } .folder-pill { border:1px solid rgba(255,255,255,.12); border-radius:999px; padding:.15em .55em; font-size:.78em; color:rgba(255,255,255,.68); }
.folder-actions { margin-top:.65em; display:flex; gap:.35em; flex-wrap:wrap; } .folder-actions { margin-top:.65em; display:flex; gap:.35em; flex-wrap:wrap; }
.access-list { display:grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap:.35em .7em; margin-top:.75em; } .access-list { display:grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap:.4em .7em; margin-top:.75em; }
.access-item { display:flex; align-items:center; gap:.45em; padding:.45em .55em; border:1px solid rgba(255,255,255,.08); border-radius:6px; background:rgba(255,255,255,.025); cursor:pointer; } .access-item { display:flex; align-items:center; gap:.45em; padding:.55em .65em; border:1px solid rgba(255,255,255,.08); border-radius:6px; background:rgba(255,255,255,.025); cursor:pointer; }
.access-item input { width:auto; margin:0; } .access-item input { width:auto; margin:0; }
.preview-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); gap:.55em; margin-top:.75em; max-height: 360px; overflow:auto; padding-right:.25em; } .preview-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap:.55em; margin-top:.75em; max-height: 560px; overflow:auto; padding-right:.25em; }
.preview-grid a { border:0; display:block; border-radius:6px; overflow:hidden; background:#0b0d10; aspect-ratio: 1 / 1; } .preview-grid a { border:0; display:block; border-radius:6px; overflow:hidden; background:#0b0d10; aspect-ratio: 1 / 1; }
.preview-grid img { width:100%; height:100%; object-fit:cover; display:block; } .preview-grid img { width:100%; height:100%; object-fit:cover; display:block; }
#folder-preview-title { margin-top:0; } .signal-list { display: grid; gap: .55em; }
#assign-status, #sync-status { font-size:.85em; color:#8ee6d2; } .signal-item { display: flex; align-items: center; justify-content: space-between; gap: .7em; padding: .6em .7em; border: 1px solid rgba(255,255,255,.08); border-radius: 7px; background: rgba(255,255,255,.03); }
@media screen and (max-width: 900px) { .signal-item strong { color: #fff; font-weight: 400; }
.admin-grid { grid-template-columns: 1fr; } #assign-status { font-size:.85em; color:#8ee6d2; display:inline-block; margin-top:10px; }
.admin-section { padding: .9em; } @media screen and (max-width: 1000px) {
.dashboard-shell { padding: .75em; } .admin-shell { grid-template-columns: 1fr; padding-top: 3.25em; }
.admin-sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid rgba(255,255,255,.08); }
.admin-nav { grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); }
.sidebar-footer { margin-top: 0; }
.span-3, .span-4, .span-5, .span-6, .span-7, .span-8 { grid-column: 1 / -1; }
.compact-form { grid-template-columns: 1fr; }
}
@media screen and (max-width: 600px) {
.admin-content { padding: .75em; }
.view-head { display: block; }
.view-actions { justify-content: flex-start; margin-top: .75em; }
table { font-size:.82em; } table { font-size:.82em; }
} }
</style> </style>
@@ -64,96 +97,200 @@
<header id="header"> <header id="header">
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1> <h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
<nav><ul> <nav><ul>
<li><a href="/index.html">Website</a></li>
<li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li> <li><a href="#" id="logout-btn">Logout <i class="fa fa-sign-out"></i></a></li>
</ul></nav> </ul></nav>
</header> </header>
<div id="main" class="dashboard-shell"> <main id="main" class="admin-shell">
<div class="admin-grid"> <aside class="admin-sidebar">
<div class="admin-brand">
<div class="admin-section"> <strong>Kontrollraum</strong>
<h2>Nutzer erstellen</h2> <span id="admin-user-label">Admin</span>
<form id="create-user-form" class="compact-form">
<div class="field-inline">
<label>Username</label>
<input type="text" id="new-username" placeholder="Nutzername" required />
</div>
<div class="field-inline">
<label>Passwort</label>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<div class="field-inline">
<label>Rolle</label>
<select id="new-role">
<option value="client">Client (30 Tage)</option>
<option value="user">User (permanent)</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<button type="submit">Erstellen</button>
</div>
</form>
</div>
<div class="admin-section">
<h2>Website aktualisieren</h2>
<p>Laedt das neueste Registry-Image und startet den Container neu.</p>
<button id="sync-site" class="btn-small">Sync starten</button>
<span id="sync-status" style="margin-left:10px;"></span>
</div>
<div class="admin-section wide">
<h2>Nutzer, Rollen und Ordnerzugriffe</h2>
<table>
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Status</th><th>Ordnerzugriff</th><th>Aktionen</th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<div class="admin-section wide">
<h2>Ordner im Bilderpfad</h2>
<p>Unterordner sind privat. Nur explizit freigegebene Nutzer sehen sie nach dem Login.</p>
<div id="folders-overview" class="folder-grid"></div>
</div>
<div class="admin-section">
<h2>Ordner-Zuweisung</h2>
<p>Nutzer waehlen, Ordner anhaken, fertig.</p>
<select id="user-select"><option value="">Nutzer waehlen...</option></select>
<div id="folder-table-wrap">
<div id="folder-access-list" class="access-list"></div>
<span id="assign-status" style="display:inline-block; margin-top:10px;"></span>
</div> </div>
</div> <nav class="admin-nav" aria-label="Admin Bereiche">
<button type="button" class="nav-item active" data-view="overview"><i class="fa fa-chart-pie"></i><span>Uebersicht</span></button>
<button type="button" class="nav-item" data-view="users"><i class="fa fa-users"></i><span>Nutzer und Rollen</span></button>
<button type="button" class="nav-item" data-view="folders"><i class="fa fa-folder-open"></i><span>Ordner</span></button>
<button type="button" class="nav-item" data-view="access"><i class="fa fa-key"></i><span>Freigaben</span></button>
<button type="button" class="nav-item" data-view="preview"><i class="fa fa-images"></i><span>Ordnerinhalt</span></button>
<button type="button" class="nav-item" data-view="account"><i class="fa fa-user-shield"></i><span>Account</span></button>
<button type="button" class="nav-item" data-view="system"><i class="fa fa-server"></i><span>System</span></button>
</nav>
<div class="sidebar-footer">
<a href="/index.html"><i class="fa fa-arrow-left"></i> Zur Galerie</a>
</div>
</aside>
<div class="admin-section"> <div class="admin-content">
<h2 id="folder-preview-title">Ordnerinhalt</h2> <section class="admin-view active" data-view="overview">
<p id="folder-preview-note">Waehle in der Ordneruebersicht "Ansehen".</p> <div class="view-head">
<div id="folder-preview" class="preview-grid"></div> <div>
</div> <h2>Uebersicht</h2>
<p>Aktuelle Nutzer, private Ordner und offene Freigaben.</p>
</div>
<div class="view-actions">
<button type="button" class="btn-small" id="refresh-dashboard">Aktualisieren</button>
</div>
</div>
<div class="admin-grid">
<div class="panel stat-card span-3"><div><div class="stat-label">Nutzer</div><div class="stat-value" id="stat-users">0</div></div><div class="stat-sub" id="stat-users-sub">0 Admins</div></div>
<div class="panel stat-card span-3"><div><div class="stat-label">Private Ordner</div><div class="stat-value" id="stat-folders">0</div></div><div class="stat-sub" id="stat-folders-sub">0 Bilder</div></div>
<div class="panel stat-card span-3"><div><div class="stat-label">Freigaben</div><div class="stat-value" id="stat-access">0</div></div><div class="stat-sub" id="stat-access-sub">0 ohne Zugriff</div></div>
<div class="panel stat-card span-3"><div><div class="stat-label">Clients</div><div class="stat-value" id="stat-clients">0</div></div><div class="stat-sub" id="stat-clients-sub">0 abgelaufen</div></div>
<div class="panel span-6">
<h3>Aufmerksamkeit</h3>
<div id="attention-list" class="signal-list"></div>
</div>
<div class="panel span-6">
<h3>Rollenverteilung</h3>
<div id="role-list" class="signal-list"></div>
</div>
</div>
</section>
<div class="admin-section"> <section class="admin-view" data-view="users">
<h2>Eigenes Passwort aendern</h2> <div class="view-head">
<form id="admin-pw-form" class="compact-form"> <div>
<div class="field-inline"> <h2>Nutzer und Rollen</h2>
<label>Altes Passwort</label> <p>Accounts, Laufzeiten, Rollen und Ordnerzugriffe.</p>
<input type="password" id="admin-pw-old" required /> </div>
</div> </div>
<div class="field-inline"> <div class="admin-grid">
<label>Neues Passwort</label> <div class="panel span-12">
<input type="password" id="admin-pw-new" required /> <h3>Nutzer erstellen</h3>
<form id="create-user-form" class="compact-form">
<div class="field-inline">
<label>Username</label>
<input type="text" id="new-username" placeholder="Nutzername" required />
</div>
<div class="field-inline">
<label>Passwort</label>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<div class="field-inline">
<label>Rolle</label>
<select id="new-role">
<option value="client">Client (30 Tage)</option>
<option value="user">User (permanent)</option>
<option value="admin">Admin</option>
</select>
</div>
<div><button type="submit">Erstellen</button></div>
</form>
</div>
<div class="panel span-12">
<h3>Bestand</h3>
<div class="table-wrap">
<table>
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Status</th><th>Ordnerzugriff</th><th>Aktionen</th></tr></thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
</div>
</div> </div>
<div> </section>
<button type="submit" class="btn-small">Aendern</button>
</div>
</form>
</div>
<section class="admin-view" data-view="folders">
<div class="view-head">
<div>
<h2>Ordner</h2>
<p>Unterordner im Bilderpfad sind privat und werden nur explizit freigegeben.</p>
</div>
</div>
<div class="panel">
<div id="folders-overview" class="folder-grid"></div>
</div>
</section>
<section class="admin-view" data-view="access">
<div class="view-head">
<div>
<h2>Freigaben</h2>
<p>Nutzer auswaehlen und private Ordner zuweisen.</p>
</div>
</div>
<div class="admin-grid">
<div class="panel span-4">
<h3>Nutzer</h3>
<select id="user-select"><option value="">Nutzer waehlen...</option></select>
<div id="selected-user-summary" class="signal-list" style="margin-top:.75em;"></div>
</div>
<div class="panel span-8">
<h3>Ordnerzugriff</h3>
<div id="folder-table-wrap" style="display:none;">
<div id="folder-access-list" class="access-list"></div>
<span id="assign-status"></span>
</div>
<p id="folder-access-empty" class="muted">Kein Nutzer ausgewaehlt.</p>
</div>
</div>
</section>
<section class="admin-view" data-view="preview">
<div class="view-head">
<div>
<h2 id="folder-preview-title">Ordnerinhalt</h2>
<p id="folder-preview-note">Waehle einen Ordner aus der Ordneruebersicht.</p>
</div>
</div>
<div class="panel">
<div id="folder-preview" class="preview-grid"></div>
</div>
</section>
<section class="admin-view" data-view="account">
<div class="view-head">
<div>
<h2>Account</h2>
<p>Passwort fuer den aktuellen Admin-Zugang.</p>
</div>
</div>
<div class="panel">
<form id="admin-pw-form" class="compact-form">
<div class="field-inline">
<label>Altes Passwort</label>
<input type="password" id="admin-pw-old" required />
</div>
<div class="field-inline">
<label>Neues Passwort</label>
<input type="password" id="admin-pw-new" required />
</div>
<div><button type="submit" class="btn-small">Aendern</button></div>
</form>
</div>
</section>
<section class="admin-view" data-view="system">
<div class="view-head">
<div>
<h2>System</h2>
<p>Deployment- und Laufzeitdaten fuer tom.mischlabs.de.</p>
</div>
</div>
<div class="admin-grid">
<div class="panel span-6">
<h3>Deployment</h3>
<div class="signal-list">
<div class="signal-item"><strong>Registry</strong><span>git.mischlabs.de/mrdiderot/photography:latest</span></div>
<div class="signal-item"><strong>Updates</strong><span>Watchtower</span></div>
<div class="signal-item"><strong>Port</strong><span>8090</span></div>
</div>
</div>
<div class="panel span-6">
<h3>Speicherorte</h3>
<div class="signal-list">
<div class="signal-item"><strong>Bilder</strong><span>/app/public/images/fulls</span></div>
<div class="signal-item"><strong>Thumbnails</strong><span>/app/public/images/thumbs</span></div>
<div class="signal-item"><strong>Datenbank</strong><span>/app/data/database.sqlite</span></div>
</div>
</div>
</div>
</section>
</div> </div>
</div> </main>
</div> </div>
<script src="/assets/js/jquery.min.js"></script> <script src="/assets/js/jquery.min.js"></script>
<script src="/assets/js/admin.js?v=2"></script> <script src="/assets/js/admin.js?v=3"></script>
</body> </body>
</html> </html>

View File

@@ -1,4 +1,5 @@
$(document).ready(function() { $(document).ready(function() {
var currentAdmin = null;
var users = []; var users = [];
var folders = []; var folders = [];
var assignedFolders = []; var assignedFolders = [];
@@ -6,28 +7,46 @@ $(document).ready(function() {
$.get('/api/auth/me') $.get('/api/auth/me')
.done(function(user) { .done(function(user) {
if(user.role !== 'admin') { if (user.role !== 'admin') {
alert('Kein Zugriff!'); alert('Kein Zugriff!');
window.location.href = '/'; window.location.href = '/';
return; return;
} }
currentAdmin = user;
$('#admin-user-label').text(user.username || 'Admin');
refreshDashboard(); refreshDashboard();
$('body').removeClass('is-preload'); $('body').removeClass('is-preload');
}) })
.fail(function() { window.location.href = '/'; }); .fail(function() { window.location.href = '/'; });
function showView(name) {
$('.admin-view').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
$('.nav-item').removeClass('active').filter('[data-view="' + name + '"]').addClass('active');
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$('.admin-nav').on('click', '.nav-item', function() {
showView($(this).data('view'));
});
$('#logout-btn').click(function(e) { $('#logout-btn').click(function(e) {
e.preventDefault(); e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; }); $.post('/api/auth/logout').done(function() { window.location.href = '/'; });
}); });
$('#refresh-dashboard').click(refreshDashboard);
function refreshDashboard() { function refreshDashboard() {
$.when(loadUsers(), loadFolders()).done(function() { $.when(loadUsers(), loadFolders()).done(function() {
renderStats();
renderUsers(); renderUsers();
renderUserSelect(); renderUserSelect();
renderFolders(); renderFolders();
if (selectedUserId) { if (selectedUserId) {
loadAssignments(selectedUserId); loadAssignments(selectedUserId);
} else {
renderSelectedUserSummary(null);
} }
}); });
} }
@@ -51,6 +70,72 @@ $(document).ready(function() {
}); });
} }
function roleCounts() {
return users.reduce(function(acc, user) {
var role = user.role || 'client';
acc[role] = (acc[role] || 0) + 1;
return acc;
}, { admin: 0, user: 0, client: 0 });
}
function totalAssignments() {
return users.reduce(function(total, user) {
return total + ((user.folders && user.folders.length) || 0);
}, 0);
}
function totalImages() {
return folders.reduce(function(total, folder) {
return total + (folder.image_count || 0);
}, 0);
}
function renderStats() {
var roles = roleCounts();
var expiredClients = users.filter(function(user) { return user.expired; }).length;
var unassignedUsers = users.filter(function(user) {
return (user.role || 'client') !== 'admin' && (!user.folders || !user.folders.length);
}).length;
var unsharedFolders = folders.filter(function(folder) {
return !folder.assigned_users || !folder.assigned_users.length;
}).length;
$('#stat-users').text(users.length);
$('#stat-users-sub').text(roles.admin + ' Admins, ' + roles.user + ' User');
$('#stat-folders').text(folders.length);
$('#stat-folders-sub').text(totalImages() + ' Bilder in privaten Ordnern');
$('#stat-access').text(totalAssignments());
$('#stat-access-sub').text(unassignedUsers + ' Nutzer ohne Ordner');
$('#stat-clients').text(roles.client);
$('#stat-clients-sub').text(expiredClients + ' abgelaufen');
renderAttentionList(unassignedUsers, unsharedFolders, expiredClients);
renderRoleList(roles);
}
function appendSignal($target, label, value, muted) {
var $item = $('<div></div>').addClass('signal-item');
$('<strong></strong>').text(label).appendTo($item);
$('<span></span>').toggleClass('muted', !!muted).text(value).appendTo($item);
$target.append($item);
}
function renderAttentionList(unassignedUsers, unsharedFolders, expiredClients) {
var $list = $('#attention-list').empty();
appendSignal($list, 'Nutzer ohne Ordner', unassignedUsers, unassignedUsers === 0);
appendSignal($list, 'Ordner ohne Freigabe', unsharedFolders, unsharedFolders === 0);
appendSignal($list, 'Abgelaufene Clients', expiredClients, expiredClients === 0);
appendSignal($list, 'Gesamtbilder privat', totalImages(), totalImages() === 0);
}
function renderRoleList(roles) {
var $list = $('#role-list').empty();
appendSignal($list, 'Admins', roles.admin || 0);
appendSignal($list, 'Permanente User', roles.user || 0);
appendSignal($list, 'Clients', roles.client || 0);
appendSignal($list, 'Freigaben', totalAssignments());
}
function getRoleBadge(role, expired) { function getRoleBadge(role, expired) {
if (expired) return $('<span></span>').addClass('badge badge-expired').text('abgelaufen'); if (expired) return $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
return $('<span></span>').addClass('badge badge-' + (role || 'client')).text(role || 'client'); return $('<span></span>').addClass('badge badge-' + (role || 'client')).text(role || 'client');
@@ -63,20 +148,20 @@ $(document).ready(function() {
return; return;
} }
users.forEach(function(u) { users.forEach(function(user) {
var role = u.role || 'client'; var role = user.role || 'client';
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-'; var created = user.created_at ? new Date(user.created_at).toLocaleDateString('de-DE') : '-';
var status = created; var status = created;
if (role === 'client' && u.expires_at) { if (role === 'client' && user.expires_at) {
status = (u.expired ? 'Abgelaufen: ' : 'Bis: ') + status = (user.expired ? 'Abgelaufen: ' : 'Bis: ') +
new Date(u.expires_at).toLocaleDateString('de-DE'); new Date(user.expires_at).toLocaleDateString('de-DE');
} }
var $row = $('<tr></tr>'); var $row = $('<tr></tr>');
var $userCell = $('<td></td>').text(u.username + ' ').append(getRoleBadge(role, u.expired)); var $userCell = $('<td></td>').text(user.username + ' ').append(getRoleBadge(role, user.expired));
var $foldersCell = $('<td></td>'); var $foldersCell = $('<td></td>');
if (u.folders && u.folders.length) { if (user.folders && user.folders.length) {
u.folders.forEach(function(folder) { user.folders.forEach(function(folder) {
$('<span></span>').addClass('badge folder-badge').text(folder).appendTo($foldersCell); $('<span></span>').addClass('badge folder-badge').text(folder).appendTo($foldersCell);
}); });
} else { } else {
@@ -85,18 +170,18 @@ $(document).ready(function() {
var $actions = $('<td></td>'); var $actions = $('<td></td>');
$('<button type="button" class="btn-small user-access">Zugriff</button>') $('<button type="button" class="btn-small user-access">Zugriff</button>')
.data('user-id', u.id) .data('user-id', user.id)
.appendTo($actions); .appendTo($actions);
$('<button type="button" class="btn-small user-password">PW</button>') $('<button type="button" class="btn-small user-password">PW</button>')
.data('user-id', u.id) .data('user-id', user.id)
.data('username', u.username) .data('username', user.username)
.appendTo($actions); .appendTo($actions);
$('<button type="button" class="btn-small btn-danger user-delete">Loeschen</button>') $('<button type="button" class="btn-small btn-danger user-delete">Loeschen</button>')
.data('user-id', u.id) .data('user-id', user.id)
.data('username', u.username) .data('username', user.username)
.appendTo($actions); .appendTo($actions);
$row.append($('<td></td>').text(u.id)); $row.append($('<td></td>').text(user.id));
$row.append($userCell); $row.append($userCell);
$row.append($('<td></td>').text(role)); $row.append($('<td></td>').text(role));
$row.append($('<td></td>').text(status)); $row.append($('<td></td>').text(status));
@@ -108,11 +193,11 @@ $(document).ready(function() {
function renderUserSelect() { function renderUserSelect() {
var $select = $('#user-select').html('<option value="">Nutzer waehlen...</option>'); var $select = $('#user-select').html('<option value="">Nutzer waehlen...</option>');
users.forEach(function(u) { users.forEach(function(user) {
if ((u.role || 'client') === 'admin') return; if ((user.role || 'client') === 'admin') return;
$('<option></option>') $('<option></option>')
.val(u.id) .val(user.id)
.text(u.username + ' (' + (u.role || 'client') + ')') .text(user.username + ' (' + (user.role || 'client') + ')')
.appendTo($select); .appendTo($select);
}); });
if (selectedUserId) $select.val(selectedUserId); if (selectedUserId) $select.val(selectedUserId);
@@ -174,11 +259,31 @@ $(document).ready(function() {
}); });
} }
function findUser(userId) {
var id = String(userId || '');
return users.find(function(user) { return String(user.id) === id; }) || null;
}
function renderSelectedUserSummary(user) {
var $summary = $('#selected-user-summary').empty();
if (!user) {
appendSignal($summary, 'Auswahl', 'Kein Nutzer', true);
return;
}
appendSignal($summary, 'Rolle', user.role || 'client');
appendSignal($summary, 'Ordner', (assignedFolders.length || 0) + ' freigegeben');
if (user.expires_at) {
appendSignal($summary, 'Client bis', new Date(user.expires_at).toLocaleDateString('de-DE'), !!user.expired);
}
}
function loadAssignments(userId) { function loadAssignments(userId) {
selectedUserId = String(userId || ""); selectedUserId = String(userId || "");
if (!selectedUserId) { if (!selectedUserId) {
$('#folder-table-wrap').hide(); $('#folder-table-wrap').hide();
$('#folder-access-empty').show();
assignedFolders = []; assignedFolders = [];
renderSelectedUserSummary(null);
return; return;
} }
@@ -186,10 +291,14 @@ $(document).ready(function() {
$.get('/api/admin/assign?user_id=' + encodeURIComponent(selectedUserId)).done(function(data) { $.get('/api/admin/assign?user_id=' + encodeURIComponent(selectedUserId)).done(function(data) {
assignedFolders = data || []; assignedFolders = data || [];
renderAccessList(); renderAccessList();
renderSelectedUserSummary(findUser(selectedUserId));
$('#folder-access-empty').hide();
$('#folder-table-wrap').show(); $('#folder-table-wrap').show();
}).fail(function() { }).fail(function() {
assignedFolders = []; assignedFolders = [];
renderAccessList(); renderAccessList();
renderSelectedUserSummary(findUser(selectedUserId));
$('#folder-access-empty').hide();
$('#folder-table-wrap').show(); $('#folder-table-wrap').show();
}); });
} }
@@ -215,7 +324,7 @@ $(document).ready(function() {
$('#users-tbody').on('click', '.user-access', function() { $('#users-tbody').on('click', '.user-access', function() {
loadAssignments($(this).data('user-id')); loadAssignments($(this).data('user-id'));
document.getElementById('user-select').scrollIntoView({ behavior: 'smooth', block: 'center' }); showView('access');
}); });
$('#user-select').change(function() { $('#user-select').change(function() {
@@ -226,8 +335,9 @@ $(document).ready(function() {
$('#folders-overview').on('click', '.folder-assign-btn', function() { $('#folders-overview').on('click', '.folder-assign-btn', function() {
var folder = $(this).data('folder'); var folder = $(this).data('folder');
showView('access');
if (!selectedUserId) { if (!selectedUserId) {
alert('Bitte zuerst einen Nutzer in der Ordner-Zuweisung waehlen.'); alert('Bitte zuerst einen Nutzer in den Freigaben waehlen.');
return; return;
} }
var $checkbox = $('#folder-access-list input').filter(function() { var $checkbox = $('#folder-access-list input').filter(function() {
@@ -240,6 +350,7 @@ $(document).ready(function() {
$('#folders-overview').on('click', '.folder-preview-btn', function() { $('#folders-overview').on('click', '.folder-preview-btn', function() {
var folder = $(this).data('folder'); var folder = $(this).data('folder');
showView('preview');
$('#folder-preview-title').text('Ordnerinhalt: ' + folder); $('#folder-preview-title').text('Ordnerinhalt: ' + folder);
$('#folder-preview-note').text('Lade Bilder...'); $('#folder-preview-note').text('Lade Bilder...');
$('#folder-preview').empty(); $('#folder-preview').empty();
@@ -288,7 +399,13 @@ $(document).ready(function() {
var name = $(this).data('username'); var name = $(this).data('username');
if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return; if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return;
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' }) $.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(refreshDashboard) .done(function() {
if (String(selectedUserId) === String(id)) {
selectedUserId = "";
assignedFolders = [];
}
refreshDashboard();
})
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); }); .fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
}); });
@@ -309,22 +426,6 @@ $(document).ready(function() {
}); });
}); });
$('#sync-site').click(function() {
$('#sync-status').text('Sync laeuft...');
$('#sync-site').prop('disabled', true);
$.post('/api/admin/sync')
.done(function(resp) {
$('#sync-status').text(resp.message || 'Sync gestartet.');
})
.fail(function(xhr) {
var msg = (xhr.responseJSON && (xhr.responseJSON.error || xhr.responseJSON.details)) || 'Sync fehlgeschlagen';
$('#sync-status').text(msg);
})
.always(function() {
$('#sync-site').prop('disabled', false);
});
});
$('#admin-pw-form').submit(function(e) { $('#admin-pw-form').submit(function(e) {
e.preventDefault(); e.preventDefault();
$.post('/api/auth/change-password', { $.post('/api/auth/change-password', {

View File

@@ -4,7 +4,6 @@ const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { execFile } = require('child_process');
const archiver = require('archiver'); const archiver = require('archiver');
const db = require('./database'); const db = require('./database');
@@ -20,10 +19,7 @@ const DEV_FULLS_DIR = path.join(__dirname, '../images/fulls');
const DEV_THUMBS_DIR = path.join(__dirname, '../images/thumbs'); const DEV_THUMBS_DIR = path.join(__dirname, '../images/thumbs');
const FULLS_DIR = process.env.FULLS_DIR || (fs.existsSync(DEFAULT_FULLS_DIR) ? DEFAULT_FULLS_DIR : DEV_FULLS_DIR); const FULLS_DIR = process.env.FULLS_DIR || (fs.existsSync(DEFAULT_FULLS_DIR) ? DEFAULT_FULLS_DIR : DEV_FULLS_DIR);
const THUMBS_DIR = process.env.THUMBS_DIR || (fs.existsSync(DEFAULT_THUMBS_DIR) ? DEFAULT_THUMBS_DIR : DEV_THUMBS_DIR); const THUMBS_DIR = process.env.THUMBS_DIR || (fs.existsSync(DEFAULT_THUMBS_DIR) ? DEFAULT_THUMBS_DIR : DEV_THUMBS_DIR);
const SYNC_IMAGE = process.env.SYNC_IMAGE || 'git.mischlabs.de/mrdiderot/photography:latest';
const SYNC_CONTAINER = process.env.SYNC_CONTAINER || 'photography-website';
const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i; const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp)$/i;
let syncInProgress = false;
console.log('Using full-size images at', FULLS_DIR); console.log('Using full-size images at', FULLS_DIR);
console.log('Using thumbnails at', THUMBS_DIR); console.log('Using thumbnails at', THUMBS_DIR);
@@ -151,12 +147,6 @@ function serveProtectedImage(req, res) {
}); });
} }
function runDocker(args, callback) {
execFile('docker', args, { timeout: 5 * 60 * 1000 }, (err, stdout, stderr) => {
callback(err, `${stdout || ''}${stderr || ''}`.trim());
});
}
// ==================== AUTH API ==================== // ==================== AUTH API ====================
app.post('/api/auth/login', (req, res) => { app.post('/api/auth/login', (req, res) => {
@@ -418,41 +408,6 @@ app.post('/api/admin/assign', requireAdmin, (req, res) => {
}); });
}); });
app.post('/api/admin/sync', requireAdmin, (req, res) => {
if (syncInProgress) {
return res.status(409).json({ error: 'Sync laeuft bereits' });
}
syncInProgress = true;
runDocker(['pull', SYNC_IMAGE], (pullErr, pullOutput) => {
syncInProgress = false;
if (pullErr) {
console.error('Sync pull failed:', pullOutput || pullErr.message);
return res.status(500).json({
error: 'Docker pull fehlgeschlagen',
details: pullOutput || pullErr.message
});
}
res.json({
success: true,
message: 'Neues Image geladen. Container startet neu.',
output: pullOutput
});
setTimeout(() => {
runDocker(['restart', SYNC_CONTAINER], (restartErr, restartOutput) => {
if (restartErr) {
console.error('Sync restart failed:', restartOutput || restartErr.message);
return;
}
console.log('Sync restart complete:', restartOutput);
});
}, 500);
});
});
// ==================== DOWNLOAD API ==================== // ==================== DOWNLOAD API ====================
function isValidName(name) { function isValidName(name) {

View File

@@ -8,8 +8,6 @@ services:
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment} - JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
- FULLS_DIR=/app/public/images/fulls - FULLS_DIR=/app/public/images/fulls
- THUMBS_DIR=/app/public/images/thumbs - THUMBS_DIR=/app/public/images/thumbs
- SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
- SYNC_CONTAINER=photography-website
volumes: volumes:
# Nextcloud-Ordner mit Originalbildern (read-only) # Nextcloud-Ordner mit Originalbildern (read-only)
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro - /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
@@ -17,25 +15,19 @@ services:
- thumbs:/app/public/images/thumbs - thumbs:/app/public/images/thumbs
# SQLite Database (persistent) # SQLite Database (persistent)
- database:/app/data - database:/app/data
# Zugriff auf Docker & Credentials fuer den One-Click "Website Aktualisieren" Button
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/root/.docker/config.json:ro
restart: unless-stopped restart: unless-stopped
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert den Container # Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert Container
watchtower: watchtower:
image: containrrr/watchtower image: containrrr/watchtower
container_name: watchtower container_name: watchtower
# Nur unsere Webseite updaten, andere UGREEN Container ignorieren!
command: photography-website
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# Registry Login von deinem NAS uebernehmen, um private Images lesen zu duerfen # Registry Login von deinem NAS uebernehmen, um private Images lesen zu duerfen
- ~/.docker/config.json:/config.json:ro - /volume2/docker/watchtower/config.json:/config.json:ro
environment: environment:
- WATCHTOWER_CLEANUP=true - WATCHTOWER_CLEANUP=true
- WATCHTOWER_POLL_INTERVAL=300 - WATCHTOWER_POLL_INTERVAL=300
- WATCHTOWER_LABEL_ENABLE=false
restart: unless-stopped restart: unless-stopped
volumes: volumes: