Spaces:
Running
Running
| import { useEffect, useRef, useState } from "react"; | |
| import { X, Smartphone, Check, Copy, Image as ImageIcon, Film, Music } from "lucide-react"; | |
| import { useStore, type VideoAsset } from "../store"; | |
| // "Scan to upload from your phone": mints a short-lived, project-scoped link on the server, shows its | |
| // QR code, then polls for files the phone sends — adding each to the project's asset library live. | |
| export function PhoneUploadModal({ open, onClose }: { open: boolean; onClose: () => void }) { | |
| const projectId = useStore((s) => s.projectId); | |
| const projectName = useStore((s) => s.projectName); | |
| const addAsset = useStore((s) => s.addAsset); | |
| const [qr, setQr] = useState<string | null>(null); | |
| const [url, setUrl] = useState(""); | |
| const [err, setErr] = useState(""); | |
| const [received, setReceived] = useState<VideoAsset[]>([]); | |
| const [copied, setCopied] = useState(false); | |
| const tokenRef = useRef<string | null>(null); | |
| const seen = useRef<Set<string>>(new Set()); | |
| useEffect(() => { | |
| if (!open || !projectId) return; | |
| let stop = false; | |
| let timer: ReturnType<typeof setTimeout> | undefined; | |
| setQr(null); setUrl(""); setErr(""); setReceived([]); setCopied(false); seen.current = new Set(); tokenRef.current = null; | |
| (async () => { | |
| try { | |
| const res = await fetch("/api/uplink", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId }) }); | |
| const d = await res.json(); | |
| if (!res.ok || !d.token) throw new Error(d.error || "couldn't create an upload link"); | |
| if (stop) return; | |
| tokenRef.current = d.token; setQr(d.qr); setUrl(d.url); | |
| const poll = async () => { | |
| if (stop || !tokenRef.current) return; | |
| try { | |
| const r = await fetch("/api/uplink/" + tokenRef.current); | |
| if (r.ok) { | |
| const j = await r.json(); | |
| for (const a of (j.assets || []) as VideoAsset[]) { | |
| if (!seen.current.has(a.id)) { seen.current.add(a.id); addAsset(a); setReceived((prev) => [a, ...prev]); } | |
| } | |
| } | |
| } catch { /* keep polling */ } | |
| if (!stop) timer = setTimeout(poll, 2500); | |
| }; | |
| timer = setTimeout(poll, 2500); | |
| } catch (e) { if (!stop) setErr(String((e as Error)?.message || e)); } | |
| })(); | |
| return () => { stop = true; if (timer) clearTimeout(timer); }; | |
| }, [open, projectId, addAsset]); | |
| if (!open) return null; | |
| const kindIcon = (k?: string) => (k === "image" ? ImageIcon : k === "audio" ? Music : Film); | |
| return ( | |
| <div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/55 p-4 backdrop-blur-sm" onClick={onClose}> | |
| <div className="w-full max-w-md overflow-hidden rounded-2xl border border-border bg-surface-raised shadow-2xl" onClick={(e) => e.stopPropagation()}> | |
| <div className="flex items-center justify-between border-b border-border px-5 py-3.5"> | |
| <div className="flex items-center gap-2.5"> | |
| <span className="grid h-8 w-8 place-items-center rounded-lg bg-primary/12 text-primary"><Smartphone size={16} /></span> | |
| <div> | |
| <div className="text-[14px] font-semibold text-fg">Upload from your phone</div> | |
| <div className="text-[11px] text-fg-subtle">Files land in <span className="font-medium text-fg-muted">{projectName}</span></div> | |
| </div> | |
| </div> | |
| <button onClick={onClose} className="grid h-7 w-7 place-items-center rounded-md text-fg-muted hover:bg-white/5 hover:text-fg"><X size={16} /></button> | |
| </div> | |
| <div className="flex flex-col items-center px-5 py-5"> | |
| {err ? ( | |
| <div className="my-6 text-center text-[13px] text-destructive">{err}</div> | |
| ) : ( | |
| <> | |
| <div className="rounded-2xl border border-border bg-white p-3 shadow-sm"> | |
| {qr ? <img src={qr} alt="Scan to upload" width={208} height={208} className="block h-52 w-52" /> | |
| : <div className="grid h-52 w-52 animate-pulse place-items-center rounded-lg bg-white/5 text-[12px] text-fg-subtle">Generating link…</div>} | |
| </div> | |
| <p className="mt-4 text-center text-[13px] leading-relaxed text-fg-muted"> | |
| Scan with your phone camera, then pick photos or videos.<br /> | |
| <span className="text-[11.5px] text-fg-subtle">Phone must be on the same Wi-Fi. Link expires in 30 min.</span> | |
| </p> | |
| {url && ( | |
| <button | |
| type="button" | |
| onClick={async () => { | |
| try { await navigator.clipboard.writeText(url); } | |
| catch { | |
| // clipboard API needs a secure context; on plain-http dev LAN it can throw, | |
| // so fall back to a hidden textarea + execCommand so copy still works there. | |
| const t = document.createElement("textarea"); | |
| t.value = url; t.style.position = "fixed"; t.style.opacity = "0"; | |
| document.body.appendChild(t); t.select(); | |
| try { document.execCommand("copy"); } catch { /* ignore */ } | |
| t.remove(); | |
| } | |
| setCopied(true); setTimeout(() => setCopied(false), 1600); | |
| }} | |
| className="mt-3 inline-flex items-center gap-2 rounded-lg border border-border bg-white/[0.06] px-3 py-1.5 text-[12px] font-medium text-fg-muted transition hover:bg-white/10 hover:text-fg" | |
| > | |
| {copied ? <Check size={13} className="text-[#28c840]" /> : <Copy size={13} />} | |
| {copied ? "Link copied" : "Copy link"} | |
| </button> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| <div className="border-t border-border px-5 py-3.5"> | |
| <div className="mb-2 flex items-center gap-2 text-[12px] font-medium text-fg-muted"> | |
| <span className={"h-1.5 w-1.5 rounded-full " + (received.length ? "bg-[#28c840]" : "animate-pulse bg-primary")} /> | |
| {received.length ? `${received.length} file${received.length === 1 ? "" : "s"} received` : "Waiting for uploads…"} | |
| </div> | |
| {received.length === 0 ? ( | |
| <div className="text-[11.5px] text-fg-subtle">Anything you send appears here and in My Assets automatically.</div> | |
| ) : ( | |
| <div className="max-h-28 space-y-1.5 overflow-y-auto"> | |
| {received.map((a) => { | |
| const Icon = kindIcon(a.kind); | |
| return ( | |
| <div key={a.id} className="flex items-center gap-2 rounded-md bg-white/[0.06] px-2.5 py-1.5 text-[12px]"> | |
| <Icon size={13} className="shrink-0 text-primary" /> | |
| <span className="flex-1 truncate text-fg">{a.label}</span> | |
| <Check size={13} className="shrink-0 text-[#28c840]" /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| <button onClick={onClose} className="mt-3 w-full rounded-lg bg-primary py-2.5 text-[13px] font-semibold text-white transition hover:brightness-105">Done</button> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |