feat: per-source .md sidecar toggles, Picdrop folder-list diagnostic, fast JPEG fullscreen preview + thumb-first, thumbnail backfill
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
@@ -37,6 +37,10 @@ export default function AdminApp() {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) });
|
||||
load();
|
||||
};
|
||||
const toggleTargetSidecar = async (t: any) => {
|
||||
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, metadata_sidecar: !t.metadata_sidecar }) });
|
||||
load();
|
||||
};
|
||||
const backupAllTargets = async () => {
|
||||
if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return;
|
||||
notify('Sichere … (kann dauern)');
|
||||
@@ -74,7 +78,7 @@ export default function AdminApp() {
|
||||
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
|
||||
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
|
||||
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
|
||||
metadata_sidecar: !!s.metadata_sidecar,
|
||||
picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar,
|
||||
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
|
||||
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
|
||||
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
|
||||
@@ -91,6 +95,12 @@ export default function AdminApp() {
|
||||
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
|
||||
};
|
||||
const [pdList, setPdList] = useState<any>(null);
|
||||
const lsPicdrop = async (path?: string) => {
|
||||
notify('Lese Picdrop-Ordner …');
|
||||
const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }).then((x) => x.json());
|
||||
setPdList(r); notify(r.error ? r.error : `${(r.entries || []).length} Einträge in ${r.path}`);
|
||||
};
|
||||
const testNas = async () => {
|
||||
notify('Teste NAS …');
|
||||
const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json());
|
||||
@@ -109,7 +119,7 @@ export default function AdminApp() {
|
||||
if (confirmMsg && !confirm(confirmMsg)) return;
|
||||
notify('Räume auf …');
|
||||
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json());
|
||||
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : (r.error || 'OK'));
|
||||
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const mirrorAll = async () => {
|
||||
@@ -193,7 +203,8 @@ export default function AdminApp() {
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Picdrop</span>
|
||||
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div>
|
||||
<div className="reihe"><button className="mini" onClick={() => lsPicdrop()}>Ordner anzeigen</button>
|
||||
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div></div>
|
||||
<div className="steuer">
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Host</label><input className="input" value={s.picdrop_host ?? ''} onChange={(e) => field('picdrop_host', e.target.value)} /></div>
|
||||
@@ -211,7 +222,20 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
|
||||
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.picdrop_metadata_sidecar} onChange={(e) => field('picdrop_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten (.md) an Picdrop mitschicken</b><em>Nur aktivieren, wenn Picdrop die Textdateien verarbeiten soll — sonst aus lassen.</em></span></label>
|
||||
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> — z. B. <code>/ + POSTER LEA</code>. „Ordner anzeigen" listet, was per SFTP tatsächlich wo liegt.</div>
|
||||
{pdList && (
|
||||
<div className="pdlist">
|
||||
<div className="fein"><b>{pdList.path}</b>{pdList.error ? ` — ${pdList.error}` : ''}</div>
|
||||
{(pdList.entries || []).map((e: any, i: number) => (
|
||||
<div key={i} className="pdrow">
|
||||
<button className="link2" onClick={() => lsPicdrop((pdList.path.replace(/\/$/, '')) + '/' + e.name)}>{e.type === 'd' || e.type === 2 ? '📁' : '📄'} {e.name}</button>
|
||||
<span className="fein">{e.size ? `${Math.round(e.size / 1024)} KB` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -223,13 +247,13 @@ export default function AdminApp() {
|
||||
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
|
||||
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz — Originale werden nach dem Ergebnis entfernt.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.metadata_sidecar} onChange={(e) => field('metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten als .md mitschicken</b><em>Zu jedem Bild eine begleitende Textdatei (Prompt, Modell, Format …) bei Picdrop/NAS/FTP.</em></span></label>
|
||||
<div className="fein">Metadaten-Beileger (.md mit Prompt/Modell/Format) lassen sich jetzt <b>pro Ziel</b> ein-/ausschalten — bei Picdrop, beim NAS und bei jedem FTP-Ziel einzeln (siehe die jeweilige Karte).</div>
|
||||
<div className="feld"><label>Aufbewahrung (Tage)</label>
|
||||
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
|
||||
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
|
||||
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
|
||||
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', `Positionen älter als ${s.retention_days} Tage jetzt löschen?`)}>Retention jetzt anwenden</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,6 +279,8 @@ export default function AdminApp() {
|
||||
<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="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!s.nas_metadata_sidecar} onChange={(e) => field('nas_metadata_sidecar', e.target.checked)} />
|
||||
<span><b>Metadaten (.md) aufs NAS mitschicken</b><em>Begleitende Textdatei (Prompt, Modell, Format …) neben jedem gesicherten Bild.</em></span></label>
|
||||
<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>
|
||||
@@ -270,9 +296,10 @@ export default function AdminApp() {
|
||||
<div className="liste">
|
||||
{targets.map((t) => (
|
||||
<div key={t.id} className="zeile">
|
||||
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>}{t.metadata_sidecar && <span className="pille alpha">.md</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
|
||||
<button className="mini" onClick={() => toggleTargetSidecar(t)}>{t.metadata_sidecar ? '.md aus' : '.md an'}</button>
|
||||
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
|
||||
<button className="mini" onClick={() => delTarget(t.id)}>✕</button>
|
||||
</div>
|
||||
@@ -297,6 +324,8 @@ export default function AdminApp() {
|
||||
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
|
||||
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
|
||||
<label className="schalt"><input type="checkbox" checked={!!nt.metadata_sidecar} onChange={(e) => setNt({ ...nt, metadata_sidecar: e.target.checked })} />
|
||||
<span><b>Metadaten (.md) mitschicken</b><em>Begleitende Textdatei je Bild.</em></span></label>
|
||||
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -435,5 +464,8 @@ function AdminStyles() {
|
||||
.reihe{display:flex;gap:6px;}
|
||||
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
|
||||
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
|
||||
.pdlist{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:10px;margin-top:8px;max-height:260px;overflow:auto;}
|
||||
.pdrow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 0;}
|
||||
.link2{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:12px;text-align:left;padding:0;}
|
||||
`}</style>;
|
||||
}
|
||||
|
||||
@@ -249,10 +249,13 @@ export default function LibraryApp() {
|
||||
)}
|
||||
{saveView && (
|
||||
<div className="lupe" onClick={() => setSaveView(null)}>
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file`} alt={saveView.filename || ''} onClick={(e) => e.stopPropagation()} />
|
||||
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
|
||||
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
|
||||
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
|
||||
</div>
|
||||
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
||||
<span>📱 iPhone/iPad: lange auf das Bild tippen → <b>„In Fotos sichern"</b>.</span>
|
||||
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Als Datei herunterladen</a>
|
||||
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +312,10 @@ function LibStyles() {
|
||||
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
|
||||
.tagmenu button:hover{background:var(--paper);}
|
||||
.tagmenu .flag{font-size:15px;}
|
||||
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
|
||||
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
|
||||
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
|
||||
.saveimg.full{position:relative;z-index:1;}
|
||||
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
|
||||
.fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
|
||||
.varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
|
||||
|
||||
@@ -96,10 +96,10 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
|
||||
</div>
|
||||
{saveView && (
|
||||
<div className="lupe" onClick={() => setSaveView(null)}>
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||
<img className="saveimg" src={`/api/items/${saveView.id}/file?preview=1`} alt="" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
|
||||
<span>📱 iPhone/iPad: lange auf das Bild tippen → „In Fotos sichern".</span>
|
||||
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>Als Datei herunterladen</a>
|
||||
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
|
||||
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user