openvisengine/src/components/ava/directory-tab.tsx
2026-11-05 22:25:24 +00:00

496 lines
17 KiB
TypeScript
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// OVE — Directory tab (v7.11): the community script browser.
//
// Lists every script published to the OVE-Directory (its own VPS, reached
// through this server's backend proxy) with the name its author chose, its
// type, its date, and its star rating. Visitors rate entries 1–5 (one vote
// per script per visitor, enforced upstream), see the live average to the
// right of the stars, sort the whole list by Name / Type / Date / Rating,
// and "Get" any entry: the hash string is fetched, decoded back into the
// exact script, and filed in this browser's preset tree under
// "root/user created/OVE-Directory" -- from where it opens in the editor.
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
Check,
CloudUpload,
Copy,
Download,
FileCode2,
Loader2,
RefreshCw,
Star,
TriangleAlert,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { decodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { useAvaStore } from '@/lib/store'
import { useToast } from '@/hooks/use-toast'
import {
DEFAULT_ORDER,
DEFAULT_SORT,
DIR_SORTS,
DOWNLOAD_FOLDER,
fetchDirList,
fetchDirScript,
fetchDirStatus,
formatDirDate,
loadMyVotes,
rateDirScript,
rememberMyVote,
saveDownloadedPreset,
type DirMeta,
type DirOrder,
type DirSortKey,
type DirStatus,
} from '@/lib/ove-directory'
import { cn } from '@/lib/utils'
/** Copy text to the clipboard with a legacy fallback (same trick as the share dialogs). */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
ta.remove()
return ok
} catch {
return false
}
}
}
interface GotPanel {
hash: string
script: string
compiles: boolean
savedAs: string
alreadyHad: boolean
}
// ── one directory row ─────────────────────────────────────────────────────
function StarRow({
entry,
myVote,
busy,
onVote,
}: {
entry: DirMeta
myVote: number
busy: boolean
onVote: (stars: number) => void
}) {
const [hover, setHover] = useState(0)
const active = hover || myVote
const locked = myVote > 0
return (
<span
className="flex items-center"
onMouseLeave={() => setHover(0)}
role="group"
aria-label={`Rating for ${entry.name}`}
>
{[1, 2, 3, 4, 5].map((n) => {
const filled = n <= active
return (
<button
key={n}
type="button"
disabled={busy || locked}
aria-label={
locked ? `You rated this ${myVote} of 5` : `Rate ${entry.name}: ${n} of 5 stars`
}
title={locked ? `You rated this ${myVote}\u2605` : `Rate ${n} of 5`}
onMouseEnter={() => setHover(n)}
onFocus={() => setHover(n)}
onClick={() => onVote(n)}
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md transition-colors',
locked ? 'cursor-default' : 'hover:bg-zinc-800/70',
)}
>
<Star
aria-hidden
className={cn(
'size-4 transition-colors',
filled ? 'fill-current text-amber-400' : 'text-zinc-600',
locked && 'opacity-70',
)}
/>
</button>
)
})}
</span>
)
}
function DirRow({
entry,
myVote,
voting,
getting,
got,
onVote,
onGet,
}: {
entry: DirMeta
myVote: number
voting: boolean
getting: boolean
got: GotPanel | null
onVote: (stars: number) => void
onGet: () => void
}) {
const [copied, setCopied] = useState(false)
async function copyHash() {
if (!got) return
const ok = await copyText(got.hash)
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
function openInEditor() {
if (!got) return
useAvaStore.getState().setScriptText(got.script)
useAvaStore.getState().setTab('script')
}
return (
<article className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
<div className="flex items-baseline gap-2">
<h4 className="min-w-0 truncate text-sm font-medium text-zinc-100" title={entry.name}>
{entry.name}
</h4>
<span
className="max-w-32 shrink-0 truncate rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-zinc-400"
title={entry.type}
>
{entry.type}
</span>
<time className="ml-auto shrink-0 font-mono text-[11px] text-zinc-500" dateTime={entry.created_at}>
{formatDirDate(entry.created_at)}
</time>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<StarRow entry={entry} myVote={myVote} busy={voting} onVote={onVote} />
<span className="font-mono text-[11px] text-zinc-400" role="status">
Average: <span className="text-amber-300">{entry.average.toFixed(1)}</span>
<span className="text-zinc-600"> · {entry.votes} {entry.votes === 1 ? 'vote' : 'votes'}</span>
</span>
<span className="ml-auto">
{getting ? (
<Button size="sm" disabled className="h-8 gap-1.5 border-zinc-700 text-xs">
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Getting…
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onGet}
className="h-8 gap-1.5 border-zinc-700 text-xs"
title="Fetch this script: it's saved to this browser's presets and can open in the editor"
>
{got ? <Check className="size-3.5 text-emerald-400" aria-hidden /> : <Download className="size-3.5" aria-hidden />}
{got ? 'Get again' : 'Get'}
</Button>
)}
</span>
</div>
{got && (
<div className="mt-3 space-y-2 border-t border-zinc-800 pt-3">
<p className="text-[11px] leading-relaxed text-zinc-400">
{got.alreadyHad ? (
<>You already had this exact script -- no duplicate was made.</>
) : (
<>
Saved to your presets as <span className="text-emerald-300">{got.savedAs}</span> in{' '}
<span className="font-mono">{DOWNLOAD_FOLDER}</span>.
</>
)}
</p>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[11px] text-zinc-500">Share string (the stored hash):</span>
<Button
size="sm"
variant="outline"
onClick={() => void copyHash()}
className="h-7 gap-1 border-zinc-700 px-2 text-[11px]"
title="Copy the full share string"
>
{copied ? <Check className="size-3" aria-hidden /> : <Copy className="size-3" aria-hidden />}
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
<p className="max-h-20 overflow-y-auto break-all rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-emerald-200/70 select-all">
{got.hash}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p
className={cn(
'flex items-center gap-1.5 font-mono text-[11px]',
got.compiles ? 'text-emerald-400' : 'text-amber-400',
)}
>
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
{got.compiles ? 'Decodes and compiles OK' : "Decodes, but doesn't compile (yet)"}
</p>
<Button
size="sm"
onClick={openInEditor}
className="h-8 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
title="Load this script into the editor"
>
<FileCode2 className="size-3.5" aria-hidden /> Open in editor
</Button>
</div>
</div>
)}
</article>
)
}
// ── the tab ───────────────────────────────────────────────────────────────
export function DirectoryTab() {
const { toast } = useToast()
const [status, setStatus] = useState<DirStatus | null>(null)
const [scripts, setScripts] = useState<DirMeta[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sort, setSort] = useState<DirSortKey>(DEFAULT_SORT)
const [order, setOrder] = useState<DirOrder>(DEFAULT_ORDER)
const [myVotes, setMyVotes] = useState<Record<string, number>>({})
const [voting, setVoting] = useState<number | null>(null)
const [getting, setGetting] = useState<number | null>(null)
const [got, setGot] = useState<Record<number, GotPanel>>({})
useEffect(() => {
setMyVotes(loadMyVotes())
void (async () => {
const s = await fetchDirStatus()
setStatus(s)
})()
}, [])
const reload = useCallback(async () => {
setLoading(true)
setError(null)
try {
const list = await fetchDirList(sort, order)
setScripts(list)
} catch (e) {
setError(e instanceof Error ? e.message : 'The directory could not be listed.')
} finally {
setLoading(false)
}
}, [sort, order])
useEffect(() => {
void reload()
}, [reload])
const vote = useCallback(
async (entry: DirMeta, stars: number) => {
if (voting !== null) return
setVoting(entry.id)
try {
const r = await rateDirScript(entry.id, stars)
rememberMyVote(entry.id, stars)
setMyVotes(loadMyVotes())
setScripts((prev) =>
prev.map((s) => (s.id === entry.id ? { ...s, average: r.average, votes: r.votes } : s)),
)
if (r.duplicate) {
toast({
title: 'Already rated',
description: 'One vote per script per visitor -- the directory kept the first one.',
})
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Rating failed.'
if (msg.includes('no longer in the directory')) {
setScripts((prev) => prev.filter((s) => s.id !== entry.id))
}
toast({ title: 'Rating failed', description: msg, variant: 'destructive' })
} finally {
setVoting(null)
}
},
[voting, toast],
)
const get = useCallback(
async (entry: DirMeta) => {
if (getting !== null) return
setGetting(entry.id)
try {
const full = await fetchDirScript(entry.id)
const decoded = decodeScriptHash(full.hash)
if (!decoded.ok) throw new Error('The stored share string did not decode -- it may be corrupted.')
const v = validateScript(decoded.script)
const save = saveDownloadedPreset(decoded.script, entry.name)
if (!save.ok) throw new Error(save.error ?? 'Could not save to this browser.')
setGot((prev) => ({
...prev,
[entry.id]: {
hash: full.hash,
script: decoded.script,
compiles: v.ok,
savedAs: save.preset?.name ?? entry.name,
alreadyHad: Boolean(save.alreadyHad),
},
}))
toast({
title: save.alreadyHad ? 'Already in your presets' : 'Saved to your presets',
description: `"${save.preset?.name ?? entry.name}" -- open it in the editor from the panel below, or hit Apply after switching to the Script tab.`,
})
} catch (e) {
toast({
title: 'Get failed',
description: e instanceof Error ? e.message : 'Get failed.',
variant: 'destructive',
})
} finally {
setGetting(null)
}
},
[getting, toast],
)
const online = status?.status === 'ok'
return (
<div className="space-y-3">
{/* header: service status + refresh */}
<div className="flex items-center gap-2">
<span
aria-hidden
className={cn(
'size-2 shrink-0 rounded-full',
status === null ? 'bg-zinc-600' : online ? 'bg-emerald-400' : 'bg-amber-400',
)}
/>
<h3 className="text-sm font-semibold text-zinc-100">OVE-Directory</h3>
<span className="truncate font-mono text-[11px] text-zinc-500">
{status === null
? 'connecting…'
: online
? `${status.scripts_total} ${status.scripts_total === 1 ? 'script' : 'scripts'} · v${status.version}`
: 'unreachable'}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
void reload()
void fetchDirStatus().then(setStatus)
}}
className="ml-auto h-8 w-8 p-0 text-zinc-400 hover:text-zinc-100"
title="Refresh the list"
aria-label="Refresh the directory list"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} aria-hidden />
</Button>
</div>
{/* sort controls: Name / Type / Date / Rating + direction */}
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label="Sort the directory">
{DIR_SORTS.map((s) => (
<Button
key={s.key}
size="sm"
variant="outline"
onClick={() => setSort(s.key)}
className={cn(
'h-8 border-zinc-700 px-2.5 text-xs',
sort === s.key && 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
)}
aria-pressed={sort === s.key}
>
{s.label}
</Button>
))}
<Button
size="sm"
variant="outline"
onClick={() => setOrder((o) => (o === 'asc' ? 'desc' : 'asc'))}
className="h-8 border-zinc-700 px-2.5 text-xs"
title="Toggle sort direction"
>
{order === 'asc' ? '↑ asc' : '↓ desc'}
</Button>
<span className="ml-auto hidden font-mono text-[11px] text-zinc-600 sm:inline" aria-hidden>
{sort}/{order}
</span>
</div>
{/* list / states */}
{error ? (
<div className="space-y-3 rounded-lg border border-rose-500/25 bg-rose-500/5 p-4" role="alert">
<p className="flex items-start gap-2 text-sm text-rose-300">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
{error}
</p>
<Button size="sm" variant="outline" onClick={() => void reload()} className="h-8 border-zinc-700 text-xs">
<RefreshCw className="size-3.5" aria-hidden /> Try again
</Button>
</div>
) : loading ? (
<div className="space-y-2" aria-busy>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-20 animate-pulse rounded-lg border border-zinc-800 bg-zinc-900/40" />
))}
</div>
) : scripts.length === 0 ? (
<div className="space-y-2 rounded-lg border border-zinc-800 bg-zinc-900/40 p-4 text-center">
<p className="text-sm text-zinc-300">Nothing published yet.</p>
<p className="text-xs leading-relaxed text-zinc-500">
Build a script in the Script tab, make sure it shows{' '}
<span className="font-mono text-emerald-400">✔ Compiled OK</span>, then hit{' '}
<span className="text-emerald-300">Upload to OVE-Directory</span> -- published scripts appear
here for everyone to browse, rate, and Get.
</p>
</div>
) : (
<div className="space-y-2">
{scripts.map((entry) => (
<DirRow
key={entry.id}
entry={entry}
myVote={myVotes[String(entry.id)] ?? 0}
voting={voting === entry.id}
getting={getting === entry.id}
got={got[entry.id] ?? null}
onVote={(stars) => void vote(entry, stars)}
onGet={() => void get(entry)}
/>
))}
<p className="flex items-center justify-center gap-1.5 pt-1 text-center font-mono text-[11px] text-zinc-600">
<CloudUpload className="size-3" aria-hidden />
{scripts.length} {scripts.length === 1 ? 'entry' : 'entries'} -- publish yours from the Script tab
</p>
</div>
)}
</div>
)
}