Add consent-gated media exchange library
This commit is contained in:
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 }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user