/*
* MIT License
*
* Copyright (c) 2026 OwnlyPlans
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
"use client";
import { useEffect, useRef, type CSSProperties } from "react";
const css = `.su{--su-accent:#4f46e5;--su-ok:#16a34a;--su-bad:#dc2626;--su-card:#fff;--su-ink:#0b1020;--su-muted:#5c627c;--su-line:#d8deeb;--su-soft:#f4f6fb;
width:min(460px,100%);padding:14px 16px 10px;border:1px solid var(--su-line);border-radius:20px;background:var(--su-card);color:var(--su-ink);font:14px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;
box-shadow:0 24px 60px -30px rgba(15,23,42,.35);user-select:none;-webkit-user-select:none}
:root[data-stage=dark] .su,.su.su--dark{--su-card:#111827;--su-ink:#f4f6fb;--su-muted:#9ea6bc;--su-line:#2a3550;--su-soft:#0b1222;--su-accent:#818cf8}
.su-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}
.su-title{margin:0;font-size:16px;font-weight:700}
.su-sub{margin:1px 0 0;color:var(--su-muted);font-size:12.5px}
.su-tools{display:flex;align-items:center;gap:6px;flex:none}
.su-sound{display:grid;place-items:center;width:30px;height:30px;padding:0;border:0;border-radius:50%;background:var(--su-soft);color:var(--su-muted);cursor:pointer}
.su-sound svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.su-sound .w{display:none}.su-sound[aria-pressed=true]{color:var(--su-accent)}.su-sound[aria-pressed=true] .w{display:inline}.su-sound[aria-pressed=true] .x{display:none}
.su-count{height:28px;padding:0 12px;border:1px solid color-mix(in srgb,var(--su-accent) 35%,transparent);border-radius:99px;background:color-mix(in srgb,var(--su-accent) 12%,transparent);color:var(--su-accent);font-size:12.5px;font-weight:700;display:flex;align-items:center;white-space:nowrap}
.su-count.bump{animation:su-bump .45s}
@keyframes su-bump{40%{transform:scale(1.15)}}
.su-court{position:relative;height:var(--su-h,210px);margin-top:10px;border:1.5px dashed var(--su-line);border-radius:16px;background:var(--su-soft);overflow:hidden;touch-action:none;transition:border-color .2s,background .2s}
.su-court.is-over{border-color:var(--su-accent);background:color-mix(in srgb,var(--su-accent) 8%,var(--su-soft))}
.su-hint{position:absolute;left:0;right:0;top:12px;display:grid;justify-items:center;gap:2px;color:var(--su-muted);font-size:12.5px;text-align:center;pointer-events:none;transition:opacity .25s}
.su-hint b{color:var(--su-ink);font-size:13.5px}
.su-hint button{pointer-events:auto;padding:0;border:0;background:none;color:var(--su-accent);font:inherit;font-weight:700;text-decoration:underline;text-underline-offset:3px;cursor:pointer}
.su-court.has-rows .su-hint{opacity:0;pointer-events:none}.su-court.has-rows .su-hint button{pointer-events:none}
.su-rows{position:absolute;left:10px;right:10px;top:10px;z-index:3;display:grid;gap:6px;max-height:58px;overflow:auto;pointer-events:auto}
.su-row{display:grid;grid-template-columns:auto 1fr auto;gap:2px 8px;align-items:center;padding:7px 10px;border:1px solid var(--su-line);border-radius:12px;background:var(--su-card);box-shadow:0 8px 20px -14px rgba(0,0,0,.35);animation:su-row .35s cubic-bezier(.2,.8,.2,1)}
@keyframes su-row{from{transform:translateY(-10px);opacity:0}}
.su-type{padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--su-accent) 14%,transparent);color:var(--su-accent);font-size:10px;font-weight:800;letter-spacing:.04em}
.su-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:700;font-size:13px}
.su-state{font-size:12px;font-weight:700;color:var(--su-accent)}
.su-bar{grid-column:1/-1;height:5px;border-radius:99px;background:var(--su-line);overflow:hidden}
.su-bar i{display:block;height:100%;width:0;border-radius:inherit;background:var(--su-accent);transition:width .2s}
.su-meta{grid-column:1/-1;display:flex;justify-content:space-between;color:var(--su-muted);font-size:11.5px}
.su-row.is-done .su-bar i{background:var(--su-ok)}.su-row.is-done .su-state,.su-row.is-done .su-msg{color:var(--su-ok)}
.su-row.is-error .su-state,.su-row.is-error .su-msg{color:var(--su-bad)}.su-row.is-error .su-bar{display:none}
.su-hoop{position:absolute;right:16%;bottom:var(--su-hoop-bottom,15%);width:88px;pointer-events:none}
.su-board{width:88px;height:56px;margin:0 auto;border:2px solid var(--su-line);border-radius:6px;background:var(--su-card);display:grid;place-items:end center;box-shadow:0 6px 16px -10px rgba(0,0,0,.35)}
.su-board i{width:40px;height:28px;margin-bottom:6px;border:2.5px solid var(--su-accent);border-bottom:0;border-radius:2px}
.su-rim{position:relative;z-index:4;width:76px;height:12px;margin:-7px auto 0;border:3.5px solid var(--su-accent);border-radius:50%;background:transparent;box-shadow:0 2px 0 rgba(0,0,0,.08)}
.su-net{width:60px;height:38px;margin:-4px auto 0;transform-origin:top;
background:repeating-linear-gradient(60deg,transparent 0 6px,color-mix(in srgb,var(--su-muted) 70%,transparent) 6px 7.5px),repeating-linear-gradient(-60deg,transparent 0 6px,color-mix(in srgb,var(--su-muted) 70%,transparent) 6px 7.5px);
clip-path:polygon(0 0,100% 0,82% 100%,18% 100%);opacity:.75}
.su-hoop.swish .su-net{animation:su-swish .5s ease-out}
@keyframes su-swish{30%{transform:scaleY(1.35) scaleX(.86)}65%{transform:scaleY(.92)}}
.su-ball{position:absolute;left:0;top:0;z-index:2;width:34px;height:42px;padding:0;border:0;background:none;cursor:grab;touch-action:none;will-change:transform}
.su-ball:active{cursor:grabbing}
.su-ball.is-front{z-index:5}
.su-ball:focus-visible{outline:none}.su-ball:focus-visible .su-file{box-shadow:0 0 0 3px var(--su-card),0 0 0 6px var(--su-accent)}
.su-file{display:grid;place-items:end center;width:34px;height:42px;padding-bottom:6px;border-radius:5px 12px 5px 5px;background:linear-gradient(180deg,#fff,#eef1f8);border:1px solid #d6dbe8;box-shadow:0 8px 16px -8px rgba(0,0,0,.45);position:relative}
.su-file::before{content:"";position:absolute;top:8px;left:6px;width:16px;height:2px;background:#cfd5e3;box-shadow:0 5px 0 #cfd5e3,0 10px 0 #cfd5e3}
.su-file b{padding:1px 4px;border-radius:4px;background:var(--su-accent);color:#fff;font-size:8.5px;font-weight:800;letter-spacing:.03em}
.su-ball.is-empty .su-file{background:var(--su-card);border-style:dashed;border-color:var(--su-accent)}
.su-ball.is-empty .su-file::before{display:none}
.su-ball.is-empty .su-file b{background:none;color:var(--su-accent);font-size:20px;line-height:1;margin-bottom:6px}
.su-dots{position:absolute;inset:0;z-index:1;width:100%;height:100%;pointer-events:none;overflow:visible}
.su-dots circle{fill:var(--su-accent)}
.su-foot{margin:7px 0 0;color:var(--su-muted);font-size:11.5px;text-align:center;min-height:16px}
.su-foot.is-miss{color:#c2410c;font-weight:600}
.su-file-input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.su-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-width:420px){.su{padding:12px 12px 8px}.su-hoop{right:8%;transform:scale(.84);transform-origin:bottom right}}`;
export type ShootUploaderOptions = {
title?: string;
subtitle?: string;
/** Like the input accept attribute: "image/*,application/pdf,.zip". Empty allows everything. */
accept?: string;
maxBytes?: number;
multiple?: boolean;
/** Dropped or browsed files fly into the basket by themselves (default true). */
autoShoot?: boolean;
/** Start with sound effects on (default off; people can turn them on). */
sound?: boolean;
/** Keep a sample file on the court so people can play (for demos). */
demoFiles?: boolean;
/** Upload one file. Call onProgress with 0-100; resolve when done, reject on failure. */
upload?: (file: File, onProgress: (pct: number) => void) => Promise<void>;
onUploaded?: (file: File) => void;
};
/** Ready-made uploader: POSTs each file as multipart form data to `url`, with real progress. */
export const xhrUpload = (url: string, field = "file") => (file: File, onProgress: (pct: number) => void) =>
new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100);
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(`Upload failed (${xhr.status})`)));
xhr.onerror = () => reject(new Error("Network error"));
const body = new FormData();
body.append(field, file);
xhr.send(body);
});
/* Shoot-to-Upload engine: builds the uploader inside `root` and returns a cleanup function. */
export function mountShootUploader(root: HTMLElement, o: ShootUploaderOptions = {}): () => void {
const opt = {
title: 'Upload files', subtitle: 'Drag and drop, browse, or take the shot.', accept: '', maxBytes: 25 * 1024 * 1024, multiple: true,
autoShoot: true, sound: false, demoFiles: false, ...o,
};
const upload = opt.upload || fakeUpload;
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const uid = 'su' + Math.random().toString(36).slice(2, 8);
root.classList.add('su');
root.innerHTML =
'<div class="su-head"><div><h3 class="su-title"></h3><p class="su-sub"></p></div><div class="su-tools">' +
'<button type="button" class="su-sound" aria-pressed="false" aria-label="Sound effects"><svg viewBox="0 0 24 24"><path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z"/><path class="w" d="M15.5 9a4 4 0 0 1 0 6M18 6.5a7.5 7.5 0 0 1 0 11"/><path class="x" d="M16 9.5l5 5M21 9.5l-5 5"/></svg></button>' +
'<span class="su-count" aria-live="polite">Uploaded 0</span></div></div>' +
'<div class="su-court" id="' + uid + '-court"><div class="su-hint"><b>Drop files here</b><span>or <button type="button" class="su-browse">browse</button></span></div>' +
'<div class="su-rows" aria-label="Uploads"></div>' +
'<svg class="su-dots" aria-hidden="true"></svg>' +
'<div class="su-hoop" aria-hidden="true"><div class="su-board"><i></i></div><div class="su-rim"></div><div class="su-net"></div></div>' +
'<button type="button" class="su-ball"><span class="su-file"><b>PDF</b></span></button>' +
'<input class="su-file-input" type="file" tabindex="-1" aria-hidden="true"></div>' +
'<p class="su-foot" aria-live="polite"></p><p class="su-sr" aria-live="assertive"></p>';
const $ = (s: string) => root.querySelector(s) as HTMLElement;
const court = $('.su-court'), ball = $('.su-ball'), rows = $('.su-rows'), dots = $('.su-dots'), hoop = $('.su-hoop'), rim = $('.su-rim');
const foot = $('.su-foot'), count = $('.su-count'), sr = $('.su-sr'), soundBtn = $('.su-sound');
const input = $('.su-file-input') as HTMLInputElement;
$('.su-title').textContent = opt.title; $('.su-sub').textContent = opt.subtitle;
input.multiple = opt.multiple; if (opt.accept) input.accept = opt.accept;
let soundOn = !!opt.sound; soundBtn.setAttribute('aria-pressed', String(soundOn));
const queue: File[] = [];
const autoSet = new WeakSet<File>();
let uploaded = 0, flying = false, alive = true, raf = 0, drag: { id: number; x: number; y: number; moved: boolean } | null = null;
let angle = 0, demoIndex = 0;
const R = 17;
const SAMPLES = [['document.pdf', 2516582, 'application/pdf'], ['team-photo.png', 1468006, 'image/png'], ['slides.pdf', 4404019, 'application/pdf'], ['notes.txt', 18432, 'text/plain']];
/* ---------- geometry ---------- */
function geo() {
const c = court.getBoundingClientRect(), r = rim.getBoundingClientRect();
const W = c.width, H = c.height;
const rimL = r.left - c.left + 7, rimR = r.right - c.left - 7, rimY = r.top - c.top + r.height / 2;
const start = { x: 14 + R + 6, y: H - R - 12 };
const g = H * 8;
return { W, H, rimL, rimR, rimY, start, g, maxPull: Math.min(110, H * 0.5) };
}
function place(x: number, y: number, rot = 0, scale = 1) {
angle = rot;
ball.style.transform = 'translate(' + (x - 17) + 'px,' + (y - 21) + 'px) rotate(' + rot + 'deg) scale(' + scale + ')';
}
function perfect() {
const G = geo(), tx = (G.rimL + G.rimR) / 2, ty = G.rimY - 2;
/* The lowest arc that drops through the rim at a steep angle and stays under the top of the court. */
let best = { vx: 0, vy: 0, G };
for (let T = 0.3; T <= 1.8; T += 0.01) {
const vx = (tx - G.start.x) / T, vy = (ty - G.start.y - 0.5 * G.g * T * T) / T;
if (G.start.y - (vy * vy) / (2 * G.g) < R + 4) break;
best = { vx, vy, G };
if (vy + G.g * T >= Math.abs(vx) * 1.2) break;
}
return best;
}
function power() { const p = perfect(); return (Math.hypot(p.vx, p.vy) * 1.3) / p.G.maxPull; }
/* ---------- sound (synthesised, off by default) ---------- */
let ac: AudioContext | null = null;
function blip(kind: 'swish' | 'bounce' | 'miss') {
if (!soundOn) return;
try {
ac = ac || new AudioContext();
const t = ac.currentTime, gain = ac.createGain(); gain.connect(ac.destination);
if (kind === 'swish') {
const len = Math.floor(ac.sampleRate * 0.35), buf = ac.createBuffer(1, len, ac.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2);
const src = ac.createBufferSource(), f = ac.createBiquadFilter(); f.type = 'bandpass'; f.frequency.setValueAtTime(2500, t); f.frequency.exponentialRampToValueAtTime(700, t + 0.3);
src.buffer = buf; src.connect(f); f.connect(gain); gain.gain.setValueAtTime(0.25, t); src.start(t);
} else {
const osc = ac.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(kind === 'miss' ? 220 : 160, t); osc.frequency.exponentialRampToValueAtTime(kind === 'miss' ? 110 : 60, t + 0.18);
gain.gain.setValueAtTime(0.3, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2); osc.connect(gain); osc.start(t); osc.stop(t + 0.22);
}
} catch (e) { /* audio not available: stay silent */ }
}
/* ---------- queue + ball ---------- */
const ext = (n: string) => { const m = /\.([a-z0-9]{1,4})$/i.exec(n); return m ? m[1].toUpperCase() : 'FILE'; };
const size = (b: number) => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + ' KB' : (b / 1048576).toFixed(1) + ' MB';
function okType(f: File) {
if (!opt.accept) return true;
return opt.accept.split(',').map((a) => a.trim().toLowerCase()).some((a) =>
a.endsWith('/*') ? f.type.toLowerCase().startsWith(a.slice(0, -1)) : a.startsWith('.') ? f.name.toLowerCase().endsWith(a) : f.type.toLowerCase() === a);
}
function setBall() {
const f = queue[0];
ball.classList.toggle('is-empty', !f);
(ball.querySelector('b') as HTMLElement).textContent = f ? ext(f.name) : '+';
ball.setAttribute('aria-label', f ? 'Shoot ' + f.name + ' into the basket to upload it' : 'Choose files to upload');
foot.classList.remove('is-miss');
foot.textContent = f ? (reduce ? 'Press the file to upload it' : 'Pull the file back, aim and release. Or just tap it.') : 'Add files: drop them here or tap the +';
if (!flying) place(geo().start.x, geo().start.y);
}
function addFiles(list: FileList | File[], auto: boolean) {
let added = 0;
Array.from(list).forEach((f) => {
const why = !okType(f) ? 'This file type is not allowed' : f.size > opt.maxBytes ? 'Too large (max ' + size(opt.maxBytes) + ')' : '';
if (why) { row(f).fail(why); return; }
if (auto && opt.autoShoot) {
autoSet.add(f);
/* real files go ahead of a waiting demo/manual file */
let i = 0; while (i < queue.length && autoSet.has(queue[i])) i++;
if (flying && i === 0 && queue.length) i = 1; /* never jump ahead of the file in the air */
queue.splice(i, 0, f);
} else queue.push(f);
added++;
});
setBall();
if (added) sr.textContent = added + ' file' + (added > 1 ? 's' : '') + ' ready';
autoNext();
}
/* Only files that were dropped or browsed fly in by themselves. */
function autoNext() { setTimeout(() => { if (alive && !flying && queue.length && autoSet.has(queue[0])) shoot(null); }, 160); }
/* ---------- rows ---------- */
function row(f: File) {
const el = document.createElement('div'); el.className = 'su-row';
el.innerHTML = '<span class="su-type"></span><span class="su-name"></span><span class="su-state">0%</span><span class="su-bar" role="progressbar" aria-label="Upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><i></i></span><span class="su-meta"><span class="su-msg">Uploading…</span><span></span></span>';
(el.querySelector('.su-type') as HTMLElement).textContent = ext(f.name);
(el.querySelector('.su-name') as HTMLElement).textContent = f.name; (el.querySelector('.su-name') as HTMLElement).title = f.name;
(el.querySelector('.su-meta span:last-child') as HTMLElement).textContent = size(f.size);
rows.prepend(el); court.classList.add('has-rows');
const st = el.querySelector('.su-state') as HTMLElement, bar = el.querySelector('.su-bar') as HTMLElement, msg = el.querySelector('.su-msg') as HTMLElement;
return {
progress(p: number) { const v = Math.max(0, Math.min(100, Math.round(p))); (bar.firstChild as HTMLElement).style.width = v + '%'; bar.setAttribute('aria-valuenow', String(v)); st.textContent = v + '%'; },
done() { el.classList.add('is-done'); st.textContent = 'Done'; msg.textContent = '✓ File uploaded successfully'; (bar.firstChild as HTMLElement).style.width = '100%'; bar.setAttribute('aria-valuenow', '100'); },
fail(why: string) { el.classList.add('is-error'); st.textContent = 'Failed'; msg.textContent = why; sr.textContent = f.name + ': ' + why; },
};
}
function send(f: File) {
const r = row(f);
upload(f, (p) => r.progress(p)).then(() => {
r.done(); uploaded++; count.textContent = 'Uploaded ' + uploaded; count.classList.remove('bump'); void count.offsetWidth; count.classList.add('bump');
sr.textContent = f.name + ' uploaded'; if (opt.onUploaded) opt.onUploaded(f);
}).catch((e: Error) => r.fail(e && e.message ? e.message : 'Upload failed'));
}
/* ---------- flight ---------- */
function drawDots(vx: number, vy: number) {
if (vx === 0 && vy === 0) { dots.innerHTML = ''; return; }
const G = geo(); let x = G.start.x, y = G.start.y, s = '';
for (let i = 1; i <= 16; i++) { const t = i * 0.045; const px = x + vx * t, py = y + vy * t + 0.5 * G.g * t * t; if (py > G.H) break; s += '<circle cx="' + px.toFixed(1) + '" cy="' + py.toFixed(1) + '" r="' + (2.6 - i * 0.1).toFixed(2) + '" opacity="' + (1 - i / 18).toFixed(2) + '"/>'; }
dots.innerHTML = s;
}
function shoot(v: { vx: number; vy: number } | null) {
const f = queue[0];
if (!f || flying) return;
flying = true; drawDots(0, 0);
const G = geo(); const p = v || perfect();
if (reduce) { scored(f); return; }
let x = G.start.x, y = G.start.y, vx = p.vx, vy = p.vy, last = -1, age = 0, rest = 0;
ball.classList.add('is-front');
const ends = [{ x: G.rimL, y: G.rimY }, { x: G.rimR, y: G.rimY }];
const step = (now: number) => {
if (!alive) return;
if (last < 0) last = now;
let dt = Math.max(0, Math.min(0.034, (now - last) / 1000)); last = now; age += dt;
const sub = 4; dt /= sub;
for (let k = 0; k < sub; k++) {
const py = y;
vy += G.g * dt; x += vx * dt; y += vy * dt;
if (py < G.rimY && y >= G.rimY && vy > 0 && x > G.rimL + 3 && x < G.rimR - 3) { scored(f, x); return; }
for (const e of ends) {
const dx = x - e.x, dy = y - e.y, d = Math.hypot(dx, dy), min = R * 0.55 + 3;
if (d < min && d > 0) { const nx = dx / d, ny = dy / d, dot = vx * nx + vy * ny; if (dot < 0) { vx -= 1.6 * dot * nx; vy -= 1.6 * dot * ny; blip('bounce'); } x = e.x + nx * min; y = e.y + ny * min; }
}
if (x < R) { x = R; vx = Math.abs(vx) * 0.6; } if (x > G.W - R) { x = G.W - R; vx = -Math.abs(vx) * 0.6; }
if (y < R) { y = R; vy = Math.abs(vy) * 0.5; }
if (y > G.H - R) { y = G.H - R; if (Math.abs(vy) > 120) blip('bounce'); vy = -Math.abs(vy) * 0.42; vx *= 0.8; if (Math.abs(vy) < 60) rest++; }
}
place(x, y, angle + vx * 0.012);
if (rest > 8 || age > 3.2) { missed(); return; }
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function scored(f: File, x: number = NaN) {
const G = geo(); const sx = isNaN(x) ? (G.rimL + G.rimR) / 2 : x;
queue.shift(); blip('swish');
hoop.classList.remove('swish'); void hoop.offsetWidth; hoop.classList.add('swish');
let t0 = -1; const y0 = G.rimY;
const fall = (now: number) => {
if (t0 < 0) t0 = now;
const k = Math.min(1, (now - t0) / (reduce ? 1 : 380));
ball.style.opacity = String(1 - k);
place(sx + ((G.rimL + G.rimR) / 2 - sx) * k, y0 + 46 * k, angle, 1 - 0.35 * k);
if (k < 1 && alive) { raf = requestAnimationFrame(fall); return; }
ball.classList.remove('is-front'); flying = false; ball.style.opacity = '0';
send(f);
if (opt.demoFiles && !queue.length) queue.push(sample());
setBall();
requestAnimationFrame(() => { ball.style.transition = 'opacity .25s'; ball.style.opacity = '1'; setTimeout(() => (ball.style.transition = ''), 260); });
autoNext();
};
raf = requestAnimationFrame(fall);
}
function missed() {
flying = false; ball.classList.remove('is-front'); blip('miss');
foot.textContent = 'Missed! Pull back further or tap the file for a sure shot.'; foot.classList.add('is-miss');
ball.style.transition = 'transform .35s cubic-bezier(.2,.8,.2,1)'; const G = geo(); place(G.start.x, G.start.y);
setTimeout(() => (ball.style.transition = ''), 360);
}
function sample() { const s = SAMPLES[demoIndex++ % SAMPLES.length]; const f = new File([new Uint8Array(16)], s[0] as string, { type: s[2] as string }); Object.defineProperty(f, 'size', { value: s[1] }); return f; }
/* ---------- input ---------- */
function onDown(e: PointerEvent) {
if (flying || !queue.length || e.button > 0) return;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false };
ball.setPointerCapture(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!drag || e.pointerId !== drag.id) return;
const G = geo(); let dx = e.clientX - drag.x, dy = e.clientY - drag.y; const d = Math.hypot(dx, dy);
if (d > 6) drag.moved = true;
if (!drag.moved || reduce) return;
if (d > G.maxPull) { dx *= G.maxPull / d; dy *= G.maxPull / d; }
place(G.start.x + dx * 0.35, G.start.y + dy * 0.35, dx * 0.15);
const k = power(); drawDots(-dx * k, -dy * k);
}
function onUp(e: PointerEvent) {
if (!drag || e.pointerId !== drag.id) return;
const G = geo(); let dx = e.clientX - drag.x, dy = e.clientY - drag.y; const d = Math.hypot(dx, dy), moved = drag.moved;
drag = null; drawDots(0, 0);
if (!moved || reduce) return; /* a tap is handled by click */
if (d < 18) { place(G.start.x, G.start.y); return; }
if (d > G.maxPull) { dx *= G.maxPull / d; dy *= G.maxPull / d; }
const k = power(); ball.dataset.dragged = '1'; shoot({ vx: -dx * k, vy: -dy * k });
}
function onClick() {
if (ball.dataset.dragged) { delete ball.dataset.dragged; return; }
if (flying) return;
if (!queue.length) { input.click(); return; }
shoot(null);
}
const prevent = (e: DragEvent) => { e.preventDefault(); court.classList.add('is-over'); };
const leave = (e: DragEvent) => { if (!court.contains(e.relatedTarget as Node)) court.classList.remove('is-over'); };
const drop = (e: DragEvent) => { e.preventDefault(); court.classList.remove('is-over'); if (e.dataTransfer && e.dataTransfer.files.length) addFiles(e.dataTransfer.files, true); };
const picked = () => { if (input.files) addFiles(input.files, true); input.value = ''; };
const browse = () => input.click();
const toggleSound = () => { soundOn = !soundOn; soundBtn.setAttribute('aria-pressed', String(soundOn)); soundBtn.setAttribute('aria-label', soundOn ? 'Sound effects on' : 'Sound effects off'); };
const resize = () => { if (!flying) { const G = geo(); place(G.start.x, G.start.y); } };
ball.addEventListener('pointerdown', onDown); ball.addEventListener('pointermove', onMove); ball.addEventListener('pointerup', onUp); ball.addEventListener('pointercancel', onUp);
ball.addEventListener('click', onClick);
court.addEventListener('dragenter', prevent); court.addEventListener('dragover', prevent); court.addEventListener('dragleave', leave); court.addEventListener('drop', drop);
input.addEventListener('change', picked); $('.su-browse').addEventListener('click', browse); soundBtn.addEventListener('click', toggleSound);
window.addEventListener('resize', resize);
if (opt.demoFiles) queue.push(sample());
setBall();
return () => { alive = false; cancelAnimationFrame(raf); window.removeEventListener('resize', resize); root.innerHTML = ''; };
}
/* Demo uploader: fake progress so the preview works without a server. */
function fakeUpload(file: File, onProgress: (pct: number) => void): Promise<void> {
return new Promise((res) => { let p = 0; const t = setInterval(() => { p = Math.min(100, p + 7 + Math.random() * 16); onProgress(p); if (p >= 100) { clearInterval(t); res(); } }, 110); });
}
export function ShootUploader({ courtHeight = 210, dark, ...options }: ShootUploaderOptions & { courtHeight?: number; dark?: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const opts = useRef(options);
opts.current = options;
useEffect(() => {
if (!ref.current) return;
// Callbacks always use the latest props without remounting the court.
return mountShootUploader(ref.current, {
...opts.current,
upload: (f, p) => (opts.current.upload ? opts.current.upload(f, p) : fakeUpload(f, p)),
onUploaded: (f) => opts.current.onUploaded?.(f),
});
}, []);
return (
<>
<style>{css}</style>
<div ref={ref} className={`su${dark ? " su--dark" : ""}`} style={{ "--su-h": `${courtHeight}px` } as CSSProperties} />
</>
);
}
/* Usage:
<ShootUploader accept="image/*,application/pdf" maxBytes={10 * 1024 * 1024}
upload={xhrUpload("/api/upload")} onUploaded={(f) => console.log("uploaded", f.name)} />
*/