Add components and hooks for event theme management
Some checks failed
Build and Push Docker Images / changes (push) Successful in 5s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Failing after 49s

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:
2025-03-13 08:36:20 +01:00
parent f2f8d85775
commit 4044d85410
7 changed files with 1137 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
"use client";
import React, { useEffect } from "react";
import { useParams } from "next/navigation";
import { useEventThemes } from "@/context/event-theme-context";
import { Card, CardContent } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { EventThemeForm } from "@/components/event-themes/event-theme-form";
export default function EditThemePage() {
const params = useParams();
const themeId = params.id as string;
const { theme, isLoadingTheme, fetchThemeById } = useEventThemes();
useEffect(() => {
if (themeId) {
fetchThemeById(themeId);
}
}, [themeId, fetchThemeById]);
if (isLoadingTheme) {
return (
<Card className="w-full flex justify-center items-center p-12">
<CardContent>
<div className="flex flex-col items-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-lg text-gray-600">Loading theme...</p>
</div>
</CardContent>
</Card>
);
}
if (!theme) {
return (
<Card className="w-full p-12">
<CardContent>
<div className="text-center text-red-600">
<h2 className="text-xl font-semibold">Theme not found</h2>
<p className="mt-2">
The theme you are trying to edit could not be found.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<div className="container mx-auto py-6 space-y-6">
<h1 className="text-3xl font-bold">Edit Theme: {theme.name}</h1>
<p className="text-gray-600">
Make changes to your theme. These changes will affect all events using
this theme.
</p>
<EventThemeForm mode="edit" theme={theme} />
</div>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import React from "react";
import { EventThemeForm } from "@/components/event-themes/event-theme-form";
export default function NewThemePage() {
return (
<div className="container mx-auto py-6 space-y-6">
<h1 className="text-3xl font-bold">Create New Theme</h1>
<p className="text-gray-600">
Create a new theme for your events. Themes control the visual appearance
of your invitation pages and can include colors, fonts, and images.
</p>
<EventThemeForm mode="create" />
</div>
);
}

View File

@@ -0,0 +1,144 @@
// src/components/themes/event-theme-assets-uploader.tsx
"use client";
import React, { useState } from "react";
import { PlusCircle, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ImageUploader } from "@/components/ui/image-uploader";
interface AssetImage {
key: string;
url: string | null;
}
interface EventThemeAssetsUploaderProps {
initialAssets?: Record<string, string>;
onChange: (assets: Record<string, string>) => void;
}
export function EventThemeAssetsUploader({
initialAssets = {},
onChange,
}: EventThemeAssetsUploaderProps) {
// Convert record to array of assets for easier manipulation
const initialAssetsArray = Object.entries(initialAssets).map(
([key, url]) => ({ key, url }),
);
const [assets, setAssets] = useState<AssetImage[]>(
initialAssetsArray.length > 0
? initialAssetsArray
: [{ key: "", url: null }],
);
// Update the parent component when assets change
const updateAssets = (newAssets: AssetImage[]) => {
setAssets(newAssets);
// Convert array back to record for onChange callback
const assetsRecord: Record<string, string> = {};
newAssets.forEach((asset) => {
if (asset.key && asset.url) {
assetsRecord[asset.key] = asset.url;
}
});
onChange(assetsRecord);
};
// Add a new empty asset
const handleAddAsset = () => {
updateAssets([...assets, { key: "", url: null }]);
};
// Remove an asset by index
const handleRemoveAsset = (index: number) => {
const newAssets = [...assets];
newAssets.splice(index, 1);
updateAssets(newAssets);
};
// Update an asset key
const handleKeyChange = (index: number, newKey: string) => {
const newAssets = [...assets];
newAssets[index].key = newKey;
updateAssets(newAssets);
};
// Update an asset URL
const handleUrlChange = (index: number, newUrl: string | null) => {
const newAssets = [...assets];
newAssets[index].url = newUrl;
updateAssets(newAssets);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base">Theme Assets</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddAsset}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Asset
</Button>
</div>
<div className="space-y-4">
{assets.map((asset, index) => (
<div
key={index}
className="grid grid-cols-1 md:grid-cols-[1fr,2fr,auto] gap-4 items-start p-4 border rounded-md bg-gray-50"
>
<div>
<Label htmlFor={`asset-key-${index}`} className="mb-2 block">
Asset Key
</Label>
<Input
id={`asset-key-${index}`}
value={asset.key}
onChange={(e) => handleKeyChange(index, e.target.value)}
placeholder="E.g., 'animal1', 'balloon', etc."
/>
</div>
<ImageUploader
id={`asset-url-${index}`}
label="Asset Image"
imageUrl={asset.url}
purpose="theme-asset"
onChange={(url) => handleUrlChange(index, url)}
aspectRatio="square"
maxWidth={400}
maxHeight={400}
/>
<div className="flex items-end h-full pb-2">
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => handleRemoveAsset(index)}
disabled={assets.length === 1}
title="Remove Asset"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{assets.length === 0 && (
<p className="text-sm text-gray-500 italic">
No assets added. Click &quot;Add Asset&quot; to add theme assets.
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,480 @@
// src/components/themes/theme-form.tsx
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useEventThemes } from "@/context/event-theme-context";
import {
EventThemeCreate,
EventThemeResponse,
EventThemeUpdate,
} from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ImageUploader } from "@/components/ui/image-uploader";
import { EventThemeAssetsUploader } from "@/components/event-themes/event-theme-assets-uploader";
import { Loader2, PlusCircle, Trash2 } from "lucide-react";
interface ColorInput {
name: string;
value: string;
}
interface FontInput {
name: string;
value: string;
}
type ThemeFormProps = {
theme?: EventThemeResponse;
mode: "create" | "edit";
};
export function EventThemeForm({ theme, mode }: ThemeFormProps) {
const router = useRouter();
const { createTheme, updateTheme } = useEventThemes();
// Form state
const [formState, setFormState] = useState<
Omit<EventThemeCreate, "color_palette" | "fonts"> & {
color_palette?: Record<string, string>;
fonts?: Record<string, string>;
}
>({
name: theme?.name || "",
description: theme?.description || "",
preview_image_url: theme?.preview_image_url || null,
background_image_url: theme?.background_image_url || null,
foreground_image_url: theme?.foreground_image_url || null,
color_palette: theme?.color_palette || {
primary: "#3B82F6",
secondary: "#10B981",
accent: "#F59E0B",
background: "#FFFFFF",
text: "#1F2937",
},
asset_image_urls: theme?.asset_image_urls || {},
fonts: theme?.fonts || { heading: "Inter", body: "Inter" },
is_active: theme?.is_active !== undefined ? theme.is_active : true,
});
// Colors input state
const [colorInputs, setColorInputs] = useState<ColorInput[]>(
Object.entries(formState.color_palette || {}).map(([name, value]) => ({
name,
value,
})),
);
// Fonts input state
const [fontInputs, setFontInputs] = useState<FontInput[]>(
Object.entries(formState.fonts || {}).map(([name, value]) => ({
name,
value,
})),
);
// Submission state
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Convert color inputs to color palette object
useEffect(() => {
const palette: Record<string, string> = {};
colorInputs.forEach((input) => {
if (input.name && input.value) {
palette[input.name] = input.value;
}
});
setFormState((prev) => ({ ...prev, color_palette: palette }));
}, [colorInputs]);
// Convert font inputs to fonts object
useEffect(() => {
const fonts: Record<string, string> = {};
fontInputs.forEach((input) => {
if (input.name && input.value) {
fonts[input.name] = input.value;
}
});
setFormState((prev) => ({ ...prev, fonts }));
}, [fontInputs]);
// Handle form field changes
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
// Handle switch toggle
const handleSwitchChange = (checked: boolean) => {
setFormState((prev) => ({ ...prev, is_active: checked }));
};
// Handle image URL changes
const handleImageChange = (field: string, url: string | null) => {
setFormState((prev) => ({ ...prev, [field]: url }));
};
// Handle asset images change
const handleAssetImagesChange = (assets: Record<string, string>) => {
setFormState((prev) => ({ ...prev, asset_image_urls: assets }));
};
// Add a new color input
const addColorInput = () => {
setColorInputs([...colorInputs, { name: "", value: "#000000" }]);
};
// Remove a color input
const removeColorInput = (index: number) => {
const newInputs = [...colorInputs];
newInputs.splice(index, 1);
setColorInputs(newInputs);
};
// Update a color input
const updateColorInput = (index: number, field: string, value: string) => {
const newInputs = [...colorInputs];
newInputs[index] = { ...newInputs[index], [field]: value };
setColorInputs(newInputs);
};
// Add a new font input
const addFontInput = () => {
setFontInputs([...fontInputs, { name: "", value: "" }]);
};
// Remove a font input
const removeFontInput = (index: number) => {
const newInputs = [...fontInputs];
newInputs.splice(index, 1);
setFontInputs(newInputs);
};
// Update a font input
const updateFontInput = (index: number, field: string, value: string) => {
const newInputs = [...fontInputs];
newInputs[index] = { ...newInputs[index], [field]: value };
setFontInputs(newInputs);
};
// Form validation
const validateForm = (): boolean => {
if (!formState.name) {
setError("Theme name is required");
return false;
}
if (Object.keys(formState.color_palette || {}).length === 0) {
setError("At least one color is required");
return false;
}
if (Object.keys(formState.fonts || {}).length === 0) {
setError("At least one font is required");
return false;
}
return true;
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!validateForm()) {
return;
}
try {
setIsSubmitting(true);
if (mode === "create") {
await createTheme(formState as EventThemeCreate);
router.push("/dashboard/event-themes");
} else if (mode === "edit" && theme) {
await updateTheme(theme.id, formState as EventThemeUpdate);
router.push("/dashboard/event-themes");
}
} catch (err) {
console.error("Error submitting theme:", err);
setError(
err instanceof Error
? err.message
: "Failed to save theme. Please try again.",
);
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">
{mode === "create" ? "Create New Theme" : "Edit Theme"}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Theme Name *</Label>
<Input
id="name"
name="name"
value={formState.name}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<Label
htmlFor="is_active"
className="flex items-center space-x-2"
>
<span>Active</span>
<Switch
id="is_active"
checked={formState.is_active}
onCheckedChange={handleSwitchChange}
/>
</Label>
<p className="text-sm text-gray-500">
Only active themes can be selected for events
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
value={formState.description || ""}
onChange={handleChange}
rows={3}
/>
</div>
{/* Theme Images */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<ImageUploader
id="preview_image"
label="Preview Image"
imageUrl={formState.preview_image_url}
purpose="theme-preview"
onChange={(url) => handleImageChange("preview_image_url", url)}
aspectRatio="square"
maxWidth={400}
maxHeight={400}
required
/>
<ImageUploader
id="background_image"
label="Background Image"
imageUrl={formState.background_image_url}
purpose="theme-background"
onChange={(url) => handleImageChange("background_image_url", url)}
aspectRatio="wide"
maxWidth={1200}
maxHeight={800}
/>
<ImageUploader
id="foreground_image"
label="Foreground Image"
imageUrl={formState.foreground_image_url}
purpose="theme-foreground"
onChange={(url) => handleImageChange("foreground_image_url", url)}
aspectRatio="square"
maxWidth={600}
maxHeight={600}
/>
</div>
{/* Theme Assets */}
<EventThemeAssetsUploader
initialAssets={formState.asset_image_urls || {}}
onChange={handleAssetImagesChange}
/>
{/* Theme Colors */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base">Theme Colors *</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addColorInput}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Color
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{colorInputs.map((input, index) => (
<div
key={index}
className="flex items-center space-x-4 p-3 border rounded-md bg-gray-50"
>
<div className="flex-1">
<Label htmlFor={`color-name-${index}`} className="sr-only">
Color Name
</Label>
<Input
id={`color-name-${index}`}
value={input.name}
onChange={(e) =>
updateColorInput(index, "name", e.target.value)
}
placeholder="Color name (e.g., primary, secondary)"
/>
</div>
<div className="flex-1 flex items-center space-x-2">
<Input
type="color"
id={`color-value-${index}`}
value={input.value}
onChange={(e) =>
updateColorInput(index, "value", e.target.value)
}
className="w-16 h-10 p-1"
/>
<Input
type="text"
value={input.value}
onChange={(e) =>
updateColorInput(index, "value", e.target.value)
}
placeholder="#RRGGBB"
className="flex-1"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => removeColorInput(index)}
disabled={colorInputs.length <= 1}
title="Remove Color"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{/* Theme Fonts */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base">Theme Fonts *</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addFontInput}
>
<PlusCircle className="h-4 w-4 mr-2" />
Add Font
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{fontInputs.map((input, index) => (
<div
key={index}
className="flex items-center space-x-4 p-3 border rounded-md bg-gray-50"
>
<div className="flex-1">
<Label htmlFor={`font-name-${index}`} className="sr-only">
Font Purpose
</Label>
<Input
id={`font-name-${index}`}
value={input.name}
onChange={(e) =>
updateFontInput(index, "name", e.target.value)
}
placeholder="Font purpose (e.g., heading, body)"
/>
</div>
<div className="flex-1">
<Label htmlFor={`font-value-${index}`} className="sr-only">
Font Name
</Label>
<Input
id={`font-value-${index}`}
value={input.value}
onChange={(e) =>
updateFontInput(index, "value", e.target.value)
}
placeholder="Font name (e.g., Inter, Roboto)"
/>
</div>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => removeFontInput(index)}
disabled={fontInputs.length <= 1}
title="Remove Font"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{/* Error message */}
{error && (
<div className="p-3 bg-red-100 border border-red-300 text-red-700 rounded">
{error}
</div>
)}
{/* Form actions */}
<div className="flex justify-end space-x-3">
<Button
type="button"
variant="outline"
onClick={() => router.push("/dashboard/event-themes")}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{mode === "create" ? "Creating..." : "Saving..."}
</>
) : (
<>{mode === "create" ? "Create Theme" : "Save Changes"}</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View 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>
);
}

View 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 }

View File

@@ -0,0 +1,186 @@
import { useState, useCallback, useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { generatePresignedUrl, PresignedUrlResponse } from "@/client";
export interface FileUploadState {
file?: File;
preview?: string;
isUploading: boolean;
progress: number;
error?: string;
fileUrl?: string;
}
const initialState: FileUploadState = {
file: undefined,
preview: undefined,
isUploading: false,
progress: 0,
error: undefined,
fileUrl: undefined,
};
export interface UsePresignedUploadOptions {
fileType?: "image" | "document";
purpose?: string;
onUploadSuccess?: (fileUrl: string) => void;
onUploadError?: (error: string) => void;
}
export function usePresignedUpload(options: UsePresignedUploadOptions = {}) {
const {
fileType = "image",
purpose = "general",
onUploadSuccess,
onUploadError,
} = options;
const [uploadState, setUploadState] = useState<FileUploadState>(initialState);
const presignedUrlMutation = useMutation({
mutationFn: async (file: File): Promise<PresignedUrlResponse> => {
const { data } = await generatePresignedUrl({
body: {
filename: file.name,
content_type: file.type,
folder: purpose,
},
});
if (!data || !data.upload_url || !data.file_url) {
throw new Error("Invalid response obtaining presigned URLs");
}
return data;
},
});
const uploadFileToPresignedUrl = useCallback(
async (file: File, uploadUrl: string): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = ({ loaded, total }) => {
const progress = total ? Math.round((loaded / total) * 100) : 0;
setUploadState((prev) => ({ ...prev, progress }));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () =>
reject(new Error("Network error during file upload."));
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);
});
},
[],
);
useEffect(() => {
return () => {
if (uploadState.preview) {
URL.revokeObjectURL(uploadState.preview);
}
};
}, [uploadState.preview]);
const reset = useCallback(() => {
setUploadState((prev) => {
if (prev.preview) URL.revokeObjectURL(prev.preview);
return initialState;
});
}, []);
const selectFile = useCallback(
(file: File | null) => {
reset();
if (!file) return;
if (fileType === "image" && !file.type.startsWith("image/")) {
const error = "Selected file must be an image.";
setUploadState((prev) => ({ ...prev, error }));
onUploadError?.(error);
return;
}
const preview =
fileType === "image" ? URL.createObjectURL(file) : undefined;
setUploadState({
file,
preview,
isUploading: false,
progress: 0,
error: undefined,
fileUrl: undefined,
});
},
[reset, fileType, onUploadError],
);
const uploadFile = useCallback(async () => {
if (!uploadState.file) {
const error = "File not selected.";
setUploadState((prev) => ({ ...prev, error }));
onUploadError?.(error);
return;
}
setUploadState((prev) => ({
...prev,
isUploading: true,
progress: 0,
error: undefined,
}));
try {
const { upload_url, file_url } = await presignedUrlMutation.mutateAsync(
uploadState.file,
);
await uploadFileToPresignedUrl(uploadState.file, upload_url);
setUploadState((prev) => ({
...prev,
isUploading: false,
fileUrl: file_url,
progress: 100,
}));
onUploadSuccess?.(file_url);
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: "Unexpected error during upload.";
setUploadState((prev) => ({
...prev,
isUploading: false,
error: message,
}));
onUploadError?.(message);
}
}, [
uploadState.file,
presignedUrlMutation,
uploadFileToPresignedUrl,
onUploadSuccess,
onUploadError,
]);
return {
...uploadState,
selectFile,
uploadFile,
reset,
isPresignedUrlLoading: presignedUrlMutation.isPending,
};
}