feat: mirror-all-to-NAS, content reset (danger zone), in-app changelog + CHANGELOG.md, How-To refresh
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
@@ -80,6 +80,22 @@ export default function AdminApp() {
|
||||
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const mirrorAll = async () => {
|
||||
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
|
||||
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
|
||||
notify('Sichere aufs NAS … (kann dauern)');
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json());
|
||||
notify(r.total != null ? `${r.mirrored}/${r.total} aufs NAS gesichert${r.failed ? `, ${r.failed} fehlgeschlagen` : ''}.` : (r.error || 'Fehler'));
|
||||
load();
|
||||
};
|
||||
const resetAll = async () => {
|
||||
const t = prompt('ACHTUNG: Löscht ALLE Bilder, Aufträge und Ordner unwiderruflich (Nutzer, Einstellungen, Modelle & Rezepte bleiben).\n\nZum Bestätigen RESET eingeben:');
|
||||
if (t !== 'RESET') { if (t != null) notify('Abgebrochen — nicht bestätigt.'); return; }
|
||||
notify('Setze zurück …');
|
||||
const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json());
|
||||
notify(r.ok ? `Zurückgesetzt: ${r.deleted_items} Bilder entfernt.` : (r.error || 'Fehler'));
|
||||
load();
|
||||
};
|
||||
const addModel = async (m: any) => {
|
||||
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
|
||||
@@ -200,8 +216,9 @@ export default function AdminApp() {
|
||||
</div>
|
||||
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
|
||||
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
|
||||
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/homes/klarbild oder /volume1/…" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
|
||||
<div className="fein">Bei Synology am einfachsten SFTP aktivieren (Systemsteuerung → Terminal & SNMP → SFTP). Zugangsdaten werden verschlüsselt gespeichert.</div>
|
||||
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
|
||||
<button className="mini" onClick={mirrorAll}>Alle vorhandenen Bilder aufs NAS sichern</button>
|
||||
<div className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -257,6 +274,15 @@ export default function AdminApp() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte gefahr">
|
||||
<div className="kopfzeile"><span className="mono-label">Zurücksetzen</span></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">Löscht <b>alle Bilder, Aufträge und Ordner</b> unwiderruflich, um sauber neu zu starten.
|
||||
Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder aufs NAS sichern".</div>
|
||||
<button className="knopf warn" onClick={resetAll}>Bibliothek & Aufträge zurücksetzen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
<AdminStyles />
|
||||
</div>
|
||||
@@ -275,6 +301,10 @@ function AdminStyles() {
|
||||
.input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);}
|
||||
.fein{font-size:11.5px;color:var(--soft);line-height:1.5;}
|
||||
.knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
|
||||
.knopf.warn{background:var(--err);}
|
||||
.karte.gefahr{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
|
||||
.karte.gefahr .mono-label{color:var(--err);}
|
||||
.steuer code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
|
||||
.zahlen{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
|
||||
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
|
||||
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
|
||||
|
||||
@@ -69,6 +69,21 @@ export async function purgeSources(): Promise<{ purged: number }> {
|
||||
return { purged };
|
||||
}
|
||||
|
||||
/** Spiegelt alle fertigen Bilder aufs NAS (einmalige Nachsicherung). */
|
||||
export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: number; total: number }> {
|
||||
const { mirrorItemToNas, loadNasConfig } = await import('./nas');
|
||||
if (!(await loadNasConfig())) return { mirrored: 0, failed: 0, total: 0 };
|
||||
const rows = await query<{ id: string }>(
|
||||
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL
|
||||
AND (nas_status IS NULL OR nas_status <> 'mirrored') ORDER BY created_at`);
|
||||
let mirrored = 0, failed = 0;
|
||||
for (const it of rows) {
|
||||
const r = await mirrorItemToNas(it.id);
|
||||
r.ok ? mirrored++ : failed++;
|
||||
}
|
||||
return { mirrored, failed, total: rows.length };
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
/** Täglicher Retention-Lauf (falls in den Einstellungen aktiviert). */
|
||||
export function startMaintenance(): void {
|
||||
|
||||
@@ -5,7 +5,10 @@ import Base from '../layouts/Base.astro';
|
||||
<div class="howto">
|
||||
<div class="hkopf">
|
||||
<h1>So funktioniert Klarbild</h1>
|
||||
<button class="druck" onclick="window.print()">Als PDF / drucken</button>
|
||||
<div class="hkopf-akt">
|
||||
<a class="druck" href="/changelog">Changelog</a>
|
||||
<button class="druck" onclick="window.print()">Als PDF / drucken</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="lead">Aus Screenshots und Fundstücken werden druckfertige Bilder — bereinigt, freigestellt,
|
||||
im richtigen Format, optional direkt bei Picdrop. Alles geht über die Website und über Telegram.</p>
|
||||
@@ -48,6 +51,10 @@ import Base from '../layouts/Base.astro';
|
||||
<li><b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut
|
||||
bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.</li>
|
||||
<li><b>Vorher/Nachher</b>: zeigt den Vergleich Vorlage ↔ Ergebnis (bei reinen Text-Bildern entfällt das).</li>
|
||||
<li><b>Ordner verwalten</b>: Ordner-Chip antippen, dann ✎ zum Umbenennen (inkl. Galerie) oder ✕ zum Löschen
|
||||
(die Bilder bleiben erhalten).</li>
|
||||
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
|
||||
verschieben.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -67,6 +74,16 @@ import Base from '../layouts/Base.astro';
|
||||
<p class="mini">Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6 · Sicherung & sauber neu starten (Admin)</h2>
|
||||
<p>Im Admin-Bereich lässt sich eine <b>zweite Sicherung aufs NAS</b> einrichten (SFTP/FTPS, auch über
|
||||
Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit <b>„NAS ✓"</b>
|
||||
markiert. Mit <b>„Alle vorhandenen Bilder aufs NAS sichern"</b> lassen sich auch ältere Bilder nachsichern.</p>
|
||||
<p>Zum Aufräumen gibt es <b>„Bibliothek & Aufträge zurücksetzen"</b>: löscht alle Bilder, Aufträge und Ordner
|
||||
(Nutzer, Einstellungen, Modelle und Rezepte bleiben) — mit Sicherheitsabfrage. Am besten vorher einmal
|
||||
aufs NAS sichern.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Tipps</h2>
|
||||
<ul>
|
||||
@@ -81,7 +98,8 @@ import Base from '../layouts/Base.astro';
|
||||
.howto{max-width:760px;margin:0 auto;}
|
||||
.hkopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
|
||||
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);}
|
||||
.hkopf-akt{display:flex;gap:8px;align-items:center;}
|
||||
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);text-decoration:none;}
|
||||
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
|
||||
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
|
||||
.howto h2{font-size:1.05rem;margin:0 0 10px;}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { query } from '../../../lib/db';
|
||||
import { deleteObject } from '../../../lib/storage';
|
||||
|
||||
export const prerender = false;
|
||||
const json = (b: unknown, s = 200) =>
|
||||
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
|
||||
|
||||
/** Setzt Inhalte zurück: löscht alle Bilder/Aufträge/Ordner + zugehörige Dateien.
|
||||
* Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten.
|
||||
* Erfordert { confirm: 'RESET' }. */
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
|
||||
const b = await request.json().catch(() => ({} as any));
|
||||
if (b.confirm !== 'RESET') return json({ error: 'Bestätigung fehlt (confirm=RESET).' }, 400);
|
||||
|
||||
// Zuerst alle Objekte aus dem Speicher entfernen.
|
||||
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
|
||||
let objects = 0;
|
||||
for (const it of items) {
|
||||
const keys = [it.source_path, it.result_path, it.thumb_path,
|
||||
...((it.source_paths as string[]) || [])].filter(Boolean);
|
||||
for (const k of keys) { await deleteObject(k).catch(() => {}); objects++; }
|
||||
}
|
||||
|
||||
// Inhalts-Tabellen leeren (Konfiguration bleibt).
|
||||
await query('DELETE FROM print_job_items');
|
||||
await query('DELETE FROM print_jobs');
|
||||
await query('DELETE FROM share_links');
|
||||
await query('DELETE FROM telegram_drafts');
|
||||
await query('DELETE FROM items');
|
||||
await query('DELETE FROM jobs');
|
||||
await query('DELETE FROM folders');
|
||||
|
||||
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { storageStats, runRetention, purgeSources } from '../../../lib/maintenance';
|
||||
import { storageStats, runRetention, purgeSources, mirrorAllToNas } from '../../../lib/maintenance';
|
||||
import { one } from '../../../lib/db';
|
||||
|
||||
export const prerender = false;
|
||||
@@ -12,6 +12,7 @@ export const GET: APIRoute = async () => json({ stats: await storageStats() });
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
if (b.action === 'purge_sources') return json(await purgeSources());
|
||||
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
|
||||
if (b.action === 'retention') {
|
||||
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
|
||||
if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
---
|
||||
<Base title="Changelog · Klarbild">
|
||||
<div class="clog">
|
||||
<div class="ckopf">
|
||||
<h1>Changelog</h1>
|
||||
<a class="zurueck" href="/anleitung">← Zur Hilfe</a>
|
||||
</div>
|
||||
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
|
||||
|
||||
<section>
|
||||
<div class="ver"><span class="tag">23.07.2026</span><h2>Kombinieren, Erzeugen & zweite Sicherung</h2></div>
|
||||
<ul>
|
||||
<li><b>Kombinieren:</b> mehrere Bilder + Beschreibung zu einem neuen Bild (z. B. „…mit unserer Hündin Frieda").</li>
|
||||
<li><b>Neu erzeugen:</b> Bilder allein aus Text, ganz ohne Vorlage.</li>
|
||||
<li><b>The Frame „Erweitern":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.</li>
|
||||
<li><b>Weiterbearbeiten:</b> ein fertiges Bild als neue Vorlage übernehmen und erneut bearbeiten.</li>
|
||||
<li><b>Alternativen:</b> weitere Fassungen desselben Bildes erzeugen — gruppiert mit Umschalter.</li>
|
||||
<li><b>Ordner:</b> in der Bibliothek filtern, umbenennen, löschen; je Ordner eigene Picdrop-Galerie.</li>
|
||||
<li><b>Sammel-Aktionen:</b> mehrere Bilder als ZIP laden und auf einmal in einen Ordner verschieben.</li>
|
||||
<li><b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).</li>
|
||||
<li><b>Modelle:</b> Qualitätsstufen inkl. Open Source (FLUX.2); genutztes Modell wird angezeigt.</li>
|
||||
<li><b>Zweite Sicherung (NAS):</b> jedes Ergebnis zusätzlich per SFTP/FTPS aufs NAS (auch über Tailscale) — mit „NAS ✓"-Anzeige und „Alle Bilder aufs NAS sichern".</li>
|
||||
<li><b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.</li>
|
||||
<li><b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild", Weiterbearbeiten, ganz ohne Befehle.</li>
|
||||
<li><b>Zurücksetzen:</b> Bibliothek & Aufträge sauber leeren (mit Sicherheitsabfrage).</li>
|
||||
<li><b>Hilfe-Seite</b> und dieser Changelog.</li>
|
||||
<li>Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose fürs NAS.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clog{max-width:760px;margin:0 auto;}
|
||||
.ckopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.clog h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
|
||||
.zurueck{color:var(--accent);text-decoration:none;font-size:.9rem;}
|
||||
.lead{color:var(--soft);margin:4px 0 20px;}
|
||||
.clog section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
|
||||
.ver{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.ver h2{font-size:1.05rem;margin:0;}
|
||||
.tag{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;background:var(--accent-bg);color:var(--accent);padding:3px 9px;border-radius:20px;}
|
||||
.clog ul{margin:0;padding-left:20px;line-height:1.65;}
|
||||
.clog li{margin-bottom:6px;}
|
||||
</style>
|
||||
</Base>
|
||||
Reference in New Issue
Block a user