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:
@@ -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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/app/(main)/dashboard/event-themes/new/page.tsx
Normal file
17
frontend/src/app/(main)/dashboard/event-themes/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Add Asset" to add theme assets.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
480
frontend/src/components/event-themes/event-theme-form.tsx
Normal file
480
frontend/src/components/event-themes/event-theme-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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 }
|
||||
186
frontend/src/hooks/usePresignedUpload.ts
Normal file
186
frontend/src/hooks/usePresignedUpload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user