Components
Loading preview...
A file upload component for React.
npx shadcn@latest add https://21st.dev/r/sean0205/file-upload'use client';
import { useState } from 'react';
import { useFileUpload } from '@/components/ui/file-upload';
import { Alert, AlertContent, AlertDescription, AlertIcon, AlertTitle } from '@/components/ui/alert-1';
import { Button } from '@/components/ui/button-1';
import { CloudUpload, ImageIcon, TriangleAlert, Upload, XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Throttle function
* @param func - The function to throttle.
* @param delay - Delay in milliseconds.
*/
export const throttle = (func: Function, delay: number) => {
let lastCall = 0;
return function (...args: any[]) {
const now = new Date().getTime();
if (now - lastCall < delay) return;
lastCall = now;
return func(...args);
};
};
/**
* Debounces a function to delay its execution until after a specified delay.
*
* @param func - The function to debounce.
* @param wait - The delay in milliseconds.
* @returns A debounced version of the provided function.
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function (...args: Parameters<T>): void {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}
/**
* Generates a unique identifier using the current timestamp and a random number.
*
* @returns A string representing the unique ID.
*/
export function uid(): string {
return (Date.now() + Math.floor(Math.random() * 1000)).toString();
}
/**
* Extracts initials from a given name.
*
* @param name - The full name to extract initials from.
* @param count - The number of initials to return. Defaults to all initials.
* @returns A string of initials from the name.
*/
export const getInitials = (name: string | null | undefined, count?: number): string => {
if (!name || typeof name !== 'string') {
return '';
}
const initials = name
.split(' ')
.filter(Boolean)
.map((part) => part[0].toUpperCase());
return count && count > 0 ? initials.slice(0, count).join('') : initials.join('');
};
/**
* Formats a date as a readable string in "Month Day, Year" format.
*
* @param input - A date string or timestamp to format.
* @returns A string formatted as "Month Day, Year".
*/
export function formatDate(input: Date | string | number): string {
const date = new Date(input);
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
/**
* Formats a date and time as a readable string in "Month Day, Year, Hour:Minute AM/PM" format.
*
* @param input - A date string or timestamp to format.
* @returns A string formatted as "Month Day, Year, Hour:Minute AM/PM".
*/
export function formatDateTime(input: Date | string | number): string {
const date = new Date(input);
return date.toLocaleString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
});
}
/**
* Formats a number as a currency string.
*
* @param amount - The numeric value to format as currency.
* @param currency - The currency code (e.g., "USD", "EUR"). Defaults to "USD".
* @param locale - The locale for formatting (e.g., "en-US"). Defaults to "en-US".
* @returns A string formatted as currency.
*/
export function formatCurrency(amount: number, currency: string = 'USD', locale: string = 'en-US'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
/**
* Constructs an absolute URL based on the base application URL.
*
* @param path - The relative path to append to the base URL.
* @returns A string representing the absolute URL.
*/
export function absoluteUrl(path: string): string {
return `${process.env.NEXT_PUBLIC_APP_URL}${path}`;
}
/**
* Constructs an absolute URL for media assets.
*
* @param path - The relative path to the media asset (e.g., "/media/avatars/1.png").
* @returns A string representing the absolute URL to the media asset.
*/
export function toAbsoluteUrl(path: string): string {
// Remove leading slash if present to avoid double slashes
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
return `/${cleanPath}`;
}
/**
Retrieves a list of supported time zones with their labels and values.
This function fetches the available time zones from the environment,
formats their offsets (e.g., "GMT+2"), and returns them in a sorted array.
*/
export const getTimeZones = (): { label: string; value: string }[] => {
// Fetch supported timezones
const timezones = Intl.supportedValuesOf('timeZone');
return timezones
.map((timezone) => {
const formatter = new Intl.DateTimeFormat('en', {
timeZone: timezone,
timeZoneName: 'shortOffset',
});
const parts = formatter.formatToParts(new Date());
const offset = parts.find((part) => part.type === 'timeZoneName')?.value || '';
const formattedOffset = offset === 'GMT' ? 'GMT+0' : offset;
return {
value: timezone,
label: `(${formattedOffset}) ${timezone.replace(/_/g, ' ')}`,
numericOffset: parseInt(formattedOffset.replace('GMT', '').replace('+', '') || '0'),
};
})
.sort((a, b) => a.numericOffset - b.numericOffset);
};
/**
* Generates a URL-friendly slug from a given title.
* @param title - The title to convert into a slug (e.g., "Write a Proposal")
* @returns A slug string (e.g., "write-a-proposal")
*/
export function getSlug(title: string): string {
// Return empty string for invalid input
if (!title || typeof title !== 'string') {
return '';
}
return title
.toLowerCase() // Convert to lowercase for consistency
.trim() // Remove leading/trailing whitespace
.normalize('NFD') // Normalize unicode (e.g., "é" -> "e")
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters except spaces/hyphens
.replaceAll(/\s+/g, '-') // Replace spaces with single hyphen
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
function formatBytes(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Byte';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
}
export function useCopyToClipboard() {
const [copied, setCopied] = useState(false);
const copy = async (text: string) => {
if (!navigator?.clipboard) return false;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
return true;
} catch (error) {
console.error('Failed to copy:', error);
setCopied(false);
return false;
}
};
return { copy, copied };
}
interface CoverUploadProps {
maxSize?: number;
accept?: string;
className?: string;
onImageChange?: (file: File | null) => void;
}
export default function CoverUpload({
maxSize = 5 * 1024 * 1024, // 5MB default
accept = 'image/*',
className,
onImageChange,
}: CoverUploadProps) {
// Default cover image
const defaultCoverImage: FileMetadata = {
id: 'default-cover',
name: 'cover-image.jpg',
size: 2048000,
type: 'image/jpeg',
url: 'https://picsum.photos/1000/800?grayscale&random=3',
};
const [coverImage, setCoverImage] = useState<FileWithPreview | null>({
id: defaultCoverImage.id,
file: defaultCoverImage,
preview: defaultCoverImage.url,
});
const [imageLoading, setImageLoading] = useState(true);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [
{ isDragging, errors },
{ handleDragEnter, handleDragLeave, handleDragOver, handleDrop, openFileDialog, getInputProps },
] = useFileUpload({
maxFiles: 1,
maxSize,
accept,
multiple: false,
onFilesChange: (files) => {
if (files.length > 0) {
setImageLoading(true);
setIsUploading(true);
setUploadProgress(0);
setUploadError(null);
setCoverImage(files[0]);
onImageChange?.(files[0].file as File);
// Simulate upload progress
simulateUpload();
}
},
});
// Simulate upload progress
const simulateUpload = () => {
const interval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 100) {
clearInterval(interval);
setIsUploading(false);
// Simulate occasional upload failure (10% chance)
if (Math.random() < 0.1) {
setUploadError('Upload failed. Please try again.');
return 0;
}
return 100;
}
// Random progress increment between 5-15%
const increment = Math.random() * 10 + 5;
return Math.min(prev + increment, 100);
});
}, 200);
};
const removeCoverImage = () => {
setCoverImage(null);
setImageLoading(false);
setIsUploading(false);
setUploadProgress(0);
setUploadError(null);
onImageChange?.(null);
};
const retryUpload = () => {
if (coverImage) {
setUploadError(null);
setIsUploading(true);
setUploadProgress(0);
simulateUpload();
}
};
const hasImage = coverImage && coverImage.preview;
return (
<div className="flex flex-col gap-5 p-10 w-full mx-auto h-screen justify-center items-center">
<div className={cn('w-full space-y-4', className)}>
{/* Cover Upload Area */}
<div
className={cn(
'group relative overflow-hidden rounded-xl transition-all duration-200 border border-border',
isDragging
? 'border-dashed border-primary bg-primary/5'
: hasImage
? 'border-border bg-background hover:border-primary/50'
: 'border-dashed border-muted-foreground/25 bg-muted/30 hover:border-primary hover:bg-primary/5',
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Hidden file input */}
<input {...getInputProps()} className="sr-only" />
{hasImage ? (
<>
{/* Cover Image Display */}
<div className="relative aspect-[21/9] w-full">
{/* Loading placeholder */}
{imageLoading && (
<div className="absolute inset-0 animate-pulse bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<ImageIcon className="size-5" />
<span className="text-sm">Loading image...</span>
</div>
</div>
)}
{/* Actual image */}
<img
src={coverImage.preview}
alt="Cover"
className={cn(
'h-full w-full object-cover transition-opacity duration-300',
imageLoading ? 'opacity-0' : 'opacity-100',
)}
onLoad={() => setImageLoading(false)}
onError={() => setImageLoading(false)}
/>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black/0 transition-all duration-200 group-hover:bg-black/40" />
{/* Action buttons overlay */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="flex gap-2">
<Button
onClick={openFileDialog}
variant="secondary"
size="sm"
className="bg-white/90 text-gray-900 hover:bg-white"
>
<Upload />
Change Cover
</Button>
<Button onClick={removeCoverImage} variant="destructive" size="sm">
<XIcon />
Remove
</Button>
</div>
</div>
{/* Upload progress */}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<div className="relative">
<svg className="size-16 -rotate-90" viewBox="0 0 64 64">
<circle
cx="32"
cy="32"
r="28"
fill="none"
stroke="currentColor"
strokeWidth="4"
className="text-white/20"
/>
<circle
cx="32"
cy="32"
r="28"
fill="none"
stroke="currentColor"
strokeWidth="4"
strokeDasharray={`${2 * Math.PI * 28}`}
strokeDashoffset={`${2 * Math.PI * 28 * (1 - uploadProgress / 100)}`}
className="text-white transition-all duration-300"
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-medium text-white">{Math.round(uploadProgress)}%</span>
</div>
</div>
</div>
)}
</div>
</>
) : (
/* Empty State */
<div
className="flex aspect-[21/9] w-full cursor-pointer flex-col items-center justify-center gap-4 p-8 text-center"
onClick={openFileDialog}
>
<div className="rounded-full bg-primary/10 p-4">
<CloudUpload className="size-8 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Upload Cover Image</h3>
<p className="text-sm text-muted-foreground">Drag and drop an image here, or click to browse</p>
<p className="text-xs text-muted-foreground">Recommended size: 1200x514px • Max size: 5MB</p>
</div>
<Button variant="outline" size="sm">
<ImageIcon />
Browse Files
</Button>
</div>
)}
</div>
{/* Error Messages */}
{errors.length > 0 && (
<Alert variant="destructive" appearance="light" className="mt-5">
<AlertIcon>
<TriangleAlert />
</AlertIcon>
<AlertContent>
<AlertTitle>File upload error(s)</AlertTitle>
<AlertDescription>
{errors.map((error, index) => (
<p key={index} className="last:mb-0">
{error}
</p>
))}
</AlertDescription>
</AlertContent>
</Alert>
)}
{/* Upload Error */}
{uploadError && (
<Alert variant="destructive" appearance="light" className="mt-5">
<AlertIcon>
<TriangleAlert />
</AlertIcon>
<AlertContent>
<AlertTitle>Upload failed</AlertTitle>
<AlertDescription>
<p>{uploadError}</p>
<Button onClick={retryUpload} variant="primary" size="sm">
Retry Upload
</Button>
</AlertDescription>
</AlertContent>
</Alert>
)}
{/* Upload Tips */}
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="mb-2 text-sm font-medium">Cover Image Guidelines</h4>
<ul className="space-y-1 text-xs text-muted-foreground">
<li>• Use high-quality images with good lighting and composition</li>
<li>• Recommended aspect ratio: 21:9 (ultrawide) for best results</li>
<li>• Avoid images with important content near the edges</li>
<li>• Supported formats: JPG, PNG, WebP</li>
</ul>
</div>
</div>
</div>
);
}