feat: per-user visibility (own/shared), anonymous generations, private sessions (generate+forget), NSFW model gating

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 21:46:47 +00:00
parent 2588a54eb7
commit e6b325eb53
14 changed files with 186 additions and 35 deletions
+30 -2
View File
@@ -75,6 +75,8 @@ export default function AdminApp() {
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,
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,
nas_user: s.nas_user, nas_base_path: s.nas_base_path,
};
@@ -140,6 +142,10 @@ export default function AdminApp() {
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
notify('Passwort gesetzt.');
};
const setUserPrivate = async (id: string, v: boolean) => {
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, private_forced: v }) });
load();
};
const addUser = async () => {
const username = prompt('Benutzername?'); if (!username) return;
const password = prompt('Passwort?'); if (!password) return;
@@ -306,6 +312,25 @@ export default function AdminApp() {
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Rechte & Datenschutz</span></div>
<div className="steuer">
<div className="feld"><label>Wer sieht welche Generierungen?</label>
<div className="schalter">
<button className={s.library_visibility !== 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'own')}>Nur eigene</button>
<button className={s.library_visibility === 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'shared')}>Alle sehen alles</button>
</div>
<div className="fein">Admins sehen immer alles. „Nur eigene": jede/r sieht ausschließlich die selbst erzeugten Bilder.</div>
</div>
<label className="schalt"><input type="checkbox" checked={!!s.anonymous_generations} onChange={(e) => field('anonymous_generations', e.target.checked)} />
<span><b>Generierungen anonym</b><em>Wer ein Bild erzeugt hat, wird für Nicht-Admins ausgeblendet.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!!s.private_allowed} onChange={(e) => field('private_allowed', e.target.checked)} />
<span><b>Private Sessions erlauben</b><em>Nutzer bekommen im Studio den Schalter Private Session" (nur erzeugen, danach vergessen).</em></span></label>
<label className="schalt"><input type="checkbox" checked={!!s.allow_nsfw} onChange={(e) => field('allow_nsfw', e.target.checked)} />
<span><b>NSFW-Modelle erlauben</b><em>Als NSFW markierte Modelle werden wähl-/sichtbar. Verantwortung liegt beim Betreiber; Inhalte mit Minderjährigen sind ausnahmslos untersagt.</em></span></label>
</div>
</section>
<button className="knopf" onClick={saveSettings}>Einstellungen speichern</button>
<section className="karte">
@@ -313,9 +338,10 @@ export default function AdminApp() {
<div className="liste">
{models.map((m) => (
<div key={m.id} className="zeile">
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}<div className="fein">{m.model_id}</div></div>
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}{m.nsfw && <span className="pille nsfw">NSFW</span>}<div className="fein">{m.model_id}</div></div>
<div className="reihe">
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>}
<button className="mini" onClick={() => modelAction(m.id, 'nsfw')}>{m.nsfw ? 'NSFW aus' : 'NSFW'}</button>
<button className="mini" onClick={() => modelAction(m.id, 'delete')}>✕</button>
</div>
</div>
@@ -334,8 +360,9 @@ export default function AdminApp() {
<div className="liste">
{users.map((u) => (
<div key={u.id} className="zeile">
<div><b>{u.display_name || u.username}</b> <span className="fein">{u.username} · {u.role}</span></div>
<div><b>{u.display_name || u.username}</b>{u.private_forced && <span className="pille">privat</span>} <span className="fein">{u.username} · {u.role}</span></div>
<div className="reihe">
<button className="mini" onClick={() => setUserPrivate(u.id, !u.private_forced)}>{u.private_forced ? 'Privat aus' : 'Privat erzwingen'}</button>
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
</div>
@@ -398,6 +425,7 @@ function AdminStyles() {
.zeile b{font-size:14px;}
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
.pille.alpha{background:var(--paper);color:var(--soft);}
.pille.nsfw{background:var(--err);color:#fff;}
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
.schalt b{display:block;font-size:14px;}
+16 -1
View File
@@ -58,6 +58,8 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const [targetId, setTargetId] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [me, setMe] = useState<any>({});
const [priv, setPriv] = useState(false);
const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>('');
const [dragging, setDragging] = useState(false);
@@ -119,6 +121,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
if (def) setModelKey(def.model_id);
}).catch(() => {});
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {});
loadPresets();
}, []);
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
@@ -208,7 +211,7 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
};
const body: any = { recipe, mode, delivery };
const body: any = { recipe, mode, delivery, private: priv };
if (mode !== 'each') body.prompt_text = desc.trim();
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
try {
@@ -395,6 +398,14 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
)}
</div>
{(me.private_allowed || me.private_forced) && (
<label className="privat">
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
<span><b>Private Session {me.private_forced ? '(erzwungen)' : ''}</b>
<em>Nur erzeugen, nichts merken: kein Eintrag in der Bibliothek, keine Auslieferung/Sicherung, kein gespeicherter Prompt. Ergebnis kurz danach abrufbar, dann gelöscht.</em></span>
</label>
)}
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt …</>
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
@@ -461,6 +472,10 @@ function StudioStyles() {
.reihe-presets{display:flex;gap:6px;align-items:center;}
.reihe-presets .select{flex:1;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.privat{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
.privat input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
.privat b{display:block;font-size:13.5px;}
.privat em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
.schalter.wrap{flex-wrap:wrap;}
.schalter.wrap button{flex:1 1 auto;min-width:110px;}
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}