Add components and hooks for event theme management
Introduced the `usePresignedUpload` hook for file uploads and multiple components for event theme management, including form handling, asset uploads, and UI enhancements. These additions support creating, editing, and managing event themes effectively.
This commit is contained in:
220
frontend/src/components/ui/image-uploader.tsx
Normal file
220
frontend/src/components/ui/image-uploader.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
// src/components/ui/image-uploader.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { usePresignedUpload } from "@/hooks/usePresignedUpload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Loader2, RefreshCw, Upload, X } from "lucide-react";
|
||||
|
||||
export interface ImageUploaderProps {
|
||||
id: string;
|
||||
label?: string;
|
||||
imageUrl?: string | null;
|
||||
purpose?: string;
|
||||
className?: string;
|
||||
onChange?: (url: string | null) => void;
|
||||
aspectRatio?: "square" | "wide" | "tall";
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function ImageUploader({
|
||||
id,
|
||||
label,
|
||||
imageUrl: initialImageUrl,
|
||||
purpose = "theme",
|
||||
className = "",
|
||||
onChange,
|
||||
aspectRatio = "square",
|
||||
maxWidth = 800,
|
||||
maxHeight = 800,
|
||||
required = false,
|
||||
}: ImageUploaderProps) {
|
||||
const [existingImage, setExistingImage] = useState<string | null | undefined>(
|
||||
initialImageUrl,
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
file,
|
||||
preview,
|
||||
isUploading,
|
||||
progress,
|
||||
error,
|
||||
fileUrl,
|
||||
selectFile,
|
||||
uploadFile,
|
||||
reset,
|
||||
} = usePresignedUpload({
|
||||
purpose,
|
||||
onUploadSuccess: (url) => {
|
||||
setExistingImage(null);
|
||||
if (onChange) onChange(url);
|
||||
},
|
||||
});
|
||||
|
||||
// Set aspect ratio class
|
||||
const getAspectRatioClass = () => {
|
||||
switch (aspectRatio) {
|
||||
case "wide":
|
||||
return "aspect-video";
|
||||
case "tall":
|
||||
return "aspect-[3/4]";
|
||||
case "square":
|
||||
default:
|
||||
return "aspect-square";
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] || null;
|
||||
selectFile(file);
|
||||
// Reset the input value so the same file can be selected again if needed
|
||||
if (event.target.value) event.target.value = "";
|
||||
};
|
||||
|
||||
// Handle file upload
|
||||
const handleUpload = async () => {
|
||||
await uploadFile();
|
||||
};
|
||||
|
||||
// Clear the selection or existing image
|
||||
const handleClear = () => {
|
||||
if (existingImage) {
|
||||
setExistingImage(null);
|
||||
if (onChange) onChange(null);
|
||||
} else if (file || preview || fileUrl) {
|
||||
reset();
|
||||
if (onChange && fileUrl) onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the file input click
|
||||
const handleSelectClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// Determine the current image to display
|
||||
const currentImage = fileUrl || preview || existingImage;
|
||||
|
||||
return (
|
||||
<div className={`w-full space-y-2 ${className}`}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{currentImage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" /> Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`relative border-2 border-dashed border-gray-300 rounded-md ${getAspectRatioClass()} overflow-hidden bg-gray-50 hover:bg-gray-100 transition-colors`}
|
||||
>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id={id}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Preview image */}
|
||||
{currentImage ? (
|
||||
<div className="w-full h-full relative group">
|
||||
<Image
|
||||
src={currentImage}
|
||||
alt="Selected image"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSelectClick}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" /> Change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Upload placeholder
|
||||
<div
|
||||
className="w-full h-full flex flex-col items-center justify-center p-4 cursor-pointer"
|
||||
onClick={handleSelectClick}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-center text-gray-500">
|
||||
Click to select an image
|
||||
</p>
|
||||
<p className="text-xs text-center text-gray-400 mt-1">
|
||||
Recommended: {maxWidth}x{maxHeight} pixels
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress overlay */}
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-white animate-spin mb-2" />
|
||||
<p className="text-white text-sm mb-2">Uploading...</p>
|
||||
<div className="w-3/4">
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
{file && !fileUrl && !isUploading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
className="w-full"
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/ui/progress.tsx
Normal file
31
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
Reference in New Issue
Block a user