b8fd4912-690d-432c-9235-71b5108014df
This commit is contained in:
parent
f72eae5ecb
commit
40c6d2887e
13 changed files with 1434 additions and 8 deletions
2
.env
2
.env
|
|
@ -1 +1,3 @@
|
|||
DATABASE_URL=file:/home/z/my-project/db/custom.db
|
||||
OVE_DIRECTORY_URL=https://s3374dir.openvisengine.com
|
||||
OVE_DIRECTORY_API_KEY=+7MkRB+hkV0UBhPqM1haTQ0f8JGKqkuXwoBTdTSAouU=
|
||||
|
|
|
|||
71
src/app/api/ove-directory/scripts/[id]/ratings/route.ts
Normal file
71
src/app/api/ove-directory/scripts/[id]/ratings/route.ts
Normal 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
|
||||
}
|
||||
23
src/app/api/ove-directory/scripts/[id]/route.ts
Normal file
23
src/app/api/ove-directory/scripts/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
98
src/app/api/ove-directory/scripts/route.ts
Normal file
98
src/app/api/ove-directory/scripts/route.ts
Normal 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 })
|
||||
}
|
||||
19
src/app/api/ove-directory/status/route.ts
Normal file
19
src/app/api/ove-directory/status/route.ts
Normal 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 })
|
||||
}
|
||||
|
|
@ -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
|
||||
// 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 { PresetsTab } from '@/components/ava/presets-tab'
|
||||
import { ScriptTab } from '@/components/ava/script-tab'
|
||||
import { DirectoryTab } from '@/components/ava/directory-tab'
|
||||
import { DetectTab } from '@/components/ava/detect-tab'
|
||||
import { BeatTab } from '@/components/ava/beat-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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
|
||||
function TabBody({ children }: { children: React.ReactNode }) {
|
||||
|
|
@ -45,7 +50,7 @@ export function ControlPanel() {
|
|||
<TabsList
|
||||
className={cn(
|
||||
'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">
|
||||
|
|
@ -54,6 +59,9 @@ export function ControlPanel() {
|
|||
<TabsTrigger value="script" className="text-xs">
|
||||
Script
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="directory" className="text-xs">
|
||||
Directory
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="detect" className="text-xs">
|
||||
Detect
|
||||
</TabsTrigger>
|
||||
|
|
@ -81,6 +89,11 @@ export function ControlPanel() {
|
|||
<ScriptTab />
|
||||
</TabBody>
|
||||
</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">
|
||||
<TabBody>
|
||||
<DetectTab />
|
||||
|
|
|
|||
496
src/components/ava/directory-tab.tsx
Normal file
496
src/components/ava/directory-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,10 +3,11 @@
|
|||
// 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,
|
||||
// Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
|
||||
// string and paste one back), New, Help. Editor: line-number gutter +
|
||||
// monospace textarea (synced scroll, Tab inserts two spaces), error lines
|
||||
// flagged in the gutter. Status line under the editor shows compile status
|
||||
// or runtime errors.
|
||||
// string and paste one back), Upload to OVE-Directory (v7.11 — publish the
|
||||
// compiled script to the community directory), New, Help. Editor: line-number
|
||||
// gutter + monospace textarea (synced scroll, Tab inserts two spaces), error
|
||||
// lines flagged in the gutter. Status line under the editor shows compile
|
||||
// status or runtime errors.
|
||||
|
||||
'use client'
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
Check,
|
||||
CircleHelp,
|
||||
ClipboardPaste,
|
||||
CloudUpload,
|
||||
Copy,
|
||||
Download,
|
||||
FilePlus2,
|
||||
|
|
@ -44,6 +46,7 @@ import {
|
|||
import { applyScriptText } from '@/components/ava/audio-source'
|
||||
import { HelpPanel } from '@/components/ava/help-panel'
|
||||
import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs'
|
||||
import { UploadToDirectoryDialog } from '@/components/ava/upload-dialog'
|
||||
import { sanitizeFileName } from '@/components/ava/shared'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -80,6 +83,12 @@ export function ScriptTab() {
|
|||
// v7.7 — share-string dialogs
|
||||
const [shareOutOpen, setShareOutOpen] = 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)
|
||||
useEffect(() => {
|
||||
|
|
@ -368,6 +377,21 @@ export function ScriptTab() {
|
|||
>
|
||||
<ClipboardPaste className="size-3.5 text-emerald-400/90" /> Hash⇢Script
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -494,6 +518,9 @@ export function ScriptTab() {
|
|||
onImport={(text) => setScriptText(text)}
|
||||
/>
|
||||
|
||||
{/* v7.11 -- publish to the community directory */}
|
||||
<UploadToDirectoryDialog open={uploadOpen} onOpenChange={setUploadOpen} scriptText={scriptText} />
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
|
|
|||
216
src/components/ava/upload-dialog.tsx
Normal file
216
src/components/ava/upload-dialog.tsx
Normal 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
|
||||
“Getting” 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
274
src/lib/ove-directory.ts
Normal 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' })
|
||||
}
|
||||
|
|
@ -39,6 +39,28 @@ export interface 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',
|
||||
name: 'Ten demo tracks',
|
||||
|
|
|
|||
145
src/lib/server/ove-directory.ts
Normal file
145
src/lib/server/ove-directory.ts
Normal 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'
|
||||
}
|
||||
|
|
@ -12,6 +12,26 @@
|
|||
// warns in the console when the two drift apart.
|
||||
//
|
||||
// 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
|
||||
// source is now a COLLECTION: ten mastered tracks in
|
||||
// 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_SHORT = 'OVE'
|
||||
export const APP_VERSION = '7.10'
|
||||
export const APP_VERSION = '7.11'
|
||||
|
|
|
|||
Loading…
Reference in a new issue