Add consent-gated media exchange library

This commit is contained in:
jjbrunton committed 2026-08-09 19:24:38 +01:00
1 parent b8e4a1bb88
commit bf474dfe10
42 files changed
+2533 -9

No files matched your search

@@ -0,0 +1,51 @@
import type { ZodType } from 'zod';
import {
ErrorResponseSchema,
MediaLibraryAssetSchema,
MediaLibraryResponseSchema,
type MediaLibraryAsset,
type MediaLibraryUpdate,
} from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsed = ErrorResponseSchema.safeParse(payload);
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
export function fetchMediaLibrary(): Promise<MediaLibraryAsset[]> {
return dashboardRequest('/api/media-library').then((response) => parseResponse(response, MediaLibraryResponseSchema));
}
export function uploadMediaLibraryImage(file: File): Promise<MediaLibraryAsset> {
return dashboardRequest('/api/media-library', {
method: 'POST',
headers: {
'Content-Type': file.type,
'X-File-Name': encodeURIComponent(file.name),
'X-Consent-Confirmed': 'true',
},
body: file,
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
}
export function updateMediaLibraryImage(id: string, update: MediaLibraryUpdate): Promise<MediaLibraryAsset> {
return dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update),
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
}
export async function deleteMediaLibraryImage(id: string): Promise<void> {
const response = await dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, { method: 'DELETE' });
const payload: unknown = await response.json();
if (!response.ok) {
const parsed = ErrorResponseSchema.safeParse(payload);
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
}
}
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import type { MediaLibraryAsset, MediaLibraryTag, MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
import { useDeleteMediaLibraryMutation, useMediaLibraryQuery, useUpdateMediaLibraryMutation, useUploadMediaLibraryMutation } from './queries.js';
import './media-library.css';
const TAGS: readonly MediaLibraryTag[] = ['selfie', 'chest', 'topless', 'feet', 'penis'];
export function MediaLibraryPage() {
const query = useMediaLibraryQuery();
const upload = useUploadMediaLibraryMutation();
const [file, setFile] = useState<File | null>(null);
const [consent, setConsent] = useState(false);
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading exchange library…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Exchange library unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
const assets = query.data ?? [];
return (
<div className="page exchange-library-page">
<header className="page-header">
<div>
<span className="eyebrow">Operator-owned media</span>
<h1>Exchange library</h1>
<p>Add consented images, review the automatic anatomy classification, and control what the bot may send after a request.</p>
</div>
</header>
<section aria-label="Nonspecific request progression" className="exchange-ladder">
<div><span>First ask</span><strong>Random SFW</strong><small>Selfie, chest, or feet</small></div>
<div><span>Asked again</span><strong>Broader reveal</strong><small>SFW or topless</small></div>
<div><span>Further ask</span><strong>Full library</strong><small>Still exact-match when named</small></div>
</section>
<section className="panel library-upload-panel">
<div>
<span className="eyebrow">Add an image</span>
<h2>Strip metadata and classify</h2>
<p>JPEG, PNG, or WebP up to 15 MB. Location and camera metadata are removed before the cleaned image is sent to the configured OpenRouter model for classification.</p>
</div>
<label className="library-file-field">
<span>Choose image</span>
<input accept="image/jpeg,image/png,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} type="file" />
<small>{file ? file.name : 'No image selected'}</small>
</label>
<label className="library-consent-field">
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
<span>I confirm every depicted adult consented to this image being stored and sent by the bot.</span>
</label>
<button
className="button primary"
disabled={!file || !consent || upload.isPending}
onClick={() => file && upload.mutate(file, { onSuccess: () => { setFile(null); setConsent(false); } })}
type="button"
>
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
</button>
{upload.isError ? <div className="media-notice error" role="alert">{upload.error.message}</div> : null}
{upload.isSuccess ? <div className="media-notice success" role="status">Image added. Review its tags before relying on it.</div> : null}
</section>
<div className="library-section-heading">
<div><span className="eyebrow">Available inventory</span><h2>{assets.length} {assets.length === 1 ? 'image' : 'images'}</h2></div>
<small>Images remain private and blurred until revealed.</small>
</div>
{assets.length === 0 ? (
<section className="panel empty-state"><strong>No exchange images yet.</strong><span>Add the first consented image above.</span></section>
) : (
<section aria-label="Exchange library images" className="library-grid">
{assets.map((asset) => <LibraryAssetCard asset={asset} key={asset.id} />)}
</section>
)}
</div>
);
}
function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
const updateMutation = useUpdateMediaLibraryMutation();
const deleteMutation = useDeleteMediaLibraryMutation();
const [revealed, setRevealed] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [form, setForm] = useState<MediaLibraryUpdate>(() => ({
tags: asset.tags,
safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw',
enabled: asset.enabled,
}));
useEffect(() => {
setForm({ tags: asset.tags, safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw', enabled: asset.enabled });
}, [asset]);
function toggleTag(tag: MediaLibraryTag) {
setForm((current) => ({
...current,
tags: current.tags.includes(tag) ? current.tags.filter((item) => item !== tag) : [...current.tags, tag],
}));
}
const needsReview = asset.classificationStatus === 'failed' || asset.safety === 'unknown' || (asset.confidence ?? 0) < 0.7;
return (
<article className={asset.enabled ? 'library-card is-enabled' : 'library-card'}>
<div className="library-image-frame">
<img alt={`Library image tagged ${asset.tags.join(', ') || 'unclassified'}`} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
<button className="library-reveal" onClick={() => setRevealed((value) => !value)} type="button">{revealed ? 'Blur' : 'Reveal'}</button>
<span className={`library-safety is-${asset.safety}`}>{asset.safety.toUpperCase()}</span>
</div>
<div className="library-card-body">
<div className="library-card-title">
<strong>{asset.fileName}</strong>
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
{asset.description ? <p>{asset.description}</p> : <p>AI classification was unavailable. Add tags manually.</p>}
<small>{asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`}</small>
<small>{asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`}</small>
<fieldset>
<legend>Anatomy tags</legend>
<div className="library-tag-grid">
{TAGS.map((tag) => <label key={tag}><input checked={form.tags.includes(tag)} onChange={() => toggleTag(tag)} type="checkbox" /><span>{tag}</span></label>)}
</div>
</fieldset>
<div className="library-controls">
<label>Safety<select onChange={(event) => setForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
<label className="library-enable"><input checked={form.enabled} onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
</div>
{updateMutation.isError ? <div className="media-notice error" role="alert">{updateMutation.error.message}</div> : null}
{deleteMutation.isError ? <div className="media-notice error" role="alert">{deleteMutation.error.message}</div> : null}
<div className="library-card-actions">
<button className="button primary" disabled={form.tags.length === 0 || updateMutation.isPending} onClick={() => updateMutation.mutate({ id: asset.id, update: form })} type="button">{updateMutation.isPending ? 'Saving…' : 'Save changes'}</button>
{confirmDelete ? <><button className="button danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(asset.id)} type="button">Confirm delete</button><button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button></> : <button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>}
</div>
</div>
</article>
);
}
function formatBytes(value: number): string {
return value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(value: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
}
@@ -0,0 +1,56 @@
.exchange-library-page { --library-cyan: #7dd8d0; --library-violet: #b7a0ff; }
.exchange-ladder { display: grid; gap: 1px; margin-bottom: 1rem; overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--border); }
.exchange-ladder > div { position: relative; display: grid; gap: .25rem; min-height: 8rem; padding: 1.15rem; background: linear-gradient(145deg, #111c18, #14211d); }
.exchange-ladder > div::after { content: ''; position: absolute; inset: auto 1.15rem 0; height: .2rem; background: var(--accent); }
.exchange-ladder > div:nth-child(2)::after { background: var(--library-cyan); }
.exchange-ladder > div:nth-child(3)::after { background: var(--library-violet); }
.exchange-ladder span { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.exchange-ladder strong { align-self: end; font-size: 1.2rem; }
.exchange-ladder small { color: var(--muted); }
.library-upload-panel { display: grid; gap: 1rem; margin-bottom: 2rem; }
.library-upload-panel p { margin: .35rem 0 0; color: var(--muted); }
.library-file-field { display: grid; gap: .4rem; padding: 1rem; border: 1px dashed #4d685d; border-radius: .75rem; background: #0c1511; }
.library-file-field span { font-weight: 750; }
.library-file-field small { color: var(--muted); }
.library-file-field input { color: var(--muted); }
.library-consent-field, .library-enable { display: flex; align-items: flex-start; gap: .65rem; color: var(--muted); }
.library-consent-field input, .library-enable input { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin-top: .1rem; accent-color: var(--accent); }
.library-upload-panel > .button { justify-self: start; }
.library-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.library-section-heading small { color: var(--muted); text-align: right; }
.library-grid { display: grid; gap: 1rem; }
.library-card { overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); }
.library-card.is-enabled { border-color: #386d4d; box-shadow: inset 0 0 0 1px rgba(117, 214, 157, .08); }
.library-image-frame { position: relative; min-height: 17rem; overflow: hidden; background: #050907; }
.library-image-frame img { display: block; width: 100%; height: 17rem; object-fit: contain; transition: filter 180ms ease, transform 180ms ease; }
.library-image-frame img.is-blurred { filter: blur(24px) brightness(.5); transform: scale(1.12); }
.library-reveal { position: absolute; right: .75rem; bottom: .75rem; padding: .45rem .65rem; color: white; border: 1px solid rgba(255,255,255,.35); border-radius: .55rem; background: rgba(0,0,0,.78); font-weight: 750; }
.library-safety { position: absolute; top: .75rem; left: .75rem; padding: .3rem .5rem; border-radius: 999px; color: #08110d; background: var(--muted); font-size: .68rem; font-weight: 900; letter-spacing: .08em; }
.library-safety.is-sfw { background: var(--accent); }
.library-safety.is-nsfw { color: #21132d; background: var(--library-violet); }
.library-card-body { display: grid; gap: .85rem; padding: 1rem; }
.library-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.library-card-title strong { overflow-wrap: anywhere; }
.library-card-body p { margin: 0; color: var(--muted); line-height: 1.45; }
.library-card-body > small { color: var(--muted); }
.library-review-note { padding: .6rem .7rem; color: #ffe1a0; border: 1px solid #59451e; border-radius: .55rem; background: #2a2111; font-size: .82rem; }
.library-card fieldset { padding: 0; margin: 0; border: 0; }
.library-card legend { margin-bottom: .55rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
.library-tag-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
.library-tag-grid label { position: relative; }
.library-tag-grid input { position: absolute; opacity: 0; pointer-events: none; }
.library-tag-grid span { display: block; padding: .35rem .6rem; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: #0c1511; text-transform: capitalize; cursor: pointer; }
.library-tag-grid input:checked + span { color: #07130c; border-color: var(--accent); background: var(--accent); font-weight: 800; }
.library-tag-grid input:focus-visible + span { outline: 2px solid white; outline-offset: 2px; }
.library-controls { display: grid; gap: .8rem; }
.library-controls > label:first-child { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
.library-controls select { min-height: 2.5rem; padding: .45rem; color: white; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
.library-card-actions { display: flex; flex-wrap: wrap; gap: .55rem; padding-top: .25rem; }
@media (min-width: 48rem) {
.exchange-ladder { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.library-upload-panel { grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.2fr); align-items: center; }
.library-upload-panel .library-consent-field, .library-upload-panel .media-notice { grid-column: 1 / -1; }
.library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.library-controls { grid-template-columns: 1fr 1fr; align-items: end; }
}
@media (prefers-reduced-motion: reduce) { .library-image-frame img { transition: none; } }
@@ -0,0 +1,33 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
import { deleteMediaLibraryImage, fetchMediaLibrary, updateMediaLibraryImage, uploadMediaLibraryImage } from './api.js';
export const mediaLibraryQueryKey = ['dashboard', 'media-library'] as const;
export function useMediaLibraryQuery() {
return useQuery({ queryKey: mediaLibraryQueryKey, queryFn: fetchMediaLibrary });
}
export function useUploadMediaLibraryMutation() {
const client = useQueryClient();
return useMutation({
mutationFn: uploadMediaLibraryImage,
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
});
}
export function useUpdateMediaLibraryMutation() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, update }: { id: string; update: MediaLibraryUpdate }) => updateMediaLibraryImage(id, update),
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
});
}
export function useDeleteMediaLibraryMutation() {
const client = useQueryClient();
return useMutation({
mutationFn: deleteMediaLibraryImage,
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
});
}