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:
@@ -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;}
|
||||
|
||||
@@ -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;}
|
||||
|
||||
Reference in New Issue
Block a user