Improve folder access admin dashboard
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 20s

This commit is contained in:
Kroonk
2026-05-20 18:44:47 +02:00
parent c211d2bf9d
commit e6a11f2eed
8 changed files with 556 additions and 277 deletions

View File

@@ -6,6 +6,7 @@ on:
env:
REGISTRY: git.mischlabs.de
REGISTRY_USER: MrDiderot
REGISTRY_IMAGE: git.mischlabs.de/mrdiderot/photography
jobs:
@@ -44,13 +45,12 @@ jobs:
- name: Log in to Gitea Package Registry
shell: sh
run: |
if [ -z "${{ secrets.REGISTRY_USER }}" ] || [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "Missing registry credentials."
echo "Create repository secrets REGISTRY_USER and REGISTRY_TOKEN."
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "Missing REGISTRY_TOKEN secret."
echo "REGISTRY_TOKEN must be a Gitea access token with package write permission."
exit 1
fi
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ env.REGISTRY_USER }}" --password-stdin
- name: Build and push Docker image
shell: sh

View File

@@ -5,4 +5,4 @@
<script src="assets/js/breakpoints.min.js" defer></script>
<script src="assets/js/util.min.js" defer></script>
<script src="assets/js/exif.min.js" defer></script>
<script src="{{ site.baseurl }}/assets/js/main.min.js?v={{ site.time | date: '%s' }}" defer></script>
<script src="{{ site.baseurl }}/assets/js/main.js?v={{ site.time | date: '%s' }}" defer></script>

View File

@@ -6,18 +6,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/assets/css/main.min.css" />
<style>
.admin-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25em; align-items: start; }
.admin-section { background: rgba(0,0,0,0.5); padding: 1.5em; border-radius: 4px; min-width: 0; }
.admin-section h2 { margin-top: 0; }
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; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.2); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; }
.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; }
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 { 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; }
.badge-expired { background: #666; }
.btn-small { padding: 4px 10px; font-size: 0.85em; margin: 2px; cursor: pointer; }
.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-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); }
@@ -26,9 +37,25 @@
.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 h3 { margin:0 0 .35em 0; font-size:1em; color:#fff; }
.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-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 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: 1.25em; }
.admin-section { padding: .9em; }
.dashboard-shell { padding: .75em; }
table { font-size:.82em; }
}
</style>
</head>
@@ -41,22 +68,22 @@
</ul></nav>
</header>
<div id="main" style="display:block; padding: 2em; max-width: 1180px; margin: 0 auto; width: 100%;">
<div id="main" class="dashboard-shell">
<div class="admin-grid">
<div class="admin-section">
<h2>Nutzer erstellen</h2>
<form id="create-user-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<div>
<label>Username</label><br/>
<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>
<label>Passwort</label><br/>
<div class="field-inline">
<label>Passwort</label>
<input type="password" id="new-password" placeholder="Passwort" required />
</div>
<div>
<label>Rolle</label><br/>
<div class="field-inline">
<label>Rolle</label>
<select id="new-role">
<option value="client">Client (30 Tage)</option>
<option value="user">User (permanent)</option>
@@ -71,47 +98,54 @@
<div class="admin-section">
<h2>Website aktualisieren</h2>
<p>Laedt das neueste Docker-Image und startet den Website-Container neu.</p>
<button id="sync-site">Sync starten</button>
<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>Nutzerverwaltung</h2>
<h2>Nutzer, Rollen und Ordnerzugriffe</h2>
<table>
<thead><tr><th>ID</th><th>Username</th><th>Rolle</th><th>Erstellt</th><th>Aktionen</th></tr></thead>
<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>Waehle einen Nutzer und klicke auf Ordner um sie zuzuweisen oder zu entfernen.</p>
<select id="user-select">
<option value="">Nutzer waehlen...</option>
</select>
<p>Nutzer waehlen, Ordner anhaken, fertig.</p>
<select id="user-select"><option value="">Nutzer waehlen...</option></select>
<div id="folder-table-wrap">
<table id="folder-table">
<thead><tr><th><i class="fa fa-folder"></i> Ordner</th><th style="width:120px; text-align:center;">Status</th></tr></thead>
<tbody id="folder-tbody"></tbody>
</table>
<div id="folder-access-list" class="access-list"></div>
<span id="assign-status" style="display:inline-block; margin-top:10px;"></span>
</div>
</div>
<div class="admin-section">
<h2 id="folder-preview-title">Ordnerinhalt</h2>
<p id="folder-preview-note">Waehle in der Ordneruebersicht "Ansehen".</p>
<div id="folder-preview" class="preview-grid"></div>
</div>
<div class="admin-section">
<h2>Eigenes Passwort aendern</h2>
<form id="admin-pw-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<div>
<label>Altes Passwort</label><br/>
<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>
<label>Neues Passwort</label><br/>
<div class="field-inline">
<label>Neues Passwort</label>
<input type="password" id="admin-pw-new" required />
</div>
<div>
<button type="submit">Aendern</button>
<button type="submit" class="btn-small">Aendern</button>
</div>
</form>
</div>
@@ -120,6 +154,6 @@
</div>
</div>
<script src="/assets/js/jquery.min.js"></script>
<script src="/assets/js/admin.js"></script>
<script src="/assets/js/admin.js?v=2"></script>
</body>
</html>

View File

@@ -1 +1 @@
.text-fg{color:#a0a0a1}#selection-toolbar{position:fixed;top:0;left:0;width:100%;background:rgba(31,34,36,.95);padding:.75em 1.5em;display:flex;align-items:center;gap:1em;z-index:10003;transform:translateY(-100%);transition:transform .3s ease;box-sizing:border-box}#selection-toolbar.visible{transform:translateY(0)}#selection-toolbar #sel-count{color:#fff;font-weight:400;margin-right:auto;white-space:nowrap}#selection-toolbar button{white-space:nowrap}body.selection-mode #main .thumb{cursor:pointer;position:relative}body.selection-mode #main .thumb::before{content:"";position:absolute;bottom:.6em;right:.6em;width:1.6em;height:1.6em;border:2px solid rgba(255,255,255,.8);border-radius:4px;background:rgba(0,0,0,.5);z-index:2;transition:all .2s ease;pointer-events:none}body.selection-mode #main .thumb .image{opacity:.85;transition:opacity .2s ease}body.selection-mode #main .thumb:active .image{opacity:.6}body.selection-mode #main .thumb.selected::before{background:#34a58e;border-color:#34a58e}body.selection-mode #main .thumb.selected .image{opacity:1;outline:3px solid #34a58e;outline-offset:-3px}@media screen and (max-width: 736px){#selection-toolbar{top:4em;padding:.6em 1em;gap:.6em}#selection-toolbar #sel-count{font-size:.9em}#selection-toolbar button{font-size:.85em;padding:.5em 1em}body.selection-mode #main .thumb::before{bottom:.3em;right:.3em;width:2em;height:2em;border-width:2.5px}body.selection-mode #main .thumb.selected .image{outline-width:4px}}#footer .presentify{margin:0 0 1.4em 0;display:grid;grid-template-columns:auto 1fr;column-gap:1rem;align-items:center}#footer .presentify .presentify-logo{display:flex;align-items:center;justify-content:center}#footer .presentify .presentify-logo a{display:flex;align-items:center;justify-content:center;border:0}#footer .presentify .presentify-logo img{width:72px;height:72px;display:block}#footer .presentify .presentify-text{margin:0}
.text-fg{color:#a0a0a1}#folder-picker-wrap{display:inline-flex;align-items:center;padding:0 .5em}#folder-picker-wrap #folder-picker{width:auto;min-width:13rem;height:2.35em;margin:0;padding:0 2.25em 0 .8em;border-radius:4px;background-color:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);color:#fff;font-size:.78em;text-transform:none;letter-spacing:0}@media screen and (max-width:736px){#folder-picker-wrap{max-width:48vw;padding:0 .35em}#folder-picker-wrap #folder-picker{min-width:0;width:100%;font-size:.74rem}}#selection-toolbar{position:fixed;top:0;left:0;width:100%;background:rgba(31,34,36,.95);padding:.75em 1.5em;display:flex;align-items:center;gap:1em;z-index:10003;transform:translateY(-100%);transition:transform .3s ease;box-sizing:border-box}#selection-toolbar.visible{transform:translateY(0)}#selection-toolbar #sel-count{color:#fff;font-weight:400;margin-right:auto;white-space:nowrap}#selection-toolbar button{white-space:nowrap}body.selection-mode #main .thumb{cursor:pointer;position:relative}body.selection-mode #main .thumb::before{content:"";position:absolute;bottom:.6em;right:.6em;width:1.6em;height:1.6em;border:2px solid rgba(255,255,255,.8);border-radius:4px;background:rgba(0,0,0,.5);z-index:2;transition:all .2s ease;pointer-events:none}body.selection-mode #main .thumb .image{opacity:.85;transition:opacity .2s ease}body.selection-mode #main .thumb:active .image{opacity:.6}body.selection-mode #main .thumb.selected::before{background:#34a58e;border-color:#34a58e}body.selection-mode #main .thumb.selected .image{opacity:1;outline:3px solid #34a58e;outline-offset:-3px}@media screen and (max-width: 736px){#selection-toolbar{top:4em;padding:.6em 1em;gap:.6em}#selection-toolbar #sel-count{font-size:.9em}#selection-toolbar button{font-size:.85em;padding:.5em 1em}body.selection-mode #main .thumb::before{bottom:.3em;right:.3em;width:2em;height:2em;border-width:2.5px}body.selection-mode #main .thumb.selected .image{outline-width:4px}}#footer .presentify{margin:0 0 1.4em 0;display:grid;grid-template-columns:auto 1fr;column-gap:1rem;align-items:center}#footer .presentify .presentify-logo{display:flex;align-items:center;justify-content:center}#footer .presentify .presentify-logo a{display:flex;align-items:center;justify-content:center;border:0}#footer .presentify .presentify-logo img{width:72px;height:72px;display:block}#footer .presentify .presentify-text{margin:0}

View File

@@ -1,83 +1,265 @@
$(document).ready(function() {
// Auth check
var users = [];
var folders = [];
var assignedFolders = [];
var selectedUserId = "";
$.get('/api/auth/me')
.done(function(user) {
if(user.role !== 'admin') {
alert('Kein Zugriff!');
window.location.href = '/';
} else {
loadUsers();
loadFolders();
$('body').removeClass('is-preload');
return;
}
refreshDashboard();
$('body').removeClass('is-preload');
})
.fail(function() { window.location.href = '/'; });
// Logout
$('#logout-btn').click(function(e) {
e.preventDefault();
$.post('/api/auth/logout').done(function() { window.location.href = '/'; });
});
// ==================== NUTZER ====================
function refreshDashboard() {
$.when(loadUsers(), loadFolders()).done(function() {
renderUsers();
renderUserSelect();
renderFolders();
if (selectedUserId) {
loadAssignments(selectedUserId);
}
});
}
function loadUsers() {
$.get('/api/admin/users').done(function(users) {
$('#users-tbody').empty();
$('#user-select').html('<option value="">Nutzer waehlen...</option>');
return $.get('/api/admin/users').done(function(data) {
users = data || [];
}).fail(function(xhr) {
$('#users-tbody').html(
'<tr><td colspan="6" style="color:#e44d26;">Fehler beim Laden: HTTP ' +
xhr.status + '</td></tr>'
);
});
}
if (!users || users.length === 0) {
$('#users-tbody').html('<tr><td colspan="5" style="text-align:center;">Keine Nutzer gefunden</td></tr>');
function loadFolders() {
return $.get('/api/admin/folders').done(function(data) {
folders = data || [];
}).fail(function() {
$('#folders-overview').html('<p style="color:#e44d26;">Ordner konnten nicht geladen werden.</p>');
});
}
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');
}
function renderUsers() {
var $tbody = $('#users-tbody').empty();
if (!users.length) {
$tbody.html('<tr><td colspan="6" style="text-align:center;">Keine Nutzer gefunden</td></tr>');
return;
}
users.forEach(function(u) {
var role = u.role || 'unbekannt';
var badge = $('<span></span>').addClass('badge badge-' + role).text(role);
var role = u.role || 'client';
var created = u.created_at ? new Date(u.created_at).toLocaleDateString('de-DE') : '-';
var info = created;
var status = created;
if (role === 'client' && u.expires_at) {
var expDate = new Date(u.expires_at).toLocaleDateString('de-DE');
if (u.expired) {
badge = $('<span></span>').addClass('badge badge-expired').text('abgelaufen');
info = 'Abgelaufen: ' + expDate;
} else {
info = 'Bis: ' + expDate;
}
status = (u.expired ? 'Abgelaufen: ' : 'Bis: ') +
new Date(u.expires_at).toLocaleDateString('de-DE');
}
var row = $('<tr></tr>');
var userCell = $('<td></td>').text(u.username + ' ').append(badge);
var actions = $('<td></td>');
var $row = $('<tr></tr>');
var $userCell = $('<td></td>').text(u.username + ' ').append(getRoleBadge(role, u.expired));
var $foldersCell = $('<td></td>');
if (u.folders && u.folders.length) {
u.folders.forEach(function(folder) {
$('<span></span>').addClass('badge folder-badge').text(folder).appendTo($foldersCell);
});
} else {
$foldersCell.append($('<span></span>').addClass('muted').text('Keine privaten Ordner'));
}
var $actions = $('<td></td>');
$('<button type="button" class="btn-small user-access">Zugriff</button>')
.data('user-id', u.id)
.appendTo($actions);
$('<button type="button" class="btn-small user-password">PW</button>')
.data('user-id', u.id)
.data('username', u.username)
.appendTo(actions);
$('<button type="button" class="btn-small user-delete">X</button>')
.appendTo($actions);
$('<button type="button" class="btn-small btn-danger user-delete">Loeschen</button>')
.data('user-id', u.id)
.data('username', u.username)
.appendTo(actions);
.appendTo($actions);
row.append($('<td></td>').text(u.id));
row.append(userCell);
row.append($('<td></td>').text(role));
row.append($('<td></td>').text(info));
row.append(actions);
$('#users-tbody').append(row);
// Dropdown fuer Ordner-Zuweisung (nur non-admin)
if (role !== 'admin') {
$('#user-select').append($('<option></option>').val(u.id).text(u.username + ' (' + role + ')'));
$row.append($('<td></td>').text(u.id));
$row.append($userCell);
$row.append($('<td></td>').text(role));
$row.append($('<td></td>').text(status));
$row.append($foldersCell);
$row.append($actions);
$tbody.append($row);
});
}
function renderUserSelect() {
var $select = $('#user-select').html('<option value="">Nutzer waehlen...</option>');
users.forEach(function(u) {
if ((u.role || 'client') === 'admin') return;
$('<option></option>')
.val(u.id)
.text(u.username + ' (' + (u.role || 'client') + ')')
.appendTo($select);
});
if (selectedUserId) $select.val(selectedUserId);
}
function renderFolders() {
var $wrap = $('#folders-overview').empty();
if (!folders.length) {
$wrap.html('<p class="muted">Keine Unterordner im Bilderpfad gefunden.</p>');
return;
}
folders.forEach(function(folder) {
var $card = $('<div></div>').addClass('folder-card');
$('<h3></h3>').text(folder.name).appendTo($card);
var $meta = $('<div></div>').addClass('folder-meta').appendTo($card);
$('<span></span>').addClass('folder-pill').text((folder.image_count || 0) + ' Bilder').appendTo($meta);
$('<span></span>').addClass('folder-pill').text('privat').appendTo($meta);
$('<span></span>').addClass('folder-pill').text((folder.assigned_users || []).length + ' Freigaben').appendTo($meta);
var $assigned = $('<div></div>').appendTo($card);
if (folder.assigned_users && folder.assigned_users.length) {
folder.assigned_users.forEach(function(user) {
$('<span></span>').addClass('badge folder-badge').text(user.username).appendTo($assigned);
});
} else {
$assigned.append($('<span></span>').addClass('muted').text('Noch niemandem freigegeben'));
}
var $actions = $('<div></div>').addClass('folder-actions').appendTo($card);
$('<button type="button" class="btn-small folder-preview-btn">Ansehen</button>')
.data('folder', folder.name)
.appendTo($actions);
$('<button type="button" class="btn-small folder-assign-btn">Freigaben</button>')
.data('folder', folder.name)
.appendTo($actions);
$wrap.append($card);
});
}
function renderAccessList() {
var $list = $('#folder-access-list').empty();
if (!folders.length) {
$list.html('<p class="muted">Keine Unterordner gefunden.</p>');
return;
}
folders.forEach(function(folder) {
var checked = assignedFolders.indexOf(folder.name) !== -1;
var $label = $('<label></label>').addClass('access-item');
$('<input type="checkbox" />')
.val(folder.name)
.prop('checked', checked)
.appendTo($label);
$('<span></span>').text(folder.name + ' (' + (folder.image_count || 0) + ')').appendTo($label);
$list.append($label);
});
}
function loadAssignments(userId) {
selectedUserId = String(userId || "");
if (!selectedUserId) {
$('#folder-table-wrap').hide();
assignedFolders = [];
return;
}
$('#user-select').val(selectedUserId);
$.get('/api/admin/assign?user_id=' + encodeURIComponent(selectedUserId)).done(function(data) {
assignedFolders = data || [];
renderAccessList();
$('#folder-table-wrap').show();
}).fail(function() {
assignedFolders = [];
renderAccessList();
$('#folder-table-wrap').show();
});
}
function saveAssignments() {
if (!selectedUserId) return;
assignedFolders = [];
$('#folder-access-list input:checked').each(function() {
assignedFolders.push($(this).val());
});
$.ajax({
url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: selectedUserId, folders: assignedFolders })
}).done(function() {
$('#assign-status').text('Gespeichert').show().delay(1200).fadeOut();
refreshDashboard();
}).fail(function(xhr) {
alert((xhr.responseJSON && xhr.responseJSON.error) || 'Speichern fehlgeschlagen');
});
}
$('#users-tbody').on('click', '.user-access', function() {
loadAssignments($(this).data('user-id'));
document.getElementById('user-select').scrollIntoView({ behavior: 'smooth', block: 'center' });
});
$('#user-select').change(function() {
loadAssignments($(this).val());
});
$('#folder-access-list').on('change', 'input[type="checkbox"]', saveAssignments);
$('#folders-overview').on('click', '.folder-assign-btn', function() {
var folder = $(this).data('folder');
if (!selectedUserId) {
alert('Bitte zuerst einen Nutzer in der Ordner-Zuweisung waehlen.');
return;
}
var $checkbox = $('#folder-access-list input').filter(function() {
return $(this).val() === folder;
});
if ($checkbox.length) {
$checkbox.prop('checked', !$checkbox.prop('checked')).trigger('change');
}
});
$('#folders-overview').on('click', '.folder-preview-btn', function() {
var folder = $(this).data('folder');
$('#folder-preview-title').text('Ordnerinhalt: ' + folder);
$('#folder-preview-note').text('Lade Bilder...');
$('#folder-preview').empty();
$.get('/api/admin/folders/' + encodeURIComponent(folder) + '/images').done(function(images) {
$('#folder-preview-note').text((images || []).length + ' Bilder im Ordner');
var $preview = $('#folder-preview').empty();
if (!images || !images.length) {
$preview.html('<p class="muted">Dieser Ordner ist leer.</p>');
return;
}
images.forEach(function(img) {
var $link = $('<a></a>').attr({ href: img.full_url, target: '_blank', rel: 'noopener' });
$('<img />').attr({ src: img.thumb_url, alt: img.name, title: img.name }).appendTo($link);
$preview.append($link);
});
}).fail(function(xhr) {
var msg = 'HTTP ' + xhr.status + ': ' + xhr.statusText;
$('#users-tbody').html('<tr><td colspan="5" style="color:#e44d26;">Fehler beim Laden: ' + msg + '</td></tr>');
$('#folder-preview-note').text((xhr.responseJSON && xhr.responseJSON.error) || 'Ordner konnte nicht geladen werden.');
});
});
}
// Nutzer erstellen
$('#create-user-form').submit(function(e) {
e.preventDefault();
var username = $('#new-username').val().trim();
@@ -92,34 +274,27 @@ $(document).ready(function() {
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ username: username, password: password, role: role })
}).done(function(data) {
alert('Nutzer "' + username + '" erfolgreich erstellt!');
$('#new-username').val('');
$('#new-password').val('');
loadUsers();
}).done(function() {
$('#new-username, #new-password').val('');
refreshDashboard();
}).fail(function(xhr) {
var msg = 'Unbekannter Fehler';
if (xhr.responseJSON && xhr.responseJSON.error) msg = xhr.responseJSON.error;
else if (xhr.status) msg = 'HTTP ' + xhr.status + ': ' + xhr.statusText;
var msg = (xhr.responseJSON && xhr.responseJSON.error) || 'Unbekannter Fehler';
alert('Fehler beim Erstellen: ' + msg);
});
});
// Nutzer loeschen
function deleteUser(id, name) {
if (confirm('Nutzer "' + name + '" wirklich loeschen?')) {
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(loadUsers)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
}
}
$('#users-tbody').on('click', '.user-delete', function() {
deleteUser($(this).data('user-id'), $(this).data('username'));
var id = $(this).data('user-id');
var name = $(this).data('username');
if (!confirm('Nutzer "' + name + '" wirklich loeschen?')) return;
$.ajax({ url: '/api/admin/users/' + id, type: 'DELETE' })
.done(refreshDashboard)
.fail(function(err) { alert(err.responseJSON ? err.responseJSON.error : 'Fehler'); });
});
// Passwort aendern (Admin setzt fuer anderen User)
function changePassword(id, name) {
$('#users-tbody').on('click', '.user-password', function() {
var id = $(this).data('user-id');
var name = $(this).data('username');
var newPw = prompt('Neues Passwort fuer "' + name + '":');
if (!newPw) return;
$.ajax({
@@ -128,142 +303,38 @@ $(document).ready(function() {
contentType: 'application/json',
data: JSON.stringify({ new_password: newPw })
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
});
}
$('#users-tbody').on('click', '.user-password', function() {
changePassword($(this).data('user-id'), $(this).data('username'));
});
// ==================== ORDNER-ZUWEISUNG ====================
var allFolders = [];
var assignedFolders = [];
function loadFolders() {
$.get('/api/admin/folders').done(function(folders) {
allFolders = folders || [];
renderFolderTable();
});
}
function renderFolderTable() {
var $tbody = $('#folder-tbody');
$tbody.empty();
if (allFolders.length === 0) {
$tbody.html('<tr><td colspan="2" style="text-align:center;">Keine Unterordner gefunden. Erstelle Ordner in Nextcloud.</td></tr>');
return;
}
allFolders.forEach(function(f) {
var isAssigned = assignedFolders.indexOf(f) !== -1;
var cls = isAssigned ? 'folder-row assigned' : 'folder-row';
var statusCls = isAssigned ? 'folder-status yes' : 'folder-status no';
var statusText = isAssigned ? 'Zugewiesen' : '—';
$tbody.append(
'<tr class="' + cls + '" data-folder="' + f + '">' +
'<td><i class="fa fa-folder"></i> ' + f + '</td>' +
'<td style="text-align:center;"><span class="' + statusCls + '">' + statusText + '</span></td>' +
'</tr>'
);
});
}
// Bei User-Auswahl: zugewiesene Ordner laden und Tabelle anzeigen
$('#user-select').change(function() {
var uid = $(this).val();
if (!uid) {
$('#folder-table-wrap').hide();
assignedFolders = [];
return;
}
// Ordner neu laden falls noch leer, dann Zuweisung anzeigen
var showTable = function() {
$.get('/api/admin/assign?user_id=' + uid).done(function(folders) {
assignedFolders = folders || [];
renderFolderTable();
$('#folder-table-wrap').show();
}).fail(function() {
assignedFolders = [];
renderFolderTable();
$('#folder-table-wrap').show();
});
};
if (allFolders.length === 0) {
$.get('/api/admin/folders').done(function(folders) {
allFolders = folders || [];
showTable();
}).fail(function() {
allFolders = [];
showTable();
});
} else {
showTable();
}
});
// Klick auf Ordner-Zeile: Toggle + sofort speichern
$(document).on('click', '.folder-row', function() {
var uid = $('#user-select').val();
if (!uid) return;
var folder = $(this).data('folder');
var idx = assignedFolders.indexOf(folder);
if (idx !== -1) {
assignedFolders.splice(idx, 1);
} else {
assignedFolders.push(folder);
}
renderFolderTable();
$.ajax({
url: '/api/admin/assign',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ user_id: uid, folders: assignedFolders })
}).done(function() {
$('#assign-status').text('Gespeichert!').show().delay(1500).fadeOut();
}).fail(function() {
alert('Fehler beim Speichern');
});
});
// ==================== WEBSITE SYNC ====================
$('#sync-site').click(function() {
if (!confirm('Neueste Version laden und Website neu starten?')) return;
var $button = $(this);
var $status = $('#sync-status');
$button.prop('disabled', true);
$status.text('Sync laeuft...');
$('#sync-status').text('Sync laeuft...');
$('#sync-site').prop('disabled', true);
$.post('/api/admin/sync')
.done(function(res) {
$status.text(res.message || 'Sync gestartet. Website startet neu...');
window.setTimeout(function() {
window.location.reload();
}, 12000);
.done(function(resp) {
$('#sync-status').text(resp.message || 'Sync gestartet.');
})
.fail(function(err) {
var msg = err.responseJSON ? err.responseJSON.error : 'Sync fehlgeschlagen';
$status.text(msg);
$button.prop('disabled', false);
.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);
});
});
// ==================== EIGENES PASSWORT ====================
$('#admin-pw-form').submit(function(e) {
e.preventDefault();
$.post('/api/auth/change-password', {
old_password: $('#admin-pw-old').val(),
new_password: $('#admin-pw-new').val()
}).done(function() {
alert('Passwort geaendert!');
alert('Passwort geaendert.');
$('#admin-pw-old, #admin-pw-new').val('');
}).fail(function(err) {
alert(err.responseJSON ? err.responseJSON.error : 'Fehler');
}).fail(function(xhr) {
alert(xhr.responseJSON ? xhr.responseJSON.error : 'Fehler');
});
});
});

View File

@@ -153,10 +153,17 @@
exifDatas = {};
var imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
var currentFolder = null;
function loadGallery() {
function loadGallery(folderName) {
currentFolder = folderName || null;
$main.empty();
var galleryUrl = '/images/fulls/';
if (currentFolder) {
galleryUrl += '?folder=' + encodeURIComponent(currentFolder);
}
$.ajax({
url: '/images/fulls/',
url: galleryUrl,
dataType: 'json',
cache: false,
success: function(files) {
@@ -296,6 +303,10 @@
// ==================== SELECTION MODE ====================
function initSelectionMode(allImages) {
$('#selection-toolbar').remove();
$('#select-mode-toggle').closest('li').remove();
$('.folder-download-btn').closest('li').remove();
var selectedImages = [];
var selectionActive = false;
@@ -481,6 +492,32 @@
});
}
function loadAvailableFolders() {
$.get('/api/folders/available').done(function(folders) {
renderFolderPicker(folders || []);
});
}
function renderFolderPicker(folders) {
$('#folder-picker-wrap').remove();
if (!folders.length) return;
var $item = $('<li id="folder-picker-wrap"></li>');
var $select = $('<select id="folder-picker" title="Galerie-Ordner waehlen"></select>');
$select.append($('<option></option>').val('').text('Oeffentliche Galerie'));
folders.forEach(function(folder) {
var label = folder.name + ' (' + (folder.image_count || 0) + ')';
$select.append($('<option></option>').val(folder.name).text(label));
});
$item.append($select);
$('#header nav > ul').prepend($item);
$select.on('change', function(e) {
e.preventDefault();
loadGallery($(this).val() || null);
});
}
// Galerie laden
loadGallery();
@@ -509,6 +546,7 @@
$('#login-username, #login-password, #login-submit').hide();
$('#logout-btn').show().css('display', 'inline-block');
$('#pw-change-section').show();
loadAvailableFolders();
if(u.role === 'admin') {
$('#admin-link').html('<a href="/admin.html" class="button">Admin-Dashboard</a>');
}

View File

@@ -6,6 +6,40 @@
color: _palette(fg);
}
#folder-picker-wrap {
display: inline-flex;
align-items: center;
padding: 0 0.5em;
#folder-picker {
width: auto;
min-width: 13rem;
height: 2.35em;
margin: 0;
padding: 0 2.25em 0 0.8em;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
color: _palette(fg-bold);
font-size: 0.78em;
text-transform: none;
letter-spacing: 0;
}
}
@media screen and (max-width: 736px) {
#folder-picker-wrap {
max-width: 48vw;
padding: 0 0.35em;
#folder-picker {
min-width: 0;
width: 100%;
font-size: 0.74rem;
}
}
}
/* ==================== Selection Mode ==================== */
#selection-toolbar {

View File

@@ -90,6 +90,18 @@ function listSubfolders(dir) {
}
}
function getFolderInfo(folder) {
const fullPath = path.join(FULLS_DIR, folder);
const thumbPath = path.join(THUMBS_DIR, folder);
const images = listImagesInDir(fullPath);
return {
name: folder,
image_count: images.length,
thumb_count: listImagesInDir(thumbPath).length,
sample: images[0] || null
};
}
function isSafePath(baseDir, requestedPath) {
const resolvedBase = path.resolve(baseDir);
const resolvedPath = path.resolve(resolvedBase, requestedPath);
@@ -206,29 +218,52 @@ app.get('/images/fulls/', (req, res) => {
const rootImages = listImagesInDir(FULLS_DIR).map(name => ({
name, type: 'file', folder: null
}));
const requestedFolder = req.query.folder;
if (requestedFolder) {
if (!isValidName(requestedFolder)) {
return res.status(400).json({ error: 'Ungueltiger Ordnername' });
}
if (!req.user) {
return res.status(401).json({ error: 'Nicht eingeloggt' });
}
const sendFolderImages = () => {
const folderPath = path.join(FULLS_DIR, requestedFolder);
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
return res.status(404).json({ error: 'Ordner nicht gefunden' });
}
return res.json(listImagesInDir(folderPath).map(name => ({
name, type: 'file', folder: requestedFolder
})));
};
if (req.user.role === 'admin') {
return sendFolderImages();
}
return db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[req.user.id, requestedFolder], (err, row) => {
if (err || !row) return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' });
return sendFolderImages();
});
}
// Nicht eingeloggt: nur Root-Bilder
if (!req.user) return res.json(rootImages);
const folders = listSubfolders(FULLS_DIR);
// Admin: Nur Root-Bilder (damit die Startseite sauber bleibt)
if (req.user.role === 'admin') {
// Eingeloggt: Startseite bleibt die normale oeffentliche Galerie.
return res.json(rootImages);
}
});
// User/Client: NUR zugewiesene Ordner (keine Root-Bilder untermischen)
app.get('/api/folders/available', requireAuth, (req, res) => {
const folders = listSubfolders(FULLS_DIR);
if (req.user.role === 'admin') {
return res.json(folders.map(getFolderInfo));
}
db.all('SELECT folder_name FROM user_folders WHERE user_id = ?', [req.user.id], (err, rows) => {
const assignedFolders = err ? [] : rows.map(r => r.folder_name);
const result = [];
assignedFolders.forEach(folder => {
if (folders.includes(folder)) {
listImagesInDir(path.join(FULLS_DIR, folder)).forEach(name => {
result.push({ name, type: 'file', folder });
});
}
});
return res.json(result);
return res.json(assignedFolders.filter(folder => folders.includes(folder)).map(getFolderInfo));
});
});
@@ -244,6 +279,15 @@ app.get('/api/admin/users', requireAdmin, (req, res) => {
res.json(rows2.map(u => ({ id: u.id, username: u.username, role: u.role || 'client', created_at: u.created_at || null })));
});
}
db.all('SELECT user_id, folder_name FROM user_folders ORDER BY folder_name', (folderErr, folderRows) => {
const foldersByUser = {};
if (!folderErr && folderRows) {
folderRows.forEach(row => {
if (!foldersByUser[row.user_id]) foldersByUser[row.user_id] = [];
foldersByUser[row.user_id].push(row.folder_name);
});
}
// Ablaufdatum berechnen fuer Clients
const result = rows.map(u => {
const out = { ...u };
@@ -253,11 +297,13 @@ app.get('/api/admin/users', requireAdmin, (req, res) => {
out.expires_at = expires.toISOString();
out.expired = new Date() > expires;
}
out.folders = foldersByUser[u.id] || [];
return out;
});
res.json(result);
});
});
});
app.post('/api/admin/users', requireAdmin, (req, res) => {
const { username, password, role } = req.body;
@@ -298,7 +344,49 @@ app.put('/api/admin/users/:id/password', requireAdmin, (req, res) => {
// Ordner-Verwaltung
app.get('/api/admin/folders', requireAdmin, (req, res) => {
res.json(listSubfolders(FULLS_DIR));
const folders = listSubfolders(FULLS_DIR).map(getFolderInfo);
db.all(
`SELECT uf.folder_name, u.id, u.username, u.role
FROM user_folders uf
JOIN users u ON u.id = uf.user_id
ORDER BY uf.folder_name, u.username`,
(err, rows) => {
const assignedByFolder = {};
if (!err && rows) {
rows.forEach(row => {
if (!assignedByFolder[row.folder_name]) assignedByFolder[row.folder_name] = [];
assignedByFolder[row.folder_name].push({
id: row.id,
username: row.username,
role: row.role || 'client'
});
});
}
res.json(folders.map(folder => ({
...folder,
assigned_users: assignedByFolder[folder.name] || [],
private: true
})));
}
);
});
app.get('/api/admin/folders/:folderName/images', requireAdmin, (req, res) => {
const folderName = req.params.folderName;
if (!isValidName(folderName)) {
return res.status(400).json({ error: 'Ungueltiger Ordnername' });
}
const folderPath = path.join(FULLS_DIR, folderName);
if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) {
return res.status(404).json({ error: 'Ordner nicht gefunden' });
}
res.json(listImagesInDir(folderPath).map(name => ({
name,
folder: folderName,
full_url: '/images/fulls/' + encodeURIComponent(folderName) + '/' + encodeURIComponent(name),
thumb_url: '/images/thumbs/' + encodeURIComponent(folderName) + '/' + encodeURIComponent(name)
})));
});
app.get('/api/admin/assign', requireAdmin, (req, res) => {
@@ -317,7 +405,10 @@ app.get('/api/admin/assign', requireAdmin, (req, res) => {
app.post('/api/admin/assign', requireAdmin, (req, res) => {
const { user_id, folders } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id erforderlich' });
const folderList = Array.isArray(folders) ? folders : [];
const existingFolders = listSubfolders(FULLS_DIR);
const folderList = Array.isArray(folders)
? folders.filter(name => isValidName(name) && existingFolders.includes(name))
: [];
db.serialize(() => {
db.run('DELETE FROM user_folders WHERE user_id = ?', [user_id]);
const stmt = db.prepare('INSERT INTO user_folders (user_id, folder_name) VALUES (?, ?)');
@@ -408,6 +499,10 @@ app.post('/api/download/selected', (req, res) => {
} else {
// Ordner-Bild: nur wenn User den Ordner zugewiesen hat
if (!req.user) return checkAccess(false);
if (req.user.role === 'admin') {
const filePath = path.join(FULLS_DIR, folder, name);
return checkAccess(fs.existsSync(filePath));
}
db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[req.user.id, folder], (err, row) => {
if (err || !row) return checkAccess(false);
@@ -453,11 +548,19 @@ app.get('/api/download/folder/:folderName', (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Nicht eingeloggt' });
}
if (req.user.role === 'admin') {
return sendFolderDownload(folderName, res);
}
db.get('SELECT 1 FROM user_folders WHERE user_id = ? AND folder_name = ?',
[req.user.id, folderName], (err, row) => {
if (err || !row) {
return res.status(403).json({ error: 'Kein Zugriff auf diesen Ordner' });
}
return sendFolderDownload(folderName, res);
});
});
function sendFolderDownload(folderName, res) {
const folderPath = path.join(FULLS_DIR, folderName);
const images = listImagesInDir(folderPath);
if (images.length === 0) {
@@ -474,8 +577,7 @@ app.get('/api/download/folder/:folderName', (req, res) => {
archive.file(path.join(folderPath, name), { name });
});
archive.finalize();
});
});
}
// ==================== STATIC FILES ====================