openvisengine/src/components/ava/share-dialogs.tsx
2026-11-04 18:48:09 +00:00

321 lines
12 KiB
TypeScript
Executable file

// AVA — Script ⇢ Hash share dialogs (v7.8).
//
// Two dialogs used by the Script tab toolbar:
// • ScriptToHashDialog — encodes the current editor script into ONE
// shareable Unicode string (see lib/ava/share-codec.ts for the format),
// shows it with a Copy button, live stats and the symbol legend.
// Compiling is NOT required — sharing a broken script to get it
// debugged is a feature.
// • HashToScriptDialog — pastes a share string back; on success it shows
// what came out (name, size, compile status) and loads the script into
// the editor exactly as it was, ready to Apply / Save As….
'use client'
import { useMemo, useState } from 'react'
import { ArrowRightLeft, Check, ClipboardPaste, CircleHelp, FileCode2, Share2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { decodeScriptHash, encodeScriptHash, SHARE_LEGEND } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
/** Copy text to the clipboard with a legacy fallback. */
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
}
}
}
// ── Script ⇢ Hash ─────────────────────────────────────────────────────────
export function ScriptToHashDialog({
open,
onOpenChange,
scriptText,
}: {
open: boolean
onOpenChange: (open: boolean) => void
scriptText: string
}) {
const { toast } = useToast()
const [copied, setCopied] = useState(false)
const encoded = useMemo(() => encodeScriptHash(scriptText), [scriptText])
const savedPct = Math.round((1 - encoded.hashLength / Math.max(1, encoded.scriptLength)) * 100)
const empty = scriptText.trim() === ''
async function copy() {
const ok = await copyText(encoded.hash)
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} else {
toast({
title: 'Copy failed',
description: 'Select the string in the box and copy it manually (⌘/Ctrl+C).',
variant: 'destructive',
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="size-4 text-emerald-400" aria-hidden />
Script ⇢ Hash
</DialogTitle>
<DialogDescription>
Your script as ONE shareable string -- post it anywhere (chat, social, forums). Anyone
pastes it back with Hash⇢Script and gets your script exactly as it is now.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
readOnly
value={encoded.hash}
aria-label="The share string for this script"
onFocus={(e) => e.currentTarget.select()}
rows={5}
spellCheck={false}
className="max-h-[240px] min-h-[110px] resize-none border-zinc-700 bg-zinc-950 font-mono text-xs leading-relaxed text-emerald-200/90"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-[11px] text-zinc-500" role="status">
{encoded.hashLength} characters
{savedPct > 0 ? (
<span className="text-emerald-400"> · {savedPct}% shorter than the script</span>
) : null}
{empty ? <span className="text-amber-400"> · the editor is empty</span> : null}
</p>
<Button
onClick={() => void copy()}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
{copied ? <Check className="size-3.5" /> : <ClipboardPaste className="size-3.5" />}
{copied ? 'Copied!' : 'Copy string'}
</Button>
</div>
<p className="rounded-md border border-zinc-800 bg-zinc-900/40 px-2.5 py-2 text-[11px] leading-relaxed text-zinc-400">
Short scripts spell themselves in built-in references (
<span className="font-mono text-zinc-300">
{SHARE_LEGEND.map((l) => `${l.symbol} = ${l.meaning}`).join(' · ')}
</span>
) -- longer ones additionally pack ~15 bits of script into every
character, which is why they read as dense CJK. Either way the string
works even if the script doesn&apos;t compile -- perfect for asking for
help or sharing work in progress.
</p>
</div>
<DialogFooter>
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── Hash ⇢ Script ─────────────────────────────────────────────────────────
interface Decoded {
script: string
name: string | null
lines: number
compiles: boolean
compileNote: string
}
export function HashToScriptDialog({
open,
onOpenChange,
onImport,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** Load the decoded script into the editor (script-tab wires this).
* v7.11: also hands back the script's display name (its name: header,
* else the share-string label) so Save can auto-name the new preset. */
onImport: (script: string, name: string | null) => void
}) {
const { toast } = useToast()
const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null)
const [decoded, setDecoded] = useState<Decoded | null>(null)
// Reset whenever the dialog is (re)opened. The toolbar button sets the
// parent's open state directly, so Radix's onOpenChange never fires for
// it — resetting during render on the closed→open transition is the
// React-blessed pattern (re-renders immediately, no effect needed).
const [wasOpen, setWasOpen] = useState(false)
if (open && !wasOpen) {
setWasOpen(true)
setInput('')
setError(null)
setDecoded(null)
} else if (!open && wasOpen) {
setWasOpen(false)
}
function tryDecode() {
const text = input.trim()
if (!text) {
setError('Paste a share string first.')
setDecoded(null)
return
}
const result = decodeScriptHash(text)
if (!result.ok) {
setError(result.error)
setDecoded(null)
return
}
setError(null)
const v = validateScript(result.script)
const meta = v.meta
const lines = result.script.split('\n').length
const compiles = v.ok
setDecoded({
script: result.script,
name: meta && meta.name !== 'Untitled' ? meta.name : result.name,
lines,
compiles,
compileNote: v.ok
? `Compiles OK -- mode: ${meta?.mode ?? '?'}`
: v.error
? `Doesn't compile (yet) -- [${v.error.section}] line ${v.error.line}: ${v.error.message}`
: "Doesn't compile (yet)",
})
}
function load() {
if (!decoded) return
onImport(decoded.script, decoded.name ?? null)
onOpenChange(false)
toast({
title: 'Script loaded from share string',
description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${
decoded.compiles ? ' Hit Apply to run it, or Save to keep it as a preset.' : ' Fix the errors, then Apply.'
}`,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="size-4 text-emerald-400" aria-hidden />
Hash ⇢ Script
</DialogTitle>
<DialogDescription>
Paste a share string someone posted -- the script it carries reappears in the editor,
exactly as they wrote it, ready to test or save.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
setError(null)
}}
placeholder={'My Script:[▼…]'}
aria-label="Paste a share string here"
rows={5}
spellCheck={false}
autoFocus
className="max-h-[240px] min-h-[110px] resize-none border-zinc-700 bg-zinc-950 font-mono text-xs leading-relaxed text-zinc-200"
/>
{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>
)}
{decoded && (
<div className="space-y-2 rounded-lg border border-emerald-400/25 bg-emerald-400/5 p-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-emerald-200">
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{decoded.name ?? 'Untitled'}</span>
</span>
<span className="font-mono text-[11px] text-zinc-400">
{decoded.lines} lines · {decoded.script.length} characters
</span>
</div>
<p
className={cn(
'flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed',
decoded.compiles ? 'text-emerald-400' : 'text-amber-400',
)}
>
{decoded.compiles ? (
<Check className="mt-0.5 size-3.5 shrink-0" aria-hidden />
) : (
<CircleHelp className="mt-0.5 size-3.5 shrink-0" aria-hidden />
)}
{decoded.compileNote}
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{!decoded ? (
<Button
onClick={tryDecode}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<ArrowRightLeft className="size-3.5" /> Decode
</Button>
) : (
<Button
onClick={load}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<FileCode2 className="size-3.5" /> Load into Editor
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}