Spaces:
Build error
Build error
| import { useState, useRef } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { | |
| Music, Upload, CheckCircle2, AlertCircle, Terminal, Cpu, | |
| Plus, Trash2, UserPlus, FileAudio, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Card, CardContent } from "@/components/ui/card"; | |
| import { useWallet } from "@/hooks/useWallet"; | |
| const ISRC_PATTERN = /^[A-Z]{2}-[A-Z0-9]{3}-\d{2}-\d{5}$/; | |
| const EVM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; | |
| const IPI_PATTERN = /^\d{9,11}$/; | |
| const VALID_ROLES = ["Songwriter", "Composer", "Lyricist", "Publisher", "Admin Publisher"] as const; | |
| type ContributorRole = typeof VALID_ROLES[number]; | |
| interface Contributor { | |
| id: string; | |
| address: string; | |
| ipiNumber: string; | |
| role: ContributorRole; | |
| bps: string; | |
| } | |
| interface UploadResult { | |
| cid: string; | |
| isrc: string; | |
| band: number; | |
| rarity: string; | |
| dsp_ready: boolean; | |
| registration_id?: string; | |
| soulbound_pending?: boolean; | |
| ddex_submitted?: boolean; | |
| } | |
| const makeId = () => Math.random().toString(36).slice(2, 9); | |
| const emptyContributor = (): Contributor => ({ | |
| id: makeId(), | |
| address: "", | |
| ipiNumber: "", | |
| role: "Songwriter", | |
| bps: "", | |
| }); | |
| const MetadataUpload = () => { | |
| const { wallet, authHeaders } = useWallet(); | |
| const audioInputRef = useRef<HTMLInputElement>(null); | |
| const [step, setStep] = useState<"form" | "uploading" | "registering" | "success">("form"); | |
| const [serverError, setServerError] = useState<string | null>(null); | |
| const [result, setResult] = useState<UploadResult | null>(null); | |
| const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); | |
| const [audioFile, setAudioFile] = useState<File | null>(null); | |
| const [title, setTitle] = useState(""); | |
| const [isrc, setIsrc] = useState(""); | |
| const [contributors, setContributors] = useState<Contributor[]>([ | |
| { ...emptyContributor(), address: wallet.address, role: "Songwriter" }, | |
| ]); | |
| const bpsSum = contributors.reduce((sum, c) => sum + (parseInt(c.bps) || 0), 0); | |
| const bpsValid = bpsSum === 10000; | |
| const addContributor = () => { | |
| if (contributors.length < 16) { | |
| setContributors((prev) => [...prev, emptyContributor()]); | |
| } | |
| }; | |
| const removeContributor = (id: string) => { | |
| setContributors((prev) => prev.filter((c) => c.id !== id)); | |
| }; | |
| const updateContributor = (id: string, field: keyof Contributor, value: string) => { | |
| setContributors((prev) => | |
| prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)) | |
| ); | |
| const key = `${id}_${field}`; | |
| if (validationErrors[key]) { | |
| setValidationErrors((prev) => ({ ...prev, [key]: "" })); | |
| } | |
| }; | |
| const validate = (): boolean => { | |
| const errors: Record<string, string> = {}; | |
| if (!title.trim()) errors.title = "Song title is required."; | |
| const isrcNorm = isrc.trim().toUpperCase(); | |
| if (!isrcNorm) { | |
| errors.isrc = "ISRC code is required."; | |
| } else if (!ISRC_PATTERN.test(isrcNorm)) { | |
| errors.isrc = "Format: CC-XXX-YY-NNNNN e.g. US-ABC-24-00001"; | |
| } | |
| if (!audioFile) errors.audio = "Audio file is required."; | |
| if (contributors.length === 0) { | |
| errors.contributors = "At least one contributor is required."; | |
| } | |
| contributors.forEach((c) => { | |
| if (!EVM_ADDRESS_PATTERN.test(c.address)) { | |
| errors[`${c.id}_address`] = "Must be a valid 0x EVM wallet address (42 chars)."; | |
| } | |
| if (!IPI_PATTERN.test(c.ipiNumber.replace(/\D/g, ""))) { | |
| errors[`${c.id}_ipiNumber`] = "IPI must be 9β11 digits."; | |
| } | |
| const bpsVal = parseInt(c.bps); | |
| if (isNaN(bpsVal) || bpsVal <= 0 || bpsVal > 10000) { | |
| errors[`${c.id}_bps`] = "Must be 1β10000."; | |
| } | |
| }); | |
| if (!errors.contributors && !bpsValid) { | |
| errors.bpsSum = `Splits must total 10,000 bps (100%). Current: ${bpsSum.toLocaleString()}`; | |
| } | |
| setValidationErrors(errors); | |
| return Object.keys(errors).length === 0; | |
| }; | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!wallet.connected || !validate()) return; | |
| setServerError(null); | |
| try { | |
| // ββ Step 1: Upload audio to BTFS via /api/upload (multipart) ββββββββ | |
| setStep("uploading"); | |
| const fd = new FormData(); | |
| fd.append("title", title.trim()); | |
| fd.append("artist", wallet.address); | |
| fd.append("isrc", isrc.trim().toUpperCase()); | |
| fd.append("audio", audioFile!); | |
| const uploadRes = await fetch("/api/upload", { | |
| method: "POST", | |
| headers: { ...authHeaders() }, | |
| body: fd, | |
| }); | |
| if (!uploadRes.ok) { | |
| const text = await uploadRes.text().catch(() => ""); | |
| throw new Error(`Audio upload failed (${uploadRes.status}): ${text || uploadRes.statusText}`); | |
| } | |
| const uploadData = await uploadRes.json(); | |
| const { cid, band, rarity, dsp_ready } = uploadData; | |
| // ββ Step 2: Register publishing agreement via /api/register (JSON) ββ | |
| setStep("registering"); | |
| const registerPayload = { | |
| title: title.trim(), | |
| isrc: isrc.trim().toUpperCase(), | |
| btfs_cid: cid, | |
| band: band ?? 0, | |
| contributors: contributors.map((c) => ({ | |
| address: c.address.trim().toLowerCase(), | |
| ipi_number: c.ipiNumber.replace(/\D/g, ""), | |
| role: c.role, | |
| bps: parseInt(c.bps), | |
| })), | |
| }; | |
| const regRes = await fetch("/api/register", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...authHeaders(), | |
| }, | |
| body: JSON.stringify(registerPayload), | |
| }); | |
| if (!regRes.ok) { | |
| const text = await regRes.text().catch(() => ""); | |
| throw new Error(`Registration failed (${regRes.status}): ${text || regRes.statusText}`); | |
| } | |
| const regData = await regRes.json(); | |
| setResult({ | |
| cid, | |
| isrc: isrc.trim().toUpperCase(), | |
| band: band ?? 0, | |
| rarity: rarity ?? "Common", | |
| dsp_ready: dsp_ready ?? false, | |
| registration_id: regData.registration_id, | |
| soulbound_pending: regData.soulbound_pending ?? true, | |
| ddex_submitted: regData.ddex_submitted ?? false, | |
| }); | |
| setStep("success"); | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : "Submission failed. Please try again."; | |
| setServerError(message); | |
| setStep("form"); | |
| } | |
| }; | |
| const reset = () => { | |
| setStep("form"); | |
| setResult(null); | |
| setServerError(null); | |
| setAudioFile(null); | |
| setTitle(""); | |
| setIsrc(""); | |
| setContributors([{ ...emptyContributor(), address: wallet.address, role: "Songwriter" }]); | |
| setValidationErrors({}); | |
| if (audioInputRef.current) audioInputRef.current.value = ""; | |
| }; | |
| if (!wallet.connected) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center p-12 text-center border border-zinc-800 bg-zinc-950"> | |
| <div className="w-16 h-16 bg-primary/10 border border-primary/50 flex items-center justify-center mb-6"> | |
| <AlertCircle className="w-8 h-8 text-primary" /> | |
| </div> | |
| <h2 className="text-2xl font-black italic uppercase mb-2 text-white tracking-tighter">Access Denied</h2> | |
| <p className="text-zinc-500 font-mono text-sm max-w-sm mb-8 leading-tight"> | |
| > Error: Wallet_Not_Connected<br /> | |
| > Action: Connect a valid TronLink or Coinbase wallet to access the upload portal. | |
| </p> | |
| </div> | |
| ); | |
| } | |
| if (step === "success" && result) { | |
| return ( | |
| <motion.div | |
| className="flex flex-col items-center justify-center p-12 text-center border border-primary/50 bg-primary/5" | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| > | |
| <div className="w-16 h-16 bg-primary border border-primary flex items-center justify-center mb-6"> | |
| <CheckCircle2 className="w-8 h-8 text-primary-foreground" /> | |
| </div> | |
| <h2 className="text-2xl font-black italic uppercase mb-2 text-white tracking-tighter"> | |
| Transmission Successful | |
| </h2> | |
| <div className="text-left w-full max-w-md font-mono text-[11px] text-zinc-400 space-y-1 mb-8 bg-zinc-900/60 border border-zinc-800 p-4"> | |
| <div>> ISRC: <span className="text-primary">{result.isrc}</span></div> | |
| <div>> BTFS CID: <span className="text-primary break-all">{result.cid}</span></div> | |
| <div>> Band: <span className="text-primary">{result.band}</span> ({result.rarity})</div> | |
| {result.registration_id && ( | |
| <div>> Reg ID: <span className="text-primary">{result.registration_id}</span></div> | |
| )} | |
| <div>> DSP Ready: <span className={result.dsp_ready ? "text-green-400" : "text-yellow-400"}>{result.dsp_ready ? "YES" : "PENDING"}</span></div> | |
| <div>> DDEX Submitted: <span className={result.ddex_submitted ? "text-green-400" : "text-yellow-400"}>{result.ddex_submitted ? "YES" : "PENDING"}</span></div> | |
| {result.soulbound_pending && ( | |
| <div className="pt-2 border-t border-zinc-800 text-yellow-400"> | |
| > Soulbound NFT: PENDING β all contributors must sign<br /> | |
| the on-chain publishing agreement from their wallets. | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| onClick={reset} | |
| className="px-8 py-3 bg-zinc-900 border border-zinc-800 text-[10px] font-black uppercase tracking-widest hover:border-primary transition-all" | |
| > | |
| [ New_Transmission ] | |
| </button> | |
| </motion.div> | |
| ); | |
| } | |
| const isProcessing = step === "uploading" || step === "registering"; | |
| return ( | |
| <div className="max-w-3xl mx-auto py-8 font-mono"> | |
| <Card className="bg-zinc-950 border border-zinc-800 rounded-none overflow-hidden relative"> | |
| <div className="absolute top-0 right-0 p-4 opacity-10"> | |
| <Cpu className="w-20 h-20 text-primary" /> | |
| </div> | |
| <div className="p-8 border-b border-zinc-800 bg-zinc-900/30 flex items-center gap-4"> | |
| <div className="p-3 bg-primary/10 border border-primary/50"> | |
| <Music className="w-6 h-6 text-primary" /> | |
| </div> | |
| <div> | |
| <h2 className="text-2xl font-black italic uppercase tracking-tighter">Upload Protocol</h2> | |
| <div className="text-[10px] text-zinc-500 font-bold uppercase tracking-[0.2em]"> | |
| BTFS_Upload β Publishing_Agreement β Soulbound_NFT β DDEX | |
| </div> | |
| </div> | |
| </div> | |
| <CardContent className="p-8 space-y-8"> | |
| <div className="p-4 bg-primary/5 border border-primary/20 flex items-start gap-3"> | |
| <Terminal className="w-5 h-5 text-primary mt-0.5 shrink-0" /> | |
| <p className="text-[11px] text-zinc-400 leading-tight"> | |
| <span className="text-primary font-bold">PROTOCOL:</span> Audio is uploaded to BTFS. | |
| A publishing agreement is created on-chain linking all contributors via their IPI-verified wallets. | |
| Once all parties sign, a <span className="text-primary">soulbound NFT</span> is minted and the | |
| track is delivered to Spotify, Apple Music, and other DSPs via DDEX ERN 4.1. | |
| </p> | |
| </div> | |
| <form onSubmit={handleSubmit} className="space-y-8"> | |
| {/* ββ Core metadata ββ */} | |
| <div className="space-y-5"> | |
| <div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500 border-b border-zinc-800 pb-2"> | |
| _track_metadata | |
| </div> | |
| <div className="space-y-2"> | |
| <Label htmlFor="title" className="text-[10px] font-black uppercase tracking-widest text-zinc-500"> | |
| _song_title * | |
| </Label> | |
| <Input | |
| id="title" | |
| placeholder="Enter title" | |
| className="bg-black border-zinc-800 rounded-none focus:border-primary transition-colors text-sm" | |
| value={title} | |
| onChange={(e) => { setTitle(e.target.value); if (validationErrors.title) setValidationErrors(p => ({ ...p, title: "" })); }} | |
| disabled={isProcessing} | |
| /> | |
| {validationErrors.title && <p className="text-[10px] text-destructive">{validationErrors.title}</p>} | |
| </div> | |
| <div className="space-y-2"> | |
| <Label htmlFor="isrc" className="text-[10px] font-black uppercase tracking-widest text-zinc-500"> | |
| _isrc_code * | |
| </Label> | |
| <Input | |
| id="isrc" | |
| placeholder="US-ABC-24-00001" | |
| className="bg-black border-zinc-800 rounded-none focus:border-primary transition-colors text-sm font-mono" | |
| value={isrc} | |
| onChange={(e) => { setIsrc(e.target.value); if (validationErrors.isrc) setValidationErrors(p => ({ ...p, isrc: "" })); }} | |
| disabled={isProcessing} | |
| /> | |
| <p className="text-[10px] text-zinc-600">Format: CC-XXX-YY-NNNNN</p> | |
| {validationErrors.isrc && <p className="text-[10px] text-destructive">{validationErrors.isrc}</p>} | |
| </div> | |
| <div className="space-y-2"> | |
| <Label className="text-[10px] font-black uppercase tracking-widest text-zinc-500"> | |
| _audio_file * | |
| </Label> | |
| <div | |
| className="border border-dashed border-zinc-700 hover:border-primary transition-colors p-6 cursor-pointer text-center" | |
| onClick={() => audioInputRef.current?.click()} | |
| > | |
| <input | |
| ref={audioInputRef} | |
| type="file" | |
| accept="audio/*" | |
| className="hidden" | |
| onChange={(e) => { | |
| const f = e.target.files?.[0] ?? null; | |
| setAudioFile(f); | |
| if (validationErrors.audio) setValidationErrors(p => ({ ...p, audio: "" })); | |
| }} | |
| disabled={isProcessing} | |
| /> | |
| {audioFile ? ( | |
| <div className="flex items-center justify-center gap-3 text-primary"> | |
| <FileAudio className="w-5 h-5" /> | |
| <span className="text-[11px] font-bold">{audioFile.name}</span> | |
| <span className="text-zinc-500 text-[10px]">({(audioFile.size / 1024 / 1024).toFixed(2)} MB)</span> | |
| </div> | |
| ) : ( | |
| <div className="text-zinc-600 text-[11px]"> | |
| <Upload className="w-6 h-6 mx-auto mb-2 opacity-40" /> | |
| Click to select audio file (max 100 MB) | |
| </div> | |
| )} | |
| </div> | |
| {validationErrors.audio && <p className="text-[10px] text-destructive">{validationErrors.audio}</p>} | |
| </div> | |
| </div> | |
| {/* ββ Contributors ββ */} | |
| <div className="space-y-4"> | |
| <div className="flex items-center justify-between border-b border-zinc-800 pb-2"> | |
| <div className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500"> | |
| _contributors_&_publishing_splits | |
| </div> | |
| <div className={`text-[10px] font-bold font-mono ${bpsValid ? "text-green-500" : bpsSum > 0 ? "text-yellow-500" : "text-zinc-600"}`}> | |
| {bpsSum.toLocaleString()} / 10,000 bps | |
| </div> | |
| </div> | |
| <p className="text-[10px] text-zinc-500 leading-relaxed"> | |
| Add all songwriters, composers, and publishers. Splits must total exactly{" "} | |
| <strong className="text-zinc-300">10,000 basis points</strong> (100%). Each contributor | |
| must have a KYC-verified wallet linked to their IPI number before the soulbound NFT will mint. | |
| </p> | |
| <AnimatePresence> | |
| {contributors.map((c, idx) => ( | |
| <motion.div | |
| key={c.id} | |
| initial={{ opacity: 0, y: -8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="border border-zinc-800 bg-zinc-900/30 p-4 space-y-3" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="text-[10px] text-zinc-500 uppercase font-bold tracking-widest"> | |
| Party_{String(idx + 1).padStart(2, "0")} | |
| </span> | |
| {contributors.length > 1 && ( | |
| <button | |
| type="button" | |
| onClick={() => removeContributor(c.id)} | |
| disabled={isProcessing} | |
| className="p-1 hover:text-red-400 transition-colors text-zinc-600" | |
| > | |
| <Trash2 className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] uppercase tracking-widest text-zinc-600">Wallet Address *</label> | |
| <Input | |
| value={c.address} | |
| onChange={(e) => updateContributor(c.id, "address", e.target.value)} | |
| placeholder="0x..." | |
| className="bg-black border-zinc-800 rounded-none focus:border-primary text-[11px] font-mono h-8" | |
| disabled={isProcessing} | |
| /> | |
| {validationErrors[`${c.id}_address`] && ( | |
| <p className="text-[9px] text-destructive">{validationErrors[`${c.id}_address`]}</p> | |
| )} | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] uppercase tracking-widest text-zinc-600">IPI Number *</label> | |
| <Input | |
| value={c.ipiNumber} | |
| onChange={(e) => updateContributor(c.id, "ipiNumber", e.target.value.replace(/\D/g, "").slice(0, 11))} | |
| placeholder="00523879412" | |
| className="bg-black border-zinc-800 rounded-none focus:border-primary text-[11px] font-mono h-8" | |
| disabled={isProcessing} | |
| maxLength={11} | |
| /> | |
| {validationErrors[`${c.id}_ipiNumber`] && ( | |
| <p className="text-[9px] text-destructive">{validationErrors[`${c.id}_ipiNumber`]}</p> | |
| )} | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] uppercase tracking-widest text-zinc-600">Role *</label> | |
| <select | |
| value={c.role} | |
| onChange={(e) => updateContributor(c.id, "role", e.target.value)} | |
| disabled={isProcessing} | |
| className="w-full h-8 bg-black border border-zinc-800 text-[11px] font-mono px-2 focus:outline-none focus:border-primary text-zinc-300" | |
| > | |
| {VALID_ROLES.map((r) => ( | |
| <option key={r} value={r}>{r}</option> | |
| ))} | |
| </select> | |
| </div> | |
| <div className="space-y-1"> | |
| <label className="text-[9px] uppercase tracking-widest text-zinc-600"> | |
| Split (bps) * <span className="text-zinc-700 normal-case">/ 10,000 = 100%</span> | |
| </label> | |
| <Input | |
| value={c.bps} | |
| onChange={(e) => updateContributor(c.id, "bps", e.target.value.replace(/\D/g, "").slice(0, 5))} | |
| placeholder="e.g. 5000 = 50%" | |
| className="bg-black border-zinc-800 rounded-none focus:border-primary text-[11px] font-mono h-8" | |
| disabled={isProcessing} | |
| /> | |
| {validationErrors[`${c.id}_bps`] && ( | |
| <p className="text-[9px] text-destructive">{validationErrors[`${c.id}_bps`]}</p> | |
| )} | |
| </div> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| {validationErrors.bpsSum && ( | |
| <p className="text-[10px] text-destructive">{validationErrors.bpsSum}</p> | |
| )} | |
| {validationErrors.contributors && ( | |
| <p className="text-[10px] text-destructive">{validationErrors.contributors}</p> | |
| )} | |
| {contributors.length < 16 && ( | |
| <button | |
| type="button" | |
| onClick={addContributor} | |
| disabled={isProcessing} | |
| className="flex items-center gap-2 text-[10px] uppercase tracking-widest text-zinc-500 hover:text-primary transition-colors border border-dashed border-zinc-700 hover:border-primary px-4 py-2 w-full justify-center" | |
| > | |
| <UserPlus className="w-3.5 h-3.5" /> | |
| Add Contributor | |
| </button> | |
| )} | |
| </div> | |
| {/* ββ Error / status ββ */} | |
| {serverError && ( | |
| <div className="p-3 bg-destructive/10 border border-destructive/30 text-[11px] text-destructive font-mono"> | |
| > Error: {serverError} | |
| </div> | |
| )} | |
| {/* ββ Submit ββ */} | |
| <div className="pt-4"> | |
| <div className="flex items-center justify-between mb-6 p-3 bg-zinc-900/50 border-l-2 border-primary"> | |
| <span className="text-[10px] text-zinc-500 uppercase font-black tracking-widest">Signer_ID</span> | |
| <span className="text-[10px] font-mono text-primary font-bold truncate max-w-[200px]"> | |
| {wallet.address} | |
| </span> | |
| </div> | |
| {isProcessing && ( | |
| <div className="mb-4 p-3 border border-zinc-800 text-[10px] font-mono text-zinc-400"> | |
| {step === "uploading" && "> Step 1/2: Uploading audio to BTFS..."} | |
| {step === "registering" && "> Step 2/2: Registering publishing agreement + DDEX delivery..."} | |
| </div> | |
| )} | |
| <button | |
| type="submit" | |
| disabled={isProcessing || !bpsValid} | |
| className="w-full py-5 bg-primary text-primary-foreground font-black uppercase tracking-[0.2em] text-sm hover:bg-primary/90 shadow-[4px_4px_0px_0px_rgba(255,255,255,0.1)] active:translate-x-[2px] active:translate-y-[2px] active:shadow-none flex items-center justify-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed" | |
| > | |
| {isProcessing ? ( | |
| <> | |
| <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> | |
| EXECUTING... | |
| </> | |
| ) : ( | |
| <> | |
| <Upload className="w-4 h-4" /> | |
| [ START_TRANSMISSION ] | |
| </> | |
| )} | |
| </button> | |
| {!bpsValid && contributors.length > 0 && bpsSum > 0 && ( | |
| <p className="text-[10px] text-yellow-500 text-center mt-2"> | |
| Adjust splits so they total exactly 10,000 bps to enable submission. | |
| </p> | |
| )} | |
| </div> | |
| </form> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| }; | |
| export default MetadataUpload; | |