b8fd4912-690d-432c-9235-71b5108014df

This commit is contained in:
Z User 2026-11-05 06:53:07 +00:00
parent f72eae5ecb
commit 40c6d2887e
13 changed files with 1434 additions and 8 deletions

2
.env
View file

@ -1 +1,3 @@
DATABASE_URL=file:/home/z/my-project/db/custom.db DATABASE_URL=file:/home/z/my-project/db/custom.db
OVE_DIRECTORY_URL=https://s3374dir.openvisengine.com
OVE_DIRECTORY_API_KEY=+7MkRB+hkV0UBhPqM1haTQ0f8JGKqkuXwoBTdTSAouU=

View file

@ -0,0 +1,71 @@
// OVE — /api/ove-directory/scripts/[id]/ratings: cast a 1–5 rating.
//
// The visitor's anonymous voter id is minted HERE (cookie "ove_voter",
// HttpOnly, one year) and forwarded as the opaque `voter` string the
// directory dedupes on — one vote per script per visitor, enforced upstream.
// 200 and 409 DUPLICATE_VOTE both pass through with the live aggregates.
import { NextRequest, NextResponse } from 'next/server'
import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
const VOTER_COOKIE = 'ove_voter'
const VOTER_MAX_AGE = 365 * 24 * 60 * 60
function mintVoter(): string {
return crypto.randomUUID()
}
/** Read the existing voter id, or mint one (the response then sets it). */
function voterFor(req: NextRequest): { voter: string; minted: boolean } {
const existing = req.cookies.get(VOTER_COOKIE)?.value
if (existing && existing.trim().length >= 1 && existing.trim().length <= 64) {
return { voter: existing.trim(), minted: false }
}
return { voter: mintVoter(), minted: true }
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const { id } = await params
if (!/^\d+$/.test(id) || Number.parseInt(id, 10) < 1) {
return jsonError(400, 'VALIDATION', 'id must be a positive integer.', 'id')
}
const ct = req.headers.get('content-type') ?? ''
if (!ct.toLowerCase().startsWith('application/json')) {
return jsonError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json.')
}
let body: unknown
try {
body = await req.json()
} catch {
return jsonError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
}
const rating = (body as { rating?: unknown } ?? {}).rating
if (typeof rating !== 'number' || !Number.isInteger(rating) || rating < 1 || rating > 5) {
return jsonError(400, 'VALIDATION', 'rating must be an integer from 1 to 5.', 'rating')
}
const { voter, minted } = voterFor(req)
const r = await dirFetch(`/v1/scripts/${id}/ratings`, {
method: 'POST',
body: { rating, voter },
})
const res = NextResponse.json(r.data, { status: r.status })
if (minted && (r.status === 200 || r.status === 409)) {
res.cookies.set(VOTER_COOKIE, voter, {
httpOnly: true,
sameSite: 'lax',
maxAge: VOTER_MAX_AGE,
path: '/',
})
}
return res
}

View file

@ -0,0 +1,23 @@
// OVE — /api/ove-directory/scripts/[id]: fetch one full record (including
// the hash string) by numeric id — the "Get" flow's first hop.
import { NextRequest, NextResponse } from 'next/server'
import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const { id } = await params
if (!/^\d+$/.test(id) || Number.parseInt(id, 10) < 1) {
return jsonError(400, 'VALIDATION', 'id must be a positive integer.', 'id')
}
const r = await dirFetch(`/v1/scripts/${id}`)
return NextResponse.json(r.data, { status: r.status })
}

View file

