Compare commits
8 Commits
2763b76abe
...
37874e3eea
| Author | SHA1 | Date | |
|---|---|---|---|
| 37874e3eea | |||
| 9126589de3 | |||
| 413c677173 | |||
| 002fa037b7 | |||
| ea464ef797 | |||
| 50cf5823f9 | |||
| 7da20c37c1 | |||
| 4927fb9a93 |
@@ -1,73 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
import Logo from '@/components/Logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Home } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen dark text-foreground flex flex-col">
|
||||
<AnimatedBackground />
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 py-20 relative z-10">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<Logo size={100} />
|
||||
</motion.div>
|
||||
|
||||
{/* 404 heading */}
|
||||
<motion.h1
|
||||
className="text-7xl md:text-9xl font-bold mb-6 text-primary"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 py-20 relative z-10 text-center">
|
||||
|
||||
{/* Logo */}
|
||||
<div style={{ animation: 'fadeIn 0.5s ease-out both' }}>
|
||||
<Logo size={52} />
|
||||
</div>
|
||||
|
||||
{/* 404 */}
|
||||
<div
|
||||
className="mt-8"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.15s both' }}
|
||||
>
|
||||
<span className="text-[80px] md:text-[120px] font-bold font-mono text-primary leading-none tabular-nums block">
|
||||
404
|
||||
</motion.h1>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
className="text-xl md:text-3xl font-medium mb-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
>
|
||||
Page Not Found
|
||||
</motion.p>
|
||||
{/* Message */}
|
||||
<div
|
||||
className="mt-4 space-y-1"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground/70">Page not found</p>
|
||||
<p className="text-[11px] text-muted-foreground/50 font-mono max-w-xs mx-auto leading-relaxed">
|
||||
The tool or page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-md mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
{/* CTA */}
|
||||
<div
|
||||
className="mt-8"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.45s both' }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 glass rounded-lg border border-primary/30 hover:border-primary/60 hover:bg-primary/10 text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200"
|
||||
>
|
||||
The tool or page you are looking for doesn't exist or has been moved.
|
||||
</motion.p>
|
||||
|
||||
{/* CTA Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
>
|
||||
<Link href="/">
|
||||
<Button size="lg" className="rounded-full px-8 h-14 text-lg font-semibold bg-gradient-to-r from-purple-500 to-cyan-500 hover:from-purple-600 hover:to-cyan-600 border-none transition-all duration-300">
|
||||
<Home className="mr-2 h-5 w-5" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
<ArrowLeft className="w-3.5 h-3.5 text-primary" />
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
import Hero from '@/components/Hero';
|
||||
import Stats from '@/components/Stats';
|
||||
|
||||
@@ -1,76 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useScroll, useSpring } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function BackToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const toggleVisibility = () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
const onScroll = () => {
|
||||
setIsVisible(window.scrollY > 300);
|
||||
if (barRef.current) {
|
||||
const el = document.documentElement;
|
||||
const scrolled = el.scrollTop / (el.scrollHeight - el.clientHeight);
|
||||
barRef.current.style.transform = `scaleX(${scrolled})`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility);
|
||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-gradient-to-r from-purple-500 to-cyan-500 transform origin-left z-50"
|
||||
style={{ scaleX }}
|
||||
{/* Scroll progress bar */}
|
||||
<div
|
||||
ref={barRef}
|
||||
className="fixed top-0 left-0 right-0 h-px bg-primary z-50 origin-left"
|
||||
style={{ transform: 'scaleX(0)', transition: 'transform 0.1s linear' }}
|
||||
/>
|
||||
|
||||
{/* Back to top button */}
|
||||
{isVisible && (
|
||||
<motion.button
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 p-4 rounded-full glass hover:bg-accent/50 text-purple-400 hover:text-purple-300 transition-colors shadow-lg z-40 group"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
<button
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
className="fixed bottom-6 right-6 w-8 h-8 glass rounded-lg flex items-center justify-center text-muted-foreground/40 hover:text-primary hover:border-primary/40 transition-all duration-200 z-40"
|
||||
aria-label="Back to top"
|
||||
style={{ animation: 'fadeIn 0.2s ease-out both' }}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
<span className="absolute bottom-full right-0 mb-2 px-3 py-1 text-xs text-white bg-gray-900 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
Back to top
|
||||
</span>
|
||||
</motion.button>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,47 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { GitFork, Heart } from 'lucide-react';
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="relative py-12 px-4">
|
||||
<div className="max-w-6xl mx-auto border-t border-border pt-12">
|
||||
<motion.div
|
||||
className="flex flex-col md:flex-row items-center justify-between gap-6"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Copyright */}
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
© {currentYear} Kit.
|
||||
<Heart className="h-4 w-4 text-primary shrink-0 animate-pulse" fill="currentColor" />
|
||||
<a
|
||||
href="https://pivoine.art"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Pivoine.Art"
|
||||
className="font-medium underline underline-offset-4 decoration-primary/0 hover:decoration-primary transition-all duration-300"
|
||||
>
|
||||
Valknar
|
||||
</a>
|
||||
<footer className="relative py-10 px-6">
|
||||
<div className="max-w-5xl mx-auto border-t border-border/20 pt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1 text-[9px] text-muted-foreground/40 font-mono">
|
||||
© {currentYear} Kit
|
||||
<Heart className="w-2 h-2 text-primary/70 shrink-0 animate-pulse" fill="currentColor" />
|
||||
<a
|
||||
href="https://pivoine.art"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground/70 transition-colors"
|
||||
>
|
||||
Valknar
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* Source link */}
|
||||
<a
|
||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View source"
|
||||
className="text-muted-foreground hover:text-primary transition-colors duration-300"
|
||||
className="text-muted-foreground/30 hover:text-primary transition-colors"
|
||||
>
|
||||
<GitFork className="h-5 w-5" />
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,108 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Toolbox } from 'lucide-react';
|
||||
import Logo from './Logo';
|
||||
|
||||
export default function Hero() {
|
||||
/**
|
||||
* Smoothly scrolls the window to the tools section without modifying the URL hash.
|
||||
*/
|
||||
const scrollToTools = () => {
|
||||
const toolsSection = document.getElementById('tools');
|
||||
if (toolsSection) {
|
||||
toolsSection.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
document.getElementById('tools')?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen flex flex-col items-center justify-center px-4 py-20">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<Logo size={130} />
|
||||
</motion.div>
|
||||
<section className="relative min-h-screen flex flex-col items-center justify-center px-6 py-24">
|
||||
<div className="flex flex-col items-center text-center max-w-2xl mx-auto">
|
||||
|
||||
{/* Main heading */}
|
||||
<motion.h1
|
||||
className="text-6xl md:text-8xl font-bold mb-6 text-primary"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
{/* Logo */}
|
||||
<div style={{ animation: 'fadeIn 0.6s ease-out both' }}>
|
||||
<Logo size={64} />
|
||||
</div>
|
||||
|
||||
{/* Badge */}
|
||||
<div
|
||||
className="mt-8 flex items-center gap-2 px-3 py-1 glass rounded-full"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.2s both' }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-[10px] font-mono text-muted-foreground/60 tracking-widest uppercase">
|
||||
Browser-first toolkit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1
|
||||
className="mt-5 text-6xl md:text-8xl font-bold text-foreground tracking-tight leading-none"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
|
||||
>
|
||||
Kit
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<motion.p
|
||||
className="text-xl md:text-2xl text-muted-foreground mb-4 max-w-2xl mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
>
|
||||
Your Creative Toolkit
|
||||
</motion.p>
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<motion.p
|
||||
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-xl mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
<p
|
||||
className="mt-5 text-sm text-muted-foreground/60 max-w-sm leading-relaxed"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.4s both' }}
|
||||
>
|
||||
A curated collection of creative and utility tools for developers and creators.
|
||||
Simple, powerful, and always at your fingertips.
|
||||
</motion.p>
|
||||
A curated collection of browser-based tools for developers and creators.
|
||||
Everything runs locally.
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.8 }}
|
||||
{/* CTA */}
|
||||
<div
|
||||
className="mt-8"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
|
||||
>
|
||||
<motion.button
|
||||
<button
|
||||
onClick={scrollToTools}
|
||||
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex items-center gap-2 px-5 py-2.5 glass rounded-lg border border-primary/30 hover:border-primary/60 hover:bg-primary/10 text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200"
|
||||
>
|
||||
<span className="relative z-10 inline-flex items-center gap-2">
|
||||
<Toolbox className="h-5 w-5" />
|
||||
Explore Tools
|
||||
</span>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-purple-600 to-cyan-600"
|
||||
initial={{ x: '100%' }}
|
||||
whileHover={{ x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
</motion.div>
|
||||
<Toolbox className="w-3.5 h-3.5 text-primary" />
|
||||
Explore Tools
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.button
|
||||
<button
|
||||
onClick={scrollToTools}
|
||||
className="mx-auto flex flex-col items-center gap-2 cursor-pointer group"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 1 }}
|
||||
className="mt-20 flex flex-col items-center gap-2 group"
|
||||
style={{ animation: 'fadeIn 0.5s ease-out 0.9s both' }}
|
||||
>
|
||||
<span className="text-base text-gray-500 group-hover:text-gray-400 transition-colors">Scroll to explore</span>
|
||||
<motion.div
|
||||
className="w-6 h-10 border-2 border-gray-600 group-hover:border-purple-400 rounded-full p-1 transition-colors"
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
<span className="text-[9px] font-mono text-muted-foreground/25 uppercase tracking-widest group-hover:text-muted-foreground/50 transition-colors">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="w-4 h-7 border border-muted-foreground/15 rounded-full flex items-start justify-center pt-1.5 group-hover:border-primary/30 transition-colors">
|
||||
<div
|
||||
className="w-0.5 h-1.5 bg-primary/50 rounded-full"
|
||||
style={{ animation: 'float 1.5s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,68 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { tools } from '@/lib/tools';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Box, Code2, Shield } from 'lucide-react';
|
||||
|
||||
const stats = [
|
||||
{
|
||||
number: tools.length,
|
||||
label: 'Tools',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
number: '100%',
|
||||
label: 'Open Source',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
number: '∞',
|
||||
label: 'Privacy First',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ value: tools.length, label: 'Tools', icon: Box },
|
||||
{ value: '100%', label: 'Open Source', icon: Code2 },
|
||||
{ value: '∞', label: 'Privacy First', icon: Shield },
|
||||
];
|
||||
|
||||
export default function Stats() {
|
||||
return (
|
||||
<section className="relative py-16 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
className="glass rounded-2xl p-8 text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -5 }}
|
||||
>
|
||||
<motion.div
|
||||
className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-primary/10 text-primary"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
<section className="relative py-8 px-6">
|
||||
<div className="max-w-xl mx-auto">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map((stat, i) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="glass rounded-xl p-5 flex flex-col items-center text-center"
|
||||
style={{ animation: `slideUp 0.5s ease-out ${0.1 + i * 0.1}s both` }}
|
||||
>
|
||||
{stat.icon}
|
||||
</motion.div>
|
||||
<div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
||||
{stat.number}
|
||||
<div className="w-7 h-7 rounded-md bg-primary/10 flex items-center justify-center mb-3">
|
||||
<Icon className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tabular-nums text-foreground">{stat.value}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground/40 uppercase tracking-widest mt-1">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-base font-medium">
|
||||
{stat.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,91 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const MotionLink = motion.create(Link);
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ElementType } from 'react';
|
||||
|
||||
interface ToolCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
icon: ElementType;
|
||||
url: string;
|
||||
index: number;
|
||||
badges?: string[];
|
||||
}
|
||||
|
||||
export default function ToolCard({ title, description, icon, url, index, badges }: ToolCardProps) {
|
||||
export default function ToolCard({ title, description, icon: Icon, url, index, badges }: ToolCardProps) {
|
||||
return (
|
||||
<MotionLink
|
||||
<Link
|
||||
href={url}
|
||||
className="group relative block h-full"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ y: -10 }}
|
||||
className="group glass rounded-xl p-4 flex flex-col h-full transition-all duration-200 hover:border-primary/30 hover:bg-primary/3"
|
||||
style={{ animation: `slideUp 0.5s ease-out ${0.05 * index}s both` }}
|
||||
>
|
||||
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl group-hover:bg-card/80">
|
||||
{/* Subtle hover overlay */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-10 transition-opacity duration-300 bg-primary" />
|
||||
{/* Icon */}
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center mb-3 shrink-0 group-hover:bg-primary/15 transition-colors">
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className="mb-6 flex justify-center"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<div className="p-4 rounded-xl bg-primary/10 text-primary shadow-lg shadow-black/5">
|
||||
{icon}
|
||||
</div>
|
||||
</motion.div>
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-semibold text-foreground/80 group-hover:text-foreground transition-colors mb-1.5">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
{/* Description */}
|
||||
<p className="text-[11px] text-muted-foreground/50 leading-relaxed flex-1 mb-3">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Badges */}
|
||||
{badges && badges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{badges.map((badge) => (
|
||||
{/* Footer: badges + arrow */}
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
{badges && badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{badges.slice(0, 2).map((badge) => (
|
||||
<span
|
||||
key={badge}
|
||||
className="text-xs px-2 py-1 rounded-full bg-primary/5 border border-primary/10 text-muted-foreground font-medium"
|
||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded border border-border/30 text-muted-foreground/35"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Arrow icon */}
|
||||
<motion.div
|
||||
className="absolute bottom-8 right-8 text-muted-foreground group-hover:text-primary transition-colors duration-300"
|
||||
initial={{ x: 0 }}
|
||||
whileHover={{ x: 5 }}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/25 group-hover:text-primary group-hover:translate-x-0.5 transition-all duration-200 shrink-0" />
|
||||
</div>
|
||||
</MotionLink>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import ToolCard from './ToolCard';
|
||||
import { tools } from '@/lib/tools';
|
||||
|
||||
export default function ToolsGrid() {
|
||||
return (
|
||||
<section id="tools" className="relative py-20 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<section id="tools" className="relative py-16 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
|
||||
{/* Section heading */}
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
<div
|
||||
className="mb-8"
|
||||
style={{ animation: 'fadeIn 0.5s ease-out both' }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-1">
|
||||
Available Tools
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Explore our collection of carefully crafted tools designed to boost your productivity and creativity
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground/40">
|
||||
Carefully crafted tools for your workflow
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Tools grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{tools.map((tool, index) => {
|
||||
const Icon = tool.icon;
|
||||
return (
|
||||
<ToolCard
|
||||
key={tool.href}
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={<Icon className="w-12 h-12" />}
|
||||
url={tool.href}
|
||||
badges={tool.badges}
|
||||
index={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
{tools.map((tool, index) => (
|
||||
<ToolCard
|
||||
key={tool.href}
|
||||
title={tool.shortTitle}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
url={tool.href}
|
||||
badges={tool.badges}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,14 +8,20 @@ import { KeyframeProperties } from './KeyframeProperties';
|
||||
import { PresetLibrary } from './PresetLibrary';
|
||||
import { ExportPanel } from './ExportPanel';
|
||||
import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate';
|
||||
|
||||
type MobileTab = 'edit' | 'preview';
|
||||
type RightTab = 'keyframes' | 'export' | 'presets';
|
||||
|
||||
export function AnimationEditor() {
|
||||
const [config, setConfig] = useState<AnimationConfig>(DEFAULT_CONFIG);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id
|
||||
);
|
||||
const [previewElement, setPreviewElement] = useState<PreviewElement>('box');
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>('edit');
|
||||
const [rightTab, setRightTab] = useState<RightTab>('keyframes');
|
||||
|
||||
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
|
||||
|
||||
@@ -35,8 +41,7 @@ export function AnimationEditor() {
|
||||
const deleteKeyframe = useCallback((id: string) => {
|
||||
setConfig((c) => {
|
||||
if (c.keyframes.length <= 2) return c;
|
||||
const next = c.keyframes.filter((k) => k.id !== id);
|
||||
return { ...c, keyframes: next };
|
||||
return { ...c, keyframes: c.keyframes.filter((k) => k.id !== id) };
|
||||
});
|
||||
setSelectedId((prev) => {
|
||||
if (prev !== id) return prev;
|
||||
@@ -58,47 +63,90 @@ export function AnimationEditor() {
|
||||
setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id);
|
||||
}, []);
|
||||
|
||||
const timelineProps = {
|
||||
keyframes: config.keyframes,
|
||||
selectedId,
|
||||
onSelect: setSelectedId,
|
||||
onAdd: addKeyframe,
|
||||
onDelete: deleteKeyframe,
|
||||
onMove: moveKeyframe,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Settings + Preview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||
<div className="lg:col-span-1">
|
||||
<AnimationSettings config={config} onChange={setConfig} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<AnimationPreview
|
||||
config={config}
|
||||
element={previewElement}
|
||||
onElementChange={setPreviewElement}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ─────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['edit', 'preview'] as MobileTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMobileTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||
mobileTab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'edit' ? 'Edit' : 'Preview'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Keyframe Timeline */}
|
||||
<KeyframeTimeline
|
||||
keyframes={config.keyframes}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onAdd={addKeyframe}
|
||||
onDelete={deleteKeyframe}
|
||||
onMove={moveKeyframe}
|
||||
/>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '660px' }}
|
||||
>
|
||||
|
||||
{/* Row 3: Keyframe Properties + Export */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||
<div className="lg:col-span-1">
|
||||
<KeyframeProperties
|
||||
keyframe={selectedKeyframe}
|
||||
onChange={updateKeyframeProps}
|
||||
/>
|
||||
{/* Left: Settings + Properties */}
|
||||
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'edit' && 'hidden lg:flex')}>
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
|
||||
|
||||
<AnimationSettings config={config} onChange={setConfig} />
|
||||
|
||||
<div className="border-t border-border/25" />
|
||||
|
||||
<KeyframeProperties keyframe={selectedKeyframe} onChange={updateKeyframeProps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<ExportPanel config={config} />
|
||||
|
||||
{/* Right: Preview + tabbed panel */}
|
||||
<div className={cn('lg:col-span-3 flex flex-col gap-3 overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
|
||||
|
||||
{/* Preview canvas */}
|
||||
<AnimationPreview config={config} element={previewElement} onElementChange={setPreviewElement} />
|
||||
|
||||
{/* Keyframes / Export / Presets tab panel */}
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Tab switcher */}
|
||||
<div className="flex glass rounded-lg p-0.5 gap-0.5 mb-4 shrink-0">
|
||||
{(['keyframes', 'export', 'presets'] as RightTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setRightTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 rounded-md text-xs font-medium capitalize transition-all',
|
||||
rightTab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'keyframes' ? 'Keyframes' : t === 'export' ? 'Export' : 'Presets'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
|
||||
{rightTab === 'keyframes' && <KeyframeTimeline {...timelineProps} embedded />}
|
||||
{rightTab === 'export' && <ExportPanel config={config} />}
|
||||
{rightTab === 'presets' && <PresetLibrary onSelect={loadPreset} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Preset Library */}
|
||||
<PresetLibrary onSelect={loadPreset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { buildCSS } from '@/lib/animate/cssBuilder';
|
||||
import type { AnimationConfig, PreviewElement } from '@/types/animate';
|
||||
|
||||
@@ -23,13 +21,26 @@ const SPEEDS: { label: string; value: string }[] = [
|
||||
{ label: '2×', value: '2' },
|
||||
];
|
||||
|
||||
const ELEMENTS: { value: PreviewElement; icon: React.ReactNode; title: string }[] = [
|
||||
{ value: 'box', icon: <Square className="w-3 h-3" />, title: 'Box' },
|
||||
{ value: 'circle', icon: <Circle className="w-3 h-3" />, title: 'Circle' },
|
||||
{ value: 'text', icon: <Type className="w-3 h-3" />, title: 'Text' },
|
||||
];
|
||||
|
||||
const actionBtn = 'flex items-center justify-center w-7 h-7 glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
|
||||
const pillCls = (active: boolean) =>
|
||||
cn(
|
||||
'px-2 py-0.5 rounded text-[10px] font-mono transition-all',
|
||||
active ? 'text-primary bg-primary/10' : 'text-muted-foreground/50 hover:text-muted-foreground'
|
||||
);
|
||||
|
||||
export function AnimationPreview({ config, element, onElementChange }: Props) {
|
||||
const styleRef = useRef<HTMLStyleElement | null>(null);
|
||||
const [restartKey, setRestartKey] = useState(0);
|
||||
const [animState, setAnimState] = useState<AnimState>('playing');
|
||||
const [speed, setSpeed] = useState('1');
|
||||
|
||||
// Inject @keyframes CSS into document head
|
||||
useEffect(() => {
|
||||
if (!styleRef.current) {
|
||||
styleRef.current = document.createElement('style');
|
||||
@@ -37,125 +48,113 @@ export function AnimationPreview({ config, element, onElementChange }: Props) {
|
||||
document.head.appendChild(styleRef.current);
|
||||
}
|
||||
styleRef.current.textContent = buildCSS(config);
|
||||
// Restart preview whenever config changes so changes are immediately visible
|
||||
setAnimState('playing');
|
||||
setRestartKey((k) => k + 1);
|
||||
}, [config]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => { styleRef.current?.remove(); };
|
||||
}, []);
|
||||
|
||||
const restart = () => {
|
||||
setAnimState('playing');
|
||||
setRestartKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (animState === 'ended') {
|
||||
// Animation finished — restart it
|
||||
restart();
|
||||
} else {
|
||||
setAnimState('playing');
|
||||
}
|
||||
};
|
||||
const restart = () => { setAnimState('playing'); setRestartKey((k) => k + 1); };
|
||||
|
||||
const scaledDuration = Math.round(config.duration / Number(speed));
|
||||
const isInfinite = config.iterationCount === 'infinite';
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<ToggleGroup type="single" value={speed} onValueChange={(v) => v && setSpeed(v)} variant="outline" size="sm">
|
||||
<div className="glass rounded-xl p-4 shrink-0 flex flex-col gap-3">
|
||||
{/* Header: speed pills */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Preview</span>
|
||||
<div className="flex items-center glass rounded-md border border-border/30 px-1 gap-0.5">
|
||||
{SPEEDS.map((s) => (
|
||||
<ToggleGroupItem key={s.value} value={s.value} className="h-6 px-1.5 min-w-0 text-[10px]">
|
||||
<button key={s.value} onClick={() => setSpeed(s.value)} className={pillCls(speed === s.value)}>
|
||||
{s.label}
|
||||
</ToggleGroupItem>
|
||||
</button>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col gap-4">
|
||||
{/* Preview canvas */}
|
||||
<div className="flex-1 min-h-52 flex items-center justify-center rounded-xl bg-gradient-to-br from-muted/20 to-muted/5 border border-border relative overflow-hidden">
|
||||
{/* Grid overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-5 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)',
|
||||
backgroundSize: '32px 32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated element */}
|
||||
<div
|
||||
key={restartKey}
|
||||
className="animated relative z-10"
|
||||
style={{
|
||||
animationDuration: `${scaledDuration}ms`,
|
||||
animationPlayState: animState === 'paused' ? 'paused' : 'running',
|
||||
}}
|
||||
onAnimationEnd={() => !isInfinite && setAnimState('ended')}
|
||||
{/* Canvas */}
|
||||
<div
|
||||
className="h-44 rounded-xl flex items-center justify-center relative overflow-hidden"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
|
||||
backgroundImage: [
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
|
||||
'linear-gradient(var(--border) 1px, transparent 1px)',
|
||||
'linear-gradient(90deg, var(--border) 1px, transparent 1px)',
|
||||
].join(', '),
|
||||
backgroundSize: 'auto, 32px 32px, 32px 32px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
key={restartKey}
|
||||
className="animated relative z-10"
|
||||
style={{
|
||||
animationDuration: `${scaledDuration}ms`,
|
||||
animationPlayState: animState === 'paused' ? 'paused' : 'running',
|
||||
}}
|
||||
onAnimationEnd={() => !isInfinite && setAnimState('ended')}
|
||||
>
|
||||
{element === 'box' && (
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
|
||||
)}
|
||||
{element === 'circle' && (
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
|
||||
)}
|
||||
{element === 'text' && (
|
||||
<span className="text-3xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
|
||||
Hello
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls: element selector + playback */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
{/* Element picker */}
|
||||
<div className="flex items-center glass rounded-md border border-border/30 p-0.5 gap-0.5">
|
||||
{ELEMENTS.map(({ value, icon, title }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onElementChange(value)}
|
||||
title={title}
|
||||
className={cn(
|
||||
'w-7 h-7 flex items-center justify-center rounded transition-all',
|
||||
element === value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Playback */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => animState === 'ended' ? restart() : setAnimState('playing')}
|
||||
disabled={animState === 'playing'}
|
||||
title={animState === 'ended' ? 'Replay' : 'Play'}
|
||||
className={actionBtn}
|
||||
>
|
||||
{element === 'box' && (
|
||||
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
|
||||
)}
|
||||
{element === 'circle' && (
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
|
||||
)}
|
||||
{element === 'text' && (
|
||||
<span className="text-4xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
|
||||
Hello
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Play className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAnimState('paused')}
|
||||
disabled={animState !== 'playing'}
|
||||
title="Pause"
|
||||
className={actionBtn}
|
||||
>
|
||||
<Pause className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={restart} title="Restart" className={actionBtn}>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<ToggleGroup type="single" value={element} onValueChange={(v) => v && onElementChange(v as PreviewElement)} variant="outline" size="sm">
|
||||
<ToggleGroupItem value="box" className="h-6 px-1.5 min-w-0" title="Box">
|
||||
<Square className="h-3 w-3" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="circle" className="h-6 px-1.5 min-w-0" title="Circle">
|
||||
<Circle className="h-3 w-3" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="text" className="h-6 px-1.5 min-w-0" title="Text">
|
||||
<Type className="h-3 w-3" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
onClick={handlePlay}
|
||||
disabled={animState === 'playing'}
|
||||
title={animState === 'ended' ? 'Replay' : 'Play'}
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
onClick={() => setAnimState('paused')}
|
||||
disabled={animState !== 'playing'}
|
||||
title="Pause"
|
||||
>
|
||||
<Pause className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
onClick={restart}
|
||||
title="Restart"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Infinity } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { AnimationConfig } from '@/types/animate';
|
||||
|
||||
interface Props {
|
||||
@@ -30,14 +20,38 @@ const EASINGS = [
|
||||
{ value: 'steps(8, end)', label: 'Steps (8)' },
|
||||
];
|
||||
|
||||
const DIRECTIONS: { value: AnimationConfig['direction']; label: string }[] = [
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'reverse', label: 'Reverse' },
|
||||
{ value: 'alternate', label: 'Alt' },
|
||||
{ value: 'alternate-reverse', label: 'Alt-Rev' },
|
||||
];
|
||||
|
||||
const FILL_MODES: { value: AnimationConfig['fillMode']; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'forwards', label: 'Fwd' },
|
||||
{ value: 'backwards', label: 'Bwd' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
|
||||
|
||||
const pillCls = (active: boolean) =>
|
||||
cn(
|
||||
'flex-1 py-1.5 rounded-lg border text-[10px] font-mono transition-all',
|
||||
active
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||
);
|
||||
|
||||
export function AnimationSettings({ config, onChange }: Props) {
|
||||
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
|
||||
onChange({ ...config, [key]: value });
|
||||
|
||||
const isInfinite = config.iterationCount === 'infinite';
|
||||
const isCubic = config.easing === 'cubic-bezier';
|
||||
const isCubic = config.easing.startsWith('cubic-bezier');
|
||||
|
||||
// Parse cubic-bezier values from string like "cubic-bezier(x1,y1,x2,y2)"
|
||||
const cubicValues = (() => {
|
||||
const m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
|
||||
if (!m) return [0.25, 0.1, 0.25, 1.0];
|
||||
@@ -50,167 +64,153 @@ export function AnimationSettings({ config, onChange }: Props) {
|
||||
set('easing', `cubic-bezier(${v.join(',')})`);
|
||||
};
|
||||
|
||||
const easingSelectValue = isCubic ? 'cubic-bezier' : config.easing;
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={config.name}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
|
||||
set('name', val || 'myAnimation');
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
<div className="space-y-4">
|
||||
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
|
||||
Settings
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.name}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
|
||||
set('name', val || 'myAnimation');
|
||||
}}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration + Delay */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Duration (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={50}
|
||||
max={10000}
|
||||
step={50}
|
||||
value={config.duration}
|
||||
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Delay (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={50}
|
||||
value={config.delay}
|
||||
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration + Delay */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Duration</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={50}
|
||||
max={10000}
|
||||
step={50}
|
||||
value={config.duration}
|
||||
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
|
||||
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Delay</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={50}
|
||||
value={config.delay}
|
||||
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
|
||||
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground shrink-0">ms</span>
|
||||
</div>
|
||||
{/* Easing */}
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Easing</label>
|
||||
<select
|
||||
value={easingSelectValue}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
set('easing', v === 'cubic-bezier' ? 'cubic-bezier(0.25,0.1,0.25,1)' : v);
|
||||
}}
|
||||
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{EASINGS.map((e) => (
|
||||
<option key={e.value} value={e.value} className="bg-[#1a1a2e]">
|
||||
{e.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Cubic-bezier inputs */}
|
||||
{isCubic && (
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">
|
||||
cubic-bezier(P1x, P1y, P2x, P2y)
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
|
||||
<div key={label}>
|
||||
<label className="text-[9px] text-muted-foreground/40 font-mono block mb-1">{label}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={i % 2 === 0 ? 0 : -1}
|
||||
max={i % 2 === 0 ? 1 : 2}
|
||||
step={0.01}
|
||||
value={cubicValues[i] ?? 0}
|
||||
onChange={(e) => setCubic(i, Number(e.target.value))}
|
||||
className="w-full bg-transparent border border-border/40 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 text-center"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Easing */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Easing</Label>
|
||||
<Select
|
||||
value={isCubic ? 'cubic-bezier' : config.easing}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'cubic-bezier') {
|
||||
set('easing', 'cubic-bezier(0.25,0.1,0.25,1)');
|
||||
} else {
|
||||
set('easing', v);
|
||||
}
|
||||
}}
|
||||
{/* Iterations */}
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Iterations</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={isInfinite ? '' : (config.iterationCount as number)}
|
||||
disabled={isInfinite}
|
||||
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
|
||||
placeholder="1"
|
||||
className={cn(inputCls, 'flex-1', isInfinite && 'opacity-30')}
|
||||
/>
|
||||
<button
|
||||
onClick={() => set('iterationCount', isInfinite ? 1 : 'infinite')}
|
||||
title="Toggle infinite"
|
||||
className={cn(
|
||||
'w-9 h-9 flex items-center justify-center rounded-lg border text-xs transition-all shrink-0',
|
||||
isInfinite
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'border-border/40 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EASINGS.map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Infinity className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cubic-bezier inputs */}
|
||||
{isCubic && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">cubic-bezier(P1x, P1y, P2x, P2y)</Label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
|
||||
<div key={label} className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={i % 2 === 0 ? 0 : -1}
|
||||
max={i % 2 === 0 ? 1 : 2}
|
||||
step={0.01}
|
||||
value={cubicValues[i] ?? 0}
|
||||
onChange={(e) => setCubic(i, Number(e.target.value))}
|
||||
className="text-xs px-1.5"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iteration */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Iterations</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={isInfinite ? '' : config.iterationCount}
|
||||
disabled={isInfinite}
|
||||
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
|
||||
className="text-xs flex-1"
|
||||
placeholder="1"
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant={isInfinite ? 'default' : 'outline'}
|
||||
onClick={() =>
|
||||
set('iterationCount', isInfinite ? 1 : 'infinite')
|
||||
}
|
||||
title="Toggle infinite"
|
||||
>
|
||||
<Infinity className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Direction */}
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Direction</label>
|
||||
<div className="flex gap-1">
|
||||
{DIRECTIONS.map(({ value, label }) => (
|
||||
<button key={value} onClick={() => set('direction', value)} className={pillCls(config.direction === value)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Direction */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Direction</Label>
|
||||
<Select value={config.direction} onValueChange={(v) => set('direction', v as AnimationConfig['direction'])}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="reverse">Reverse</SelectItem>
|
||||
<SelectItem value="alternate">Alternate</SelectItem>
|
||||
<SelectItem value="alternate-reverse">Alternate Reverse</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Fill Mode */}
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Fill Mode</label>
|
||||
<div className="flex gap-1">
|
||||
{FILL_MODES.map(({ value, label }) => (
|
||||
<button key={value} onClick={() => set('fillMode', value)} className={pillCls(config.fillMode === value)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Fill Mode */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Fill Mode</Label>
|
||||
<Select value={config.fillMode} onValueChange={(v) => set('fillMode', v as AnimationConfig['fillMode'])}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="forwards">Forwards</SelectItem>
|
||||
<SelectItem value="backwards">Backwards</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
|
||||
import type { AnimationConfig } from '@/types/animate';
|
||||
|
||||
@@ -13,6 +11,11 @@ interface Props {
|
||||
config: AnimationConfig;
|
||||
}
|
||||
|
||||
type ExportTab = 'css' | 'tailwind';
|
||||
|
||||
const actionBtn =
|
||||
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all';
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename: string }) {
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
@@ -31,49 +34,50 @@ function CodeBlock({ code, filename }: { code: string; filename: string }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<pre className="p-4 rounded-xl bg-muted/30 border border-border text-xs font-mono leading-relaxed overflow-auto max-h-72 text-foreground/90 whitespace-pre scrollbar">
|
||||
<div className="space-y-2">
|
||||
<div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
|
||||
<pre className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed max-h-64">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<Button variant="outline" onClick={copy} className="w-full md:flex-1">
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button onClick={download} className="w-full md:flex-1">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download .css
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={copy} className={cn(actionBtn, 'flex-1')}>
|
||||
<Copy className="w-3 h-3" />Copy
|
||||
</button>
|
||||
<button onClick={download} className={cn(actionBtn, 'flex-1')}>
|
||||
<Download className="w-3 h-3" />Download .css
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportPanel({ config }: Props) {
|
||||
const [tab, setTab] = useState<ExportTab>('css');
|
||||
const css = useMemo(() => buildCSS(config), [config]);
|
||||
const tailwind = useMemo(() => buildTailwindCSS(config), [config]);
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Export</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="css">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="css" className="text-xs">Plain CSS</TabsTrigger>
|
||||
<TabsTrigger value="tailwind" className="text-xs">Tailwind v4</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="css">
|
||||
<CodeBlock code={css} filename={`${config.name}.css`} />
|
||||
</TabsContent>
|
||||
<TabsContent value="tailwind">
|
||||
<CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span>
|
||||
<div className="flex glass rounded-lg p-0.5 gap-0.5">
|
||||
{(['css', 'tailwind'] as ExportTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-[10px] font-mono transition-all',
|
||||
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'css' ? 'Plain CSS' : 'Tailwind v4'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'css' && <CodeBlock code={css} filename={`${config.name}.css`} />}
|
||||
{tab === 'tailwind' && <CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
|
||||
@@ -28,26 +24,20 @@ interface SliderRowProps {
|
||||
function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
{label}{unit && <span className="text-muted-foreground/50"> ({unit})</span>}
|
||||
</Label>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={([v]) => onChange(v)}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono">
|
||||
{label}{unit && <span className="opacity-50"> ({unit})</span>}
|
||||
</label>
|
||||
<Slider min={min} max={max} step={step} value={[value]} onValueChange={([v]) => onChange(v)} />
|
||||
</div>
|
||||
<Input
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-16 text-xs px-1.5 h-7 mt-4"
|
||||
className="w-14 bg-transparent border border-border/40 rounded-md px-1.5 py-1 text-[10px] font-mono text-center outline-none focus:border-primary/50 transition-colors text-foreground/80 mt-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -56,15 +46,12 @@ function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderR
|
||||
export function KeyframeProperties({ keyframe, onChange }: Props) {
|
||||
if (!keyframe) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Properties</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MousePointerClick className="h-8 w-8 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-xs text-muted-foreground">Select a keyframe on the timeline to edit its properties</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center gap-3">
|
||||
<MousePointerClick className="w-7 h-7 text-muted-foreground/20" />
|
||||
<p className="text-[10px] text-muted-foreground/40 font-mono leading-relaxed max-w-[180px]">
|
||||
Select a keyframe on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,10 +59,7 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
|
||||
const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform };
|
||||
|
||||
const setTransform = (key: keyof TransformValue, value: number) => {
|
||||
onChange(keyframe.id, {
|
||||
...props,
|
||||
transform: { ...t, [key]: value },
|
||||
});
|
||||
onChange(keyframe.id, { ...props, transform: { ...t, [key]: value } });
|
||||
};
|
||||
|
||||
const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => {
|
||||
@@ -85,96 +69,77 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
|
||||
const hasBg = props.backgroundColor && props.backgroundColor !== 'none';
|
||||
|
||||
return (
|
||||
<Card className="h-full overflow-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Properties
|
||||
<span className="text-muted-foreground font-normal text-sm ml-2">{keyframe.offset}%</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
</span>
|
||||
<span className="text-[9px] text-primary/60 font-mono bg-primary/10 px-1.5 py-0.5 rounded">
|
||||
{keyframe.offset}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Transform</p>
|
||||
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
|
||||
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
|
||||
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
|
||||
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
|
||||
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
|
||||
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
|
||||
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
|
||||
</div>
|
||||
{/* Transform */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Transform</p>
|
||||
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
|
||||
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
|
||||
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
|
||||
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
|
||||
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
|
||||
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
|
||||
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
|
||||
</div>
|
||||
|
||||
{/* Visual */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Visual</p>
|
||||
{/* Visual */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Visual</p>
|
||||
<SliderRow label="Opacity" value={props.opacity ?? 1} min={0} max={1} step={0.01} onChange={(v) => setProp('opacity', v)} />
|
||||
|
||||
<SliderRow
|
||||
label="Opacity"
|
||||
value={props.opacity ?? 1}
|
||||
min={0} max={1} step={0.01}
|
||||
onChange={(v) => setProp('opacity', v)}
|
||||
/>
|
||||
|
||||
{/* Background color */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">Background Color</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
|
||||
onChange={(e) => setProp('backgroundColor', e.target.value)}
|
||||
disabled={!hasBg}
|
||||
className={cn('w-9 h-9 p-1 shrink-0 cursor-pointer', !hasBg && 'opacity-30')}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={hasBg ? props.backgroundColor! : ''}
|
||||
onChange={(e) => setProp('backgroundColor', e.target.value)}
|
||||
disabled={!hasBg}
|
||||
placeholder="none"
|
||||
className="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={hasBg ? 'default' : 'outline'}
|
||||
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
|
||||
className="shrink-0"
|
||||
>
|
||||
{hasBg ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Background color */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono">Background Color</label>
|
||||
<button
|
||||
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
|
||||
className={cn(
|
||||
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
|
||||
hasBg
|
||||
? 'border-primary/40 text-primary bg-primary/10'
|
||||
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{hasBg ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
|
||||
onChange={(e) => setProp('backgroundColor', e.target.value)}
|
||||
disabled={!hasBg}
|
||||
className={cn('w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5', !hasBg && 'opacity-30 cursor-not-allowed')}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hasBg ? props.backgroundColor! : ''}
|
||||
onChange={(e) => setProp('backgroundColor', e.target.value)}
|
||||
disabled={!hasBg}
|
||||
placeholder="none"
|
||||
className="flex-1 bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 disabled:opacity-30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SliderRow
|
||||
label="Border Radius"
|
||||
unit="px"
|
||||
value={props.borderRadius ?? 0}
|
||||
min={0} max={200}
|
||||
onChange={(v) => setProp('borderRadius', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filter</p>
|
||||
<SliderRow
|
||||
label="Blur"
|
||||
unit="px"
|
||||
value={props.blur ?? 0}
|
||||
min={0} max={50}
|
||||
onChange={(v) => setProp('blur', v)}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Brightness"
|
||||
value={props.brightness ?? 1}
|
||||
min={0} max={3} step={0.01}
|
||||
onChange={(v) => setProp('brightness', v)}
|
||||
/>
|
||||
</div>
|
||||
<SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} />
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Filter</p>
|
||||
<SliderRow label="Blur" unit="px" value={props.blur ?? 0} min={0} max={50} onChange={(v) => setProp('blur', v)} />
|
||||
<SliderRow label="Brightness" value={props.brightness ?? 1} min={0} max={3} step={0.01} onChange={(v) => setProp('brightness', v)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { Keyframe } from '@/types/animate';
|
||||
@@ -14,11 +12,18 @@ interface Props {
|
||||
onAdd: (offset: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onMove: (id: string, newOffset: number) => void;
|
||||
embedded?: boolean; // when true, no glass card wrapper (use inside another card)
|
||||
}
|
||||
|
||||
const TICKS = [0, 25, 50, 75, 100];
|
||||
|
||||
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove }: Props) {
|
||||
const iconBtn = (disabled?: boolean) =>
|
||||
cn(
|
||||
'w-6 h-6 flex items-center justify-center rounded-md glass border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 transition-all',
|
||||
disabled && 'opacity-30 cursor-not-allowed pointer-events-none'
|
||||
);
|
||||
|
||||
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getOffsetFromEvent = (clientX: number): number => {
|
||||
@@ -29,7 +34,6 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
|
||||
};
|
||||
|
||||
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Ignore clicks that land directly on a keyframe marker
|
||||
if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return;
|
||||
onAdd(getOffsetFromEvent(e.clientX));
|
||||
};
|
||||
@@ -39,16 +43,11 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
|
||||
onSelect(id);
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
|
||||
const handleMove = (me: PointerEvent) => {
|
||||
onMove(id, getOffsetFromEvent(me.clientX));
|
||||
};
|
||||
|
||||
const handleMove = (me: PointerEvent) => onMove(id, getOffsetFromEvent(me.clientX));
|
||||
const handleUp = () => {
|
||||
el.removeEventListener('pointermove', handleMove);
|
||||
el.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
|
||||
el.addEventListener('pointermove', handleMove);
|
||||
el.addEventListener('pointerup', handleUp);
|
||||
};
|
||||
@@ -56,91 +55,91 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
|
||||
const sorted = [...keyframes].sort((a, b) => a.offset - b.offset);
|
||||
const selectedKf = keyframes.find((k) => k.id === selectedId);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Keyframes</CardTitle>
|
||||
const content = (
|
||||
<div className="space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''}
|
||||
{selectedKf ? ` · selected: ${selectedKf.offset}%` : ''}
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Keyframes
|
||||
</span>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
onClick={() => onAdd(50)}
|
||||
title="Add keyframe at 50%"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="outline"
|
||||
disabled={!selectedId || keyframes.length <= 2}
|
||||
<span className="text-[9px] text-muted-foreground/40 font-mono">
|
||||
{keyframes.length} kf{selectedKf ? ` · ${selectedKf.offset}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => onAdd(50)} title="Add at 50%" className={iconBtn()}>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => selectedId && onDelete(selectedId)}
|
||||
title="Delete selected keyframe"
|
||||
disabled={!selectedId || keyframes.length <= 2}
|
||||
title="Delete selected"
|
||||
className={iconBtn(!selectedId || keyframes.length <= 2)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Track */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-16 bg-muted/30 rounded-lg border border-border cursor-crosshair select-none"
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
{/* Center line */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Tick marks */}
|
||||
{TICKS.map((tick) => (
|
||||
<div
|
||||
key={tick}
|
||||
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${tick}%` }}
|
||||
>
|
||||
<div className="w-px h-2 bg-muted-foreground/30 mt-0" />
|
||||
<span className="text-[9px] text-muted-foreground/50 mt-auto mb-1">{tick}%</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Track */}
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none"
|
||||
onClick={handleTrackClick}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
|
||||
{TICKS.map((tick) => (
|
||||
<div
|
||||
key={tick}
|
||||
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none"
|
||||
style={{ left: `${tick}%` }}
|
||||
>
|
||||
<div className="w-px h-2 bg-muted-foreground/20" />
|
||||
<span className="text-[8px] text-muted-foreground/30 mt-auto mb-1 font-mono">{tick}%</span>
|
||||
</div>
|
||||
))}
|
||||
{sorted.map((kf) => (
|
||||
<button
|
||||
key={kf.id}
|
||||
data-keyframe-marker
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rotate-45 rounded-sm transition-all duration-150 touch-none',
|
||||
kf.id === selectedId
|
||||
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
|
||||
: 'bg-muted-foreground/40 hover:bg-primary/70'
|
||||
)}
|
||||
style={{ left: `${kf.offset}%` }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
|
||||
onPointerDown={(e) => handlePointerDown(e, kf.id)}
|
||||
title={`${kf.offset}% — drag to move`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Keyframe markers */}
|
||||
{sorted.map((kf) => (
|
||||
<button
|
||||
key={kf.id}
|
||||
data-keyframe-marker
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 rounded-sm transition-all duration-150 touch-none',
|
||||
kf.id === selectedId
|
||||
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
|
||||
: 'bg-muted-foreground/60 hover:bg-primary/70'
|
||||
)}
|
||||
style={{ left: `${kf.offset}%` }}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
|
||||
onPointerDown={(e) => handlePointerDown(e, kf.id)}
|
||||
title={`${kf.offset}% — drag to move`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Offset labels */}
|
||||
<div className="relative h-4">
|
||||
{sorted.map((kf) => (
|
||||
<span
|
||||
key={kf.id}
|
||||
className={cn(
|
||||
'absolute -translate-x-1/2 text-[9px] font-mono transition-colors',
|
||||
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground/40'
|
||||
)}
|
||||
style={{ left: `${kf.offset}%` }}
|
||||
>
|
||||
{kf.offset}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Offset labels below */}
|
||||
<div className="relative h-5 mt-1">
|
||||
{sorted.map((kf) => (
|
||||
<span
|
||||
key={kf.id}
|
||||
className={cn(
|
||||
'absolute -translate-x-1/2 text-[10px] transition-colors',
|
||||
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
style={{ left: `${kf.offset}%` }}
|
||||
>
|
||||
{kf.offset}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
if (embedded) return <div>{content}</div>;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl px-4 pt-4 pb-3 shrink-0">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
|
||||
import { buildKeyframesOnly } from '@/lib/animate/cssBuilder';
|
||||
import type { AnimationConfig, AnimationPreset } from '@/types/animate';
|
||||
import type { AnimationConfig, AnimationPreset, PresetCategory } from '@/types/animate';
|
||||
|
||||
interface Props {
|
||||
onSelect: (config: AnimationConfig) => void;
|
||||
}
|
||||
|
||||
function PresetCard({ preset, onSelect }: {
|
||||
preset: AnimationPreset;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
function PresetCard({ preset, onSelect }: { preset: AnimationPreset; onSelect: () => void }) {
|
||||
const styleRef = useRef<HTMLStyleElement | null>(null);
|
||||
const animName = `preview-${preset.id}`;
|
||||
const thumbDuration = Math.min(preset.config.duration, 1200);
|
||||
|
||||
// Inject only the @keyframes block under a unique name — no .animated class rule
|
||||
useEffect(() => {
|
||||
const renamedConfig = { ...preset.config, name: animName };
|
||||
if (!styleRef.current) {
|
||||
@@ -26,25 +22,18 @@ function PresetCard({ preset, onSelect }: {
|
||||
document.head.appendChild(styleRef.current);
|
||||
}
|
||||
styleRef.current.textContent = buildKeyframesOnly(renamedConfig);
|
||||
return () => {
|
||||
styleRef.current?.remove();
|
||||
styleRef.current = null;
|
||||
};
|
||||
return () => { styleRef.current?.remove(); styleRef.current = null; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Cap thumbnail duration so fast presets loop nicely; slow ones cap at 1.2s
|
||||
const thumbDuration = Math.min(preset.config.duration, 1200);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border bg-card/50 transition-all duration-200 hover:border-primary/50 hover:bg-accent/30 hover:shadow-sm"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border/20 bg-primary/3 transition-all hover:border-primary/40 hover:bg-primary/8 group"
|
||||
>
|
||||
{/* Mini preview — animation driven entirely by inline style, not .animated class */}
|
||||
<div className="w-full h-14 flex items-center justify-center rounded-lg bg-muted/30 overflow-hidden">
|
||||
<div className="w-full h-12 flex items-center justify-center rounded-lg bg-white/3 overflow-hidden">
|
||||
<div
|
||||
className="w-8 h-8 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
className="w-7 h-7 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
|
||||
style={{
|
||||
animationName: animName,
|
||||
animationDuration: `${thumbDuration}ms`,
|
||||
@@ -55,7 +44,7 @@ function PresetCard({ preset, onSelect }: {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-center leading-tight text-foreground/80">
|
||||
<span className="text-[10px] font-mono text-center leading-tight text-foreground/60 group-hover:text-foreground/80 transition-colors">
|
||||
{preset.name}
|
||||
</span>
|
||||
</button>
|
||||
@@ -63,35 +52,32 @@ function PresetCard({ preset, onSelect }: {
|
||||
}
|
||||
|
||||
export function PresetLibrary({ onSelect }: Props) {
|
||||
const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Presets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="Entrance">
|
||||
<TabsList className="mb-4">
|
||||
{PRESET_CATEGORIES.map((cat) => (
|
||||
<TabsTrigger key={cat} value={cat} className="text-xs">
|
||||
{cat}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Presets</span>
|
||||
<div className="flex glass rounded-lg p-0.5 gap-0.5">
|
||||
{PRESET_CATEGORIES.map((cat) => (
|
||||
<TabsContent key={cat} value={cat}>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||
{PRESETS.filter((p) => p.category === cat).map((preset) => (
|
||||
<PresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
onSelect={() => onSelect(preset.config)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategory(cat)}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md text-[10px] font-mono transition-all',
|
||||
category === cat ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{PRESETS.filter((p) => p.category === category).map((preset) => (
|
||||
<PresetCard key={preset.id} preset={preset} onSelect={() => onSelect(preset.config)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CodeSnippetProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function CodeSnippet({ code, language }: CodeSnippetProps) {
|
||||
export function CodeSnippet({ code }: CodeSnippetProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -21,18 +19,15 @@ export function CodeSnippet({ code, language }: CodeSnippetProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="absolute right-4 top-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
className="bg-background/50 backdrop-blur-md border border-border"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="p-4 rounded-lg bg-input backdrop-blur-sm border border-border overflow-x-auto font-mono text-xs text-muted-foreground leading-relaxed">
|
||||
<div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md border border-white/10 bg-white/5 text-white/40 hover:text-white/70 hover:border-white/20 transition-all"
|
||||
>
|
||||
{copied ? <Check className="w-2.5 h-2.5" /> : <Copy className="w-2.5 h-2.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<pre className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Upload, X, FileImage, HardDrive } from 'lucide-react';
|
||||
import { Upload, X, FileImage, HardDrive, Film } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface FaviconFileUploadProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
@@ -26,7 +25,7 @@ export function FaviconFileUpload({
|
||||
if (selectedFile) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setDimensions(`${img.width} × ${img.height}`);
|
||||
setDimensions(`${img.width}×${img.height}`);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(selectedFile);
|
||||
@@ -35,49 +34,22 @@ export function FaviconFileUpload({
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) fileInputRef.current?.click();
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<div className="w-full">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -88,66 +60,64 @@ export function FaviconFileUpload({
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<FileImage className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs font-mono text-foreground/80 truncate" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<button
|
||||
onClick={onFileRemove}
|
||||
disabled={disabled}
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-foreground truncate" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onFileRemove}
|
||||
disabled={disabled}
|
||||
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-3 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span>{(selectedFile.size / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
{dimensions && (
|
||||
<div className="flex items-center gap-1">
|
||||
<FileImage className="h-3 w-3" />
|
||||
<span>{dimensions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-2.5 h-2.5" />
|
||||
{selectedFile.size < 1024 * 1024
|
||||
? `${(selectedFile.size / 1024).toFixed(1)} KB`
|
||||
: `${(selectedFile.size / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</span>
|
||||
{dimensions && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Film className="w-2.5 h-2.5" />{dimensions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
{
|
||||
'border-primary bg-primary/10 scale-[0.98]': isDragging,
|
||||
'border-border/50': !isDragging,
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}
|
||||
'flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer text-center select-none py-8',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10 scale-[0.99]'
|
||||
: 'border-border/35 hover:border-primary/40 hover:bg-primary/5',
|
||||
disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Upload className="h-6 w-6 text-primary" />
|
||||
<div className={cn(
|
||||
'w-14 h-14 rounded-full flex items-center justify-center mb-4 transition-colors',
|
||||
isDragging ? 'bg-primary/25' : 'bg-primary/10'
|
||||
)}>
|
||||
<Upload className={cn('w-6 h-6 transition-colors', isDragging ? 'text-primary' : 'text-primary/60')} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground mb-0.5">
|
||||
Drop icon source here
|
||||
<p className="text-sm font-medium text-foreground/70 mb-1">
|
||||
{isDragging ? 'Drop to upload' : 'Drop icon here or click to browse'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
512x512 PNG or SVG recommended
|
||||
<p className="text-[10px] text-muted-foreground/35 font-mono">
|
||||
PNG · SVG · 512×512 recommended
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, Loader2, Code2, Globe, Layout } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react';
|
||||
import { FaviconFileUpload } from './FaviconFileUpload';
|
||||
import { CodeSnippet } from './CodeSnippet';
|
||||
import { generateFaviconSet } from '@/lib/favicon/faviconService';
|
||||
import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils';
|
||||
import type { FaviconSet, FaviconOptions } from '@/types/favicon';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type Tab = 'icons' | 'html' | 'manifest';
|
||||
type MobileTab = 'setup' | 'results';
|
||||
|
||||
const TABS: { value: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: 'icons', label: 'Icons', icon: <Layout className="w-3 h-3" /> },
|
||||
{ value: 'html', label: 'HTML', icon: <Code2 className="w-3 h-3" /> },
|
||||
{ value: 'manifest', label: 'Manifest', icon: <Globe className="w-3 h-3" /> },
|
||||
];
|
||||
|
||||
const actionBtn =
|
||||
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
|
||||
|
||||
export function FaviconGenerator() {
|
||||
const [sourceFile, setSourceFile] = React.useState<File | null>(null);
|
||||
const [options, setOptions] = React.useState<FaviconOptions>({
|
||||
name: 'My Awesome App',
|
||||
name: 'My App',
|
||||
shortName: 'App',
|
||||
backgroundColor: '#ffffff',
|
||||
themeColor: '#3b82f6',
|
||||
@@ -26,22 +36,18 @@ export function FaviconGenerator() {
|
||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||
const [progress, setProgress] = React.useState(0);
|
||||
const [result, setResult] = React.useState<FaviconSet | null>(null);
|
||||
const [tab, setTab] = React.useState<Tab>('icons');
|
||||
const [mobileTab, setMobileTab] = React.useState<MobileTab>('setup');
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!sourceFile) {
|
||||
toast.error('Please upload a source image');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceFile) { toast.error('Please upload a source image'); return; }
|
||||
setIsGenerating(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const resultSet = await generateFaviconSet(sourceFile, options, (p) => {
|
||||
setProgress(p);
|
||||
});
|
||||
const resultSet = await generateFaviconSet(sourceFile, options, (p) => setProgress(p));
|
||||
setResult(resultSet);
|
||||
toast.success('Favicon set generated successfully!');
|
||||
setMobileTab('results');
|
||||
toast.success('Favicon set generated!');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to generate favicons');
|
||||
@@ -52,229 +58,249 @@ export function FaviconGenerator() {
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (!result) return;
|
||||
|
||||
const files = result.icons.map((icon) => ({
|
||||
blob: icon.blob!,
|
||||
filename: icon.name,
|
||||
}));
|
||||
|
||||
// Add manifest to ZIP
|
||||
const files = result.icons.map((icon) => ({ blob: icon.blob!, filename: icon.name }));
|
||||
const manifestBlob = new Blob([result.manifest], { type: 'application/json' });
|
||||
files.push({
|
||||
blob: manifestBlob,
|
||||
filename: 'site.webmanifest',
|
||||
});
|
||||
|
||||
files.push({ blob: manifestBlob, filename: 'site.webmanifest' });
|
||||
await downloadBlobsAsZip(files, 'favicons.zip');
|
||||
toast.success('Downloading favicons ZIP...');
|
||||
toast.success('Downloading favicons ZIP…');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSourceFile(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setMobileTab('setup');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Settings Column */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>App Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="app-name" className="text-xs">Application Name</Label>
|
||||
<Input
|
||||
id="app-name"
|
||||
value={options.name}
|
||||
onChange={(e) => setOptions({ ...options, name: e.target.value })}
|
||||
placeholder="e.g. My Awesome Website"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="short-name" className="text-xs">Short Name</Label>
|
||||
<Input
|
||||
id="short-name"
|
||||
value={options.shortName}
|
||||
onChange={(e) => setOptions({ ...options, shortName: e.target.value })}
|
||||
placeholder="e.g. My App"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">Used for mobile home screen labels</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Theme Colors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bg-color" className="text-xs">Background</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
value={options.backgroundColor}
|
||||
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={options.backgroundColor}
|
||||
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="theme-color" className="text-xs">Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="theme-color"
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
value={options.themeColor}
|
||||
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={options.themeColor}
|
||||
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Mobile tab switcher ─────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['setup', 'results'] as MobileTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMobileTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||
mobileTab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'setup' ? 'Setup' : 'Results'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
>
|
||||
|
||||
{/* Left: Setup */}
|
||||
<div className={cn('lg:col-span-2 flex flex-col gap-3 overflow-hidden', mobileTab !== 'setup' && 'hidden lg:flex')}>
|
||||
|
||||
{/* Upload zone */}
|
||||
<div className="glass rounded-xl p-4 shrink-0">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
|
||||
Source Image
|
||||
</span>
|
||||
<FaviconFileUpload
|
||||
selectedFile={sourceFile}
|
||||
onFileSelect={setSourceFile}
|
||||
onFileRemove={() => setSourceFile(null)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
disabled={!sourceFile || isGenerating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
</div>
|
||||
|
||||
{/* App config */}
|
||||
<div className="glass rounded-xl p-4 flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3 shrink-0">
|
||||
App Details
|
||||
</span>
|
||||
<div className="space-y-3 flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">App Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={options.name}
|
||||
onChange={(e) => setOptions({ ...options, name: e.target.value })}
|
||||
placeholder="My Awesome App"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Short Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={options.shortName}
|
||||
onChange={(e) => setOptions({ ...options, shortName: e.target.value })}
|
||||
placeholder="App"
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground/30 font-mono mt-1">Used for mobile home screen labels</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={options.backgroundColor}
|
||||
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
||||
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={options.backgroundColor}
|
||||
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
||||
className={cn(inputCls, 'py-1')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={options.themeColor}
|
||||
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
||||
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={options.themeColor}
|
||||
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
||||
className={cn(inputCls, 'py-1')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 shrink-0 pt-3 mt-3 border-t border-border/25">
|
||||
{result && (
|
||||
<button onClick={handleReset} className={cn(actionBtn, 'px-4')}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!sourceFile || isGenerating}
|
||||
className={cn(actionBtn, 'flex-1 py-2.5')}
|
||||
>
|
||||
{isGenerating
|
||||
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating… {progress}%</>
|
||||
: 'Generate Favicons'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Results */}
|
||||
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'results' && 'hidden lg:flex')}>
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
{/* Tab bar + download button */}
|
||||
<div className="flex items-center gap-2 mb-4 shrink-0">
|
||||
<div className="flex glass rounded-lg p-0.5 gap-0.5 flex-1">
|
||||
{TABS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setTab(value)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
|
||||
tab === value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{result && (
|
||||
<button onClick={handleDownloadAll} className={cn(actionBtn, 'shrink-0 px-3')}>
|
||||
<Download className="w-3 h-3" />
|
||||
ZIP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
|
||||
{isGenerating ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground/50">
|
||||
<span>Processing…</span>
|
||||
<span className="tabular-nums">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 rounded-full overflow-hidden bg-white/5">
|
||||
<div
|
||||
className="h-full bg-primary/65 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : result ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Generating... {progress}%
|
||||
{tab === 'icons' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{result.icons.map((icon) => (
|
||||
<div
|
||||
key={icon.name}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border/20 bg-primary/3 group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-xl border border-border/25 bg-white/4 flex items-center justify-center group-hover:scale-105 transition-transform">
|
||||
{icon.previewUrl ? (
|
||||
<img src={icon.previewUrl} alt={icon.name} className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<FileImage className="w-6 h-6 text-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center w-full">
|
||||
<p className="text-[10px] font-mono text-foreground/70 truncate" title={icon.name}>{icon.name}</p>
|
||||
<p className="text-[9px] font-mono text-muted-foreground/40">{icon.width}×{icon.height} · {(icon.size / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tab === 'html' && (
|
||||
<div className="space-y-3">
|
||||
<CodeSnippet code={result.htmlCode} />
|
||||
<div className="rounded-lg border border-primary/15 bg-primary/5 p-3">
|
||||
<p className="text-[10px] text-muted-foreground/60 font-mono leading-relaxed">
|
||||
Place generated files in your site root or update the href paths.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'manifest' && (
|
||||
<CodeSnippet code={result.manifest} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'Generate Favicons'
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<FileImage className="w-6 h-6 text-primary/40" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground/40">No assets yet</p>
|
||||
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Upload an image and generate favicons</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{result && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results Column */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
{isGenerating ? (
|
||||
<Card className="h-full flex flex-col items-center justify-center p-10 space-y-4">
|
||||
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">Processing...</span>
|
||||
</div>
|
||||
<span className="tabular-nums">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1" />
|
||||
</div>
|
||||
</Card>
|
||||
) : result ? (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-bold">Generated Assets</h2>
|
||||
<Button onClick={handleDownloadAll}>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Download ZIP
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="icons" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="icons" className="flex items-center gap-1.5">
|
||||
<Layout className="h-3.5 w-3.5" />
|
||||
Icons
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="html" className="flex items-center gap-1.5">
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
HTML
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manifest" className="flex items-center gap-1.5">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Manifest
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="icons" className="mt-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{result?.icons.map((icon) => (
|
||||
<Card key={icon.name} className="group overflow-hidden">
|
||||
<div className="p-3 flex flex-col items-center text-center space-y-2">
|
||||
<div className="relative h-16 w-16 flex items-center justify-center bg-muted/50 rounded-lg p-1.5 border border-border/50 group-hover:scale-105 transition-transform duration-200">
|
||||
{icon.previewUrl && (
|
||||
<img
|
||||
src={icon.previewUrl}
|
||||
alt={icon.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<p className="text-[10px] font-medium text-foreground truncate" title={icon.name}>
|
||||
{icon.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{icon.width}x{icon.height} · {(icon.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="html" className="mt-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Embed in your <head></Label>
|
||||
{result && <CodeSnippet code={result.htmlCode} />}
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
Place generated files in your site root or update the <code className="text-primary">href</code> paths.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manifest" className="mt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">site.webmanifest</Label>
|
||||
{result && <CodeSnippet code={result.manifest} />}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useSidebar } from './SidebarProvider';
|
||||
import { getToolByHref } from '@/lib/tools';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
const iconBtn =
|
||||
'w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground/50 hover:text-foreground hover:bg-white/5 transition-all';
|
||||
|
||||
export function AppHeader() {
|
||||
const { toggle, isOpen, isCollapsed, toggleCollapse } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const tool = getToolByHref(pathname);
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b border-border bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-6 shadow-[0_1px_3px_0_rgb(0_0_0/0.05)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden lg:inline-flex text-muted-foreground hover:text-foreground"
|
||||
<header className="h-14 border-b border-border/20 bg-background/8 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-4 gap-3">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{/* Desktop: sidebar collapse toggle */}
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={cn(iconBtn, 'hidden lg:flex shrink-0')}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Link href="/" className="lg:hidden shrink-0 ml-2">
|
||||
<Logo size={24} />
|
||||
{isCollapsed
|
||||
? <PanelLeftOpen className="w-4 h-4" />
|
||||
: <PanelLeftClose className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Mobile: logo home link */}
|
||||
<Link href="/" className="lg:hidden shrink-0">
|
||||
<Logo size={20} />
|
||||
</Link>
|
||||
|
||||
{/* Current tool breadcrumb */}
|
||||
{tool && (
|
||||
<div className="flex items-center gap-1.5 min-w-0 ml-1">
|
||||
<span className="text-border/50 text-xs select-none">/</span>
|
||||
<span className="text-sm text-foreground/60 truncate font-mono">
|
||||
{tool.navTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden text-muted-foreground hover:text-foreground"
|
||||
{/* Mobile: open/close sidebar */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={isOpen ? 'Close menu' : 'Open menu'}
|
||||
className={cn(iconBtn, 'lg:hidden shrink-0')}
|
||||
>
|
||||
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
{isOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,20 +11,31 @@ interface AppPageProps {
|
||||
|
||||
export function AppPage({ title, description, icon: Icon, children, className }: AppPageProps) {
|
||||
return (
|
||||
<div className={cn("min-h-screen py-8", className)}>
|
||||
<div className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
{Icon && <Icon className="h-6 w-6 text-primary shrink-0" />}
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<div className={cn('min-h-screen', className)}>
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="py-5 border-b border-border/20 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground leading-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-[10px] text-muted-foreground/50 font-mono mt-0.5 truncate">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
<div className="pb-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { X, GitFork, Heart } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import Logo from '@/components/Logo';
|
||||
import { useSidebar } from './SidebarProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { tools } from '@/lib/tools';
|
||||
|
||||
export function AppSidebar() {
|
||||
@@ -15,7 +14,7 @@ export function AppSidebar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Overlay Backdrop */}
|
||||
{/* Mobile backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-transparent backdrop-blur-sm z-40 lg:hidden"
|
||||
@@ -24,94 +23,100 @@ export function AppSidebar() {
|
||||
)}
|
||||
|
||||
<aside className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
isCollapsed ? "lg:w-14" : "w-64"
|
||||
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border/20 bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||
isCollapsed ? 'lg:w-14' : 'w-60'
|
||||
)}>
|
||||
{/* Sidebar Header */}
|
||||
|
||||
{/* Header */}
|
||||
<div className={cn(
|
||||
"flex h-16 items-center shrink-0 border-b border-border",
|
||||
isCollapsed ? "justify-center px-2" : "justify-between px-5"
|
||||
'flex h-14 items-center shrink-0 border-b border-border/20',
|
||||
isCollapsed ? 'justify-center px-2' : 'justify-between px-4'
|
||||
)}>
|
||||
<Link href="/" className={cn(
|
||||
"flex items-center group overflow-hidden",
|
||||
isCollapsed ? "justify-center" : "gap-3"
|
||||
)}>
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
'flex items-center group overflow-hidden',
|
||||
isCollapsed ? 'justify-center' : 'gap-2.5'
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<Logo size={isCollapsed ? 20 : 28} />
|
||||
<Logo size={isCollapsed ? 18 : 24} />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-lg leading-tight block text-foreground">
|
||||
Kit
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight text-muted-foreground block">
|
||||
<span className="font-semibold text-base leading-tight block text-foreground">Kit</span>
|
||||
<span className="text-[9px] leading-tight text-muted-foreground/50 block font-mono tracking-wider">
|
||||
Browser-first toolkit
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{!isCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden text-muted-foreground"
|
||||
<button
|
||||
onClick={close}
|
||||
className="lg:hidden w-7 h-7 flex items-center justify-center rounded-lg text-muted-foreground/40 hover:text-foreground hover:bg-white/5 transition-all"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className={cn(
|
||||
"flex-1 overflow-y-auto py-2 space-y-6 mt-4 overflow-hidden scrollbar",
|
||||
isCollapsed ? "px-2" : "px-4"
|
||||
'flex-1 overflow-y-auto py-3 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/10 scrollbar-track-transparent',
|
||||
isCollapsed ? 'px-2' : 'px-3'
|
||||
)}>
|
||||
<div className="space-y-0.5">
|
||||
{tools.map((tool) => {
|
||||
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
|
||||
const Icon = tool.icon;
|
||||
{tools.map((tool) => {
|
||||
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
|
||||
const Icon = tool.icon;
|
||||
|
||||
return (
|
||||
<div key={tool.href} className="space-y-1">
|
||||
<Link
|
||||
href={tool.href}
|
||||
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-300 relative group/item",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
isCollapsed ? "justify-center" : "justify-between"
|
||||
)}
|
||||
title={isCollapsed ? tool.navTitle : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className={cn(
|
||||
"transition-colors duration-300 shrink-0",
|
||||
isActive ? "text-primary" : "text-foreground/80 group-hover/item:text-foreground"
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<span className="whitespace-nowrap block">{tool.navTitle}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight block line-clamp-2">{tool.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<Link
|
||||
key={tool.href}
|
||||
href={tool.href}
|
||||
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
||||
title={isCollapsed ? tool.navTitle : undefined}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-lg text-sm transition-all duration-200 group/item',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-foreground/55 hover:bg-white/4 hover:text-foreground',
|
||||
isCollapsed ? 'justify-center p-2' : 'gap-3 px-3 py-2'
|
||||
)}
|
||||
>
|
||||
{/* Active left bar */}
|
||||
{isActive && (
|
||||
<span className="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-primary" />
|
||||
)}
|
||||
|
||||
<span className={cn(
|
||||
'shrink-0 transition-colors duration-200',
|
||||
isActive ? 'text-primary' : 'text-foreground/40 group-hover/item:text-foreground/70'
|
||||
)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<span className="whitespace-nowrap block text-[13px] font-medium leading-tight">
|
||||
{tool.navTitle}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground/40 leading-tight block truncate font-mono mt-0.5">
|
||||
{tool.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
{/* Footer */}
|
||||
<div className={cn(
|
||||
"shrink-0 border-t border-border py-3",
|
||||
isCollapsed ? "flex justify-center px-2" : "px-4"
|
||||
'shrink-0 border-t border-border/20 py-3',
|
||||
isCollapsed ? 'flex justify-center px-2' : 'px-4'
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<a
|
||||
@@ -119,21 +124,20 @@ export function AppSidebar() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View source"
|
||||
className="text-muted-foreground hover:text-primary transition-colors duration-300"
|
||||
className="text-muted-foreground/40 hover:text-primary transition-colors"
|
||||
>
|
||||
<GitFork className="h-4 w-4" />
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
© {new Date().getFullYear()} Kit.
|
||||
<Heart className="h-2.5 w-2.5 text-primary shrink-0 animate-pulse" fill="currentColor" />
|
||||
<p className="flex items-center gap-1 text-[9px] text-muted-foreground/40 font-mono">
|
||||
© {new Date().getFullYear()} Kit
|
||||
<Heart className="w-2 h-2 text-primary/70 shrink-0 animate-pulse" fill="currentColor" />
|
||||
<a
|
||||
href="https://pivoine.art"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Pivoine.Art"
|
||||
className="font-medium underline underline-offset-4 decoration-primary/0 hover:decoration-primary transition-all duration-300"
|
||||
className="hover:text-foreground/70 transition-colors"
|
||||
>
|
||||
Valknar
|
||||
</a>
|
||||
@@ -143,14 +147,13 @@ export function AppSidebar() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View source"
|
||||
className="text-muted-foreground hover:text-primary transition-colors duration-300"
|
||||
className="text-muted-foreground/30 hover:text-primary transition-colors"
|
||||
>
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
<GitFork className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,11 @@ import { decodeQRFromUrl, updateQRUrl, getQRShareableUrl } from '@/lib/qrcode/ur
|
||||
import { downloadBlob } from '@/lib/media/utils/fileUtils';
|
||||
import { debounce } from '@/lib/utils/debounce';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
|
||||
|
||||
type MobileTab = 'configure' | 'preview';
|
||||
|
||||
export function QRCodeGenerator() {
|
||||
const [text, setText] = React.useState('https://kit.pivoine.art');
|
||||
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
|
||||
@@ -20,6 +23,7 @@ export function QRCodeGenerator() {
|
||||
const [exportSize, setExportSize] = React.useState<ExportSize>(512);
|
||||
const [svgString, setSvgString] = React.useState('');
|
||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||
const [mobileTab, setMobileTab] = React.useState<MobileTab>('configure');
|
||||
|
||||
// Load state from URL on mount
|
||||
React.useEffect(() => {
|
||||
@@ -37,11 +41,7 @@ export function QRCodeGenerator() {
|
||||
const generate = React.useMemo(
|
||||
() =>
|
||||
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
|
||||
if (!t) {
|
||||
setSvgString('');
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
}
|
||||
if (!t) { setSvgString(''); setIsGenerating(false); return; }
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const svg = await generateSvg(t, ec, fg, bg, m);
|
||||
@@ -57,13 +57,11 @@ export function QRCodeGenerator() {
|
||||
[],
|
||||
);
|
||||
|
||||
// Regenerate on changes
|
||||
React.useEffect(() => {
|
||||
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||||
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||||
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
|
||||
|
||||
// Export: PNG download
|
||||
const handleDownloadPng = async () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
@@ -71,74 +69,94 @@ export function QRCodeGenerator() {
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
downloadBlob(blob, `qrcode-${Date.now()}.png`);
|
||||
} catch {
|
||||
toast.error('Failed to export PNG');
|
||||
}
|
||||
} catch { toast.error('Failed to export PNG'); }
|
||||
};
|
||||
|
||||
// Export: SVG download
|
||||
const handleDownloadSvg = () => {
|
||||
if (!svgString) return;
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
|
||||
};
|
||||
|
||||
// Copy image to clipboard
|
||||
const handleCopyImage = async () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': blob }),
|
||||
]);
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
toast.success('Image copied to clipboard!');
|
||||
} catch {
|
||||
toast.error('Failed to copy image');
|
||||
}
|
||||
} catch { toast.error('Failed to copy image'); }
|
||||
};
|
||||
|
||||
// Share URL
|
||||
const handleShare = async () => {
|
||||
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success('Shareable URL copied!');
|
||||
} catch {
|
||||
toast.error('Failed to copy URL');
|
||||
}
|
||||
} catch { toast.error('Failed to copy URL'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
|
||||
{/* Left Column - Input and Options */}
|
||||
<div className="lg:col-span-1 space-y-6 overflow-y-auto custom-scrollbar">
|
||||
<QRInput value={text} onChange={setText} />
|
||||
<QROptions
|
||||
errorCorrection={errorCorrection}
|
||||
foregroundColor={foregroundColor}
|
||||
backgroundColor={backgroundColor}
|
||||
margin={margin}
|
||||
onErrorCorrectionChange={setErrorCorrection}
|
||||
onForegroundColorChange={setForegroundColor}
|
||||
onBackgroundColorChange={setBackgroundColor}
|
||||
onMarginChange={setMargin}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ─────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['configure', 'preview'] as MobileTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setMobileTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||
mobileTab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'configure' ? 'Configure' : 'Preview'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Preview */}
|
||||
<div className="lg:col-span-2 h-full">
|
||||
<QRPreview
|
||||
svgString={svgString}
|
||||
isGenerating={isGenerating}
|
||||
exportSize={exportSize}
|
||||
onExportSizeChange={setExportSize}
|
||||
onCopyImage={handleCopyImage}
|
||||
onShare={handleShare}
|
||||
onDownloadPng={handleDownloadPng}
|
||||
onDownloadSvg={handleDownloadSvg}
|
||||
/>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
>
|
||||
|
||||
{/* Left: Input + Options */}
|
||||
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'configure' && 'hidden lg:flex')}>
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
|
||||
<QRInput value={text} onChange={setText} />
|
||||
<div className="border-t border-border/25" />
|
||||
<QROptions
|
||||
errorCorrection={errorCorrection}
|
||||
foregroundColor={foregroundColor}
|
||||
backgroundColor={backgroundColor}
|
||||
margin={margin}
|
||||
onErrorCorrectionChange={setErrorCorrection}
|
||||
onForegroundColorChange={setForegroundColor}
|
||||
onBackgroundColorChange={setBackgroundColor}
|
||||
onMarginChange={setMargin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview */}
|
||||
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
|
||||
<QRPreview
|
||||
svgString={svgString}
|
||||
isGenerating={isGenerating}
|
||||
exportSize={exportSize}
|
||||
onExportSizeChange={setExportSize}
|
||||
onCopyImage={handleCopyImage}
|
||||
onShare={handleShare}
|
||||
onDownloadPng={handleDownloadPng}
|
||||
onDownloadSvg={handleDownloadSvg}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
const MAX_LENGTH = 2048;
|
||||
|
||||
interface QRInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const MAX_LENGTH = 2048;
|
||||
|
||||
export function QRInput({ value, onChange }: QRInputProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Enter text or URL..."
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
className="resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground text-right">
|
||||
{value.length} / {MAX_LENGTH}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
|
||||
Content
|
||||
</span>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Enter text or URL…"
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={4}
|
||||
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 resize-none"
|
||||
/>
|
||||
<div className="text-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
|
||||
{value.length} / {MAX_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { ErrorCorrectionLevel } from '@/types/qrcode';
|
||||
|
||||
interface QROptionsProps {
|
||||
@@ -25,13 +15,16 @@ interface QROptionsProps {
|
||||
onMarginChange: (margin: number) => void;
|
||||
}
|
||||
|
||||
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string }[] = [
|
||||
{ value: 'L', label: 'Low (7%)' },
|
||||
{ value: 'M', label: 'Medium (15%)' },
|
||||
{ value: 'Q', label: 'Quartile (25%)' },
|
||||
{ value: 'H', label: 'High (30%)' },
|
||||
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
|
||||
{ value: 'L', label: 'L', desc: '7%' },
|
||||
{ value: 'M', label: 'M', desc: '15%' },
|
||||
{ value: 'Q', label: 'Q', desc: '25%' },
|
||||
{ value: 'H', label: 'H', desc: '30%' },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
|
||||
|
||||
export function QROptions({
|
||||
errorCorrection,
|
||||
foregroundColor,
|
||||
@@ -45,93 +38,111 @@ export function QROptions({
|
||||
const isTransparent = backgroundColor === '#00000000';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Options</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Error Correction */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Error Correction</Label>
|
||||
<Select value={errorCorrection} onValueChange={(v) => onErrorCorrectionChange(v as ErrorCorrectionLevel)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EC_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Foreground</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Background</Label>
|
||||
<Button
|
||||
variant={isTransparent ? 'default' : 'outline'}
|
||||
size="xs"
|
||||
className="h-5 text-[10px] px-1.5"
|
||||
onClick={() =>
|
||||
onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')
|
||||
}
|
||||
>
|
||||
Transparent
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
disabled={isTransparent}
|
||||
value={backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
disabled={isTransparent}
|
||||
value={backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Error Correction */}
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
|
||||
Error Correction
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
{EC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onErrorCorrectionChange(opt.value)}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
|
||||
errorCorrection === opt.value
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="font-semibold">{opt.label}</span>
|
||||
<span className="text-[9px] opacity-50 mt-0.5">{opt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
|
||||
Colors
|
||||
</span>
|
||||
|
||||
{/* Foreground */}
|
||||
<div>
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={foregroundColor}
|
||||
onChange={(e) => onForegroundColorChange(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Margin */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">Margin</Label>
|
||||
<span className="text-xs text-muted-foreground">{margin}</span>
|
||||
{/* Background */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
|
||||
<button
|
||||
onClick={() => onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')}
|
||||
className={cn(
|
||||
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
|
||||
isTransparent
|
||||
? 'border-primary/40 text-primary bg-primary/10'
|
||||
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
|
||||
)}
|
||||
>
|
||||
Transparent
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
disabled={isTransparent}
|
||||
value={isTransparent ? '#ffffff' : backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity',
|
||||
isTransparent && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
disabled={isTransparent}
|
||||
value={isTransparent ? 'transparent' : backgroundColor}
|
||||
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
||||
className={cn(inputCls, isTransparent && 'opacity-30')}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={[margin]}
|
||||
onValueChange={([v]) => onMarginChange(v)}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Margin */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Margin
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{margin}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[margin]}
|
||||
onValueChange={([v]) => onMarginChange(v)}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty';
|
||||
import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { ExportSize } from '@/types/qrcode';
|
||||
|
||||
interface QRPreviewProps {
|
||||
@@ -30,6 +15,16 @@ interface QRPreviewProps {
|
||||
onDownloadSvg: () => void;
|
||||
}
|
||||
|
||||
const EXPORT_SIZES: { value: ExportSize; label: string }[] = [
|
||||
{ value: 256, label: '256' },
|
||||
{ value: 512, label: '512' },
|
||||
{ value: 1024, label: '1k' },
|
||||
{ value: 2048, label: '2k' },
|
||||
];
|
||||
|
||||
const actionBtn =
|
||||
'flex items-center gap-1 px-2.5 py-1 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
|
||||
|
||||
export function QRPreview({
|
||||
svgString,
|
||||
isGenerating,
|
||||
@@ -41,92 +36,81 @@ export function QRPreview({
|
||||
onDownloadSvg,
|
||||
}: QRPreviewProps) {
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onCopyImage} disabled={!svgString}>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy image to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onShare} disabled={!svgString}>
|
||||
<Share2 className="h-3 w-3 mr-1" />
|
||||
Share
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy shareable URL</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
|
||||
Preview
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onDownloadPng} disabled={!svgString}>
|
||||
<ImageIcon className="h-3 w-3 mr-1" />
|
||||
PNG
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download as PNG</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={String(exportSize)}
|
||||
onValueChange={(v) => v && onExportSizeChange(Number(v) as ExportSize)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="256" className="h-6 px-1.5 min-w-0 text-[10px]">256</ToggleGroupItem>
|
||||
<ToggleGroupItem value="512" className="h-6 px-1.5 min-w-0 text-[10px]">512</ToggleGroupItem>
|
||||
<ToggleGroupItem value="1024" className="h-6 px-1.5 min-w-0 text-[10px]">1k</ToggleGroupItem>
|
||||
<ToggleGroupItem value="2048" className="h-6 px-1.5 min-w-0 text-[10px]">2k</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<button onClick={onCopyImage} disabled={!svgString} className={actionBtn}>
|
||||
<Copy className="w-3 h-3" />Copy
|
||||
</button>
|
||||
|
||||
<button onClick={onShare} disabled={!svgString} className={actionBtn}>
|
||||
<Share2 className="w-3 h-3" />Share
|
||||
</button>
|
||||
|
||||
{/* PNG + inline size selector */}
|
||||
<div className="flex items-center glass rounded-md border border-border/30">
|
||||
<button
|
||||
onClick={onDownloadPng}
|
||||
disabled={!svgString}
|
||||
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-xs text-muted-foreground hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed border-r border-border/20"
|
||||
>
|
||||
<ImageIcon className="w-3 h-3" />PNG
|
||||
</button>
|
||||
<div className="flex items-center px-1 gap-0.5">
|
||||
{EXPORT_SIZES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onExportSizeChange(value)}
|
||||
className={cn(
|
||||
'text-[9px] font-mono px-1.5 py-0.5 rounded transition-all',
|
||||
exportSize === value
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground/40 hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="xs" onClick={onDownloadSvg} disabled={!svgString}>
|
||||
<FileCode className="h-3 w-3 mr-1" />
|
||||
SVG
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Download as SVG</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-[200px] rounded-lg p-4 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '16px 16px',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Skeleton className="h-[200px] w-[200px]" />
|
||||
) : svgString ? (
|
||||
<div
|
||||
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full"
|
||||
dangerouslySetInnerHTML={{ __html: svgString }}
|
||||
/>
|
||||
) : (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<QrCode />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Enter text to generate a QR code</EmptyTitle>
|
||||
<EmptyDescription>Type text or a URL in the input field above</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<button onClick={onDownloadSvg} disabled={!svgString} className={actionBtn}>
|
||||
<FileCode className="w-3 h-3" />SVG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* QR canvas */}
|
||||
<div
|
||||
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '16px 16px',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
|
||||
) : svgString ? (
|
||||
<div
|
||||
className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
|
||||
dangerouslySetInnerHTML={{ __html: svgString }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<QrCode className="w-6 h-6 text-primary/40" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground/40">No QR code yet</p>
|
||||
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user