Created
May 25, 2026 07:47
-
-
Save bogdan/9a16577b8fd7689690731dbcf1e65df5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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