added move/copy support

This commit is contained in:
Ludwig Lehnert
2026-01-12 21:21:56 +01:00
parent 3d1a4eb950
commit a31b9f01e1

View File

@@ -352,7 +352,7 @@ function renderFileManagerPage(title, body) {
.folder { font-weight: 700; color: var(--accent-strong); text-decoration: none; } .folder { font-weight: 700; color: var(--accent-strong); text-decoration: none; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.actions button { font-size: 0.9rem; padding: 7px 10px; } .actions button { font-size: 0.9rem; padding: 7px 10px; }
dialog { border: none; border-radius: 16px; padding: 0; width: min(480px, 92vw); } dialog { border: none; border-radius: 16px; padding: 0; width: min(520px, 92vw); }
dialog::backdrop { background: rgba(15, 23, 42, 0.4); } dialog::backdrop { background: rgba(15, 23, 42, 0.4); }
.dialog-card { padding: 18px; display: grid; gap: 12px; } .dialog-card { padding: 18px; display: grid; gap: 12px; }
.dialog-actions { display: flex; gap: 10px; justify-content: flex-end; } .dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }
@@ -990,6 +990,8 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
<td>${modifiedAt ? formatTimestamp(modifiedAt) : '—'}</td> <td>${modifiedAt ? formatTimestamp(modifiedAt) : '—'}</td>
<td class="actions"> <td class="actions">
<button type="button" class="secondary rename-trigger" data-path="${escapedPath}" data-name="${escapedName}">Umbenennen</button> <button type="button" class="secondary rename-trigger" data-path="${escapedPath}" data-name="${escapedName}">Umbenennen</button>
<button type="button" class="secondary move-trigger" data-path="${escapedPath}" data-name="${escapedName}">Verschieben</button>
<button type="button" class="secondary copy-trigger" data-path="${escapedPath}" data-name="${escapedName}">Kopieren</button>
<button type="button" class="secondary delete-trigger" data-path="${escapedPath}" data-name="${escapedName}">Löschen</button> <button type="button" class="secondary delete-trigger" data-path="${escapedPath}" data-name="${escapedName}">Löschen</button>
</td> </td>
</tr> </tr>
@@ -1106,14 +1108,56 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
</form> </form>
</dialog> </dialog>
<dialog id="move-dialog">
<form method="post" action="${baseUrl('/admin/files/move')}" class="dialog-card">
${csrfField(res.locals.csrfToken)}
<input type="hidden" name="path" id="move-path" />
<h2>Verschieben</h2>
<div class="muted" id="move-current"></div>
<label>
Zielpfad (relativ)
<input name="targetPath" id="move-target" placeholder="z.B. archiv/ordner" required />
</label>
<div class="dialog-actions">
<button type="button" class="secondary dialog-close">Abbrechen</button>
<button type="submit">Verschieben</button>
</div>
</form>
</dialog>
<dialog id="copy-dialog">
<form method="post" action="${baseUrl('/admin/files/copy')}" class="dialog-card">
${csrfField(res.locals.csrfToken)}
<input type="hidden" name="path" id="copy-path" />
<h2>Kopieren</h2>
<div class="muted" id="copy-current"></div>
<label>
Zielpfad (relativ)
<input name="targetPath" id="copy-target" placeholder="z.B. archiv/ordner" required />
</label>
<div class="dialog-actions">
<button type="button" class="secondary dialog-close">Abbrechen</button>
<button type="submit">Kopieren</button>
</div>
</form>
</dialog>
<script> <script>
const renameDialog = document.getElementById('rename-dialog'); const renameDialog = document.getElementById('rename-dialog');
const deleteDialog = document.getElementById('delete-dialog'); const deleteDialog = document.getElementById('delete-dialog');
const moveDialog = document.getElementById('move-dialog');
const copyDialog = document.getElementById('copy-dialog');
const renamePath = document.getElementById('rename-path'); const renamePath = document.getElementById('rename-path');
const renameName = document.getElementById('rename-name'); const renameName = document.getElementById('rename-name');
const renameCurrent = document.getElementById('rename-current'); const renameCurrent = document.getElementById('rename-current');
const deletePath = document.getElementById('delete-path'); const deletePath = document.getElementById('delete-path');
const deleteCurrent = document.getElementById('delete-current'); const deleteCurrent = document.getElementById('delete-current');
const movePath = document.getElementById('move-path');
const moveTarget = document.getElementById('move-target');
const moveCurrent = document.getElementById('move-current');
const copyPath = document.getElementById('copy-path');
const copyTarget = document.getElementById('copy-target');
const copyCurrent = document.getElementById('copy-current');
document.querySelectorAll('.rename-trigger').forEach((btn) => { document.querySelectorAll('.rename-trigger').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@@ -1124,6 +1168,24 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
}); });
}); });
document.querySelectorAll('.move-trigger').forEach((btn) => {
btn.addEventListener('click', () => {
movePath.value = btn.dataset.path || '';
moveTarget.value = '';
moveCurrent.textContent = 'Quelle: ' + (btn.dataset.name || '');
moveDialog.showModal();
});
});
document.querySelectorAll('.copy-trigger').forEach((btn) => {
btn.addEventListener('click', () => {
copyPath.value = btn.dataset.path || '';
copyTarget.value = '';
copyCurrent.textContent = 'Quelle: ' + (btn.dataset.name || '');
copyDialog.showModal();
});
});
document.querySelectorAll('.delete-trigger').forEach((btn) => { document.querySelectorAll('.delete-trigger').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
deletePath.value = btn.dataset.path || ''; deletePath.value = btn.dataset.path || '';
@@ -1136,6 +1198,8 @@ app.get(`${basePath}/admin/files`, requireAdminPage, async (req, res) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
renameDialog.close(); renameDialog.close();
deleteDialog.close(); deleteDialog.close();
moveDialog.close();
copyDialog.close();
}); });
}); });
</script> </script>
@@ -1235,6 +1299,73 @@ app.post(`${basePath}/admin/files/delete`, requireAdminPage, async (req, res) =>
res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`)); res.redirect(baseUrl(`/admin/files?path=${encodeURIComponent(nextPath)}`));
}); });
app.post(`${basePath}/admin/files/move`, requireAdminPage, async (req, res) => {
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
const targetPath = String(req.body.targetPath || '').replace(/^\/+/, '');
if (!relativePath || !targetPath) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültige Eingabe.</p>'));
return;
}
const source = resolveAdminPath(relativePath);
const targetBase = resolveAdminPath(targetPath);
if (!source || !targetBase) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
let target = targetBase;
try {
const stat = await fs.promises.stat(targetBase);
if (stat.isDirectory()) {
target = path.join(targetBase, path.basename(source));
}
} catch (err) {
// targetBase does not exist; treat as file/dir path.
}
try {
await fs.promises.rename(source, target);
} catch (err) {
if (err.code === 'EXDEV') {
await fs.promises.cp(source, target, { recursive: true, force: false });
await fs.promises.rm(source, { recursive: true, force: true });
} else {
throw err;
}
}
await logEvent('admin_move', 'admin', { from: relativePath, to: targetPath });
res.redirect(baseUrl('/admin/files'));
});
app.post(`${basePath}/admin/files/copy`, requireAdminPage, async (req, res) => {
const relativePath = String(req.body.path || '').replace(/^\/+/, '');
const targetPath = String(req.body.targetPath || '').replace(/^\/+/, '');
if (!relativePath || !targetPath) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültige Eingabe.</p>'));
return;
}
const source = resolveAdminPath(relativePath);
const targetBase = resolveAdminPath(targetPath);
if (!source || !targetBase) {
res.status(400).send(renderFileManagerPage('Admin-Dateien', '<p class="card">Ungültiger Pfad.</p>'));
return;
}
let target = targetBase;
try {
const stat = await fs.promises.stat(targetBase);
if (stat.isDirectory()) {
target = path.join(targetBase, path.basename(source));
}
} catch (err) {
// targetBase does not exist; treat as file/dir path.
}
await fs.promises.cp(source, target, { recursive: true, force: false });
await logEvent('admin_copy', 'admin', { from: relativePath, to: targetPath });
res.redirect(baseUrl('/admin/files'));
});
app.post(`${basePath}/admin/files/:id/delete`, requireAdminPage, async (req, res) => { app.post(`${basePath}/admin/files/:id/delete`, requireAdminPage, async (req, res) => {
const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [req.params.id]); const uploadEntry = await get('SELECT id, stored_path FROM uploads WHERE id = ?', [req.params.id]);
if (!uploadEntry) { if (!uploadEntry) {