inital working version

This commit is contained in:
Stefan Hardegger
2025-07-22 21:49:40 +02:00
parent bebb799784
commit 59d29dceaf
98 changed files with 8027 additions and 856 deletions

View File

@@ -0,0 +1,57 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import Link from 'next/link';
import LoadingSpinner from './LoadingSpinner';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
href?: string;
children: React.ReactNode;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading = false, href, className = '', children, disabled, ...props }, ref) => {
const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'theme-accent-bg text-white hover:theme-accent-bg focus:ring-theme-accent',
secondary: 'theme-card theme-text border theme-border hover:bg-opacity-80 focus:ring-theme-accent',
ghost: 'theme-text hover:bg-gray-100 dark:hover:bg-gray-800 focus:ring-theme-accent',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
const combinedClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
if (href) {
return (
<Link href={href} className={combinedClasses}>
{loading && <LoadingSpinner size="sm" className="mr-2" />}
{children}
</Link>
);
}
return (
<button
ref={ref}
className={combinedClasses}
disabled={disabled || loading}
{...props}
>
{loading && <LoadingSpinner size="sm" className="mr-2" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;

View File

@@ -0,0 +1,137 @@
'use client';
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import Image from 'next/image';
interface ImageUploadProps {
onImageSelect: (file: File | null) => void;
accept?: string;
maxSizeMB?: number;
aspectRatio?: string;
placeholder?: string;
currentImageUrl?: string;
}
export default function ImageUpload({
onImageSelect,
accept = 'image/*',
maxSizeMB = 5,
aspectRatio = '1:1',
placeholder = 'Drop an image here or click to select',
currentImageUrl,
}: ImageUploadProps) {
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
setError(null);
if (rejectedFiles.length > 0) {
const rejection = rejectedFiles[0];
if (rejection.errors?.[0]?.code === 'file-too-large') {
setError(`File is too large. Maximum size is ${maxSizeMB}MB.`);
} else if (rejection.errors?.[0]?.code === 'file-invalid-type') {
setError('Invalid file type. Please select an image file.');
} else {
setError('File rejected. Please try another file.');
}
return;
}
const file = acceptedFiles[0];
if (file) {
// Create preview
const previewUrl = URL.createObjectURL(file);
setPreview(previewUrl);
onImageSelect(file);
}
}, [onImageSelect, maxSizeMB]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': accept.split(',').map(type => type.trim()),
},
maxFiles: 1,
maxSize: maxSizeMB * 1024 * 1024, // Convert MB to bytes
});
const clearImage = () => {
setPreview(null);
setError(null);
onImageSelect(null);
};
const aspectRatioClass = {
'1:1': 'aspect-square',
'3:4': 'aspect-[3/4]',
'4:3': 'aspect-[4/3]',
'16:9': 'aspect-video',
}[aspectRatio] || 'aspect-square';
const displayImage = preview || currentImageUrl;
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragActive
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
: error
? 'border-red-300 bg-red-50 dark:bg-red-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
} ${displayImage ? 'p-0 border-0' : ''}`}
>
<input {...getInputProps()} />
{displayImage ? (
<div className={`relative ${aspectRatioClass} rounded-lg overflow-hidden group`}>
<Image
src={displayImage}
alt="Preview"
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all duration-200 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 space-x-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
clearImage();
}}
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
>
Remove
</button>
<span className="text-white text-sm">
or click to change
</span>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-4xl theme-text">📸</div>
<div className="theme-text">
{isDragActive ? (
<p>Drop the image here...</p>
) : (
<p>{placeholder}</p>
)}
</div>
<p className="text-sm text-gray-500">
Supports JPEG, PNG, WebP up to {maxSizeMB}MB
</p>
</div>
)}
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { InputHTMLAttributes, forwardRef, TextareaHTMLAttributes } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = '', ...props }, ref) => {
const baseClasses = 'w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus:outline-none focus:ring-2 focus:ring-theme-accent focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium theme-header mb-1">
{label}
</label>
)}
<input
ref={ref}
className={`${baseClasses} ${error ? 'border-red-500' : ''} ${className}`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, className = '', rows = 4, ...props }, ref) => {
const baseClasses = 'w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus:outline-none focus:ring-2 focus:ring-theme-accent focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed resize-vertical';
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium theme-header mb-1">
{label}
</label>
)}
<textarea
ref={ref}
rows={rows}
className={`${baseClasses} ${error ? 'border-red-500' : ''} ${className}`}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
</div>
);
}
);
Textarea.displayName = 'Textarea';
export { Input, Textarea };

View File

@@ -0,0 +1,29 @@
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export default function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<div className={`inline-block ${sizeClasses[size]} ${className}`}>
<div className="animate-spin rounded-full border-2 border-gray-300 border-t-theme-accent" />
</div>
);
}
export function FullPageSpinner() {
return (
<div className="min-h-screen flex items-center justify-center theme-bg">
<div className="text-center">
<LoadingSpinner size="lg" className="mb-4" />
<p className="theme-text">Loading StoryCove...</p>
</div>
</div>
);
}