505 lines
17 KiB
TypeScript
Executable file
505 lines
17 KiB
TypeScript
Executable file
// 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<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">
|
||
{/* 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. */}
|
||
<div className="flex items-center 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>
|
||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||
<time className="font-mono text-[11px] text-zinc-500" dateTime={entry.created_at}>
|
||
{formatDirDate(entry.created_at)}
|
||
</time>
|
||
{getting ? (
|
||
<Button size="sm" disabled className="h-7 gap-1 border-zinc-700 px-2.5 text-[11px]">
|
||
<Loader2 className="size-3.5 animate-spin" aria-hidden /> Getting…
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={onGet}
|
||
className="h-7 gap-1 border-zinc-700 px-2.5 text-[11px]"
|
||
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>
|
||
|
||
<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>
|
||
</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-24 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>
|
||
)
|
||
}
|