@ -0,0 +1,98 @@
// OVE — /api/ove-directory/scripts: the directory's list + submit endpoints,
// proxied server-to-server (the API key never leaves this process).
//
// GET ?sort=name|type|date|rating&order=asc|desc → meta records (no hash)
// POST { name, type, hash } → 201 full record, or
// 409 DUPLICATE_HASH with the existing row (treated as success by the
// UI). Before forwarding, the proxy re-checks what it is about to
// publish: the share string must decode, and the decoded script must
// compile — only working scripts join the community directory.
import { NextRequest, NextResponse } from 'next/server'
import { decodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { dirConfig, dirFetch, jsonError, oveRateCheck, visitorIp } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
const SORTS = new Set(['name', 'type', 'date', 'rating'])
const ORDERS = new Set(['asc', 'desc'])
/** Unicode code points after trimming (how the upstream counts name/type). */
function cpLen(s: string): number {
return [...s].length
}
export async function GET(req: NextRequest) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const url = new URL(req.url)
const sort = url.searchParams.get('sort') ?? 'date'
const order = url.searchParams.get('order') ?? 'desc'
if (!SORTS.has(sort)) {
return jsonError(400, 'VALIDATION', 'sort must be one of: name, type, date, rating.', 'sort')
}
if (!ORDERS.has(order)) {
return jsonError(400, 'VALIDATION', 'order must be asc or desc.', 'order')
}
const r = await dirFetch(`/v1/scripts?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}`)
return NextResponse.json(r.data, { status: r.status })
}
export async function POST(req: NextRequest) {
const wait = oveRateCheck(visitorIp(req))
if (wait !== null) {
return jsonError(429, 'RATE_LIMITED', 'Too many directory requests. Try again shortly.')
}
if (!dirConfig()) {
return jsonError(500, 'NOT_CONFIGURED', 'OVE-Directory is not configured on this server.')
}
const ct = req.headers.get('content-type') ?? ''
if (!ct.toLowerCase().startsWith('application/json')) {
return jsonError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Content-Type must be application/json.')
}
let body: unknown
try {
body = await req.json()
} catch {
return jsonError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
}
const b = (body ?? {}) as Record<string, unknown>
if (typeof b.name !== 'string' || typeof b.type !== 'string' || typeof b.hash !== 'string') {
return jsonError(400, 'VALIDATION', 'name, type and hash are required strings.')
}
const name = b.name.trim()
if (cpLen(name) < 1 || cpLen(name) > 100) {
return jsonError(400, 'VALIDATION', 'name must be 1–100 characters (after trimming).', 'name')
}
const type = b.type.trim()
if (cpLen(type) < 1 || cpLen(type) > 50) {
return jsonError(400, 'VALIDATION', 'type must be 1–50 characters (after trimming).', 'type')
}
const hash = b.hash
const hashBytes = Buffer.byteLength(hash, 'utf8')
if (hashBytes < 1 || hashBytes > 100_000) {
return jsonError(400, 'VALIDATION', 'hash must be 1–100,000 bytes.', 'hash')
}
// Integrity gate: the share string must decode back into a script…
const decoded = decodeScriptHash(hash)
if (!decoded.ok) {
return jsonError(400, 'INVALID_HASH', `The share string did not decode: ${decoded.error}`)
}
// …and that script must compile before it joins the directory.
const v = validateScript(decoded.script)
if (!v.ok) {
const where = v.error ? ` [${v.error.section}] line ${v.error.line}: ${v.error.message}` : ''
return jsonError(400, 'VALIDATION', `Only scripts that compile can be published -- fix it first.${where}`, 'script')
}
const r = await dirFetch('/v1/scripts', { method: 'POST', body: { name, type, hash } })
return NextResponse.json(r.data, { status: r.status })
}

View file

@ -0,0 +1,19 @@
// OVE — /api/ove-directory/status: forward the directory's public health
// probe (no API key needed upstream; harmless to send one). Used by the
// Directory tab's status line.
import { NextResponse } from 'next/server'
import { dirConfig, dirFetch } from '@/lib/server/ove-directory'
export const dynamic = 'force-dynamic'
export async function GET() {
if (!dirConfig()) {
return NextResponse.json(
{ error: { code: 'NOT_CONFIGURED', message: 'OVE-Directory is not configured on this server.' } },
{ status: 500 },
)
}
const r = await dirFetch('/status')
return NextResponse.json(r.data, { status: r.status })
}

View file

