226 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|