Devlog-Löschung: Discord-Delete-Sync + Admin-Button im Webinterface
- MessageDelete-Listener (mit Partials): in Discord gelöschte Devlogs verschwinden automatisch aus dem Archiv, inkl. lokaler Bilder - DELETE /api/devlogs/:id (nur Admin) + ✕-Button auf den Karten (nur für Admin sichtbar) - Anlass: Test-Post mit Commit-Liste von vor der Prosa-Sperre stand noch im Archiv Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,7 @@ export default function App() {
|
|||||||
<main className="main">
|
<main className="main">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
<Route path="/" element={<Navigate to="/devlogs" replace />} />
|
||||||
<Route path="/devlogs" element={<Devlogs />} />
|
<Route path="/devlogs" element={<Devlogs me={me} />} />
|
||||||
<Route path="/commits" element={<Commits me={me} />} />
|
<Route path="/commits" element={<Commits me={me} />} />
|
||||||
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
<Route path="*" element={<Navigate to="/devlogs" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
// Kleiner Fetch-Wrapper — Cookies (Session) laufen automatisch mit (same-origin)
|
// Kleiner Fetch-Wrapper — Cookies (Session) laufen automatisch mit (same-origin)
|
||||||
export async function apiGet(path) {
|
async function api(path, options = {}) {
|
||||||
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
const res = await fetch(path, { headers: { Accept: 'application/json' }, ...options });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = new Error(`API ${res.status}`);
|
const error = new Error(`API ${res.status}`);
|
||||||
error.status = res.status;
|
error.status = res.status;
|
||||||
@@ -8,3 +8,6 @@ export async function apiGet(path) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const apiGet = (path) => api(path);
|
||||||
|
export const apiDelete = (path) => api(path, { method: 'DELETE' });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { apiGet } from '../api.js';
|
import { apiGet, apiDelete } from '../api.js';
|
||||||
import { Markdown } from '../markdown.jsx';
|
import { Markdown } from '../markdown.jsx';
|
||||||
|
|
||||||
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
const dateFmt = new Intl.DateTimeFormat('de-DE', {
|
||||||
@@ -40,17 +40,32 @@ function splitContent(raw) {
|
|||||||
return { project, footer, body: blocks.join('\n\n') };
|
return { project, footer, body: blocks.join('\n\n') };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Devlogs() {
|
export default function Devlogs({ me }) {
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback(() => {
|
||||||
setError(false);
|
setError(false);
|
||||||
apiGet(`/api/devlogs?page=${page}`).then(setData).catch(() => setError(true));
|
apiGet(`/api/devlogs?page=${page}`).then(setData).catch(() => setError(true));
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Admin: Devlog aus dem Archiv entfernen (löscht auch die lokalen Bilder)
|
||||||
|
async function remove(id) {
|
||||||
|
if (!window.confirm('Dieses Devlog aus dem Archiv löschen?')) return;
|
||||||
|
try {
|
||||||
|
await apiDelete(`/api/devlogs/${id}`);
|
||||||
|
load();
|
||||||
|
} catch {
|
||||||
|
window.alert('Löschen fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pages = data ? Math.ceil(data.total / data.pageSize) : 0;
|
const pages = data ? Math.ceil(data.total / data.pageSize) : 0;
|
||||||
|
|
||||||
// Monats-Trenner in der Timeline
|
// Monats-Trenner in der Timeline
|
||||||
@@ -92,6 +107,15 @@ export default function Devlogs() {
|
|||||||
</span>
|
</span>
|
||||||
{project && <span className="card-project">{project}</span>}
|
{project && <span className="card-project">{project}</span>}
|
||||||
<span className="card-rel">{relative(d.posted_at)}</span>
|
<span className="card-rel">{relative(d.posted_at)}</span>
|
||||||
|
{me?.admin && (
|
||||||
|
<button
|
||||||
|
className="card-delete"
|
||||||
|
title="Aus dem Archiv löschen"
|
||||||
|
onClick={() => remove(d.message_id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<Markdown text={body} />
|
<Markdown text={body} />
|
||||||
|
|||||||
@@ -246,6 +246,15 @@ body::after {
|
|||||||
padding: .1rem .5rem;
|
padding: .1rem .5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.card-delete {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--muted2);
|
||||||
|
background: none; border: 1px solid transparent;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
transition: color .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
.card-delete:hover { color: var(--neon2); border-color: rgba(255, 77, 0, .4); }
|
||||||
|
|
||||||
.card-body { color: var(--text); font-weight: 300; font-size: 1.08rem; }
|
.card-body { color: var(--text); font-weight: 300; font-size: 1.08rem; }
|
||||||
.card-body p { margin: .55rem 0; }
|
.card-body p { margin: .55rem 0; }
|
||||||
|
|||||||
+16
-2
@@ -1,7 +1,7 @@
|
|||||||
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
// Discord-Client: Commands laden, registrieren und Interactions verarbeiten
|
||||||
import { Client, Collection, Events, GatewayIntentBits, REST, Routes } from 'discord.js';
|
import { Client, Collection, Events, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { archiveDevlogMessage } from './devlog-archive.js';
|
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||||
import * as ping from './commands/ping.js';
|
import * as ping from './commands/ping.js';
|
||||||
import * as devlogBackfill from './commands/devlog-backfill.js';
|
import * as devlogBackfill from './commands/devlog-backfill.js';
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ export async function startBot() {
|
|||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
],
|
],
|
||||||
|
// Partials, damit MessageDelete auch für ungecachte (ältere) Nachrichten feuert
|
||||||
|
partials: [Partials.Message],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
|
// Commands in Collection ablegen für schnellen Zugriff im Interaction-Handler
|
||||||
@@ -42,6 +44,18 @@ export async function startBot() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In Discord gelöscht = im Archiv gelöscht (inkl. lokaler Bilder)
|
||||||
|
client.on(Events.MessageDelete, async (message) => {
|
||||||
|
if (message.channelId !== config.devlogChannelId) return;
|
||||||
|
try {
|
||||||
|
if (await removeDevlog(message.id)) {
|
||||||
|
console.log(`[devlog] Archiv-Eintrag entfernt (Discord-Nachricht ${message.id} gelöscht)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[devlog] Archiv-Löschung fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction) => {
|
client.on(Events.InteractionCreate, async (interaction) => {
|
||||||
if (!interaction.isChatInputCommand()) return;
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
// Webserver unter /devlog-assets/ ausgeliefert.
|
// Webserver unter /devlog-assets/ ausgeliefert.
|
||||||
// Wird vom Live-Listener (MessageCreate) und vom /devlog-backfill-Command genutzt.
|
// Wird vom Live-Listener (MessageCreate) und vom /devlog-backfill-Command genutzt.
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile, rm } from 'node:fs/promises';
|
||||||
import { join, dirname, resolve } from 'node:path';
|
import { join, dirname, resolve } from 'node:path';
|
||||||
import { saveDevlog, hasDevlog } from '../db.js';
|
import { saveDevlog, hasDevlog, deleteDevlog } from '../db.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const MAX_IMAGES = 4;
|
const MAX_IMAGES = 4;
|
||||||
@@ -49,6 +49,18 @@ async function downloadImages(message) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devlog aus dem Archiv entfernen (inkl. lokal gespeicherter Bilder).
|
||||||
|
* @returns {Promise<boolean>} true, wenn ein Eintrag gelöscht wurde
|
||||||
|
*/
|
||||||
|
export async function removeDevlog(messageId) {
|
||||||
|
const { deleted, images } = deleteDevlog(messageId);
|
||||||
|
for (const file of images) {
|
||||||
|
await rm(join(imagesDir, file), { force: true });
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nachricht archivieren, falls sie ein Devlog ist (Webhook-/Bot-Post mit Text).
|
* Nachricht archivieren, falls sie ein Devlog ist (Webhook-/Bot-Post mit Text).
|
||||||
* @param {import('discord.js').Message} message
|
* @param {import('discord.js').Message} message
|
||||||
|
|||||||
@@ -71,6 +71,17 @@ export function hasDevlog(messageId) {
|
|||||||
return Boolean(hasDevlogStmt.get(messageId));
|
return Boolean(hasDevlogStmt.get(messageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDevlogImagesStmt = db.prepare('SELECT images FROM devlogs WHERE message_id = ?');
|
||||||
|
const deleteDevlogStmt = db.prepare('DELETE FROM devlogs WHERE message_id = ?');
|
||||||
|
|
||||||
|
/** Devlog löschen; gibt { deleted, images } zurück (Dateinamen fürs Aufräumen) */
|
||||||
|
export function deleteDevlog(messageId) {
|
||||||
|
const row = getDevlogImagesStmt.get(messageId);
|
||||||
|
if (!row) return { deleted: false, images: [] };
|
||||||
|
deleteDevlogStmt.run(messageId);
|
||||||
|
return { deleted: true, images: JSON.parse(row.images || '[]') };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Lese-Queries für die Web-API (Feature 4) ---
|
// --- Lese-Queries für die Web-API (Feature 4) ---
|
||||||
|
|
||||||
const selectDevlogs = db.prepare(`
|
const selectDevlogs = db.prepare(`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// REST-API fürs Webinterface: Devlogs öffentlich, Commits nur für den Admin
|
// REST-API fürs Webinterface: Devlogs öffentlich, Commits nur für den Admin
|
||||||
import { listDevlogs, listCommits } from '../db.js';
|
import { listDevlogs, listCommits } from '../db.js';
|
||||||
|
import { removeDevlog } from '../bot/devlog-archive.js';
|
||||||
import { getSessionUser, isAdmin } from './auth.js';
|
import { getSessionUser, isAdmin } from './auth.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -29,6 +30,17 @@ export function registerApiRoutes(app) {
|
|||||||
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
return { items: mapped, total, page, pageSize: PAGE_SIZE };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Devlog aus dem Archiv löschen — nur Admin (z. B. alte Test-Posts)
|
||||||
|
app.delete('/api/devlogs/:id', async (request, reply) => {
|
||||||
|
const user = getSessionUser(request);
|
||||||
|
if (!user) return reply.code(401).send({ error: 'login required' });
|
||||||
|
if (!isAdmin(user)) return reply.code(403).send({ error: 'admin only' });
|
||||||
|
|
||||||
|
const deleted = await removeDevlog(request.params.id);
|
||||||
|
request.log.info(`Devlog ${request.params.id} per Web-UI gelöscht: ${deleted}`);
|
||||||
|
return { deleted };
|
||||||
|
});
|
||||||
|
|
||||||
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
|
// Commit-Feed — nur für den Admin (spiegelt den privaten #-gitea-Kanal)
|
||||||
app.get('/api/commits', async (request, reply) => {
|
app.get('/api/commits', async (request, reply) => {
|
||||||
const user = getSessionUser(request);
|
const user = getSessionUser(request);
|
||||||
|
|||||||
Reference in New Issue
Block a user