@ -1,4 +1,4 @@
// OVE — right-hand control panel: the five tabs. // OVE — right-hand control panel: the six tabs.
// //
// Desktop: fixed width, content scrolls inside the panel. Mobile: natural // Desktop: fixed width, content scrolls inside the panel. Mobile: natural
// height (the page scrolls and the footer is pushed down naturally). // height (the page scrolls and the footer is pushed down naturally).
@ -9,6 +9,7 @@ import { motion } from 'framer-motion'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PresetsTab } from '@/components/ava/presets-tab' import { PresetsTab } from '@/components/ava/presets-tab'
import { ScriptTab } from '@/components/ava/script-tab' import { ScriptTab } from '@/components/ava/script-tab'
import { DirectoryTab } from '@/components/ava/directory-tab'
import { DetectTab } from '@/components/ava/detect-tab' import { DetectTab } from '@/components/ava/detect-tab'
import { BeatTab } from '@/components/ava/beat-tab' import { BeatTab } from '@/components/ava/beat-tab'
import { NativeTab } from '@/components/ava/native-tab' import { NativeTab } from '@/components/ava/native-tab'
@ -19,8 +20,12 @@ import { cn } from '@/lib/utils'
// v6.5 — the Native tab is PARKED (hidden) so the Settings tab can take its // v6.5 — the Native tab is PARKED (hidden) so the Settings tab can take its
// slot; it returns unchanged when the project nears completion. Flip this // slot; it returns unchanged when the project nears completion. Flip this
// flag to bring it back — the tab row widens to 6 columns automatically and // flag to bring it back — the tab row widens to 7 columns automatically and
// the component + /api/rust-scaffold endpoint are untouched. // the component + /api/rust-scaffold endpoint are untouched.
//
// v7.11 — the Directory tab (community script browser) joined between Script
// and Detect; the row is 3 columns on phones (two stacked rows) and one row
// from sm up.
const NATIVE_TAB_ENABLED = false const NATIVE_TAB_ENABLED = false
function TabBody({ children }: { children: React.ReactNode }) { function TabBody({ children }: { children: React.ReactNode }) {
@ -45,7 +50,7 @@ export function ControlPanel() {
<TabsList <TabsList
className={cn( className={cn(
'h-11 w-full shrink-0 bg-zinc-900/80', 'h-11 w-full shrink-0 bg-zinc-900/80',
NATIVE_TAB_ENABLED ? 'grid-cols-6' : 'grid-cols-5', NATIVE_TAB_ENABLED ? 'grid-cols-3 sm:grid-cols-7' : 'grid-cols-3 sm:grid-cols-6',
)} )}
> >
<TabsTrigger value="presets" className="text-xs"> <TabsTrigger value="presets" className="text-xs">
@ -54,6 +59,9 @@ export function ControlPanel() {
<TabsTrigger value="script" className="text-xs"> <TabsTrigger value="script" className="text-xs">
Script Script
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="directory" className="text-xs">
Directory
</TabsTrigger>
<TabsTrigger value="detect" className="text-xs"> <TabsTrigger value="detect" className="text-xs">
Detect Detect
</TabsTrigger> </TabsTrigger>
@ -81,6 +89,11 @@ export function ControlPanel() {
<ScriptTab /> <ScriptTab />
</TabBody> </TabBody>
</TabsContent> </TabsContent>
<TabsContent value="directory" className="flex min-h-0 flex-1 flex-col outline-none">
<TabBody>
<DirectoryTab />
</TabBody>
</TabsContent>
<TabsContent value="detect" className="flex min-h-0 flex-1 flex-col outline-none"> <TabsContent value="detect" className="flex min-h-0 flex-1 flex-col outline-none">
<TabBody> <TabBody>
<DetectTab /> <DetectTab />

View file

@ -0,0 +1,496 @@
// 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>
)
}

View file

@ -3,10 +3,11 @@
// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save // Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save
// target is set), Save As…, Save as Default, Save to File, Load from File, // target is set), Save As…, Save as Default, Save to File, Load from File,
// Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode // Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
// string and paste one back), New, Help. Editor: line-number gutter + // string and paste one back), Upload to OVE-Directory (v7.11 — publish the
// monospace textarea (synced scroll, Tab inserts two spaces), error lines // compiled script to the community directory), New, Help. Editor: line-number
// flagged in the gutter. Status line under the editor shows compile status // gutter + monospace textarea (synced scroll, Tab inserts two spaces), error
// or runtime errors. // lines flagged in the gutter. Status line under the editor shows compile
// status or runtime errors.
'use client' 'use client'
@ -15,6 +16,7 @@ import {
Check, Check,
CircleHelp, CircleHelp,
ClipboardPaste, ClipboardPaste,
CloudUpload,
Copy, Copy,
Download, Download,
FilePlus2, FilePlus2,
@ -44,6 +46,7 @@ import {
import { applyScriptText } from '@/components/ava/audio-source' import { applyScriptText } from '@/components/ava/audio-source'
import { HelpPanel } from '@/components/ava/help-panel' import { HelpPanel } from '@/components/ava/help-panel'
import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs' import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs'
import { UploadToDirectoryDialog } from '@/components/ava/upload-dialog'
import { sanitizeFileName } from '@/components/ava/shared' import { sanitizeFileName } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -80,6 +83,12 @@ export function ScriptTab() {
// v7.7 — share-string dialogs // v7.7 — share-string dialogs
const [shareOutOpen, setShareOutOpen] = useState(false) const [shareOutOpen, setShareOutOpen] = useState(false)
const [shareInOpen, setShareInOpen] = useState(false) const [shareInOpen, setShareInOpen] = useState(false)
// v7.11 — publish to the community OVE-Directory. Gated on the same line
// the status line shows: the editor script must pass ✔ Compiled OK and the
// engine must not be sitting on a runtime error (a stale crash from an
// earlier Apply keeps this off until Apply runs clean).
const [uploadOpen, setUploadOpen] = useState(false)
const canUpload = validation?.ok === true && !runtimeError
// debounced live validation (compile is cheap, scripts are small) // debounced live validation (compile is cheap, scripts are small)
useEffect(() => { useEffect(() => {
@ -368,6 +377,21 @@ export function ScriptTab() {
> >
<ClipboardPaste className="size-3.5 text-emerald-400/90" /> Hash⇢Script <ClipboardPaste className="size-3.5 text-emerald-400/90" /> Hash⇢Script
</Button> </Button>
<Button
size="sm"
variant="outline"
disabled={!canUpload}
onClick={() => setUploadOpen(true)}
className="h-9 gap-1.5 border-zinc-700"
title={
canUpload
? 'Publish this compiled script to the community OVE-Directory (it travels as its share string)'
: 'Publishing needs the script to pass ✔ Compiled OK with no runtime error -- fix it, then Apply'
}
>
<CloudUpload className="size-3.5 text-emerald-400/90" aria-hidden />{' '}
<span className="hidden sm:inline">Upload to </span>OVE-Directory
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -494,6 +518,9 @@ export function ScriptTab() {
onImport={(text) => setScriptText(text)} onImport={(text) => setScriptText(text)}
/> />
{/* v7.11 -- publish to the community directory */}
<UploadToDirectoryDialog open={uploadOpen} onOpenChange={setUploadOpen} scriptText={scriptText} />
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -0,0 +1,216 @@
// AVA — "Upload to OVE-Directory" dialog (v7.11).
//
// Publishes the current editor script to the community directory (its own
// VPS, reached through this server's backend proxy). The script travels as
// its compact share string -- the same Script⇢Hash encoding -- so the
// directory stores a few hundred characters instead of the full source;
// anyone "Getting" the entry later decodes the exact script back.
//
// Only scripts that pass ✔ Compiled OK can be published (the toolbar button
// is disabled otherwise, and the backend re-checks the hash decodes AND
// compiles before forwarding). Entries are permanent: the directory has no
// update and no delete, so the name deserves a second look.
'use client'
import { useMemo, useState } from 'react'
import { CloudUpload, TriangleAlert, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { encodeScriptHash } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { uploadDirScript } from '@/lib/ove-directory'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const HASH_PREVIEW = 160
export function UploadToDirectoryDialog({
open,
onOpenChange,
scriptText,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** The current editor script (already gate-checked: it compiles). */
scriptText: string
}) {
const { toast } = useToast()
const [name, setName] = useState('')
const [type, setType] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Derived once from the script: its name header and mode.
const meta = useMemo(() => validateScript(scriptText).meta, [scriptText])
const encoded = useMemo(() => encodeScriptHash(scriptText), [scriptText])
// Reset whenever the dialog is (re)opened — same render-time pattern as
// HashToScriptDialog (the toolbar button toggles `open` directly).
const [wasOpen, setWasOpen] = useState(false)
if (open && !wasOpen) {
setWasOpen(true)
const defaultName =
meta?.name && meta.name !== 'Untitled' ? meta.name : encoded.name && encoded.name !== 'OVE' ? encoded.name : 'Untitled'
setName(defaultName)
setType(meta?.mode ? meta.mode.charAt(0).toUpperCase() + meta.mode.slice(1) : 'Lines')
setBusy(false)
setError(null)
} else if (!open && wasOpen) {
setWasOpen(false)
}
const nameLen = [...name.trim()].length
const typeLen = [...type.trim()].length
const hashPreview =
encoded.hash.length > HASH_PREVIEW ? `${encoded.hash.slice(0, HASH_PREVIEW)}…` : encoded.hash
async function publish() {
if (busy) return
const trimmedName = name.trim()
const trimmedType = type.trim()
if (!trimmedName || nameLen > 100) {
setError('The name must be 1–100 characters.')
return
}
if (!trimmedType || typeLen > 50) {
setError('The type must be 1–50 characters (e.g. Lines, Dots, Solid).')
return
}
setBusy(true)
setError(null)
try {
const r = await uploadDirScript({ name: trimmedName, type: trimmedType, hash: encoded.hash })
if (r.duplicate) {
toast({
title: 'Already in the directory',
description: `A script with this exact share string was already published${
r.id ? ` (entry #${r.id})` : ''
} -- nothing was changed.`,
})
} else {
toast({
title: 'Published to OVE-Directory',
description: `"${trimmedName}" (${trimmedType}) joined the community directory -- it's live in the Directory tab.`,
})
}
onOpenChange(false)
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.')
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CloudUpload className="size-4 text-emerald-400" aria-hidden />
Upload to OVE-Directory
</DialogTitle>
<DialogDescription>
Publish this script to the community directory. It travels as its compact share string
(Script⇢Hash encoding), so the directory stores a fraction of the source -- anyone
&ldquo;Getting&rdquo; it later decodes your exact script back.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_140px]">
<div className="space-y-1.5">
<label htmlFor="ove-up-name" className="text-xs font-medium text-zinc-300">
Script name
</label>
<Input
id="ove-up-name"
value={name}
onChange={(e) => {
setName(e.target.value)
setError(null)
}}
autoFocus
maxLength={120}
placeholder="Spectrum Bars"
aria-label="Name shown in the directory"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === 'Enter') void publish()
}}
/>
<p className="font-mono text-[11px] text-zinc-500">1–100 characters -- this is what the directory lists.</p>
</div>
<div className="space-y-1.5">
<label htmlFor="ove-up-type" className="text-xs font-medium text-zinc-300">
Type
</label>
<Input
id="ove-up-type"
value={type}
onChange={(e) => {
setType(e.target.value)
setError(null)
}}
maxLength={60}
placeholder="Lines"
aria-label="Type / category"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') void publish()
}}
/>
<p className="font-mono text-[11px] text-zinc-500">1–50 characters -- e.g. Lines, Dots, Solid.</p>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-zinc-300">Share string</p>
<p className="break-all rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-2 font-mono text-[11px] leading-relaxed text-emerald-200/80 select-all">
{hashPreview}
</p>
<p className="font-mono text-[11px] text-zinc-500" role="status">
{encoded.hashLength} characters (the script itself is {encoded.scriptLength}).
</p>
</div>
<p className="flex items-start gap-1.5 rounded-md border border-amber-400/25 bg-amber-400/5 px-2.5 py-2 text-[11px] leading-relaxed text-amber-300/90">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
Directory entries are permanent -- there is no edit and no delete. Double-check the name
before publishing.
</p>
{error && (
<p className="flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed text-rose-400" role="alert">
<X className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{error}
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={() => void publish()}
disabled={busy}
className={cn('h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300')}
>
<CloudUpload className="size-3.5" /> {busy ? 'Publishing…' : 'Publish'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

274
src/lib/ove-directory.ts Normal file
View file

@ -0,0 +1,274 @@
// OVE — OVE-Directory client library (v7.11).
//
// The community script directory lives on its own server
// (https://s3374dir.openvisengine.com) behind an API key. Browsers NEVER
// talk to it directly: every call goes through this app's backend proxy at
// /api/ove-directory/* (src/app/api/ove-directory/), which holds the key in
// server env and forwards the request. This module is the browser-side
// twin: typed wrappers for each proxied endpoint, the per-visitor vote
// bookkeeping (localStorage), and the "Get" flow that decodes a fetched
// hash string back into a script and files it in this browser's preset
// tree under "root/user created/OVE-Directory".
//
// Wire shapes mirror the upstream directory 1:1 (the proxy forwards bodies
// verbatim), so the reference that matters is the OVE-Directory API card:
// meta record { id, name, type, created_at, average, votes } (lists)
// full record meta + { hash } (fetch)
// errors { error: { code, message, field? }, hint?, … }
import type { PresetItem } from '@/lib/store'
import { applyUserPresets } from '@/lib/store'
import { createUserPreset, listUserPresets } from '@/lib/ava/user-presets'
import { validateScript } from '@/lib/ava/script'
// ── types & constants ─────────────────────────────────────────────────────
export type DirSortKey = 'name' | 'type' | 'date' | 'rating'
export type DirOrder = 'asc' | 'desc'
/** Meta record — one row of a directory listing (no hash). */
export interface DirMeta {
id: number
name: string
type: string
created_at: string
average: number
votes: number
}
/** Full record — a fetched script (meta + the hash string). */
export interface DirScript extends DirMeta {
hash: string
}
export interface DirStatus {
status: string
service: string
version: string
uptime_seconds: number
scripts_total: number
timestamp: string
}
export const DIR_SORTS: ReadonlyArray<{ key: DirSortKey; label: string }> = [
{ key: 'rating', label: 'Rating' },
{ key: 'name', label: 'Name' },
{ key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' },
]
/** Default listing: top rated first; unrated sink to the bottom (upstream rule). */
export const DEFAULT_SORT: DirSortKey = 'rating'
export const DEFAULT_ORDER: DirOrder = 'desc'
/** Where "Get" files downloaded scripts in this browser's preset tree. */
export const DOWNLOAD_FOLDER = 'root/user created/OVE-Directory'
// ── low-level proxy call ──────────────────────────────────────────────────
interface ProxyErrorBody {
error?: { code?: string; message?: string; field?: string } | string
hint?: string
}
/** Pull a human-readable message out of a proxy/upstream error body. */
function errorText(body: unknown, fallback: string): string {
if (!body || typeof body !== 'object') return fallback
const e = (body as ProxyErrorBody).error
if (typeof e === 'string') return e
if (e && typeof e === 'object') {
if (typeof e.message === 'string' && e.message) return e.message
}
return fallback
}
async function proxyJson<T>(path: string, init?: RequestInit): Promise<{ status: number; body: T | null; message: string | null }> {
let res: Response
try {
res = await fetch(path, { ...init, headers: { Accept: 'application/json', ...(init?.headers ?? {}) } })
} catch {
return { status: 0, body: null, message: 'Could not reach this server (network error).' }
}
let body: T | null = null
try {
body = (await res.json()) as T
} catch {
/* non-JSON body — keep null */
}
if (!res.ok) {
const message = body ? errorText(body, `Request failed (${res.status}).`) : `Request failed (${res.status}).`
return { status: res.status, body, message }
}
return { status: res.status, body, message: null }
}
// ── endpoint wrappers ─────────────────────────────────────────────────────
/** Health probe. Null when the proxy itself is unreachable. */
export async function fetchDirStatus(): Promise<DirStatus | null> {
const r = await proxyJson<DirStatus>('/api/ove-directory/status')
return r.body ?? null
}
/** List scripts, sorted upstream. Throws Error(message) on failure. */
export async function fetchDirList(sort: DirSortKey, order: DirOrder): Promise<DirMeta[]> {
const qs = `?sort=${encodeURIComponent(sort)}&order=${encodeURIComponent(order)}`
const r = await proxyJson<{ count: number; scripts: DirMeta[] }>(`/api/ove-directory/scripts${qs}`)
if (!r.body || !Array.isArray(r.body.scripts)) throw new Error(r.message ?? 'The directory response was malformed.')
return r.body.scripts
}
/** Fetch one full record (with its hash string). Throws Error(message). */
export async function fetchDirScript(id: number): Promise<DirScript> {
const r = await proxyJson<DirScript>(`/api/ove-directory/scripts/${id}`)
if (!r.body || typeof r.body.hash !== 'string') throw new Error(r.message ?? 'The directory response was malformed.')
return r.body
}
export interface UploadResult {
id: number
duplicate: boolean
record: DirMeta
}
/**
* Publish {name, type, hash} to the directory. Both the 201 (new) and the
* 409 DUPLICATE_HASH (already there — nothing changed) paths resolve as
* success; everything else throws Error(message).
*/
export async function uploadDirScript(input: { name: string; type: string; hash: string }): Promise<UploadResult> {
const r = await proxyJson<DirMeta & { existing?: DirMeta }>('/api/ove-directory/scripts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: input.name, type: input.type, hash: input.hash }),
})
if (r.status === 201 && r.body) {
return { id: r.body.id, duplicate: false, record: r.body as DirMeta }
}
if (r.status === 409 && r.body) {
// duplicate-hash bodies carry the row as `existing`
const body = r.body as DirMeta & { existing?: DirMeta }
const existing = body.existing ?? body
return { id: existing.id, duplicate: true, record: existing }
}
throw new Error(r.message ?? 'Upload failed.')
}
export interface RateResult {
average: number
votes: number
duplicate: boolean
}
/**
* Cast a 1–5 rating for script `id` as this visitor (the backend mints the
* anonymous voter id). 200 and 409 DUPLICATE_VOTE both resolve as success —
* on duplicates the directory returns the unchanged aggregates.
*/
export async function rateDirScript(id: number, rating: number): Promise<RateResult> {
const r = await proxyJson<{ average: number; votes: number }>(`/api/ove-directory/scripts/${id}/ratings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating }),
})
if ((r.status === 200 || r.status === 409) && r.body && typeof r.body.average === 'number') {
return { average: r.body.average, votes: r.body.votes ?? 0, duplicate: r.status === 409 }
}
if (r.status === 404) throw new Error('That script is no longer in the directory.')
throw new Error(r.message ?? 'Rating failed.')
}
// ── per-visitor vote bookkeeping (this browser) ───────────────────────────
const VOTES_KEY = 'ove.directory.votes.v1'
/** { scriptId: stars } as remembered from this browser. */
export function loadMyVotes(): Record<string, number> {
try {
const raw = localStorage.getItem(VOTES_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return {}
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(parsed)) {
const n = typeof v === 'number' ? v : Number(v)
if (Number.isInteger(n) && n >= 1 && n <= 5 && /^\d{1,10}$/.test(k)) out[k] = n
}
return out
} catch {
return {}
}
}
/** Remember that this browser rated `id` with `stars` (1–5). */
export function rememberMyVote(id: number, stars: number): void {
try {
const votes = loadMyVotes()
votes[String(id)] = stars
localStorage.setItem(VOTES_KEY, JSON.stringify(votes))
} catch {
/* private mode — voting still worked, we just can't remember it */
}
}
// ── "Get" flow: decode a fetched hash and file it in this browser ─────────
export interface SaveDownloadResult {
ok: boolean
error?: string
/** The preset as stored (newly created or the already-present twin). */
preset?: PresetItem
/** True when a preset with identical content was already there (no copy made). */
alreadyHad?: boolean
}
/**
* Pick a name for a downloaded script inside DOWNLOAD_FOLDER: the exact
* name first, then "Name (1)", "Name (2)", … mirroring the directory's own
* duplicate-numbering style.
*/
function uniqueDownloadName(base: string, taken: Set<string>): string {
if (!taken.has(base)) return base
for (let i = 1; i < 100; i++) {
const candidate = `${base} (${i})`
if (!taken.has(candidate)) return candidate
}
return `${base} (${Date.now() % 1000})`
}
/**
* Save a script fetched from the directory into this browser's preset tree
* (localStorage), deduped by exact content. Does NOT touch the engine —
* the caller decides whether to also drop it into the editor.
*/
export function saveDownloadedPreset(script: string, fallbackName: string): SaveDownloadResult {
const v = validateScript(script)
const meta = v.ok ? v.meta : null
const base = (meta?.name && meta.name !== 'Untitled' ? meta.name : fallbackName).trim() || 'Untitled'
const existing = listUserPresets().filter((p) => p.folderPath === DOWNLOAD_FOLDER)
const twin = existing.find((p) => p.content === script)
if (twin) return { ok: true, preset: twin, alreadyHad: true }
const taken = new Set(existing.map((p) => p.name))
const name = uniqueDownloadName(base, taken)
const res = createUserPreset({
name,
content: script,
folderPath: DOWNLOAD_FOLDER,
author: meta?.author || 'OVE-Directory',
description: `Downloaded from the community directory as "${fallbackName}".`,
})
if (!res.ok) return { ok: false, error: res.error }
applyUserPresets()
return { ok: true, preset: res.preset, alreadyHad: false }
}
// ── display helpers ───────────────────────────────────────────────────────
/** "2026-11-05T18:40:12Z" → "Nov 5, 2026" (falls back to the raw date). */
export function formatDirDate(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(0, 10)
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}

View file

@ -39,6 +39,28 @@ export interface ReleaseNote {
} }
export const RELEASE_NOTES: ReleaseNote[] = [ export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.11',
name: 'The community OVE-Directory',
changes: [
{
kind: 'added',
text: 'A community script directory on its own server -- a sixth tab, Directory, lists every published script with its name, type and date, a 1–5 star rater with the live "Average: #.#" beside the stars, and sorting by Name, Type, Date or Rating. I reached it through this site\u2019s own backend, so the two servers stay fully independent: either can go down and the other keeps running.',
},
{
kind: 'added',
text: '"Upload to OVE-Directory" in the Script tab (enabled while the script shows \u2714 Compiled OK with no runtime error). The script travels as its compact Script\u21e2Hash string -- the directory stores a fraction of the source -- and every upload is re-checked server-side: the string must decode back into a script that still compiles.',
},
{
kind: 'added',
text: '"Get" on any directory entry fetches the exact script back, files it in this browser\u2019s preset tree under "user created/OVE-Directory" (identical copies are never duplicated; name collisions number themselves "(1) (2) (3)\u2026"), and offers a one-click "Open in editor".',
},
{
kind: 'added',
text: 'Ratings are one vote per script per visitor, enforced by the directory itself; a second attempt just shows the stored average. The anonymous voter id is a cookie minted by this site\u2019s backend -- no accounts, no identity stored.',
},
],
},
{ {
version: '7.10', version: '7.10',
name: 'Ten demo tracks', name: 'Ten demo tracks',

View file

@ -0,0 +1,145 @@
// OVE — OVE-Directory backend proxy helpers (v7.11).
//
// The community script directory runs on its own VPS
// (https://s3374dir.openvisengine.com) behind an API key. Browsers never see
// that key: every /api/ove-directory/* route on THIS server forwards the
// request server-to-server, so the directory's address and credentials stay
// in env config here. The two VPSs stay fully independent -- either can go
// down without touching the other.
//
// What lives here:
// dirConfig() — env: OVE_DIRECTORY_URL + OVE_DIRECTORY_API_KEY
// dirFetch() — authenticated upstream call (X-API-Key, 10 s timeout),
// returns { status, data } with upstream bodies verbatim;
// network failures become 502 BAD_GATEWAY envelopes
// oveRateCheck()— a small per-visitor-IP throttle for the proxy itself
// (the upstream's 240/min bucket is shared by every visitor
// behind this server's IP, so local policing matters)
// jsonError() — the shared error envelope for local rejections
import { NextRequest, NextResponse } from 'next/server'
const UPSTREAM_TIMEOUT_MS = 10_000
// ── configuration ─────────────────────────────────────────────────────────
export interface DirConfig {
url: string
key: string
}
/** Env config; null when the proxy is not set up (500 to callers). */
export function dirConfig(): DirConfig | null {
const url = (process.env.OVE_DIRECTORY_URL ?? '').trim().replace(/\/+$/, '')
const key = (process.env.OVE_DIRECTORY_API_KEY ?? '').trim()
if (!url || !key) return null
return { url, key }
}
// ── upstream calls ────────────────────────────────────────────────────────
export interface DirFetchResult {
status: number
/** Parsed upstream JSON body (envelope, record, list — verbatim). */
data: unknown
}
/**
* Call the directory. `path` is the upstream path (with query), `body` is
* JSON-encoded when present. Adds the X-API-Key header on every call; the
* upstream's one public route (/status) just ignores it.
*/
export async function dirFetch(path: string, init?: { method?: string; body?: unknown }): Promise<DirFetchResult> {
const cfg = dirConfig()
if (!cfg) {
return {
status: 500,
data: { error: { code: 'NOT_CONFIGURED', message: 'OVE-Directory is not configured on this server.' } },
}
}
let res: Response
try {
res = await fetch(`${cfg.url}${path}`, {
method: init?.method ?? 'GET',
headers: {
'X-API-Key': cfg.key,
Accept: 'application/json',
...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
cache: 'no-store',
})
} catch {
return {
status: 502,
data: {
error: {
code: 'BAD_GATEWAY',
message: 'Could not reach the OVE-Directory server. Try again in a moment.',
},
},
}
}
let data: unknown = null
try {
data = await res.json()
} catch {
data = null
}
if (data === null) {
return {
status: 502,
data: {
error: {
code: 'BAD_UPSTREAM',
message: 'The OVE-Directory server sent a malformed response.',
},
},
}
}
return { status: res.status, data }
}
// ── local error envelope (matches the upstream shape) ─────────────────────
export function jsonError(status: number, code: string, message: string, field?: string): NextResponse {
return NextResponse.json(
{ error: { code, message, ...(field !== undefined ? { field } : {}) } },
{ status },
)
}
// ── per-visitor throttle for the proxy ────────────────────────────────────
const RL_WINDOW_MS = 60_000
const RL_LIMIT = 30
const rlBuckets = new Map<string, { start: number; count: number }>()
/**
* Count this visitor IP against the proxy's own limit (30 requests/min).
* Returns null when allowed, or the seconds to wait when throttled.
*/
export function oveRateCheck(ip: string): number | null {
const now = Date.now()
let b = rlBuckets.get(ip)
if (!b || now - b.start >= RL_WINDOW_MS) {
b = { start: now, count: 0 }
rlBuckets.set(ip, b)
}
b.count++
if (rlBuckets.size > 512) {
for (const [k, v] of rlBuckets) {
if (now - v.start >= RL_WINDOW_MS) rlBuckets.delete(k)
}
}
if (b.count > RL_LIMIT) return Math.max(1, Math.ceil((b.start + RL_WINDOW_MS - now) / 1000))
return null
}
/** Best-effort visitor IP for the throttle (the gateway sets X-Forwarded-For). */
export function visitorIp(req: NextRequest): string {
const fwd = req.headers.get('x-forwarded-for')
const first = fwd?.split(',')[0]?.trim()
return first && first.length > 0 ? first : 'local'
}

View file

@ -12,6 +12,26 @@
// warns in the console when the two drift apart. // warns in the console when the two drift apart.
// //
// History: // History:
// 7.11 — the community OVE-Directory. A sixth tab (Directory) lists every
// script published to the community directory — a separate VPS at
// s3374dir.openvisengine.com, reached ONLY through this server's
// backend proxy (/api/ove-directory/*), which holds the API key in
// env and re-validates every upload (the share string must decode
// AND compile). Publishing: the Script tab gains "Upload to
// OVE-Directory" (enabled only while the editor shows ✔ Compiled OK
// with no runtime error); the script travels as its Script⇢Hash
// string, so the directory stores a fraction of the source. The
// listing carries each entry's name, type and date, a 1–5 star
// widget (the voter id is an HttpOnly cookie minted by the proxy;
// one vote per script per visitor, enforced upstream), the live
// "Average: #.#" to the right of the stars, and sorting by Name /
// Type / Date / Rating (done upstream, both directions). "Get"
// fetches the full record, decodes the hash back into the exact
// script, files it in this browser's preset tree under
// root/user created/OVE-Directory (content-deduped, names numbered
// "(1) (2) (3)…" on collision) and offers "Open in editor".
// Duplicate uploads resolve as success (409 DUPLICATE_HASH carries
// the existing row); duplicate votes show the stored aggregates.
// 7.10 — ten demo tracks + first-person changelog + declutter. The demo // 7.10 — ten demo tracks + first-person changelog + declutter. The demo
// source is now a COLLECTION: ten mastered tracks in // source is now a COLLECTION: ten mastered tracks in
// public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename order // public/demo-tracks/ (demo-01.mp3 … demo-10.mp3, filename order
@ -299,4 +319,4 @@
export const APP_NAME = 'Open Visualization Engine' export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE' export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.10' export const APP_VERSION = '7.11'