feat: port all template categories to JSON format
- Ported Minimalist templates to JSON (Swiss Grid, Brutalist, etc.) - Ported Tech templates to JSON (SaaS, Terminal, Cyberpunk, etc.) - Ported Creative templates to JSON (Art Gallery, Zine, Pop Art, etc.) - Ported Industrial templates to JSON (Blueprint, Factory, Schematic, etc.) - Ported Nature templates to JSON (Botanical, Ocean, Mountain, etc.) - Ported Lifestyle templates to JSON (Cookbook, Travel, Coffee House, etc.) - Ported Vintage templates to JSON (Art Deco, Medieval, Retro 80s, etc.) - Updated README.md to reflect the new JSON-based style system (example configuration and contribution workflow) - Completed migration of over 150 styles to the new architecture
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Upload, FileText, AlertCircle } from 'lucide-react';
|
||||
import { useKeyboardNavigation } from '../hooks/useKeyboardNavigation';
|
||||
|
||||
interface FileUploadProps {
|
||||
onFileLoaded: (content: string) => void;
|
||||
onFileLoaded: (content: string, fileName: string) => void;
|
||||
}
|
||||
|
||||
export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropzoneRef = useRef<HTMLDivElement>(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setError(null);
|
||||
@@ -20,7 +26,9 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result;
|
||||
if (typeof text === 'string') {
|
||||
onFileLoaded(text);
|
||||
// Extract filename without extension
|
||||
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||||
onFileLoaded(text, fileName);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => setError('Error reading file.');
|
||||
@@ -30,16 +38,24 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
if (e.type === 'dragenter') {
|
||||
dragCounter.current += 1;
|
||||
setDragActive(true);
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false);
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragActive(false);
|
||||
}
|
||||
} else if (e.type === 'dragover') {
|
||||
// Keep drag active during dragover
|
||||
setDragActive(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current = 0;
|
||||
setDragActive(false);
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
@@ -53,40 +69,158 @@ export const FileUpload: React.FC<FileUploadProps> = ({ onFileLoaded }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation for the dropzone
|
||||
useKeyboardNavigation({
|
||||
onEnter: () => {
|
||||
if (isFocused) {
|
||||
openFilePicker();
|
||||
}
|
||||
},
|
||||
onSpace: () => {
|
||||
if (isFocused) {
|
||||
openFilePicker();
|
||||
}
|
||||
},
|
||||
}, [isFocused]);
|
||||
|
||||
// Clear error after 5 seconds
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl mx-auto">
|
||||
<div
|
||||
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl transition-all duration-300 ease-in-out cursor-pointer overflow-hidden
|
||||
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50 hover:border-zinc-500 hover:bg-zinc-800/50'}`}
|
||||
<motion.div
|
||||
className="w-full max-w-xl mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
role="region"
|
||||
aria-label="File upload"
|
||||
>
|
||||
<motion.div
|
||||
ref={dropzoneRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Drop zone. Click or press Enter to select a file."
|
||||
className={`relative group flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-2xl cursor-pointer overflow-hidden outline-none transition-all
|
||||
${dragActive ? 'border-indigo-500 bg-indigo-500/10' : 'border-zinc-700 bg-zinc-900/50'}
|
||||
${isFocused ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-zinc-950 border-indigo-400' : ''}
|
||||
`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={openFilePicker}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openFilePicker();
|
||||
}
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.05)'
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
accept=".md,.txt,.markdown"
|
||||
aria-label="Select file"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6 text-zinc-400 group-hover:text-zinc-200 transition-colors">
|
||||
<div className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-500'}`}>
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center pt-5 pb-6 text-zinc-400 group-hover:text-zinc-200 transition-colors"
|
||||
animate={dragActive ? { y: [0, -5, 0] } : {}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className={`p-4 rounded-full mb-4 ${dragActive ? 'bg-indigo-500/20 text-indigo-400' : 'bg-zinc-800 text-zinc-500'}`}
|
||||
animate={dragActive ? {
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 10, -10, 0]
|
||||
} : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
>
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<p className="mb-2 text-lg font-medium">
|
||||
<span className="font-semibold text-indigo-400">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">Markdown or Plain Text files</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
className="mb-2 text-lg font-medium"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<motion.span
|
||||
className="font-semibold text-indigo-400"
|
||||
whileHover={{ color: '#818cf8' }}
|
||||
>
|
||||
Click to upload
|
||||
</motion.span>{' '}
|
||||
or drag and drop
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className="text-sm text-zinc-500"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
Markdown or Plain Text files
|
||||
</motion.p>
|
||||
<motion.p
|
||||
className="text-xs text-zinc-600 mt-2"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
Press Enter to browse
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Animated border gradient on hover */}
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-2xl pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1), transparent)',
|
||||
}}
|
||||
initial={{ x: '-100%' }}
|
||||
whileHover={{ x: '100%' }}
|
||||
transition={{ duration: 0.6, ease: 'easeInOut' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200 animate-in fade-in slide-in-from-top-2">
|
||||
<AlertCircle size={20} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
||||
exit={{ opacity: 0, y: -10, height: 0 }}
|
||||
className="mt-4 p-4 bg-red-900/20 border border-red-800 rounded-lg flex items-center gap-3 text-red-200"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 10, -10, 0] }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<AlertCircle size={20} />
|
||||
</motion.div>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user