Rework Photography admin dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 30s
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 30s
This commit is contained in:
@@ -24,8 +24,6 @@ RUN npm ci --omit=dev && npm cache clean --force
|
||||
# === Serve-Phase: node.js backend + Runtime-Tools ===
|
||||
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 sqlite-libs
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Deployment Setup (Docker)
|
||||
@@ -26,14 +26,10 @@ services:
|
||||
- JWT_SECRET=replace-with-a-long-random-secret
|
||||
- FULLS_DIR=/app/public/images/fulls
|
||||
- THUMBS_DIR=/app/public/images/thumbs
|
||||
- SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
|
||||
- SYNC_CONTAINER=photography-website
|
||||
volumes:
|
||||
- /path/to/nextcloud/data:/app/public/images/fulls:ro
|
||||
- thumbs:/app/public/images/thumbs
|
||||
- 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.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
- Originally based on a Jekyll template.
|
||||
|
||||
275
admin.html
275
admin.html
@@ -6,55 +6,88 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<link rel="stylesheet" href="/assets/css/main.min.css" />
|
||||
<style>
|
||||
body { background: #111418; }
|
||||
.dashboard-shell { display:block; padding: 1.25em; max-width: 1320px; margin: 0 auto; width: 100%; }
|
||||
.admin-grid { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1em; align-items: start; }
|
||||
.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-section h2 { margin: 0 0 .75em 0; font-size: .95em; letter-spacing: .08em; text-transform: uppercase; color: #fff; }
|
||||
.admin-section p { margin: 0 0 .75em 0; color: rgba(255,255,255,.62); font-size: .9em; }
|
||||
.admin-section.wide { grid-column: 1 / -1; }
|
||||
.toolbar-row, .compact-form { display:flex; gap:.55em; flex-wrap:wrap; align-items:flex-end; }
|
||||
.compact-form .field-inline { min-width: 150px; flex: 1 1 150px; }
|
||||
.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); }
|
||||
.compact-form input, .compact-form select { height: 2.45em; }
|
||||
body { background: #101316; color: rgba(255,255,255,.86); }
|
||||
#wrapper { min-height: 100vh; }
|
||||
#header { z-index: 20; }
|
||||
.admin-shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: calc(100vh - 3.25em); padding-top: 3.25em; }
|
||||
.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-brand { padding: .65em .45em; }
|
||||
.admin-brand strong { display: block; color: #fff; font-size: 1em; letter-spacing: .02em; }
|
||||
.admin-brand span { display: block; color: rgba(255,255,255,.48); font-size: .8em; margin-top: .2em; }
|
||||
.admin-nav { display: grid; gap: .35em; }
|
||||
.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; }
|
||||
.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; }
|
||||
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; }
|
||||
.badge { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 0.76em; line-height: 1.6; }
|
||||
.badge-admin { background: #e44d26; }
|
||||
.badge-user { background: #2196F3; }
|
||||
.badge-client { background: #4CAF50; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.badge { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: .76em; line-height: 1.6; white-space: nowrap; }
|
||||
.badge-admin { background: #d76345; }
|
||||
.badge-user { background: #2d7fb8; }
|
||||
.badge-client { background: #3f9f79; }
|
||||
.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); }
|
||||
.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; }
|
||||
.folder-row { cursor: pointer; transition: background 0.2s; }
|
||||
.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-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(230px, 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 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-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; }
|
||||
.access-list { display:grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap:.35em .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-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:.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; }
|
||||
.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 img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
#folder-preview-title { margin-top:0; }
|
||||
#assign-status, #sync-status { font-size:.85em; color:#8ee6d2; }
|
||||
@media screen and (max-width: 900px) {
|
||||
.admin-grid { grid-template-columns: 1fr; }
|
||||
.admin-section { padding: .9em; }
|
||||
.dashboard-shell { padding: .75em; }
|
||||
.signal-list { display: grid; gap: .55em; }
|
||||
.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); }
|
||||
.signal-item strong { color: #fff; font-weight: 400; }
|
||||
#assign-status { font-size:.85em; color:#8ee6d2; display:inline-block; margin-top:10px; }
|
||||
@media screen and (max-width: 1000px) {
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
@@ -64,15 +97,68 @@
|
||||
<header id="header">
|
||||
<h1><a href="/index.html"><strong>Mischkomposition</strong> Admin</a></h1>
|
||||
<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>
|
||||
</ul></nav>
|
||||
</header>
|
||||
|
||||
<div id="main" class="dashboard-shell">
|
||||
<div class="admin-grid">
|
||||
<main id="main" class="admin-shell">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-brand">
|
||||
<strong>Kontrollraum</strong>
|
||||
<span id="admin-user-label">Admin</span>
|
||||
</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">
|
||||
<h2>Nutzer erstellen</h2>
|
||||
<div class="admin-content">
|
||||
<section class="admin-view active" data-view="overview">
|
||||
<div class="view-head">
|
||||
<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>
|
||||
|
||||
<section class="admin-view" data-view="users">
|
||||
<div class="view-head">
|
||||
<div>
|
||||
<h2>Nutzer und Rollen</h2>
|
||||
<p>Accounts, Laufzeiten, Rollen und Ordnerzugriffe.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-grid">
|
||||
<div class="panel span-12">
|
||||
<h3>Nutzer erstellen</h3>
|
||||
<form id="create-user-form" class="compact-form">
|
||||
<div class="field-inline">
|
||||
<label>Username</label>
|
||||
@@ -90,51 +176,77 @@
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Erstellen</button>
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div class="admin-section wide">
|
||||
<h2>Ordner im Bilderpfad</h2>
|
||||
<p>Unterordner sind privat. Nur explizit freigegebene Nutzer sehen sie nach dem Login.</p>
|
||||
<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>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>Ordner-Zuweisung</h2>
|
||||
<p>Nutzer waehlen, Ordner anhaken, fertig.</p>
|
||||
<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="folder-table-wrap">
|
||||
<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" style="display:inline-block; margin-top:10px;"></span>
|
||||
<span id="assign-status"></span>
|
||||
</div>
|
||||
<p id="folder-access-empty" class="muted">Kein Nutzer ausgewaehlt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-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 in der Ordneruebersicht "Ansehen".</p>
|
||||
<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>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>Eigenes Passwort aendern</h2>
|
||||
<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>
|
||||
@@ -144,16 +256,41 @@
|
||||
<label>Neues Passwort</label>
|
||||
<input type="password" id="admin-pw-new" required />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="btn-small">Aendern</button>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
$(document).ready(function() {
|
||||
var currentAdmin = null;
|
||||
var users = [];
|
||||
var folders = [];
|
||||
var assignedFolders = [];
|
||||
@@ -11,23 +12,41 @@ $(document).ready(function() {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
currentAdmin = user;
|
||||
$('#admin-user-label').text(user.username || 'Admin');
|
||||
refreshDashboard();
|
||||
$('body').removeClass('is-preload');
|
||||
})
|
||||
.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) {
|
||||
e.preventDefault();
|
||||
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
|
||||
});
|
||||
|
||||
$('#refresh-dashboard').click(refreshDashboard);
|
||||
|
||||
function refreshDashboard() {
|
||||
$.when(loadUsers(), loadFolders()).done(function() {
|
||||
renderStats();
|
||||
renderUsers();
|
||||
renderUserSelect();
|
||||
renderFolders();
|
||||
if (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) {
|
||||
if (expired) return $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
|
||||
return $('<span></span>').addClass('badge badge-' + (role || 'client')).text(role || 'client');
|
||||
@@ -63,20 +148,20 @@ $(document).ready(function() {
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(function(u) {
|
||||
var role = u.role || 'client';
|
||||
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
|
||||
users.forEach(function(user) {
|
||||
var role = user.role || 'client';
|
||||
var created = user.created_at ? new Date(user.created_at).toLocaleDateString('de-DE') : '-';
|
||||
var status = created;
|
||||
if (role === 'client' && u.expires_at) {
|
||||
status = (u.expired ? 'Abgelaufen: ' : 'Bis: ') +
|
||||
new Date(u.expires_at).toLocaleDateString('de-DE');
|
||||
if (role === 'client' && user.expires_at) {
|
||||
status = (user.expired ? 'Abgelaufen: ' : 'Bis: ') +
|
||||
new Date(user.expires_at).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
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>');
|
||||
if (u.folders && u.folders.length) {
|
||||
u.folders.forEach(function(folder) {
|
||||
if (user.folders && user.folders.length) {
|
||||
user.folders.forEach(function(folder) {
|
||||
$('<span></span>').addClass('badge folder-badge').text(folder).appendTo($foldersCell);
|
||||
});
|
||||
} else {
|
||||
@@ -85,18 +170,18 @@ $(document).ready(function() {
|
||||
|
||||
var $actions = $('<td></td>');
|
||||
$('<button type="button" class="btn-small user-access">Zugriff</button>')
|
||||
.data('user-id', u.id)
|
||||
.data('user-id', user.id)
|
||||
.appendTo($actions);
|
||||
$('<button type="button" class="btn-small user-password">PW</button>')
|
||||
.data('user-id', u.id)
|
||||
.data('username', u.username)
|
||||
.data('user-id', user.id)
|
||||
.data('username', user.username)
|
||||
.appendTo($actions);
|
||||
$('<button type="button" class="btn-small btn-danger user-delete">Loeschen</button>')
|
||||
.data('user-id', u.id)
|
||||
.data('username', u.username)
|
||||
.data('user-id', user.id)
|
||||
.data('username', user.username)
|
||||
.appendTo($actions);
|
||||
|
||||
$row.append($('<td></td>').text(u.id));
|
||||
$row.append($('<td></td>').text(user.id));
|
||||
$row.append($userCell);
|
||||
$row.append($('<td></td>').text(role));
|
||||
$row.append($('<td></td>').text(status));
|
||||
@@ -108,11 +193,11 @@ $(document).ready(function() {
|
||||
|
||||
function renderUserSelect() {
|
||||
var $select = $('#user-select').html('<option value="">Nutzer waehlen...</option>');
|
||||
users.forEach(function(u) {
|
||||
if ((u.role || 'client') === 'admin') return;
|
||||
users.forEach(function(user) {
|
||||
if ((user.role || 'client') === 'admin') return;
|
||||
$('<option></option>')
|
||||
.val(u.id)
|
||||
.text(u.username + ' (' + (u.role || 'client') + ')')
|
||||
.val(user.id)
|
||||
.text(user.username + ' (' + (user.role || 'client') + ')')
|
||||
.appendTo($select);
|
||||
});
|
||||
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) {
|
||||
selectedUserId = String(userId || "");
|
||||
if (!selectedUserId) {
|
||||
$('#folder-table-wrap').hide();
|
||||
$('#folder-access-empty').show();
|
||||
assignedFolders = [];
|
||||
renderSelectedUserSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,10 +291,14 @@ $(document).ready(function() {
|
||||
$.get('/api/admin/assign?user_id=' + encodeURIComponent(selectedUserId)).done(function(data) {
|
||||
assignedFolders = data || [];
|
||||
renderAccessList();
|
||||
renderSelectedUserSummary(findUser(selectedUserId));
|
||||
$('#folder-access-empty').hide();
|
||||
$('#folder-table-wrap').show();
|
||||
}).fail(function() {
|
||||
assignedFolders = [];
|
||||
renderAccessList();
|
||||
renderSelectedUserSummary(findUser(selectedUserId));
|
||||
$('#folder-access-empty').hide();
|
||||
$('#folder-table-wrap').show();
|
||||
});
|
||||
}
|
||||
@@ -215,7 +324,7 @@ $(document).ready(function() {
|
||||
|
||||
$('#users-tbody').on('click', '.user-access', function() {
|
||||
loadAssignments($(this).data('user-id'));
|
||||
document.getElementById('user-select').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
showView('access');
|
||||
});
|
||||
|
||||
$('#user-select').change(function() {
|
||||
@@ -226,8 +335,9 @@ $(document).ready(function() {
|
||||
|
||||
$('#folders-overview').on('click', '.folder-assign-btn', function() {
|
||||
var folder = $(this).data('folder');
|
||||
showView('access');
|
||||
if (!selectedUserId) {
|
||||
alert('Bitte zuerst einen Nutzer in der Ordner-Zuweisung waehlen.');
|
||||
alert('Bitte zuerst einen Nutzer in den Freigaben waehlen.');
|
||||
return;
|
||||
}
|
||||
var $checkbox = $('#folder-access-list input').filter(function() {
|
||||
@@ -240,6 +350,7 @@ $(document).ready(function() {
|
||||
|
||||
$('#folders-overview').on('click', '.folder-preview-btn', function() {
|
||||
var folder = $(this).data('folder');
|
||||
showView('preview');
|
||||
$('#folder-preview-title').text('Ordnerinhalt: ' + folder);
|
||||
$('#folder-preview-note').text('Lade Bilder...');
|
||||
$('#folder-preview').empty();
|
||||
@@ -288,7 +399,13 @@ $(document).ready(function() {
|
||||
var name = $(this).data('username');
|
||||
if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return;
|
||||
$.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'); });
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
e.preventDefault();
|
||||
$.post('/api/auth/change-password', {
|
||||
|
||||
@@ -4,7 +4,6 @@ const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const archiver = require('archiver');
|
||||
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 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 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;
|
||||
let syncInProgress = false;
|
||||
|
||||
console.log('Using full-size images at', FULLS_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 ====================
|
||||
|
||||
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 ====================
|
||||
|
||||
function isValidName(name) {
|
||||
|
||||
@@ -8,8 +8,6 @@ services:
|
||||
- JWT_SECRET=${JWT_SECRET:?set JWT_SECRET in your environment}
|
||||
- FULLS_DIR=/app/public/images/fulls
|
||||
- THUMBS_DIR=/app/public/images/thumbs
|
||||
- SYNC_IMAGE=git.mischlabs.de/mrdiderot/photography:latest
|
||||
- SYNC_CONTAINER=photography-website
|
||||
volumes:
|
||||
# Nextcloud-Ordner mit Originalbildern (read-only)
|
||||
- /volume1/nextcloud/data/Tom/files/Websitebilder:/app/public/images/fulls:ro
|
||||
@@ -17,25 +15,19 @@ services:
|
||||
- thumbs:/app/public/images/thumbs
|
||||
# SQLite Database (persistent)
|
||||
- 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
|
||||
|
||||
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert den Container
|
||||
# Watchtower: Prueft automatisch auf neue Docker-Images und aktualisiert Container
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
container_name: watchtower
|
||||
# Nur unsere Webseite updaten, andere UGREEN Container ignorieren!
|
||||
command: photography-website
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 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:
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
- WATCHTOWER_POLL_INTERVAL=300
|
||||
- WATCHTOWER_LABEL_ENABLE=false
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
Reference in New Issue
Block a user