Skip to content

Instantly share code, notes, and snippets.

@bogdan
Created May 25, 2026 07:47
Show Gist options
  • Select an option

  • Save bogdan/9a16577b8fd7689690731dbcf1e65df5 to your computer and use it in GitHub Desktop.

Select an option

Save bogdan/9a16577b8fd7689690731dbcf1e65df5 to your computer and use it in GitHub Desktop.
<script lang="ts">
import * as Routes from '../routes';
import { Image, Info } from '@lucide/svelte';
export interface Face {
id: number;
oval: { cx: number; cy: number; rx: number; ry: number; angle: number };
approved_with_default: boolean;
}
export interface Asset {
id: number;
thumb_url: string;
preview_cid: string | null;
captured_at: string | null;
recently_captured: boolean;
default_action: string;
monetizable: boolean;
rejected: boolean;
image_width: number | null;
image_height: number | null;
available_actions: string[];
faces: Face[];
}
export interface ActionConfig {
value: string;
label: string;
btn_class: string;
}
export interface Decision {
liquidity_action: string;
has_body_parts: boolean;
blur_face_ids: number[];
}
interface Props {
asset: Asset;
viewMode: string;
actionsConfig: ActionConfig[];
decision: Decision;
}
interface CustomFace {
key: string; cx: number; cy: number; rx: number; ry: number,
}
let { asset, viewMode, actionsConfig, decision = $bindable() }: Props = $props();
let customFaces = $state<CustomFace[]>([]);
let customFaceCounter = 0;
let svgEl = $state<SVGSVGElement | null>(null);
let drawState: { start: { x: number; y: number }; preview: SVGEllipseElement } | null = null;
function toSVGPoint(clientX: number, clientY: number) {
const pt = svgEl!.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const m = pt.matrixTransform(svgEl!.getScreenCTM()!.inverse());
return { x: m.x, y: m.y };
}
function onPointerDown(e: PointerEvent) {
const target = e.target as Element;
if (target.closest('[data-face-oval], [data-custom-face]')) return;
const start = toSVGPoint(e.clientX, e.clientY);
const preview = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse') as SVGEllipseElement;
preview.setAttribute('cx', String(start.x));
preview.setAttribute('cy', String(start.y));
preview.setAttribute('rx', '1');
preview.setAttribute('ry', '1');
preview.setAttribute('vector-effect', 'non-scaling-stroke');
preview.style.cssText = 'fill:none;stroke:var(--color-warning);stroke-width:2';
svgEl!.appendChild(preview);
svgEl!.setPointerCapture(e.pointerId);
drawState = { start, preview };
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!drawState) return;
const pt = toSVGPoint(e.clientX, e.clientY);
const cx = (drawState.start.x + pt.x) / 2;
const cy = (drawState.start.y + pt.y) / 2;
drawState.preview.setAttribute('cx', String(cx));
drawState.preview.setAttribute('cy', String(cy));
drawState.preview.setAttribute('rx', String(Math.max(Math.abs(pt.x - drawState.start.x) / 2, 1)));
drawState.preview.setAttribute('ry', String(Math.max(Math.abs(pt.y - drawState.start.y) / 2, 1)));
}
function onPointerUp(e: PointerEvent) {
if (!drawState) return;
const pt = toSVGPoint(e.clientX, e.clientY);
const cx = (drawState.start.x + pt.x) / 2;
const cy = (drawState.start.y + pt.y) / 2;
const rx = Math.abs(pt.x - drawState.start.x) / 2;
const ry = Math.abs(pt.y - drawState.start.y) / 2;
if (rx > 5 && ry > 5) {
customFaceCounter++;
customFaces = [...customFaces, { key: `${asset.id}-${customFaceCounter}`, cx, cy, rx, ry }];
}
drawState.preview.remove();
drawState = null;
}
function onPointerCancel() {
if (!drawState) return;
drawState.preview.remove();
drawState = null;
}
function toggleFace(faceId: number) {
const ids = decision.blur_face_ids;
decision.blur_face_ids = ids.includes(faceId) ? ids.filter(id => id !== faceId) : [...ids, faceId];
}
function cycleAction() {
const available = actionsConfig.filter(a => asset.available_actions.includes(a.value));
const idx = available.findIndex(a => a.value === decision.liquidity_action);
const next = available[(idx + 1) % available.length];
if (next) decision.liquidity_action = next.value;
}
function timeAgo(iso: string): string {
const s = (Date.now() - new Date(iso).getTime()) / 1000;
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} minutes`;
if (s < 86400) return `about ${Math.floor(s / 3600)} hours`;
return `${Math.floor(s / 86400)} days`;
}
</script>
<div class="js-asset-decision flex flex-col items-center gap-1 border border-gray-300 p-2 rounded">
<div class="relative">
{#if viewMode !== 'FACES'}
<button type="button" onclick={cycleAction} class="block">
<img src={asset.thumb_url} class="w-48 h-48 object-contain rounded cursor-pointer" alt="Asset thumbnail" />
</button>
{:else}
<img src={asset.thumb_url} class="w-48 h-48 object-contain rounded" alt="Asset thumbnail" />
{/if}
{#if viewMode === 'APPROVAL' && asset.recently_captured && asset.captured_at}
<div class="absolute top-1 right-1">
<span class="badge badge-warning badge-sm">{timeAgo(asset.captured_at)}</span>
</div>
{/if}
{#if viewMode === 'FACES' && asset.image_width && asset.image_height}
<svg
bind:this={svgEl}
role="application"
aria-label="Face detection overlay"
class="absolute inset-0 w-full h-full cursor-crosshair"
viewBox="0 0 {asset.image_width} {asset.image_height}"
preserveAspectRatio="xMidYMid meet"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
>
{#each asset.faces as face}
{@const o = face.oval}
{@const selected = decision.blur_face_ids.includes(face.id)}
<ellipse
role="button"
tabindex="0"
aria-label="Toggle face blur"
aria-pressed={selected}
data-face-oval={face.id}
cx={o.cx.toFixed(1)}
cy={o.cy.toFixed(1)}
rx={o.rx.toFixed(1)}
ry={o.ry.toFixed(1)}
transform="rotate({o.angle.toFixed(2)},{o.cx.toFixed(1)},{o.cy.toFixed(1)})"
style="fill:{selected ? 'var(--color-success)' : 'none'};fill-opacity:0.5;stroke:{selected ? 'var(--color-success)' : 'gray'};stroke-width:2;transition:stroke 0.15s,fill 0.15s;vector-effect:non-scaling-stroke;pointer-events:all;cursor:pointer"
onclick={() => toggleFace(face.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggleFace(face.id) }}
/>
{/each}
{#each customFaces as cf}
<ellipse
role="button"
tabindex="0"
aria-label="Remove custom face"
data-custom-face={cf.key}
cx={cf.cx.toFixed(1)}
cy={cf.cy.toFixed(1)}
rx={cf.rx.toFixed(1)}
ry={cf.ry.toFixed(1)}
vector-effect="non-scaling-stroke"
style="fill:var(--color-warning);fill-opacity:0.5;stroke:var(--color-warning);stroke-width:2;cursor:pointer"
onclick={() => { customFaces = customFaces.filter(f => f.key !== cf.key) }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') customFaces = customFaces.filter(f => f.key !== cf.key) }}
/>
{/each}
</svg>
{/if}
</div>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center w-full gap-2">
{#if viewMode !== 'FACES'}
<div class="join">
{#if viewMode === 'CATEGORIZATION'}
<input class="join-item btn btn-xs checked:btn-success" type="radio"
name="asset_decisions[{asset.id}][has_body_parts]" value="true" aria-label="Yes"
checked={decision.has_body_parts}
onchange={() => { decision.has_body_parts = true }} />
<input class="join-item btn btn-xs checked:btn-error" type="radio"
name="asset_decisions[{asset.id}][has_body_parts]" value="false" aria-label="No"
checked={!decision.has_body_parts}
onchange={() => { decision.has_body_parts = false }} />
{:else}
{#each actionsConfig as action}
{#if asset.available_actions.includes(action.value)}
<input
class="join-item btn btn-xs {action.btn_class}"
type="radio"
name="asset_decisions[{asset.id}][liquidity_action]"
value={action.value}
aria-label={action.label}
checked={decision.liquidity_action === action.value}
onchange={() => { decision.liquidity_action = action.value }}
/>
{/if}
{/each}
{/if}
</div>
{/if}
<div class="flex-1"></div>
{#if asset.preview_cid}
<a href={Routes.preview_admin_ipfs_path(asset.preview_cid)} target="_blank" title="Preview Image">
<Image class="w-6 aspect-square shrink-0" />
</a>
{/if}
<a href={Routes.admin_asset_path(asset.id)} target="_blank" title="Asset Info">
<Info class="w-6 aspect-square shrink-0" />
</a>
</div>
{#if viewMode === 'FACES'}
<input type="hidden" name="asset_decisions[{asset.id}][blur_face_ids][]" value="" />
{#each asset.faces as face}
<input type="hidden"
name="asset_decisions[{asset.id}][blur_face_ids][]"
value={decision.blur_face_ids.includes(face.id) ? String(face.id) : ''} />
{/each}
{#each customFaces as cf, i}
<input type="hidden" name="asset_decisions[{asset.id}][custom_face_ovals][{i + 1}][cx]" value={cf.cx.toFixed(1)} />
<input type="hidden" name="asset_decisions[{asset.id}][custom_face_ovals][{i + 1}][cy]" value={cf.cy.toFixed(1)} />
<input type="hidden" name="asset_decisions[{asset.id}][custom_face_ovals][{i + 1}][rx]" value={cf.rx.toFixed(1)} />
<input type="hidden" name="asset_decisions[{asset.id}][custom_face_ovals][{i + 1}][ry]" value={cf.ry.toFixed(1)} />
{/each}
{/if}
</div>
</div>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment