// 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. // v7.20: each entry card is compact -- the Get button sits at the card's // top right with the date immediately to its left, so the rating row never // wraps the button onto its own line (the card dropped from ~126 px to // ~94 px at the sidebar width). '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 { 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 ( setHover(0)} role="group" aria-label={`Rating for ${entry.name}`} > {[1, 2, 3, 4, 5].map((n) => { const filled = n <= active return ( ) })} ) } 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 (
{/* v7.20 -- one header line: name + type on the left, the date and the Get button clustered at the top right (date immediately left of the button). The rating row below no longer carries the button, so it can never wrap Get onto a line of its own -- every card stays two lines tall. */}

{entry.name}

{entry.type} {getting ? ( ) : ( )}
Average: {entry.average.toFixed(1)} · {entry.votes} {entry.votes === 1 ? 'vote' : 'votes'}
{got && (

{got.alreadyHad ? ( <>You already had this exact script -- no duplicate was made. ) : ( <> Saved to your presets as {got.savedAs} in{' '} {DOWNLOAD_FOLDER}. )}

Share string (the stored hash):

{got.hash}

{got.compiles ? 'Decodes and compiles OK' : "Decodes, but doesn't compile (yet)"}

)}
) } // ── the tab ─────────────────────────────────────────────────────────────── export function DirectoryTab() { const { toast } = useToast() const [status, setStatus] = useState(null) const [scripts, setScripts] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [sort, setSort] = useState(DEFAULT_SORT) const [order, setOrder] = useState(DEFAULT_ORDER) const [myVotes, setMyVotes] = useState>({}) const [voting, setVoting] = useState(null) const [getting, setGetting] = useState(null) const [got, setGot] = useState>({}) 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 (
{/* header: service status + refresh */}

OVE-Directory

{status === null ? 'connecting…' : online ? `${status.scripts_total} ${status.scripts_total === 1 ? 'script' : 'scripts'} · v${status.version}` : 'unreachable'}
{/* sort controls: Name / Type / Date / Rating + direction */}
{DIR_SORTS.map((s) => ( ))} {sort}/{order}
{/* list / states */} {error ? (

{error}

) : loading ? (
{[0, 1, 2, 3].map((i) => (
))}
) : scripts.length === 0 ? (

Nothing published yet.

Build a script in the Script tab, make sure it shows{' '} ✔ Compiled OK, then hit{' '} Upload to OVE-Directory -- published scripts appear here for everyone to browse, rate, and Get.

) : (
{scripts.map((entry) => ( void vote(entry, stars)} onGet={() => void get(entry)} /> ))}

{scripts.length} {scripts.length === 1 ? 'entry' : 'entries'} -- publish yours from the Script tab

)}
) }