brackt/app/components/ui/SportIconUploader.tsx
2026-05-08 21:19:09 -07:00

226 lines
7.8 KiB
TypeScript

import { useId, useRef, useState } from "react";
import { ImageIcon, Trash2, Upload } from "lucide-react";
import { Button } from "~/components/ui/button";
import { SportIcon } from "~/components/SportIcon";
import { cn } from "~/lib/utils";
interface SportIconOption {
id: string;
name: string;
iconUrl: string;
}
interface SportIconUploaderProps {
name?: string;
uploadUrl: string;
initialIconUrl?: string | null;
sportName?: string;
existingIconOptions?: SportIconOption[];
}
const MAX_UPLOAD_BYTES = 1024 * 1024;
const SVG_TYPE = "image/svg+xml";
export function SportIconUploader({
name = "iconUrl",
uploadUrl,
initialIconUrl = null,
sportName = "Sport icon",
existingIconOptions = [],
}: SportIconUploaderProps) {
const selectId = useId();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [iconUrl, setIconUrl] = useState(initialIconUrl ?? "");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "uploading" | "uploaded" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
function prepareFile(file: File | undefined) {
setError(null);
setStatus("idle");
if (!file) return;
if (file.type !== SVG_TYPE && !file.name.toLowerCase().endsWith(".svg")) {
setError("Choose an SVG file.");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError("Choose an SVG under 1 MB.");
return;
}
setSelectedFile(file);
const reader = new FileReader();
reader.addEventListener("load", () => setPreviewUrl(String(reader.result)));
reader.readAsDataURL(file);
}
function clearSelectedFile() {
setSelectedFile(null);
setPreviewUrl(null);
setError(null);
setStatus("idle");
}
function removeIcon() {
setIconUrl("");
clearSelectedFile();
}
function chooseExistingIcon(nextIconUrl: string) {
setIconUrl(nextIconUrl);
clearSelectedFile();
}
async function uploadSelectedFile() {
if (!selectedFile) {
setError("Choose an SVG first.");
return;
}
setStatus("uploading");
setError(null);
try {
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch(uploadUrl, { method: "POST", body: formData });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof result.error === "string" ? result.error : "Upload failed.");
}
if (typeof result.iconUrl !== "string") {
throw new Error("Upload response did not include an icon URL.");
}
setIconUrl(result.iconUrl);
setSelectedFile(null);
setPreviewUrl(null);
setStatus("uploaded");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Upload failed.");
}
}
return (
<div className="space-y-4">
<input type="hidden" name={name} value={iconUrl} />
<input
ref={fileInputRef}
type="file"
accept=".svg,image/svg+xml"
onChange={(event) => {
prepareFile(event.target.files?.[0]);
event.target.value = "";
}}
className="sr-only"
/>
{existingIconOptions.length > 0 && (
<div className="space-y-2">
<label htmlFor={selectId} className="text-sm font-medium">
Use an existing sport icon
</label>
<select
id={selectId}
value={existingIconOptions.some((option) => option.iconUrl === iconUrl) ? iconUrl : ""}
onChange={(event) => chooseExistingIcon(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Choose an icon from another sport...</option>
{existingIconOptions.map((option) => (
<option key={option.id} value={option.iconUrl}>
{option.name}
</option>
))}
</select>
<p className="text-sm text-muted-foreground">
Selecting a sport reuses its current icon URL; no upload required.
</p>
</div>
)}
{iconUrl && !selectedFile && (
<div className="space-y-3 rounded-md border border-border bg-muted/40 p-4">
<div className="flex items-center gap-3">
<SportIcon sportName={sportName} iconUrl={iconUrl} size="lg" />
<div className="min-w-0">
<p className="text-sm font-medium">Current icon</p>
<p className="truncate text-xs text-muted-foreground">{iconUrl}</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
Replace SVG
</Button>
<Button type="button" variant="outline" onClick={removeIcon}>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
</div>
</div>
)}
{!iconUrl && !selectedFile && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragEnter={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
prepareFile(event.dataTransfer.files[0]);
}}
className={cn(
"flex min-h-40 w-full flex-col items-center justify-center rounded-lg border border-dashed bg-card/40 px-6 py-8 text-center transition-colors",
isDragging ? "border-[#adf661] bg-[#adf661]/10" : "border-border hover:border-muted-foreground hover:bg-accent/40"
)}
>
<ImageIcon className="mb-3 h-9 w-9 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Drop an SVG icon</span>
<span className="text-xs text-muted-foreground/80">or click to browse</span>
</button>
)}
{selectedFile && previewUrl && (
<div className="space-y-3 rounded-md border border-border bg-background p-4">
<div className="flex items-center gap-3">
<img src={previewUrl} alt="" className="h-12 w-12 object-contain" />
<div className="min-w-0">
<p className="text-sm font-medium">Ready to upload</p>
<p className="truncate text-xs text-muted-foreground">{selectedFile.name}</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" onClick={uploadSelectedFile} disabled={status === "uploading"}>
<Upload className="mr-2 h-4 w-4" />
{status === "uploading" ? "Uploading..." : "Upload SVG"}
</Button>
<Button type="button" variant="outline" onClick={clearSelectedFile}>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
</div>
</div>
)}
{status === "uploaded" && <p className="text-sm text-[#adf661]">Icon uploaded. Save the sport to apply it.</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-muted-foreground">SVG only, up to 1 MB. Existing local icons keep working until replaced.</p>
</div>
);
}