Compare commits
51 Commits
2763b76abe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba118be485 | |||
| df4db515d8 | |||
| e9927bf0f5 | |||
| d1092c7169 | |||
| 6ecdc33933 | |||
| 3305b12c02 | |||
| a1dcfa34dc | |||
| 3fffe96016 | |||
| 36e99d0973 | |||
| fe7dce1cde | |||
| b1e79e1808 | |||
| 63b4823315 | |||
| bdbd123dd4 | |||
| 3f46b46823 | |||
| c686ad82b7 | |||
| cac75041db | |||
| fbaefbf5b8 | |||
| 075aa0b6c5 | |||
| 20406c5dcf | |||
| 7424c2e899 | |||
| 547753772c | |||
| 16e1ce4558 | |||
| d476ffb613 | |||
| b5f698cf29 | |||
| 25067bca30 | |||
| c545211cf7 | |||
| 11d4207f72 | |||
| 6d6505e5dc | |||
| 19cc44c102 | |||
| 002edc1532 | |||
| 56c0d6403c | |||
| a0a0e6eaef | |||
| 8a909bc8aa | |||
| 998ac641f9 | |||
| 1276a10e9a | |||
| f9db58122c | |||
| 2abbdf407f | |||
| dc638ac4d3 | |||
| 9390c27f44 | |||
| db37fb1ae2 | |||
| e12cc6592e | |||
| 00c77ff3fe | |||
| a4cc53d774 | |||
| 37874e3eea | |||
| 9126589de3 | |||
| 413c677173 | |||
| 002fa037b7 | |||
| ea464ef797 | |||
| 50cf5823f9 | |||
| 7da20c37c1 | |||
| 4927fb9a93 |
@@ -9,7 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function AnimatePage() {
|
||||
return (
|
||||
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}>
|
||||
<AppPage>
|
||||
<AnimationEditor />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function ASCIIPage() {
|
||||
return (
|
||||
<AppPage
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<AppPage>
|
||||
<ASCIIConverter />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function CalculatePage() {
|
||||
return (
|
||||
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}>
|
||||
<AppPage>
|
||||
<Calculator />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function ColorPage() {
|
||||
return (
|
||||
<AppPage
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<AppPage>
|
||||
<ColorManipulation />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
19
app/(app)/cron/page.tsx
Normal file
19
app/(app)/cron/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { CronEditor } from '@/components/cron/CronEditor';
|
||||
import { getToolByHref } from '@/lib/tools';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
const tool = getToolByHref('/cron')!;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: tool.title,
|
||||
description: tool.summary,
|
||||
};
|
||||
|
||||
export default function CronPage() {
|
||||
return (
|
||||
<AppPage>
|
||||
<CronEditor />
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function FaviconPage() {
|
||||
return (
|
||||
<AppPage
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<AppPage>
|
||||
<FaviconGenerator />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function MediaPage() {
|
||||
return (
|
||||
<AppPage
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<AppPage>
|
||||
<FileConverter />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function QRCodePage() {
|
||||
return (
|
||||
<AppPage
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<AppPage>
|
||||
<QRCodeGenerator />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
16
app/(app)/random/page.tsx
Normal file
16
app/(app)/random/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { RandomGenerator } from '@/components/random/RandomGenerator';
|
||||
import { AppPage } from '@/components/layout/AppPage';
|
||||
import { getToolByHref } from '@/lib/tools';
|
||||
|
||||
const tool = getToolByHref('/random')!;
|
||||
|
||||
export const metadata: Metadata = { title: tool.title, description: tool.summary };
|
||||
|
||||
export default function RandomPage() {
|
||||
return (
|
||||
<AppPage>
|
||||
<RandomGenerator />
|
||||
</AppPage>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
|
||||
|
||||
export default function UnitsPage() {
|
||||
return (
|
||||
<AppPage
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
>
|
||||
<AppPage>
|
||||
<MainConverter />
|
||||
</AppPage>
|
||||
);
|
||||
|
||||
@@ -84,6 +84,27 @@
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes logoStamp {
|
||||
0% { opacity: 0; transform: scale(2) rotate(15deg); }
|
||||
38% { opacity: 1; transform: scale(0.82) rotate(-5deg); }
|
||||
58% { transform: scale(1.14) rotate(3deg); }
|
||||
74% { transform: scale(0.94) rotate(-1deg); }
|
||||
88% { transform: scale(1.04) rotate(0.3deg); }
|
||||
100% { transform: scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes pathFlicker {
|
||||
0% { opacity: 0; }
|
||||
28%, 30% { opacity: 0; }
|
||||
31%, 33% { opacity: 1; }
|
||||
34%, 40% { opacity: 0; }
|
||||
41%, 44% { opacity: 1; }
|
||||
45%, 49% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function RootLayout({
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -1,73 +1,57 @@
|
||||
'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 */}
|
||||
<Logo size={52} />
|
||||
|
||||
{/* 404 */}
|
||||
<div
|
||||
className="mt-10"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.15s both' }}
|
||||
>
|
||||
<span className="text-[80px] md:text-[120px] font-bold font-mono leading-none tabular-nums block bg-gradient-to-b from-foreground to-foreground/25 bg-clip-text text-transparent">
|
||||
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>
|
||||
{/* Divider */}
|
||||
<div
|
||||
className="mt-6 w-12 h-px bg-gradient-to-r from-transparent via-primary/50 to-transparent"
|
||||
style={{ animation: 'fadeIn 0.5s ease-out 0.3s both' }}
|
||||
/>
|
||||
|
||||
{/* 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 }}
|
||||
>
|
||||
The tool or page you are looking for doesn't exist or has been moved.
|
||||
</motion.p>
|
||||
{/* Message */}
|
||||
<div
|
||||
className="mt-6 space-y-2"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.35s both' }}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground/70">Page not found</p>
|
||||
<p className="text-[11px] text-muted-foreground/45 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>
|
||||
|
||||
{/* CTA Button */}
|
||||
<motion.div
|
||||
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' }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 glass rounded-xl border border-white/[0.06] hover:border-primary/40 hover:bg-primary/[0.07] text-sm font-medium text-foreground/60 hover:text-foreground transition-all duration-200"
|
||||
>
|
||||
<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,17 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
import Hero from '@/components/Hero';
|
||||
import Stats from '@/components/Stats';
|
||||
import ToolsGrid from '@/components/ToolsGrid';
|
||||
import Footer from '@/components/Footer';
|
||||
import BackToTop from '@/components/BackToTop';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="relative min-h-screen text-foreground">
|
||||
<AnimatedBackground />
|
||||
<BackToTop />
|
||||
<Hero />
|
||||
<Stats />
|
||||
<ToolsGrid />
|
||||
|
||||
@@ -67,6 +67,31 @@ export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RandomIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" strokeWidth={2} />
|
||||
<circle cx="8.5" cy="8.5" r="1.25" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.5" cy="8.5" r="1.25" fill="currentColor" stroke="none" />
|
||||
<circle cx="8.5" cy="15.5" r="1.25" fill="currentColor" stroke="none" />
|
||||
<circle cx="15.5" cy="15.5" r="1.25" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.25" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CronIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{/* Clock face */}
|
||||
<circle cx="12" cy="12" r="8.5" strokeWidth={2} />
|
||||
{/* Center */}
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
{/* Clock hands */}
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 7.5V12l3 2" />
|
||||
{/* Repeat arrow arcing around the top */}
|
||||
<path strokeLinecap="round" strokeWidth={1.5} d="M18.5 6.5a10.5 10.5 0 0 0-7-3.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.5 6.5l2-2M18.5 6.5l-1.5 2.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CalculateIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{/* Y-axis */}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion, useScroll, useSpring } from 'framer-motion';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function BackToTop() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const { scrollYProgress } = useScroll();
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const toggleVisibility = () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility);
|
||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
||||
}, []);
|
||||
|
||||
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 }}
|
||||
/>
|
||||
|
||||
{/* 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 }}
|
||||
aria-label="Back to top"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,35 @@
|
||||
'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-white/[0.06] pt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground/35 font-mono">
|
||||
<span>© {currentYear} Kit</span>
|
||||
<Heart className="w-2.5 h-2.5 text-primary/60 shrink-0 animate-pulse" fill="currentColor" />
|
||||
<a
|
||||
href="https://pivoine.art"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground/60 transition-colors duration-200"
|
||||
>
|
||||
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="flex items-center gap-1.5 text-xs text-muted-foreground/30 font-mono hover:text-primary transition-colors duration-200"
|
||||
>
|
||||
<GitFork className="h-5 w-5" />
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Source</span>
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,108 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Toolbox } from 'lucide-react';
|
||||
import { ArrowDown } 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">
|
||||
<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">
|
||||
|
||||
{/* 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>
|
||||
<Logo size={72} />
|
||||
|
||||
{/* 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 }}
|
||||
{/* Badge */}
|
||||
<div
|
||||
className="mt-8 flex items-center gap-2 px-3 py-1.5 glass rounded-full border border-white/[0.06]"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.2s both' }}
|
||||
>
|
||||
Kit
|
||||
</motion.h1>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
|
||||
<span className="text-[10px] font-mono text-muted-foreground/55 tracking-widest uppercase">
|
||||
Browser-first
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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 }}
|
||||
{/* Title */}
|
||||
<h1
|
||||
className="mt-6 font-bold tracking-tight leading-none"
|
||||
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
|
||||
>
|
||||
Your Creative Toolkit
|
||||
</motion.p>
|
||||
<span className="text-6xl md:text-8xl text-foreground">Kit</span>
|
||||
<span className="text-6xl md:text-8xl text-primary">.</span>
|
||||
</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-6 text-sm text-muted-foreground/55 max-w-xs 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 — no data leaves your machine.
|
||||
</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-6 py-2.5 rounded-xl border border-primary/30 bg-primary/[0.07] hover:border-primary/55 hover:bg-primary/[0.13] 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>
|
||||
Explore Tools
|
||||
<ArrowDown className="w-3.5 h-3.5 text-primary" />
|
||||
</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-24 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>
|
||||
<div className="w-px h-8 bg-gradient-to-b from-transparent via-primary/30 to-primary/60 group-hover:via-primary/50 group-hover:to-primary transition-colors duration-300" />
|
||||
<span className="text-[9px] font-mono text-muted-foreground/25 uppercase tracking-widest group-hover:text-muted-foreground/50 transition-colors">
|
||||
Scroll
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export default function Logo({ className = '', size = 120 }: { className?: string; size?: number }) {
|
||||
return (
|
||||
<motion.svg
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
style={{ animation: 'logoStamp 0.65s cubic-bezier(0.22, 1, 0.36, 1) both' }}
|
||||
>
|
||||
{/* Wrench (Lucide) - vertical */}
|
||||
<motion.g
|
||||
<g
|
||||
transform="translate(32, 32) rotate(0) scale(3.15) translate(-12.5, -11.5)"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.2, ease: 'easeInOut' }}
|
||||
style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
|
||||
>
|
||||
<motion.path
|
||||
<path
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
stroke="url(#wrenchGradient)"
|
||||
strokeWidth="1.5"
|
||||
@@ -31,16 +23,14 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</motion.g>
|
||||
</g>
|
||||
|
||||
{/* Brush (Lucide) - horizontal flipped */}
|
||||
<motion.g
|
||||
<g
|
||||
transform="translate(32, 30) rotate(90) scale(3.025) translate(-11.25, -11)"
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.2, delay: 0.3, ease: 'easeInOut' }}
|
||||
style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
|
||||
>
|
||||
<motion.path
|
||||
<path
|
||||
d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"
|
||||
stroke="url(#brushGradient)"
|
||||
strokeWidth="1.5"
|
||||
@@ -49,7 +39,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<motion.path
|
||||
<path
|
||||
d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
|
||||
stroke="url(#brushGradient)"
|
||||
strokeWidth="1.5"
|
||||
@@ -58,7 +48,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</motion.g>
|
||||
</g>
|
||||
|
||||
{/* Gradient definitions */}
|
||||
<defs>
|
||||
@@ -71,6 +61,6 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
||||
<stop offset="100%" stopColor="#ec4899" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</motion.svg>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { tools } from '@/lib/tools';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Box, Code2, Globe } 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 available', icon: Box },
|
||||
{ value: '100%', label: 'Open source', icon: Code2 },
|
||||
{ value: '100%', label: 'Browser-first', icon: Globe },
|
||||
];
|
||||
|
||||
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-4 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{stats.map((stat, i) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="glass rounded-2xl p-5 flex items-center gap-4 border border-white/[0.06]"
|
||||
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-10 h-10 rounded-xl bg-primary/10 border border-primary/15 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-4.5 h-4.5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-2xl font-bold tabular-nums text-foreground block leading-none">
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground/40 uppercase tracking-widest mt-1 block">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-base font-medium">
|
||||
{stat.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,91 +1,64 @@
|
||||
'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 relative glass rounded-2xl p-6 flex flex-col h-full transition-all duration-300 border border-white/[0.06] hover:border-primary/35 hover:shadow-[0_12px_48px_rgba(139,92,246,0.11)] overflow-hidden"
|
||||
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" />
|
||||
{/* Top shimmer accent on hover */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-primary/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
|
||||
|
||||
{/* 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>
|
||||
{/* Radial glow on hover */}
|
||||
<div className="absolute top-0 left-0 w-36 h-36 rounded-full bg-primary/[0.07] blur-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none -translate-x-6 -translate-y-6" />
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
{/* Icon */}
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/10 border border-primary/15 flex items-center justify-center mb-5 shrink-0 transition-all duration-300 group-hover:bg-primary/20 group-hover:border-primary/30 group-hover:shadow-[0_0_24px_rgba(139,92,246,0.22)]">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{badges && badges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{/* Title */}
|
||||
<h3 className="text-base font-semibold text-foreground/80 group-hover:text-foreground transition-colors duration-200 mb-2 leading-snug">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-[13px] text-muted-foreground/50 leading-relaxed flex-1 mb-5">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Footer: badges + arrow */}
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
{badges && badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{badges.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-md bg-primary/[0.07] border border-primary/20 text-primary/55 transition-colors duration-200 group-hover:border-primary/35 group-hover:text-primary/75"
|
||||
>
|
||||
{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>
|
||||
<div className="w-7 h-7 rounded-xl glass border border-white/[0.06] flex items-center justify-center shrink-0 transition-all duration-200 group-hover:border-primary/30 group-hover:bg-primary/10">
|
||||
<ArrowRight className="w-3.5 h-3.5 text-muted-foreground/30 group-hover:text-primary group-hover:translate-x-0.5 transition-all duration-200" />
|
||||
</div>
|
||||
</div>
|
||||
</MotionLink>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
'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-10"
|
||||
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">
|
||||
Available Tools
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
|
||||
Available{' '}
|
||||
<span className="bg-gradient-to-r from-primary via-violet-400 to-pink-400 bg-clip-text text-transparent">
|
||||
Tools
|
||||
</span>
|
||||
</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
|
||||
<p className="text-sm text-muted-foreground/40 mt-2">
|
||||
{tools.length} tools — everything runs in your browser, no data leaves your machine
|
||||
</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-3 gap-4">
|
||||
{tools.map((tool, index) => (
|
||||
<ToolCard
|
||||
key={tool.href}
|
||||
title={tool.title}
|
||||
description={tool.summary}
|
||||
icon={tool.icon}
|
||||
url={tool.href}
|
||||
badges={tool.badges}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,14 +8,21 @@ 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 { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
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>('export');
|
||||
|
||||
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
|
||||
|
||||
@@ -35,8 +42,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 +64,77 @@ export function AnimationEditor() {
|
||||
setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
const timelineProps = {
|
||||
keyframes: config.keyframes,
|
||||
selectedId,
|
||||
onSelect: setSelectedId,
|
||||
onAdd: addKeyframe,
|
||||
onDelete: deleteKeyframe,
|
||||
onMove: moveKeyframe,
|
||||
};
|
||||
|
||||
{/* Row 2: Keyframe Timeline */}
|
||||
<KeyframeTimeline
|
||||
keyframes={config.keyframes}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
onAdd={addKeyframe}
|
||||
onDelete={deleteKeyframe}
|
||||
onMove={moveKeyframe}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'edit', label: 'Edit' }, { value: 'preview', label: 'Preview' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as MobileTab)}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* 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" />
|
||||
|
||||
<KeyframeTimeline {...timelineProps} embedded />
|
||||
|
||||
<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">
|
||||
{(['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 === 'export' ? 'Export' : 'Presets'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content */}
|
||||
{rightTab === 'export' && <ExportPanel config={config} />}
|
||||
{rightTab === 'presets' && <PresetLibrary onSelect={loadPreset} />}
|
||||
</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, iconBtn } from '@/lib/utils';
|
||||
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 previewBtn = cn(iconBtn, 'w-7 h-7');
|
||||
|
||||
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={previewBtn}
|
||||
>
|
||||
{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={previewBtn}
|
||||
>
|
||||
<Pause className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={restart} title="Restart" className={previewBtn}>
|
||||
<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}>
|
||||
{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,79 +1,43 @@
|
||||
'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 { Copy, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
|
||||
import { CodeSnippet } from '@/components/ui/code-snippet';
|
||||
import type { AnimationConfig } from '@/types/animate';
|
||||
|
||||
interface Props {
|
||||
config: AnimationConfig;
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename }: { code: string; filename: string }) {
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
toast.success('Copied to clipboard!');
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const blob = new Blob([code], { type: 'text/css' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`Downloaded ${filename}`);
|
||||
};
|
||||
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type ExportTab = 'css' | 'tailwind';
|
||||
|
||||
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 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
|
||||
<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' && <CodeSnippet code={css} />}
|
||||
{tab === 'tailwind' && <CodeSnippet code={tailwind} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +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 { Slider } from '@/components/ui/slider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ColorInput } from '@/components/ui/color-input';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
|
||||
@@ -28,26 +25,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 +47,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 +60,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 +70,65 @@ 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>
|
||||
|
||||
<SliderRow
|
||||
label="Border Radius"
|
||||
unit="px"
|
||||
value={props.borderRadius ?? 0}
|
||||
min={0} max={200}
|
||||
onChange={(v) => setProp('borderRadius', v)}
|
||||
<ColorInput
|
||||
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
|
||||
onChange={(v) => setProp('backgroundColor', v)}
|
||||
disabled={!hasBg}
|
||||
/>
|
||||
</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,10 +1,8 @@
|
||||
'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 { cn, iconBtn } from '@/lib/utils';
|
||||
import type { Keyframe } from '@/types/animate';
|
||||
|
||||
interface Props {
|
||||
@@ -14,11 +12,14 @@ 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];
|
||||
const TICKS = [25, 50, 75];
|
||||
|
||||
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove }: Props) {
|
||||
const timelineBtn = cn(iconBtn, 'w-6 h-6');
|
||||
|
||||
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getOffsetFromEvent = (clientX: number): number => {
|
||||
@@ -29,7 +30,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 +39,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 +51,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={timelineBtn}>
|
||||
<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={timelineBtn}
|
||||
>
|
||||
<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 mx-4"
|
||||
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 -ml-1.5"
|
||||
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 mx-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 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharin
|
||||
import { toast } from 'sonner';
|
||||
import type { ASCIIFont } from '@/types/ascii';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
|
||||
type Tab = 'editor' | 'preview';
|
||||
|
||||
@@ -102,28 +103,16 @@ export function ASCIIConverter() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ────────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['editor', 'preview'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||
tab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'editor' ? 'Editor' : 'Preview'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
|
||||
active={tab}
|
||||
onChange={(v) => setTab(v as Tab)}
|
||||
/>
|
||||
|
||||
{/* ── Main layout ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
{/* Left panel: text input + font selector */}
|
||||
<div
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { toPng } from 'html-to-image';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
@@ -20,7 +13,7 @@ import {
|
||||
MessageSquareCode,
|
||||
Type,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { cn, actionBtn, cardBtn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- -->' | '"""';
|
||||
@@ -123,9 +116,6 @@ export function FontPreview({
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
return (
|
||||
<div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}>
|
||||
|
||||
@@ -143,20 +133,20 @@ export function FontPreview({
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{onCopy && (
|
||||
<button onClick={onCopy} className={actionBtn}>
|
||||
<button onClick={onCopy} className={cardBtn}>
|
||||
<Copy className="w-3 h-3" /> Copy
|
||||
</button>
|
||||
)}
|
||||
{onShare && (
|
||||
<button onClick={onShare} className={actionBtn}>
|
||||
<button onClick={onShare} className={cardBtn}>
|
||||
<Share2 className="w-3 h-3" /> Share
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleExportPNG} className={actionBtn}>
|
||||
<button onClick={handleExportPNG} className={cardBtn}>
|
||||
<ImageIcon className="w-3 h-3" /> PNG
|
||||
</button>
|
||||
{onDownload && (
|
||||
<button onClick={onDownload} className={actionBtn}>
|
||||
<button onClick={onDownload} className={cardBtn}>
|
||||
<Download className="w-3 h-3" /> TXT
|
||||
</button>
|
||||
)}
|
||||
@@ -174,7 +164,7 @@ export function FontPreview({
|
||||
disabled={commentStyle !== 'none'}
|
||||
title={label}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md transition-all border text-xs',
|
||||
'px-2 py-1 h-6 rounded-md transition-all border text-xs',
|
||||
textAlign === value && commentStyle === 'none'
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40',
|
||||
@@ -205,19 +195,18 @@ export function FontPreview({
|
||||
</div>
|
||||
|
||||
{/* Comment style */}
|
||||
<Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}>
|
||||
<SelectTrigger className="h-7 w-auto gap-1.5 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors">
|
||||
<MessageSquareCode className="w-3 h-3 text-muted-foreground/60 shrink-0" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="flex items-center gap-1 px-2 py-1.25 glass rounded-md border border-border/30 text-muted-foreground hover:border-primary/30 hover:text-primary transition-colors">
|
||||
<MessageSquareCode className="w-3 h-3 shrink-0" />
|
||||
<select
|
||||
value={commentStyle}
|
||||
onChange={(e) => setCommentStyle(e.target.value as CommentStyle)}
|
||||
className="bg-transparent outline-none text-[10px] font-mono cursor-pointer"
|
||||
>
|
||||
{COMMENT_STYLES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{!isLoading && text && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ExpressionPanel } from './ExpressionPanel';
|
||||
import { GraphPanel } from './GraphPanel';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
|
||||
type Tab = 'calc' | 'graph';
|
||||
|
||||
@@ -13,28 +14,16 @@ export default function Calculator() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* Mobile tab switcher — hidden on lg+ */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['calc', 'graph'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||
tab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'calc' ? 'Calculator' : 'Graph'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'calc', label: 'Calculator' }, { value: 'graph', label: 'Graph' }]}
|
||||
active={tab}
|
||||
onChange={(v) => setTab(v as Tab)}
|
||||
/>
|
||||
|
||||
{/* Main layout — side-by-side on lg, tabbed on mobile */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
{/* Expression panel */}
|
||||
<div
|
||||
|
||||
@@ -162,7 +162,7 @@ export function ExpressionPanel() {
|
||||
onClick={handleSubmit}
|
||||
disabled={!expression.trim()}
|
||||
className={cn(
|
||||
'mt-2 w-full py-2 rounded-lg text-sm font-medium transition-all',
|
||||
'mt-2 w-full py-2 rounded-lg text-xs font-medium transition-all',
|
||||
'bg-primary/90 text-primary-foreground hover:bg-primary',
|
||||
'disabled:opacity-30 disabled:cursor-not-allowed'
|
||||
)}
|
||||
|
||||
@@ -107,11 +107,6 @@ export function GraphPanel() {
|
||||
<GraphCanvas functions={graphFunctions} variables={variables} />
|
||||
</div>
|
||||
|
||||
{/* ── Hint bar ─────────────────────────────────────────── */}
|
||||
<p className="text-[10px] text-muted-foreground/35 text-center shrink-0 pb-1">
|
||||
Drag to pan · Scroll to zoom · Hover for coordinates
|
||||
</p>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { ExportMenu } from '@/components/color/ExportMenu';
|
||||
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
|
||||
import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, actionBtn, cardBtn } from '@/lib/utils';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
|
||||
type HarmonyType = 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
|
||||
type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient';
|
||||
@@ -31,8 +32,6 @@ const RIGHT_TABS: { value: RightTab; label: string }[] = [
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
];
|
||||
|
||||
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';
|
||||
|
||||
function ColorManipulationContent() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -102,28 +101,16 @@ function ColorManipulationContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ────────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['pick', 'explore'] 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 === 'pick' ? 'Pick' : 'Explore'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'pick', label: 'Pick' }, { value: 'explore', label: 'Explore' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as MobileTab)}
|
||||
/>
|
||||
|
||||
{/* ── Main layout ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* Left panel: Picker + ColorInfo */}
|
||||
@@ -139,7 +126,7 @@ function ColorManipulationContent() {
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Color
|
||||
</span>
|
||||
<button onClick={handleShare} className={actionBtn}>
|
||||
<button onClick={handleShare} className={cardBtn}>
|
||||
<Share2 className="w-3 h-3" /> Share
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Download, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
exportAsCSS,
|
||||
@@ -20,7 +13,8 @@ import {
|
||||
type ExportColor,
|
||||
} from '@/lib/color/utils/export';
|
||||
import { colorAPI } from '@/lib/color/api/client';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { CodeSnippet } from '@/components/ui/code-snippet';
|
||||
import { cn, actionBtn } from '@/lib/utils';
|
||||
|
||||
interface ExportMenuProps {
|
||||
colors: string[];
|
||||
@@ -30,15 +24,15 @@ interface ExportMenuProps {
|
||||
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
|
||||
type ColorSpace = 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklab' | 'lch' | 'oklch';
|
||||
|
||||
const actionBtn =
|
||||
'flex items-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 selectCls =
|
||||
'flex-1 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer';
|
||||
|
||||
|
||||
export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('css');
|
||||
const [colorSpace, setColorSpace] = useState<ColorSpace>('hex');
|
||||
const [convertedColors, setConvertedColors] = useState<string[]>(colors);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function convertColors() {
|
||||
@@ -72,13 +66,6 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
|
||||
const getExt = () => ({ css: 'css', scss: 'scss', tailwind: 'js', json: 'json', javascript: 'js' }[format]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(getContent());
|
||||
setCopied(true);
|
||||
toast.success('Copied!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadAsFile(getContent(), `palette.${getExt()}`, 'text/plain');
|
||||
toast.success('Downloaded!');
|
||||
@@ -92,56 +79,43 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
|
||||
|
||||
{/* Selectors */}
|
||||
<div className="flex gap-2">
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
|
||||
<SelectTrigger className="flex-1 h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="css">CSS Vars</SelectItem>
|
||||
<SelectItem value="scss">SCSS</SelectItem>
|
||||
<SelectItem value="tailwind">Tailwind</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="javascript">JS Array</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={colorSpace} onValueChange={(v) => setColorSpace(v as ColorSpace)}>
|
||||
<SelectTrigger className="flex-1 h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{['hex', 'rgb', 'hsl', 'lab', 'oklab', 'lch', 'oklch'].map((s) => (
|
||||
<SelectItem key={s} value={s} className="font-mono text-xs">{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="css">CSS Vars</option>
|
||||
<option value="scss">SCSS</option>
|
||||
<option value="tailwind">Tailwind</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="javascript">JS Array</option>
|
||||
</select>
|
||||
<select
|
||||
value={colorSpace}
|
||||
onChange={(e) => setColorSpace(e.target.value as ColorSpace)}
|
||||
className={selectCls}
|
||||
>
|
||||
{['hex', 'rgb', 'hsl', 'lab', 'oklab', 'lch', 'oklch'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
<div
|
||||
className="relative rounded-xl overflow-hidden border border-white/5 min-h-[80px]"
|
||||
style={{ background: '#06060e' }}
|
||||
>
|
||||
<div className="relative">
|
||||
{isConverting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-black/30">
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20 rounded-xl bg-black/40">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<pre className="p-3 text-[10px] font-mono text-white/60 overflow-x-auto leading-relaxed">
|
||||
<code>{getContent()}</code>
|
||||
</pre>
|
||||
<CodeSnippet code={getContent()} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCopy} disabled={isConverting} className={cn(actionBtn, 'flex-1 justify-center')}>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button onClick={handleDownload} disabled={isConverting} className={cn(actionBtn, 'flex-1 justify-center')}>
|
||||
<Download className="w-3 h-3" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleDownload} disabled={isConverting} className={cn(actionBtn, 'w-full justify-center')}>
|
||||
<Download className="w-3 h-3" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,13 @@ import {
|
||||
} from '@/lib/color/api/queries';
|
||||
import { toast } from 'sonner';
|
||||
import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { cn, actionBtn } from '@/lib/utils';
|
||||
|
||||
interface ManipulationPanelProps {
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}
|
||||
|
||||
const actionBtn =
|
||||
'shrink-0 px-3 py-1 text-[10px] font-mono 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 ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
|
||||
const [lightenAmount, setLightenAmount] = useState(0.2);
|
||||
@@ -118,7 +116,7 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
|
||||
onValueChange={(vals) => row.setValue(vals[0])}
|
||||
className="flex-1"
|
||||
/>
|
||||
<button onClick={row.onApply} disabled={isLoading} className={actionBtn}>
|
||||
<button onClick={row.onApply} disabled={isLoading} className={cn(actionBtn, 'shrink-0')}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
@@ -137,7 +135,7 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
|
||||
} catch { toast.error('Failed'); }
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={cn(actionBtn, 'w-full justify-center flex items-center gap-1.5 py-2')}
|
||||
className={cn(actionBtn, 'w-full justify-center py-2')}
|
||||
>
|
||||
<ArrowLeftRight className="w-3 h-3" />
|
||||
Complementary Color
|
||||
|
||||
372
components/cron/CronEditor.tsx
Normal file
372
components/cron/CronEditor.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Copy, Check, BookmarkPlus, Clock, Trash2, ChevronRight,
|
||||
AlertCircle, CalendarClock,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { cardBtn } from '@/lib/utils/styles';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
import { CronFieldEditor } from './CronFieldEditor';
|
||||
import { CronPresets } from './CronPresets';
|
||||
import { useCronStore } from '@/lib/cron/store';
|
||||
import {
|
||||
FIELD_CONFIGS,
|
||||
splitCronFields,
|
||||
buildCronExpression,
|
||||
describeCronExpression,
|
||||
validateCronExpression,
|
||||
getNextOccurrences,
|
||||
type FieldType,
|
||||
type CronFields,
|
||||
} from '@/lib/cron/cron-engine';
|
||||
|
||||
const FIELD_ORDER: FieldType[] = ['minute', 'hour', 'dom', 'month', 'dow'];
|
||||
|
||||
function getFieldValue(fields: CronFields, type: FieldType): string {
|
||||
switch (type) {
|
||||
case 'minute': return fields.minute;
|
||||
case 'hour': return fields.hour;
|
||||
case 'dom': return fields.dom;
|
||||
case 'month': return fields.month;
|
||||
case 'dow': return fields.dow;
|
||||
case 'second': return fields.second ?? '*';
|
||||
}
|
||||
}
|
||||
|
||||
function formatOccurrence(d: Date): { relative: string; absolute: string; dow: string } {
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
const diffMins = Math.round(diffMs / 60_000);
|
||||
const diffH = Math.round(diffMs / 3_600_000);
|
||||
const diffD = Math.round(diffMs / 86_400_000);
|
||||
|
||||
let relative: string;
|
||||
if (diffMins < 60) relative = `in ${diffMins}m`;
|
||||
else if (diffH < 24) relative = `in ${diffH}h`;
|
||||
else if (diffD === 1) relative = 'tomorrow';
|
||||
else relative = `in ${diffD}d`;
|
||||
|
||||
const absolute = d.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||
});
|
||||
const dow = d.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
return { relative, absolute, dow };
|
||||
}
|
||||
|
||||
// ── Schedule list ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ScheduleList({ schedule, isValid }: { schedule: Date[]; isValid: boolean }) {
|
||||
if (!isValid) return (
|
||||
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
|
||||
Fix the expression to see upcoming runs
|
||||
</p>
|
||||
);
|
||||
if (schedule.length === 0) return (
|
||||
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
|
||||
No occurrences in the next 5 years
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{schedule.map((d, i) => {
|
||||
const { relative, absolute, dow } = formatOccurrence(d);
|
||||
const isFirst = i === 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 py-2.5 border-b border-border/10 last:border-0',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'font-mono text-[10px] px-1.5 py-0.5 rounded border shrink-0 w-[36px] text-center',
|
||||
isFirst
|
||||
? 'bg-primary/20 text-primary border-primary/30'
|
||||
: 'bg-muted/15 text-muted-foreground/50 border-border/10',
|
||||
)}>
|
||||
{dow}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs font-mono flex-1',
|
||||
isFirst ? 'text-foreground font-medium' : 'text-muted-foreground',
|
||||
)}>
|
||||
{absolute}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground/35 shrink-0">
|
||||
{relative}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CronEditor() {
|
||||
const { expression, setExpression, addToHistory, history, removeFromHistory, clearHistory } =
|
||||
useCronStore();
|
||||
|
||||
const [activeField, setActiveField] = useState<FieldType>('minute');
|
||||
const [mobileTab, setMobileTab] = useState<'editor' | 'preview'>('editor');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
const [rawExpr, setRawExpr] = useState('');
|
||||
const rawInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isValid = useMemo(() => validateCronExpression(expression).valid, [expression]);
|
||||
const fields = useMemo(() => splitCronFields(expression), [expression]);
|
||||
const description = useMemo(() => describeCronExpression(expression), [expression]);
|
||||
const schedule = useMemo(
|
||||
() => (isValid ? getNextOccurrences(expression, 7) : []),
|
||||
[expression, isValid],
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(type: FieldType, value: string) => {
|
||||
if (!fields) return;
|
||||
const updated: CronFields = { ...fields, [type]: value };
|
||||
setExpression(buildCronExpression(updated));
|
||||
},
|
||||
[fields, setExpression],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(expression);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
addToHistory(expression);
|
||||
toast.success('Saved to history');
|
||||
};
|
||||
|
||||
const handleRawKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (validateCronExpression(rawExpr).valid) setExpression(rawExpr);
|
||||
setEditingRaw(false);
|
||||
}
|
||||
if (e.key === 'Escape') setEditingRaw(false);
|
||||
};
|
||||
|
||||
const startEditRaw = () => {
|
||||
setRawExpr(expression);
|
||||
setEditingRaw(true);
|
||||
setTimeout(() => rawInputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// ── Expression bar (rendered inside right panel) ──────────────────────────
|
||||
const expressionBar = (
|
||||
<div className="glass rounded-xl border border-border/40 p-4">
|
||||
{/* Row 1: Field chips + actions */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-3">
|
||||
{FIELD_ORDER.map((type) => {
|
||||
const active = activeField === type;
|
||||
const fValue = fields ? getFieldValue(fields, type) : '*';
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => { setActiveField(type); setMobileTab('editor'); }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md border transition-all',
|
||||
active
|
||||
? 'bg-primary/15 border-primary/50 shadow-[0_0_8px_rgba(139,92,246,0.2)]'
|
||||
: 'glass border-border/25 hover:border-primary/30 hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-[8px] font-mono uppercase tracking-[0.1em]',
|
||||
active ? 'text-primary/60' : 'text-muted-foreground/40',
|
||||
)}>
|
||||
{FIELD_CONFIGS[type].shortLabel}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'font-mono text-[10px] font-semibold',
|
||||
active ? 'text-primary' : fValue === '*' ? 'text-muted-foreground/50' : 'text-foreground',
|
||||
)}>
|
||||
{fValue}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button onClick={handleCopy} className={cardBtn}>
|
||||
{copied
|
||||
? <><Check className="w-3 h-3" /> Copied</>
|
||||
: <><Copy className="w-3 h-3" /> Copy</>}
|
||||
</button>
|
||||
<button onClick={handleSave} className={cardBtn}>
|
||||
<BookmarkPlus className="w-3 h-3" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Expression + description (stacked on mobile, inline on lg) */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-text font-mono text-sm tracking-[0.15em] rounded px-1 -mx-1 py-0.5 transition-colors w-full',
|
||||
!editingRaw && 'hover:bg-white/3',
|
||||
!isValid && !editingRaw && 'text-destructive/70',
|
||||
)}
|
||||
onClick={!editingRaw ? startEditRaw : undefined}
|
||||
>
|
||||
{editingRaw ? (
|
||||
<input
|
||||
ref={rawInputRef}
|
||||
value={rawExpr}
|
||||
onChange={(e) => setRawExpr(e.target.value)}
|
||||
onKeyDown={handleRawKey}
|
||||
onBlur={() => setEditingRaw(false)}
|
||||
className={cn(
|
||||
'w-full bg-transparent font-mono text-sm tracking-[0.15em] focus:outline-none',
|
||||
validateCronExpression(rawExpr).valid ? 'text-foreground' : 'text-destructive/80',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
expression
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isValid
|
||||
? <CalendarClock className="w-3 h-3 text-muted-foreground/30 shrink-0" />
|
||||
: <AlertCircle className="w-3 h-3 text-destructive/50 shrink-0" />}
|
||||
<p className={cn(
|
||||
'text-xs truncate',
|
||||
isValid ? 'text-muted-foreground' : 'text-destructive/60',
|
||||
)}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Presets select */}
|
||||
<div className="mt-3 pt-3 border-t border-border/10">
|
||||
<CronPresets onSelect={(expr) => setExpression(expr)} current={expression} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as 'editor' | 'preview')}
|
||||
/>
|
||||
|
||||
{/* Main layout — side-by-side on lg, tabbed on mobile */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* Left: Field editor + Presets ──────────────────────────────── */}
|
||||
<div className={cn(
|
||||
'lg:col-span-3 flex flex-col gap-4',
|
||||
mobileTab === 'preview' && 'hidden lg:flex',
|
||||
)}>
|
||||
{/* Field selector tabs */}
|
||||
<div className="flex glass rounded-lg p-0.5 gap-0.5">
|
||||
{FIELD_ORDER.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActiveField(type)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
|
||||
activeField === type
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{FIELD_CONFIGS[type].shortLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Field editor panel */}
|
||||
<div className="glass rounded-xl p-5 border border-border/40 flex-1 min-h-0 overflow-hidden">
|
||||
{fields ? (
|
||||
<CronFieldEditor
|
||||
fieldType={activeField}
|
||||
value={getFieldValue(fields, activeField)}
|
||||
onChange={(v) => handleFieldChange(activeField, v)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Invalid expression — fix it above to edit fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Expression bar + Schedule preview ───────────────────── */}
|
||||
<div className={cn(
|
||||
'lg:col-span-2 flex flex-col gap-4 flex-1 min-h-0',
|
||||
mobileTab === 'editor' && 'hidden lg:flex',
|
||||
)}>
|
||||
{expressionBar}
|
||||
|
||||
<div className="glass rounded-xl p-4 border border-border/40 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent overflow-auto flex-1">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground/40" />
|
||||
<span className="text-[9px] font-mono text-muted-foreground/50 uppercase tracking-widest">
|
||||
Next Occurrences
|
||||
</span>
|
||||
</div>
|
||||
<ScheduleList schedule={schedule} isValid={isValid} />
|
||||
</div>
|
||||
|
||||
{/* Saved history */}
|
||||
{history.length > 0 && (
|
||||
<div className="glass rounded-xl p-4 border border-border/40 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent overflow-auto">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[9px] font-mono text-muted-foreground/40 uppercase tracking-widest">
|
||||
Saved
|
||||
</span>
|
||||
<button onClick={clearHistory} className={cardBtn}>
|
||||
<Trash2 className="w-2.5 h-2.5" /> Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{history.slice(0, 8).map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2 group">
|
||||
<button
|
||||
onClick={() => setExpression(entry.expression)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-left',
|
||||
entry.expression === expression
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'glass border-border/20 text-muted-foreground hover:border-primary/30 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.expression === expression && <ChevronRight className="w-3 h-3 shrink-0" />}
|
||||
<span className="font-mono text-xs truncate">{entry.expression}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFromHistory(entry.id)}
|
||||
className="w-6 h-6 flex items-center justify-center text-muted-foreground/40 hover:text-destructive transition-all rounded"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
components/cron/CronFieldEditor.tsx
Normal file
262
components/cron/CronFieldEditor.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import {
|
||||
parseField,
|
||||
rebuildFieldFromValues,
|
||||
validateCronField,
|
||||
FIELD_CONFIGS,
|
||||
MONTH_SHORT_NAMES,
|
||||
DOW_SHORT_NAMES,
|
||||
type FieldType,
|
||||
} from '@/lib/cron/cron-engine';
|
||||
|
||||
// ── Per-field presets ─────────────────────────────────────────────────────────
|
||||
|
||||
interface Preset { label: string; value: string }
|
||||
|
||||
const FIELD_PRESETS: Record<FieldType, Preset[]> = {
|
||||
second: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: '*/5', value: '*/5' },
|
||||
{ label: '*/10', value: '*/10' },
|
||||
{ label: '*/15', value: '*/15' },
|
||||
{ label: '*/30', value: '*/30' },
|
||||
],
|
||||
minute: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: ':00', value: '0' },
|
||||
{ label: ':30', value: '30' },
|
||||
{ label: '*/5', value: '*/5' },
|
||||
{ label: '*/10', value: '*/10' },
|
||||
{ label: '*/15', value: '*/15' },
|
||||
{ label: '*/30', value: '*/30' },
|
||||
],
|
||||
hour: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: 'Midnight', value: '0' },
|
||||
{ label: '6 AM', value: '6' },
|
||||
{ label: '9 AM', value: '9' },
|
||||
{ label: 'Noon', value: '12' },
|
||||
{ label: '6 PM', value: '18' },
|
||||
{ label: 'Every 4h', value: '*/4' },
|
||||
{ label: 'Every 6h', value: '*/6' },
|
||||
{ label: '9–17', value: '9-17' },
|
||||
],
|
||||
dom: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: '1st', value: '1' },
|
||||
{ label: '10th', value: '10' },
|
||||
{ label: '15th', value: '15' },
|
||||
{ label: '20th', value: '20' },
|
||||
{ label: '1,15', value: '1,15' },
|
||||
{ label: '1–7', value: '1-7' },
|
||||
],
|
||||
month: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: 'Q1', value: '1-3' },
|
||||
{ label: 'Q2', value: '4-6' },
|
||||
{ label: 'Q3', value: '7-9' },
|
||||
{ label: 'Q4', value: '10-12' },
|
||||
{ label: 'H1', value: '1-6' },
|
||||
{ label: 'H2', value: '7-12' },
|
||||
],
|
||||
dow: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: 'Weekdays', value: '1-5' },
|
||||
{ label: 'Weekends', value: '0,6' },
|
||||
{ label: 'Mon', value: '1' },
|
||||
{ label: 'Wed', value: '3' },
|
||||
{ label: 'Fri', value: '5' },
|
||||
{ label: 'Sun', value: '0' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Grid configuration ────────────────────────────────────────────────────────
|
||||
|
||||
const GRID_COLS: Record<FieldType, string> = {
|
||||
second: 'grid-cols-10',
|
||||
minute: 'grid-cols-10',
|
||||
hour: 'grid-cols-8',
|
||||
dom: 'grid-cols-7',
|
||||
month: 'grid-cols-4',
|
||||
dow: 'grid-cols-7',
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CronFieldEditorProps {
|
||||
fieldType: FieldType;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CronFieldEditor({ fieldType, value, onChange }: CronFieldEditorProps) {
|
||||
const [rawInput, setRawInput] = useState('');
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [rawError, setRawError] = useState('');
|
||||
|
||||
const config = FIELD_CONFIGS[fieldType];
|
||||
const parsed = useMemo(() => parseField(value, config), [value, config]);
|
||||
const presets = FIELD_PRESETS[fieldType];
|
||||
|
||||
const isWildcard = parsed?.isWildcard ?? false;
|
||||
const isSelected = (v: number) => parsed?.values.has(v) ?? false;
|
||||
|
||||
const cellLabel = (v: number): string => {
|
||||
if (fieldType === 'month') return MONTH_SHORT_NAMES[v - 1];
|
||||
if (fieldType === 'dow') return DOW_SHORT_NAMES[v];
|
||||
return String(v).padStart(fieldType === 'second' || fieldType === 'minute' ? 2 : 1, '0');
|
||||
};
|
||||
|
||||
const handleCellClick = (v: number) => {
|
||||
if (!parsed) return;
|
||||
if (isWildcard) { onChange(String(v)); return; }
|
||||
const next = new Set(parsed.values);
|
||||
if (next.has(v)) {
|
||||
next.delete(v);
|
||||
if (next.size === 0) { onChange('*'); return; }
|
||||
} else {
|
||||
next.add(v);
|
||||
if (next.size === config.max - config.min + 1) { onChange('*'); return; }
|
||||
}
|
||||
onChange(rebuildFieldFromValues(next, config));
|
||||
};
|
||||
|
||||
const handleRawSubmit = () => {
|
||||
const { valid, error } = validateCronField(rawInput, fieldType);
|
||||
if (valid) {
|
||||
onChange(rawInput);
|
||||
setShowRaw(false);
|
||||
setRawInput('');
|
||||
setRawError('');
|
||||
} else {
|
||||
setRawError(error ?? 'Invalid');
|
||||
}
|
||||
};
|
||||
|
||||
const cells = Array.from({ length: config.max - config.min + 1 }, (_, i) => i + config.min);
|
||||
// Pad to complete rows for DOM (31 cells, 7 cols → pad to 35)
|
||||
const colCount = parseInt(GRID_COLS[fieldType].replace('grid-cols-', ''), 10);
|
||||
const rem = cells.length % colCount;
|
||||
const padded: (number | null)[] = [...cells, ...(rem === 0 ? [] : Array<null>(colCount - rem).fill(null))];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50 font-mono">
|
||||
{config.min}–{config.max}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWildcard && (
|
||||
<span className="text-[10px] font-mono text-primary/60 bg-primary/5 px-2 py-0.5 rounded border border-primary/15">
|
||||
any value
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-sm text-primary bg-primary/10 px-2.5 py-1 rounded-lg border border-primary/25">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => onChange(preset.value)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 text-[11px] font-mono rounded-lg border transition-all',
|
||||
value === preset.value
|
||||
? 'bg-primary/20 border-primary/50 text-primary shadow-[0_0_8px_rgba(139,92,246,0.2)]'
|
||||
: 'glass border-border/30 text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value grid */}
|
||||
<div className={cn('grid gap-1', GRID_COLS[fieldType])}>
|
||||
{padded.map((v, i) => {
|
||||
if (v === null) return <div key={`pad-${i}`} />;
|
||||
const selected = isSelected(v);
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => handleCellClick(v)}
|
||||
title={fieldType === 'month' ? MONTH_SHORT_NAMES[v - 1] : fieldType === 'dow' ? DOW_SHORT_NAMES[v] : String(v)}
|
||||
className={cn(
|
||||
'flex items-center justify-center text-[10px] font-mono rounded-md border transition-all',
|
||||
fieldType === 'month' || fieldType === 'dow'
|
||||
? 'py-2 px-1'
|
||||
: 'aspect-square',
|
||||
isWildcard
|
||||
? 'bg-primary/8 border-primary/20 text-primary/50 hover:bg-primary/15 hover:border-primary/40 hover:text-primary'
|
||||
: selected
|
||||
? 'bg-primary/25 border-primary/55 text-primary font-semibold shadow-[0_0_6px_rgba(139,92,246,0.25)]'
|
||||
: 'glass border-border/20 text-muted-foreground/50 hover:border-primary/35 hover:text-foreground hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{cellLabel(v)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom raw input */}
|
||||
<div className="pt-1 border-t border-border/10">
|
||||
{showRaw ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={rawInput}
|
||||
onChange={(e) => { setRawInput(e.target.value); setRawError(''); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRawSubmit();
|
||||
if (e.key === 'Escape') { setShowRaw(false); setRawError(''); }
|
||||
}}
|
||||
placeholder={`e.g. ${fieldType === 'minute' ? '*/15 or 0,30' : fieldType === 'hour' ? '9-17' : fieldType === 'dow' ? '1-5' : '*'}`}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-1.5 text-xs font-mono bg-muted/20 border rounded-lg focus:outline-none transition-colors',
|
||||
rawError ? 'border-destructive/50 focus:border-destructive' : 'border-border/30 focus:border-primary/50',
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleRawSubmit}
|
||||
className="px-3 py-1.5 text-xs font-mono bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-all"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowRaw(false); setRawError(''); }}
|
||||
className="px-3 py-1.5 text-xs font-mono glass border-border/30 text-muted-foreground rounded-lg hover:text-foreground transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{rawError && (
|
||||
<p className="text-[10px] text-destructive font-mono">{rawError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setRawInput(value); setShowRaw(true); }}
|
||||
className="text-[11px] font-mono text-muted-foreground/40 hover:text-primary/70 transition-colors"
|
||||
>
|
||||
Enter custom expression →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
components/cron/CronPresets.tsx
Normal file
91
components/cron/CronPresets.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface Preset {
|
||||
label: string;
|
||||
expr: string;
|
||||
}
|
||||
|
||||
interface PresetGroup {
|
||||
label: string;
|
||||
items: Preset[];
|
||||
}
|
||||
|
||||
const PRESET_GROUPS: PresetGroup[] = [
|
||||
{
|
||||
label: 'Common',
|
||||
items: [
|
||||
{ label: 'Every minute', expr: '* * * * *' },
|
||||
{ label: 'Every 5 min', expr: '*/5 * * * *' },
|
||||
{ label: 'Every 15 min', expr: '*/15 * * * *' },
|
||||
{ label: 'Every 30 min', expr: '*/30 * * * *' },
|
||||
{ label: 'Every hour', expr: '0 * * * *' },
|
||||
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Daily',
|
||||
items: [
|
||||
{ label: 'Midnight', expr: '0 0 * * *' },
|
||||
{ label: '6 AM', expr: '0 6 * * *' },
|
||||
{ label: '9 AM', expr: '0 9 * * *' },
|
||||
{ label: 'Noon', expr: '0 12 * * *' },
|
||||
{ label: 'Twice daily', expr: '0 6,18 * * *' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Weekly',
|
||||
items: [
|
||||
{ label: 'Weekdays 9 AM', expr: '0 9 * * 1-5' },
|
||||
{ label: 'Monday 9 AM', expr: '0 9 * * 1' },
|
||||
{ label: 'Friday 5 PM', expr: '0 17 * * 5' },
|
||||
{ label: 'Sunday 0 AM', expr: '0 0 * * 0' },
|
||||
{ label: 'Weekends 9 AM', expr: '0 9 * * 0,6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Periodic',
|
||||
items: [
|
||||
{ label: 'Monthly 1st', expr: '0 0 1 * *' },
|
||||
{ label: '1st & 15th', expr: '0 0 1,15 * *' },
|
||||
{ label: 'Quarterly', expr: '0 0 1 */3 *' },
|
||||
{ label: 'Bi-annual', expr: '0 0 1 1,7 *' },
|
||||
{ label: 'January 1st', expr: '0 0 1 1 *' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface CronPresetsProps {
|
||||
onSelect: (expr: string) => void;
|
||||
current: string;
|
||||
}
|
||||
|
||||
export function CronPresets({ onSelect, current }: CronPresetsProps) {
|
||||
const allExprs = PRESET_GROUPS.flatMap(g => g.items.map(i => i.expr));
|
||||
const isPreset = allExprs.includes(current);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={isPreset ? current : ''}
|
||||
onChange={(e) => { if (e.target.value) onSelect(e.target.value); }}
|
||||
className="w-full appearance-none bg-muted/20 border border-border/30 rounded-lg px-3 py-1.5 pr-8 text-xs font-mono text-muted-foreground focus:border-primary/50 focus:outline-none transition-colors cursor-pointer hover:border-border/50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isPreset ? '' : 'Quick preset…'}
|
||||
</option>
|
||||
{PRESET_GROUPS.map((group) => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.items.map((preset) => (
|
||||
<option key={preset.expr} value={preset.expr}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none text-muted-foreground/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'use client';
|
||||
|
||||
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) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
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">
|
||||
<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 { ColorInput } from '@/components/ui/color-input';
|
||||
import { CodeSnippet } from '@/components/ui/code-snippet';
|
||||
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, actionBtn } from '@/lib/utils';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
|
||||
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 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,217 @@ 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>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'setup', label: 'Setup' }, { value: 'results', label: 'Results' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as MobileTab)}
|
||||
/>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* 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="space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label>
|
||||
<ColorInput
|
||||
value={options.backgroundColor}
|
||||
onChange={(v) => setOptions({ ...options, backgroundColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label>
|
||||
<ColorInput
|
||||
value={options.themeColor}
|
||||
onChange={(v) => setOptions({ ...options, themeColor: v })}
|
||||
/>
|
||||
</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 justify-center')}
|
||||
>
|
||||
{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>
|
||||
{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 ml-2">
|
||||
<Logo size={24} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,28 +2,14 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AppPageProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ElementType;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AppPage({ title, description, icon: Icon, children, className }: AppPageProps) {
|
||||
export function AppPage({ 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>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn('overflow-y-auto', className)}>
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in py-6 lg:py-8">
|
||||
{children}
|
||||
</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 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ConversionOptions, ConversionFormat } from '@/types/media';
|
||||
|
||||
interface ConversionOptionsProps {
|
||||
inputFormat: ConversionFormat;
|
||||
outputFormat: ConversionFormat;
|
||||
options: ConversionOptions;
|
||||
onOptionsChange: (options: ConversionOptions) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConversionOptionsPanel({
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
options,
|
||||
onOptionsChange,
|
||||
disabled = false,
|
||||
}: ConversionOptionsProps) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
const handleOptionChange = (key: string, value: any) => {
|
||||
onOptionsChange({ ...options, [key]: value });
|
||||
};
|
||||
|
||||
const renderVideoOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Video Codec */}
|
||||
<div className="space-y-2">
|
||||
<Label>Video Codec</Label>
|
||||
<Select
|
||||
value={options.videoCodec || 'default'}
|
||||
onValueChange={(value) => handleOptionChange('videoCodec', value === 'default' ? undefined : value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select video codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Auto (Recommended)</SelectItem>
|
||||
<SelectItem value="libx264">H.264 (MP4, AVI, MOV)</SelectItem>
|
||||
<SelectItem value="libx265">H.265 (MP4)</SelectItem>
|
||||
<SelectItem value="libvpx">VP8 (WebM)</SelectItem>
|
||||
<SelectItem value="libvpx-vp9">VP9 (WebM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Video Bitrate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Video Bitrate</Label>
|
||||
<span className="text-xs text-muted-foreground">{options.videoBitrate || '2M'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[parseFloat(options.videoBitrate?.replace('M', '') || '2')]}
|
||||
onValueChange={(vals) => handleOptionChange('videoBitrate', `${vals[0]}M`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Higher bitrate = better quality, larger file</p>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div className="space-y-2">
|
||||
<Label>Resolution</Label>
|
||||
<Select
|
||||
value={options.videoResolution || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('videoResolution', value === 'original' ? undefined : value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select resolution" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="1920x-1">1080p (1920x1080)</SelectItem>
|
||||
<SelectItem value="1280x-1">720p (1280x720)</SelectItem>
|
||||
<SelectItem value="854x-1">480p (854x480)</SelectItem>
|
||||
<SelectItem value="640x-1">360p (640x360)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* FPS */}
|
||||
<div className="space-y-2">
|
||||
<Label>Frame Rate (FPS)</Label>
|
||||
<Select
|
||||
value={options.videoFps?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('videoFps', value === 'original' ? undefined : parseInt(value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="60">60 fps</SelectItem>
|
||||
<SelectItem value="30">30 fps</SelectItem>
|
||||
<SelectItem value="24">24 fps</SelectItem>
|
||||
<SelectItem value="15">15 fps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Audio Bitrate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Audio Bitrate</Label>
|
||||
<span className="text-xs text-muted-foreground">{options.audioBitrate || '128k'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={64}
|
||||
max={320}
|
||||
step={32}
|
||||
value={[parseInt(options.audioBitrate?.replace('k', '') || '128')]}
|
||||
onValueChange={(vals) => handleOptionChange('audioBitrate', `${vals[0]}k`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAudioOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Audio Codec */}
|
||||
<div className="space-y-2">
|
||||
<Label>Audio Codec</Label>
|
||||
<Select
|
||||
value={options.audioCodec || 'default'}
|
||||
onValueChange={(value) => handleOptionChange('audioCodec', value === 'default' ? undefined : value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select audio codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Auto (Recommended)</SelectItem>
|
||||
<SelectItem value="libmp3lame">MP3 (LAME)</SelectItem>
|
||||
<SelectItem value="aac">AAC</SelectItem>
|
||||
<SelectItem value="libvorbis">Vorbis (OGG)</SelectItem>
|
||||
<SelectItem value="libopus">Opus</SelectItem>
|
||||
<SelectItem value="flac">FLAC (Lossless)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Bitrate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Bitrate</Label>
|
||||
<span className="text-xs text-muted-foreground">{options.audioBitrate || '192k'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={64}
|
||||
max={320}
|
||||
step={32}
|
||||
value={[parseInt(options.audioBitrate?.replace('k', '') || '192')]}
|
||||
onValueChange={(vals) => handleOptionChange('audioBitrate', `${vals[0]}k`)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sample Rate */}
|
||||
<div className="space-y-2">
|
||||
<Label>Sample Rate</Label>
|
||||
<Select
|
||||
value={options.audioSampleRate?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('audioSampleRate', value === 'original' ? undefined : parseInt(value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select sample rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="48000">48 kHz (Studio)</SelectItem>
|
||||
<SelectItem value="44100">44.1 kHz (CD Quality)</SelectItem>
|
||||
<SelectItem value="22050">22.05 kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="space-y-2">
|
||||
<Label>Channels</Label>
|
||||
<Select
|
||||
value={options.audioChannels?.toString() || 'original'}
|
||||
onValueChange={(value) => handleOptionChange('audioChannels', value === 'original' ? undefined : parseInt(value))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channels" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="2">Stereo (2 channels)</SelectItem>
|
||||
<SelectItem value="1">Mono (1 channel)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderImageOptions = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Quality */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Quality</Label>
|
||||
<span className="text-xs text-muted-foreground">{options.imageQuality || 85}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[options.imageQuality || 85]}
|
||||
onValueChange={(vals) => handleOptionChange('imageQuality', vals[0])}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<Label className="mb-2">Width (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={options.imageWidth || ''}
|
||||
onChange={(e) => handleOptionChange('imageWidth', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="Original"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to keep original</p>
|
||||
</div>
|
||||
|
||||
{/* Height */}
|
||||
<div>
|
||||
<Label className="mb-2">Height (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={options.imageHeight || ''}
|
||||
onChange={(e) => handleOptionChange('imageHeight', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="Original"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to maintain aspect ratio</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{outputFormat.category === 'video' && renderVideoOptions()}
|
||||
{outputFormat.category === 'audio' && renderAudioOptions()}
|
||||
{outputFormat.category === 'image' && renderImageOptions()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { cn, actionBtn } from '@/lib/utils';
|
||||
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils';
|
||||
import type { ConversionJob } from '@/types/media';
|
||||
|
||||
@@ -11,9 +11,6 @@ export interface ConversionPreviewProps {
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||
const [elapsedTime, setElapsedTime] = React.useState(0);
|
||||
@@ -171,7 +168,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
|
||||
})()}
|
||||
|
||||
{/* Download */}
|
||||
<button onClick={handleDownload} className={cn(actionBtn, 'w-full')}>
|
||||
<button onClick={handleDownload} className={cn(actionBtn, 'w-full justify-center')}>
|
||||
<Download className="w-3 h-3" />
|
||||
<span className="truncate min-w-0">{filename}</span>
|
||||
</button>
|
||||
@@ -187,7 +184,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
|
||||
</div>
|
||||
)}
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className={cn(actionBtn, 'w-full')}>
|
||||
<button onClick={onRetry} className={cn(actionBtn, 'w-full justify-center')}>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Retry
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { SliderRow } from '@/components/ui/slider-row';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
import { FileUpload } from './FileUpload';
|
||||
import { ConversionPreview } from './ConversionPreview';
|
||||
import { toast } from 'sonner';
|
||||
@@ -23,12 +17,12 @@ import { addToHistory } from '@/lib/media/storage/history';
|
||||
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils';
|
||||
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media';
|
||||
import { ShieldCheck, Download, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, actionBtn, cardBtn } from '@/lib/utils';
|
||||
|
||||
type MobileTab = 'upload' | 'convert';
|
||||
|
||||
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 selectCls =
|
||||
'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer disabled:opacity-40';
|
||||
|
||||
export function FileConverter() {
|
||||
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
|
||||
@@ -58,7 +52,6 @@ export function FileConverter() {
|
||||
setCompatibleFormats(compat);
|
||||
if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]);
|
||||
toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`);
|
||||
// Auto-advance to convert tab on mobile
|
||||
setMobileTab('convert');
|
||||
} else {
|
||||
toast.error('Could not detect file format');
|
||||
@@ -187,28 +180,16 @@ export function FileConverter() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ─────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['upload', 'convert'] 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 === 'upload' ? 'Upload' : 'Convert'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'upload', label: 'Upload' }, { value: 'convert', label: 'Convert' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as MobileTab)}
|
||||
/>
|
||||
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* Left: upload zone */}
|
||||
@@ -246,7 +227,6 @@ export function FileConverter() {
|
||||
mobileTab !== 'convert' && 'hidden lg:flex'
|
||||
)}
|
||||
>
|
||||
{/* Options panel */}
|
||||
{inputFormat && compatibleFormats.length > 0 ? (
|
||||
<div className="glass rounded-xl p-4 shrink-0">
|
||||
{/* Detected format */}
|
||||
@@ -290,90 +270,70 @@ export function FileConverter() {
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Codec</span>
|
||||
<Select
|
||||
<select
|
||||
value={conversionOptions.videoCodec || 'default'}
|
||||
onValueChange={(v) => setOpt({ videoCodec: v === 'default' ? undefined : v })}
|
||||
onChange={(e) => setOpt({ videoCodec: e.target.value === 'default' ? undefined : e.target.value })}
|
||||
disabled={isConverting}
|
||||
className={selectCls}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Auto (Recommended)</SelectItem>
|
||||
<SelectItem value="libx264">H.264</SelectItem>
|
||||
<SelectItem value="libx265">H.265</SelectItem>
|
||||
<SelectItem value="libvpx">VP8 (WebM)</SelectItem>
|
||||
<SelectItem value="libvpx-vp9">VP9 (WebM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="default">Auto (Recommended)</option>
|
||||
<option value="libx264">H.264</option>
|
||||
<option value="libx265">H.265</option>
|
||||
<option value="libvpx">VP8 (WebM)</option>
|
||||
<option value="libvpx-vp9">VP9 (WebM)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Bitrate</span>
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.videoBitrate || '2M'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.5} max={10} step={0.5}
|
||||
value={[parseFloat(conversionOptions.videoBitrate?.replace('M', '') || '2')]}
|
||||
onValueChange={(v) => setOpt({ videoBitrate: `${v[0]}M` })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
</div>
|
||||
<SliderRow
|
||||
label="Video Bitrate"
|
||||
display={conversionOptions.videoBitrate || '2M'}
|
||||
value={parseFloat(conversionOptions.videoBitrate?.replace('M', '') || '2')}
|
||||
min={0.5} max={10} step={0.5}
|
||||
onChange={(v) => setOpt({ videoBitrate: `${v}M` })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Resolution</span>
|
||||
<Select
|
||||
<select
|
||||
value={conversionOptions.videoResolution || 'original'}
|
||||
onValueChange={(v) => setOpt({ videoResolution: v === 'original' ? undefined : v })}
|
||||
onChange={(e) => setOpt({ videoResolution: e.target.value === 'original' ? undefined : e.target.value })}
|
||||
disabled={isConverting}
|
||||
className={selectCls}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="1920x-1">1080p</SelectItem>
|
||||
<SelectItem value="1280x-1">720p</SelectItem>
|
||||
<SelectItem value="854x-1">480p</SelectItem>
|
||||
<SelectItem value="640x-1">360p</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="original">Original</option>
|
||||
<option value="1920x-1">1080p</option>
|
||||
<option value="1280x-1">720p</option>
|
||||
<option value="854x-1">480p</option>
|
||||
<option value="640x-1">360p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">FPS</span>
|
||||
<Select
|
||||
<select
|
||||
value={conversionOptions.videoFps?.toString() || 'original'}
|
||||
onValueChange={(v) => setOpt({ videoFps: v === 'original' ? undefined : parseInt(v) })}
|
||||
onChange={(e) => setOpt({ videoFps: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
|
||||
disabled={isConverting}
|
||||
className={selectCls}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="60">60 fps</SelectItem>
|
||||
<SelectItem value="30">30 fps</SelectItem>
|
||||
<SelectItem value="24">24 fps</SelectItem>
|
||||
<SelectItem value="15">15 fps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="original">Original</option>
|
||||
<option value="60">60 fps</option>
|
||||
<option value="30">30 fps</option>
|
||||
<option value="24">24 fps</option>
|
||||
<option value="15">15 fps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Audio Bitrate</span>
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.audioBitrate || '128k'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={64} max={320} step={32}
|
||||
value={[parseInt(conversionOptions.audioBitrate?.replace('k', '') || '128')]}
|
||||
onValueChange={(v) => setOpt({ audioBitrate: `${v[0]}k` })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
</div>
|
||||
<SliderRow
|
||||
label="Audio Bitrate"
|
||||
display={conversionOptions.audioBitrate || '128k'}
|
||||
value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '128')}
|
||||
min={64} max={320} step={32}
|
||||
onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -382,73 +342,57 @@ export function FileConverter() {
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Codec</span>
|
||||
<Select
|
||||
<select
|
||||
value={conversionOptions.audioCodec || 'default'}
|
||||
onValueChange={(v) => setOpt({ audioCodec: v === 'default' ? undefined : v })}
|
||||
onChange={(e) => setOpt({ audioCodec: e.target.value === 'default' ? undefined : e.target.value })}
|
||||
disabled={isConverting}
|
||||
className={selectCls}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Auto</SelectItem>
|
||||
<SelectItem value="libmp3lame">MP3 (LAME)</SelectItem>
|
||||
<SelectItem value="aac">AAC</SelectItem>
|
||||
<SelectItem value="libvorbis">Vorbis</SelectItem>
|
||||
<SelectItem value="libopus">Opus</SelectItem>
|
||||
<SelectItem value="flac">FLAC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="default">Auto</option>
|
||||
<option value="libmp3lame">MP3 (LAME)</option>
|
||||
<option value="aac">AAC</option>
|
||||
<option value="libvorbis">Vorbis</option>
|
||||
<option value="libopus">Opus</option>
|
||||
<option value="flac">FLAC</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Bitrate</span>
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.audioBitrate || '192k'}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={64} max={320} step={32}
|
||||
value={[parseInt(conversionOptions.audioBitrate?.replace('k', '') || '192')]}
|
||||
onValueChange={(v) => setOpt({ audioBitrate: `${v[0]}k` })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
</div>
|
||||
<SliderRow
|
||||
label="Bitrate"
|
||||
display={conversionOptions.audioBitrate || '192k'}
|
||||
value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '192')}
|
||||
min={64} max={320} step={32}
|
||||
onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Sample Rate</span>
|
||||
<Select
|
||||
<select
|
||||
value={conversionOptions.audioSampleRate?.toString() || 'original'}
|
||||
onValueChange={(v) => setOpt({ audioSampleRate: v === 'original' ? undefined : parseInt(v) })}
|
||||
onChange={(e) => setOpt({ audioSampleRate: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
|
||||
disabled={isConverting}
|
||||
className={selectCls}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="48000">48 kHz</SelectItem>
|
||||
<SelectItem value="44100">44.1 kHz</SelectItem>
|
||||
<SelectItem value="22050">22 kHz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="original">Original</option>
|
||||
<option value="48000">48 kHz</option>
|
||||
<option value="44100">44.1 kHz</option>
|
||||
<option value="22050">22 kHz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Channels</span>
|
||||
<Select
|
||||
<select
|
||||
value={conversionOptions.audioChannels?.toString() || 'original'}
|
||||
onValueChange={(v) => setOpt({ audioChannels: v === 'original' ? undefined : parseInt(v) })}
|
||||
onChange={(e) => setOpt({ audioChannels: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
|
||||
disabled={isConverting}
|
||||
className={selectCls}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="original">Original</SelectItem>
|
||||
<SelectItem value="2">Stereo</SelectItem>
|
||||
<SelectItem value="1">Mono</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="original">Original</option>
|
||||
<option value="2">Stereo</option>
|
||||
<option value="1">Mono</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -457,18 +401,14 @@ export function FileConverter() {
|
||||
{/* Image options */}
|
||||
{outputFormat.category === 'image' && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Quality</span>
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.imageQuality ?? 85}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[conversionOptions.imageQuality ?? 85]}
|
||||
onValueChange={(v) => setOpt({ imageQuality: v[0] })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
</div>
|
||||
<SliderRow
|
||||
label="Quality"
|
||||
display={`${conversionOptions.imageQuality ?? 85}%`}
|
||||
value={conversionOptions.imageQuality ?? 85}
|
||||
min={1} max={100} step={1}
|
||||
onChange={(v) => setOpt({ imageQuality: v })}
|
||||
disabled={isConverting}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['imageWidth', 'imageHeight'] as const).map((key) => (
|
||||
@@ -496,11 +436,7 @@ export function FileConverter() {
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={!selectedFiles.length || !outputFormat || isConverting}
|
||||
className={cn(actionBtn, 'flex-1 py-2 text-sm font-medium',
|
||||
!isConverting && selectedFiles.length && outputFormat
|
||||
? 'hover:text-primary'
|
||||
: ''
|
||||
)}
|
||||
className={cn(actionBtn, 'flex-1 justify-center py-2')}
|
||||
>
|
||||
{isConverting
|
||||
? <><Loader2 className="w-3 h-3 animate-spin" />Converting…</>
|
||||
@@ -534,7 +470,7 @@ export function FileConverter() {
|
||||
Results
|
||||
</span>
|
||||
{completedCount > 0 && (
|
||||
<button onClick={handleDownloadAll} className={actionBtn}>
|
||||
<button onClick={handleDownloadAll} className={cardBtn}>
|
||||
<Download className="w-3 h-3" />
|
||||
{completedCount > 1 ? `Download all (${completedCount}) as ZIP` : 'Download'}
|
||||
</button>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import type { ConversionFormat } from '@/types/media';
|
||||
|
||||
export interface FormatSelectorProps {
|
||||
formats: ConversionFormat[];
|
||||
selectedFormat?: ConversionFormat;
|
||||
onFormatSelect: (format: ConversionFormat) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FormatSelector({
|
||||
formats,
|
||||
selectedFormat,
|
||||
onFormatSelect,
|
||||
label = 'Select format',
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filteredFormats, setFilteredFormats] = React.useState<ConversionFormat[]>(formats);
|
||||
|
||||
// Set up Fuse.js for fuzzy search
|
||||
const fuse = React.useMemo(() => {
|
||||
return new Fuse(formats, {
|
||||
keys: ['name', 'extension', 'description'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
});
|
||||
}, [formats]);
|
||||
|
||||
// Filter formats based on search query
|
||||
React.useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFormats(formats);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = fuse.search(searchQuery);
|
||||
setFilteredFormats(results.map((result) => result.item));
|
||||
}, [searchQuery, formats, fuse]);
|
||||
|
||||
// Group formats by category
|
||||
const groupedFormats = React.useMemo(() => {
|
||||
const groups: Record<string, ConversionFormat[]> = {};
|
||||
|
||||
filteredFormats.forEach((format) => {
|
||||
if (!groups[format.category]) {
|
||||
groups[format.category] = [];
|
||||
}
|
||||
groups[format.category].push(format);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredFormats]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Label className="mb-2">{label}</Label>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search formats..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format list */}
|
||||
<Card className="max-h-64 overflow-y-auto custom-scrollbar">
|
||||
{Object.entries(groupedFormats).length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
No formats found matching "{searchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{Object.entries(groupedFormats).map(([category, categoryFormats]) => (
|
||||
<div key={category} className="mb-3 last:mb-0">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-2">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{categoryFormats.map((format) => (
|
||||
<button
|
||||
key={format.id}
|
||||
onClick={() => !disabled && onFormatSelect(format)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90':
|
||||
selectedFormat?.id === format.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{format.name}</p>
|
||||
{format.description && (
|
||||
<p className="text-xs opacity-75 mt-0.5">{format.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-mono opacity-75">.{format.extension}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Selected format display */}
|
||||
{selectedFormat && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Selected: <span className="font-medium text-foreground">{selectedFormat.name}</span> (.
|
||||
{selectedFormat.extension})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,23 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<SWRegistration />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'!bg-[#13131f] !border !border-white/8 !text-white/85 !rounded-xl !shadow-2xl !font-sans',
|
||||
title: '!text-sm !font-medium !text-white/85',
|
||||
description: '!text-xs !text-white/45',
|
||||
icon: '!mt-px',
|
||||
success: '!border-primary/25',
|
||||
error: '!border-red-500/25',
|
||||
warning: '!border-amber-400/25',
|
||||
info: '!border-blue-400/25',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ 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 { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
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 +24,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 +42,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 +58,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 +70,82 @@ 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>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'configure', label: 'Configure' }, { value: 'preview', label: 'Preview' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as MobileTab)}
|
||||
/>
|
||||
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* 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,8 @@
|
||||
'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 { SliderRow } from '@/components/ui/slider-row';
|
||||
import { ColorInput } from '@/components/ui/color-input';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { ErrorCorrectionLevel } from '@/types/qrcode';
|
||||
|
||||
interface QROptionsProps {
|
||||
@@ -25,11 +16,11 @@ 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%' },
|
||||
];
|
||||
|
||||
export function QROptions({
|
||||
@@ -45,93 +36,78 @@ 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 className="space-y-5">
|
||||
|
||||
{/* 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>
|
||||
<ColorInput value={foregroundColor} onChange={onForegroundColorChange} />
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* 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="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>
|
||||
</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>
|
||||
</div>
|
||||
<Slider
|
||||
value={[margin]}
|
||||
onValueChange={([v]) => onMarginChange(v)}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
<ColorInput
|
||||
value={isTransparent ? '#ffffff' : backgroundColor}
|
||||
onChange={onBackgroundColorChange}
|
||||
disabled={isTransparent}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Margin */}
|
||||
<SliderRow
|
||||
label="Margin"
|
||||
display={String(margin)}
|
||||
value={margin}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
onChange={onMarginChange}
|
||||
/>
|
||||
</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, actionBtn, cardBtn } from '@/lib/utils';
|
||||
import type { ExportSize } from '@/types/qrcode';
|
||||
|
||||
interface QRPreviewProps {
|
||||
@@ -30,6 +15,14 @@ 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' },
|
||||
];
|
||||
|
||||
|
||||
export function QRPreview({
|
||||
svgString,
|
||||
isGenerating,
|
||||
@@ -41,92 +34,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={cardBtn}>
|
||||
<Copy className="w-3 h-3" />Copy
|
||||
</button>
|
||||
|
||||
<button onClick={onShare} disabled={!svgString} className={cardBtn}>
|
||||
<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={cardBtn}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
436
components/random/RandomGenerator.tsx
Normal file
436
components/random/RandomGenerator.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { RefreshCw, Copy, Check, Clock } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn, actionBtn } from '@/lib/utils';
|
||||
import { SliderRow } from '@/components/ui/slider-row';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
import {
|
||||
generatePassword, passwordEntropy,
|
||||
generateUUID,
|
||||
generateApiKey,
|
||||
generateHash,
|
||||
generateToken,
|
||||
type PasswordOpts,
|
||||
type ApiKeyOpts,
|
||||
type HashOpts,
|
||||
type TokenOpts,
|
||||
} from '@/lib/random/generators';
|
||||
|
||||
type GeneratorType = 'password' | 'uuid' | 'apikey' | 'hash' | 'token';
|
||||
type MobileTab = 'configure' | 'output';
|
||||
|
||||
const GENERATOR_TABS: { value: GeneratorType; label: string }[] = [
|
||||
{ value: 'password', label: 'Password' },
|
||||
{ value: 'uuid', label: 'UUID' },
|
||||
{ value: 'apikey', label: 'API Key' },
|
||||
{ value: 'hash', label: 'Hash' },
|
||||
{ value: 'token', label: 'Token' },
|
||||
];
|
||||
|
||||
const selectCls =
|
||||
'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer';
|
||||
|
||||
const strengthLabel = (bits: number) => {
|
||||
if (bits < 40) return { label: 'Weak', color: 'bg-red-500' };
|
||||
if (bits < 60) return { label: 'Fair', color: 'bg-amber-400' };
|
||||
if (bits < 80) return { label: 'Good', color: 'bg-yellow-400' };
|
||||
if (bits < 100) return { label: 'Strong', color: 'bg-emerald-400' };
|
||||
return { label: 'Very Strong', color: 'bg-primary' };
|
||||
};
|
||||
|
||||
export function RandomGenerator() {
|
||||
const [type, setType] = useState<GeneratorType>('password');
|
||||
const [mobileTab, setMobileTab] = useState<MobileTab>('configure');
|
||||
const [output, setOutput] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
|
||||
// Options per type
|
||||
const [pwOpts, setPwOpts] = useState<PasswordOpts>({
|
||||
length: 24, uppercase: true, lowercase: true, numbers: true, symbols: true,
|
||||
});
|
||||
const [apiOpts, setApiOpts] = useState<ApiKeyOpts>({
|
||||
length: 32, format: 'hex', prefix: '',
|
||||
});
|
||||
const [hashOpts, setHashOpts] = useState<HashOpts>({
|
||||
algorithm: 'SHA-256', input: '',
|
||||
});
|
||||
const [tokenOpts, setTokenOpts] = useState<TokenOpts>({
|
||||
bytes: 32, format: 'hex',
|
||||
});
|
||||
|
||||
const pushHistory = (val: string) =>
|
||||
setHistory((h) => [val, ...h].slice(0, 8));
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
let result = '';
|
||||
switch (type) {
|
||||
case 'password': result = generatePassword(pwOpts); break;
|
||||
case 'uuid': result = generateUUID(); break;
|
||||
case 'apikey': result = generateApiKey(apiOpts); break;
|
||||
case 'hash': result = await generateHash(hashOpts); break;
|
||||
case 'token': result = generateToken(tokenOpts); break;
|
||||
}
|
||||
setOutput(result);
|
||||
pushHistory(result);
|
||||
setMobileTab('output');
|
||||
} catch {
|
||||
toast.error('Generation failed');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [type, pwOpts, apiOpts, hashOpts, tokenOpts]);
|
||||
|
||||
const copy = (val = output) => {
|
||||
if (!val) return;
|
||||
navigator.clipboard.writeText(val);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const entropy = type === 'password' ? passwordEntropy(pwOpts) : null;
|
||||
const strength = entropy !== null ? strengthLabel(entropy) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'configure', label: 'Configure' }, { value: 'output', label: 'Output' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as MobileTab)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
{/* ── Left: type selector + options ───────────────────── */}
|
||||
<div className={cn(
|
||||
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
|
||||
mobileTab !== 'configure' && 'hidden lg:flex'
|
||||
)}>
|
||||
{/* Type selector */}
|
||||
<div className="glass rounded-xl p-4 shrink-0">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
|
||||
Generator
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{GENERATOR_TABS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => { setType(value); setOutput(''); }}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-xs font-mono transition-all',
|
||||
type === value
|
||||
? 'bg-primary/15 border border-primary/30 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.03] border border-transparent'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-4 shrink-0">
|
||||
Options
|
||||
</span>
|
||||
|
||||
{/* ── Password ── */}
|
||||
{type === 'password' && (
|
||||
<div className="space-y-4">
|
||||
<SliderRow
|
||||
label="Length"
|
||||
display={`${pwOpts.length} chars`}
|
||||
value={pwOpts.length}
|
||||
min={4} max={128}
|
||||
onChange={(v) => setPwOpts((o) => ({ ...o, length: v }))}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Character sets
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ key: 'uppercase', label: 'A–Z', hint: 'Uppercase' },
|
||||
{ key: 'lowercase', label: 'a–z', hint: 'Lowercase' },
|
||||
{ key: 'numbers', label: '0–9', hint: 'Numbers' },
|
||||
{ key: 'symbols', label: '!@#', hint: 'Symbols' },
|
||||
] as const).map(({ key, label, hint }) => (
|
||||
<label
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-all select-none',
|
||||
pwOpts[key]
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'border-border/30 text-muted-foreground/50 hover:border-border/50 hover:text-muted-foreground'
|
||||
)}
|
||||
title={hint}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pwOpts[key]}
|
||||
onChange={(e) => setPwOpts((o) => ({ ...o, [key]: e.target.checked }))}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs font-mono">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{strength && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Strength
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground/40">
|
||||
{entropy} bits
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-white/[0.06] overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', strength.color)}
|
||||
style={{ width: `${Math.min(100, (entropy! / 128) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('text-[10px] font-mono', strength.color.replace('bg-', 'text-'))}>
|
||||
{strength.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── UUID ── */}
|
||||
{type === 'uuid' && (
|
||||
<div className="space-y-3">
|
||||
<div className="px-3 py-2.5 rounded-lg bg-white/[0.02] border border-border/20">
|
||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">
|
||||
Generates a cryptographically random UUID v4 using the browser's built-in{' '}
|
||||
<code className="text-primary/70 text-[10px]">crypto.randomUUID()</code>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-muted-foreground/30">
|
||||
Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── API Key ── */}
|
||||
{type === 'apikey' && (
|
||||
<div className="space-y-4">
|
||||
<SliderRow
|
||||
label="Length"
|
||||
display={`${apiOpts.length} chars`}
|
||||
value={apiOpts.length}
|
||||
min={8} max={64}
|
||||
onChange={(v) => setApiOpts((o) => ({ ...o, length: v }))}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Encoding
|
||||
</span>
|
||||
<select
|
||||
value={apiOpts.format}
|
||||
onChange={(e) => setApiOpts((o) => ({ ...o, format: e.target.value as ApiKeyOpts['format'] }))}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="hex">Hex (0-9, a-f)</option>
|
||||
<option value="base62">Base62 (alphanumeric)</option>
|
||||
<option value="base64url">Base64url</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Prefix <span className="normal-case font-normal text-muted-foreground/40">(optional)</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={apiOpts.prefix}
|
||||
onChange={(e) => setApiOpts((o) => ({ ...o, prefix: e.target.value }))}
|
||||
placeholder="sk, pk, api..."
|
||||
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Hash ── */}
|
||||
{type === 'hash' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Algorithm
|
||||
</span>
|
||||
<select
|
||||
value={hashOpts.algorithm}
|
||||
onChange={(e) => setHashOpts((o) => ({ ...o, algorithm: e.target.value as HashOpts['algorithm'] }))}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="SHA-1">SHA-1 (160 bit)</option>
|
||||
<option value="SHA-256">SHA-256 (256 bit)</option>
|
||||
<option value="SHA-512">SHA-512 (512 bit)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Input <span className="normal-case font-normal text-muted-foreground/40">(empty = random)</span>
|
||||
</span>
|
||||
<textarea
|
||||
value={hashOpts.input}
|
||||
onChange={(e) => setHashOpts((o) => ({ ...o, input: e.target.value }))}
|
||||
placeholder="Text to hash, or leave empty for random data..."
|
||||
rows={4}
|
||||
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/25 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Token ── */}
|
||||
{type === 'token' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Byte length
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{[16, 32, 48, 64].map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setTokenOpts((o) => ({ ...o, bytes: b }))}
|
||||
className={cn(
|
||||
'py-1.5 rounded-lg text-xs font-mono border transition-all',
|
||||
tokenOpts.bytes === b
|
||||
? 'bg-primary/15 border-primary/30 text-primary'
|
||||
: 'border-border/30 text-muted-foreground/50 hover:border-border/50 hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-muted-foreground/30">
|
||||
{tokenOpts.bytes * 8} bits of entropy
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Encoding
|
||||
</span>
|
||||
<select
|
||||
value={tokenOpts.format}
|
||||
onChange={(e) => setTokenOpts((o) => ({ ...o, format: e.target.value as TokenOpts['format'] }))}
|
||||
className={selectCls}
|
||||
>
|
||||
<option value="hex">Hex</option>
|
||||
<option value="base64url">Base64url</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: output + history ──────────────────────────── */}
|
||||
<div className={cn(
|
||||
'lg:col-span-3 flex flex-col gap-3 overflow-hidden',
|
||||
mobileTab !== 'output' && 'hidden lg:flex'
|
||||
)}>
|
||||
{/* Output display */}
|
||||
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between mb-3 shrink-0">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Output
|
||||
</span>
|
||||
{output && (
|
||||
<span className="text-[9px] font-mono text-muted-foreground/30 tabular-nums">
|
||||
{output.length} chars
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value box */}
|
||||
<div
|
||||
className="relative flex-1 min-h-0 rounded-xl overflow-hidden border border-white/[0.06]"
|
||||
style={{ background: '#06060e' }}
|
||||
>
|
||||
{output ? (
|
||||
<div className="absolute inset-0 p-5 overflow-auto scrollbar-thin scrollbar-thumb-white/10">
|
||||
<p className="font-mono text-sm text-white/80 break-all leading-relaxed select-all">
|
||||
{output}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-xs font-mono text-white/15 italic">
|
||||
Press Generate to create a value
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-3 shrink-0">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={generating}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-primary/30 bg-primary/[0.08] hover:border-primary/55 hover:bg-primary/[0.15] text-xs font-medium text-primary transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', generating && 'animate-spin')} />
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copy()}
|
||||
disabled={!output}
|
||||
className={actionBtn}
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="glass rounded-xl p-4 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-3 h-3 text-muted-foreground/40" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
Recent
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{history.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<span className="text-[10px] font-mono text-white/30 group-hover:text-white/50 transition-colors truncate flex-1">
|
||||
{item}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copy(item)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground/40 hover:text-primary"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
39
components/ui/code-snippet.tsx
Normal file
39
components/ui/code-snippet.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CodeSnippetProps {
|
||||
code: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function CodeSnippet({ code, maxHeight }: CodeSnippetProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<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 z-10"
|
||||
>
|
||||
{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"
|
||||
style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/ui/color-input.tsx
Normal file
39
components/ui/color-input.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface ColorInputProps {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour swatch (type="color") + hex text input pair.
|
||||
* Renders them in a flex row at equal height. Disabled state dims both inputs.
|
||||
*/
|
||||
export function ColorInput({ value, onChange, disabled, className }: ColorInputProps) {
|
||||
return (
|
||||
<div className={cn('flex gap-1.5', className)}>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity',
|
||||
disabled && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
33
components/ui/mobile-tabs.tsx
Normal file
33
components/ui/mobile-tabs.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
interface Tab {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MobileTabsProps {
|
||||
tabs: Tab[];
|
||||
active: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function MobileTabs({ tabs, active, onChange }: MobileTabsProps) {
|
||||
return (
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{tabs.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
'flex-1 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||
active === value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
37
components/ui/slider-row.tsx
Normal file
37
components/ui/slider-row.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
interface SliderRowProps {
|
||||
label: string;
|
||||
display: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared label+display header + Slider.
|
||||
* For the keyframe editor's slider+number-input variant, use the local SliderRow in KeyframeProperties.tsx.
|
||||
*/
|
||||
export function SliderRow({ label, display, value, min, max, step = 1, onChange, disabled }: SliderRowProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{display}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={([v]) => onChange(v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -1,14 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ArrowLeftRight, BarChart3, Grid3X3 } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowLeftRight, BarChart3, Grid3X3, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SearchUnits from './SearchUnits';
|
||||
import VisualComparison from './VisualComparison';
|
||||
import {
|
||||
@@ -21,14 +15,10 @@ import {
|
||||
type ConversionResult,
|
||||
} from '@/lib/units/units';
|
||||
import { parseNumberInput, formatNumber, cn } from '@/lib/utils';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
|
||||
type Tab = 'category' | 'convert';
|
||||
|
||||
const CATEGORY_ICONS: Partial<Record<Measure, string>> = {
|
||||
length: '📏', mass: '⚖️', temperature: '🌡️', speed: '⚡', time: '⏱️',
|
||||
area: '⬛', volume: '🧊', digital: '💾', energy: '⚡', pressure: '🔵',
|
||||
power: '🔆', frequency: '〰️', angle: '📐', current: '⚡', voltage: '🔌',
|
||||
};
|
||||
|
||||
export default function MainConverter() {
|
||||
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
|
||||
@@ -94,28 +84,16 @@ export default function MainConverter() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Mobile tab switcher ────────────────────────────────── */}
|
||||
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
|
||||
{(['category', 'convert'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
|
||||
tab === t
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'category' ? 'Category' : 'Convert'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'category', label: 'Category' }, { value: 'convert', label: 'Convert' }]}
|
||||
active={tab}
|
||||
onChange={(v) => setTab(v as Tab)}
|
||||
/>
|
||||
|
||||
{/* ── Main layout ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
style={{ height: 'calc(100svh - 120px)' }}
|
||||
>
|
||||
|
||||
{/* Left panel: search + categories */}
|
||||
@@ -154,15 +132,11 @@ export default function MainConverter() {
|
||||
onClick={() => handleCategorySelect(measure)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all text-left',
|
||||
'border-l-2',
|
||||
isSelected
|
||||
? 'bg-primary/10 border-primary text-primary'
|
||||
: 'border-transparent text-foreground/65 hover:bg-primary/8 hover:text-foreground'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-foreground/65 hover:bg-primary/8 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs leading-none shrink-0 opacity-70">
|
||||
{CATEGORY_ICONS[measure] ?? '📦'}
|
||||
</span>
|
||||
<span className="flex-1 text-xs font-mono truncate">{formatMeasureName(measure)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -195,7 +169,7 @@ export default function MainConverter() {
|
||||
</span>
|
||||
|
||||
{/* Input row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Value input */}
|
||||
<input
|
||||
type="text"
|
||||
@@ -203,51 +177,61 @@ export default function MainConverter() {
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="0"
|
||||
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 tabular-nums"
|
||||
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2.5 text-base font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 tabular-nums"
|
||||
/>
|
||||
|
||||
{/* From unit */}
|
||||
<Select value={selectedUnit} onValueChange={setSelectedUnit}>
|
||||
<SelectTrigger className="w-28 h-9 shrink-0 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Unit selectors + swap */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* From unit */}
|
||||
<select
|
||||
value={selectedUnit}
|
||||
onChange={(e) => setSelectedUnit(e.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit} value={unit} className="font-mono text-xs">
|
||||
{unit}
|
||||
</SelectItem>
|
||||
<option key={unit} value={unit}>{unit}</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
|
||||
{/* Swap */}
|
||||
<button
|
||||
onClick={handleSwapUnits}
|
||||
title="Swap units"
|
||||
className="shrink-0 w-8 h-8 flex items-center justify-center glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all"
|
||||
>
|
||||
<ArrowLeftRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/* Swap */}
|
||||
<button
|
||||
onClick={handleSwapUnits}
|
||||
title="Swap units"
|
||||
className="shrink-0 w-8 h-8 flex items-center justify-center glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all"
|
||||
>
|
||||
<ArrowLeftRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* To unit */}
|
||||
<Select value={targetUnit} onValueChange={setTargetUnit}>
|
||||
<SelectTrigger className="w-28 h-9 shrink-0 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* To unit */}
|
||||
<select
|
||||
value={targetUnit}
|
||||
onChange={(e) => setTargetUnit(e.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit} value={unit} className="font-mono text-xs">
|
||||
{unit}
|
||||
</SelectItem>
|
||||
<option key={unit} value={unit}>{unit}</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result display */}
|
||||
{resultValue !== null && (
|
||||
<div className="mt-3 px-3 py-2.5 rounded-lg bg-primary/5 border border-primary/15">
|
||||
<div className="text-[10px] text-muted-foreground/50 font-mono mb-0.5">Result</div>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<div className="text-[10px] text-muted-foreground/50 font-mono">Result</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = `${formatNumber(resultValue)} ${targetUnit}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied', { description: text, duration: 2000 });
|
||||
}}
|
||||
title="Copy result"
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-muted-foreground/40 hover:text-primary transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tabular-nums font-mono bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent">
|
||||
{formatNumber(resultValue)}
|
||||
|
||||
463
lib/cron/cron-engine.ts
Normal file
463
lib/cron/cron-engine.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
// Cron expression parser, scheduler, and describer
|
||||
|
||||
export type FieldType = 'second' | 'minute' | 'hour' | 'dom' | 'month' | 'dow';
|
||||
|
||||
export interface CronFieldConfig {
|
||||
min: number;
|
||||
max: number;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
names?: readonly string[];
|
||||
aliases?: Record<string, number>;
|
||||
}
|
||||
|
||||
export const FIELD_CONFIGS: Record<FieldType, CronFieldConfig> = {
|
||||
second: { min: 0, max: 59, label: 'Second', shortLabel: 'SEC' },
|
||||
minute: { min: 0, max: 59, label: 'Minute', shortLabel: 'MIN' },
|
||||
hour: { min: 0, max: 23, label: 'Hour', shortLabel: 'HOUR' },
|
||||
dom: { min: 1, max: 31, label: 'Day of Month', shortLabel: 'DOM' },
|
||||
month: {
|
||||
min: 1, max: 12, label: 'Month', shortLabel: 'MON',
|
||||
names: ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'],
|
||||
aliases: { JAN:1,FEB:2,MAR:3,APR:4,MAY:5,JUN:6,JUL:7,AUG:8,SEP:9,OCT:10,NOV:11,DEC:12 },
|
||||
},
|
||||
dow: {
|
||||
min: 0, max: 6, label: 'Day of Week', shortLabel: 'DOW',
|
||||
names: ['SUN','MON','TUE','WED','THU','FRI','SAT'],
|
||||
aliases: { SUN:0,MON:1,TUE:2,WED:3,THU:4,FRI:5,SAT:6 },
|
||||
},
|
||||
};
|
||||
|
||||
export const MONTH_FULL_NAMES = [
|
||||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
];
|
||||
export const DOW_FULL_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
||||
export const MONTH_SHORT_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||||
export const DOW_SHORT_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
|
||||
|
||||
export interface ParsedCronField {
|
||||
raw: string;
|
||||
values: Set<number>;
|
||||
isWildcard: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedCron {
|
||||
hasSeconds: boolean;
|
||||
fields: {
|
||||
second?: ParsedCronField;
|
||||
minute: ParsedCronField;
|
||||
hour: ParsedCronField;
|
||||
dom: ParsedCronField;
|
||||
month: ParsedCronField;
|
||||
dow: ParsedCronField;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CronFields {
|
||||
second?: string;
|
||||
minute: string;
|
||||
hour: string;
|
||||
dom: string;
|
||||
month: string;
|
||||
dow: string;
|
||||
hasSeconds: boolean;
|
||||
}
|
||||
|
||||
// ── Special expressions ───────────────────────────────────────────────────────
|
||||
|
||||
const SPECIAL_EXPRESSIONS: Record<string, string | null> = {
|
||||
'@yearly': '0 0 1 1 *',
|
||||
'@annually': '0 0 1 1 *',
|
||||
'@monthly': '0 0 1 * *',
|
||||
'@weekly': '0 0 * * 0',
|
||||
'@daily': '0 0 * * *',
|
||||
'@midnight': '0 0 * * *',
|
||||
'@hourly': '0 * * * *',
|
||||
'@reboot': null,
|
||||
};
|
||||
|
||||
// ── Low-level field parser ────────────────────────────────────────────────────
|
||||
|
||||
function resolveAlias(val: string, config: CronFieldConfig): number {
|
||||
const n = parseInt(val, 10);
|
||||
if (!isNaN(n)) return n;
|
||||
if (config.aliases) {
|
||||
const upper = val.toUpperCase();
|
||||
if (upper in config.aliases) return config.aliases[upper];
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
function parsePart(part: string, config: CronFieldConfig, values: Set<number>): boolean {
|
||||
// Step: */5 or 0-30/5 or 5/15
|
||||
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
||||
if (stepMatch) {
|
||||
const step = parseInt(stepMatch[2], 10);
|
||||
if (isNaN(step) || step < 1) return false;
|
||||
let start: number, end: number;
|
||||
if (stepMatch[1] === '*') {
|
||||
start = config.min; end = config.max;
|
||||
} else {
|
||||
const rm = stepMatch[1].match(/^(.+)-(.+)$/);
|
||||
if (rm) {
|
||||
start = resolveAlias(rm[1], config);
|
||||
end = resolveAlias(rm[2], config);
|
||||
} else {
|
||||
start = resolveAlias(stepMatch[1], config);
|
||||
end = config.max;
|
||||
}
|
||||
}
|
||||
if (isNaN(start) || isNaN(end) || start < config.min || end > config.max) return false;
|
||||
for (let i = start; i <= end; i += step) values.add(i);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Range: 1-5
|
||||
const rangeMatch = part.match(/^(.+)-(.+)$/);
|
||||
if (rangeMatch) {
|
||||
const start = resolveAlias(rangeMatch[1], config);
|
||||
const end = resolveAlias(rangeMatch[2], config);
|
||||
if (isNaN(start) || isNaN(end) || start > end || start < config.min || end > config.max) return false;
|
||||
for (let i = start; i <= end; i++) values.add(i);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single
|
||||
const n = resolveAlias(part, config);
|
||||
if (isNaN(n)) return false;
|
||||
const adjusted = (config === FIELD_CONFIGS.dow && n === 7) ? 0 : n;
|
||||
if (adjusted < config.min || adjusted > config.max) return false;
|
||||
values.add(adjusted);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseField(expr: string, config: CronFieldConfig): ParsedCronField | null {
|
||||
if (!expr) return null;
|
||||
const values = new Set<number>();
|
||||
if (expr === '*') {
|
||||
for (let i = config.min; i <= config.max; i++) values.add(i);
|
||||
return { raw: expr, values, isWildcard: true };
|
||||
}
|
||||
for (const part of expr.split(',')) {
|
||||
if (!parsePart(part.trim(), config, values)) return null;
|
||||
}
|
||||
return { raw: expr, values, isWildcard: false };
|
||||
}
|
||||
|
||||
// ── Expression parser ─────────────────────────────────────────────────────────
|
||||
|
||||
export function parseCronExpression(expr: string): ParsedCron | null {
|
||||
expr = expr.trim();
|
||||
const lower = expr.toLowerCase();
|
||||
if (lower.startsWith('@')) {
|
||||
const resolved = SPECIAL_EXPRESSIONS[lower];
|
||||
if (resolved === undefined) return null;
|
||||
if (resolved === null) return null;
|
||||
expr = resolved;
|
||||
}
|
||||
const parts = expr.split(/\s+/);
|
||||
if (parts.length < 5 || parts.length > 6) return null;
|
||||
const hasSeconds = parts.length === 6;
|
||||
const o = hasSeconds ? 1 : 0;
|
||||
let secondField: ParsedCronField | undefined;
|
||||
if (hasSeconds) {
|
||||
const f = parseField(parts[0], FIELD_CONFIGS.second);
|
||||
if (!f) return null;
|
||||
secondField = f;
|
||||
}
|
||||
const minute = parseField(parts[o + 0], FIELD_CONFIGS.minute);
|
||||
const hour = parseField(parts[o + 1], FIELD_CONFIGS.hour);
|
||||
const dom = parseField(parts[o + 2], FIELD_CONFIGS.dom);
|
||||
const month = parseField(parts[o + 3], FIELD_CONFIGS.month);
|
||||
const dow = parseField(parts[o + 4], FIELD_CONFIGS.dow);
|
||||
if (!minute || !hour || !dom || !month || !dow) return null;
|
||||
return { hasSeconds, fields: { second: secondField, minute, hour, dom, month, dow } };
|
||||
}
|
||||
|
||||
// ── Field value reconstruction ────────────────────────────────────────────────
|
||||
|
||||
export function rebuildFieldFromValues(values: Set<number>, config: CronFieldConfig): string {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return '*';
|
||||
if (sorted.length === config.max - config.min + 1) return '*';
|
||||
|
||||
// Regular step from min → */N
|
||||
if (sorted.length > 1) {
|
||||
const step = sorted[1] - sorted[0];
|
||||
if (step > 0 && sorted.every((v, i) => v === sorted[0] + i * step)) {
|
||||
if (sorted[0] === config.min) return `*/${step}`;
|
||||
return `${sorted[0]}-${sorted[sorted.length - 1]}/${step}`;
|
||||
}
|
||||
// Consecutive range
|
||||
if (sorted.every((v, i) => i === 0 || v === sorted[i - 1] + 1)) {
|
||||
return `${sorted[0]}-${sorted[sorted.length - 1]}`;
|
||||
}
|
||||
}
|
||||
return sorted.join(',');
|
||||
}
|
||||
|
||||
// ── Split / build ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function splitCronFields(expr: string): CronFields | null {
|
||||
const lower = expr.trim().toLowerCase();
|
||||
const resolved = SPECIAL_EXPRESSIONS[lower];
|
||||
if (resolved !== undefined) {
|
||||
if (resolved === null) return null;
|
||||
expr = resolved;
|
||||
}
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length === 5) {
|
||||
return { minute: parts[0], hour: parts[1], dom: parts[2], month: parts[3], dow: parts[4], hasSeconds: false };
|
||||
}
|
||||
if (parts.length === 6) {
|
||||
return { second: parts[0], minute: parts[1], hour: parts[2], dom: parts[3], month: parts[4], dow: parts[5], hasSeconds: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCronExpression(fields: CronFields): string {
|
||||
const base = `${fields.minute} ${fields.hour} ${fields.dom} ${fields.month} ${fields.dow}`;
|
||||
return fields.hasSeconds && fields.second ? `${fields.second} ${base}` : base;
|
||||
}
|
||||
|
||||
// ── Day matching ──────────────────────────────────────────────────────────────
|
||||
|
||||
function checkDay(d: Date, parsed: ParsedCron): boolean {
|
||||
const domWild = parsed.fields.dom.isWildcard;
|
||||
const dowWild = parsed.fields.dow.isWildcard;
|
||||
if (domWild && dowWild) return true;
|
||||
if (domWild) return parsed.fields.dow.values.has(d.getDay());
|
||||
if (dowWild) return parsed.fields.dom.values.has(d.getDate());
|
||||
return parsed.fields.dom.values.has(d.getDate()) || parsed.fields.dow.values.has(d.getDay());
|
||||
}
|
||||
|
||||
// ── Smart advance algorithm ───────────────────────────────────────────────────
|
||||
|
||||
function advanceToNext(date: Date, parsed: ParsedCron): Date | null {
|
||||
const d = new Date(date);
|
||||
const maxDate = new Date(date.getTime() + 5 * 366 * 24 * 60 * 60 * 1000);
|
||||
let guard = 0;
|
||||
|
||||
while (d < maxDate && guard++ < 200_000) {
|
||||
// Month
|
||||
const m = d.getMonth() + 1;
|
||||
if (!parsed.fields.month.values.has(m)) {
|
||||
const sorted = [...parsed.fields.month.values].sort((a, b) => a - b);
|
||||
const next = sorted.find(v => v > m);
|
||||
if (next !== undefined) {
|
||||
d.setMonth(next - 1, 1);
|
||||
} else {
|
||||
d.setFullYear(d.getFullYear() + 1, sorted[0] - 1, 1);
|
||||
}
|
||||
d.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Day
|
||||
if (!checkDay(d, parsed)) {
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hour
|
||||
const h = d.getHours();
|
||||
const sortedH = [...parsed.fields.hour.values].sort((a, b) => a - b);
|
||||
if (!parsed.fields.hour.values.has(h)) {
|
||||
const next = sortedH.find(v => v > h);
|
||||
if (next !== undefined) {
|
||||
d.setHours(next, 0, 0, 0);
|
||||
} else {
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(sortedH[0], 0, 0, 0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Minute
|
||||
const min = d.getMinutes();
|
||||
const sortedM = [...parsed.fields.minute.values].sort((a, b) => a - b);
|
||||
if (!parsed.fields.minute.values.has(min)) {
|
||||
const next = sortedM.find(v => v > min);
|
||||
if (next !== undefined) {
|
||||
d.setMinutes(next, 0, 0);
|
||||
} else {
|
||||
const nextH = sortedH.find(v => v > h);
|
||||
if (nextH !== undefined) {
|
||||
d.setHours(nextH, sortedM[0], 0, 0);
|
||||
} else {
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(sortedH[0], sortedM[0], 0, 0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
return new Date(d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNextOccurrences(
|
||||
expr: string,
|
||||
count: number = 8,
|
||||
from: Date = new Date(),
|
||||
): Date[] {
|
||||
const parsed = parseCronExpression(expr);
|
||||
if (!parsed) return [];
|
||||
const results: Date[] = [];
|
||||
let current = new Date(from);
|
||||
current.setSeconds(0, 0);
|
||||
current.setTime(current.getTime() + 60_000); // start from next minute
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = advanceToNext(current, parsed);
|
||||
if (!next) break;
|
||||
results.push(next);
|
||||
current = new Date(next.getTime() + 60_000);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Human-readable description ────────────────────────────────────────────────
|
||||
|
||||
function isStepRaw(raw: string): boolean {
|
||||
return /^(\*|\d+)\/\d+$/.test(raw);
|
||||
}
|
||||
|
||||
function stepValue(raw: string): number | null {
|
||||
const m = raw.match(/\/(\d+)$/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
}
|
||||
|
||||
function formatTime12(hour: number, minute: number): string {
|
||||
const ampm = hour < 12 ? 'AM' : 'PM';
|
||||
const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
||||
return `${h}:${String(minute).padStart(2, '0')} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatHour(h: number): string {
|
||||
const ampm = h < 12 ? 'AM' : 'PM';
|
||||
const d = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||
return `${d}:00 ${ampm}`;
|
||||
}
|
||||
|
||||
function formatDowList(vals: number[]): string {
|
||||
if (vals.length === 1) return DOW_FULL_NAMES[vals[0]];
|
||||
if (vals.length === 7) return 'every day';
|
||||
return vals.map(v => DOW_FULL_NAMES[v]).join(', ');
|
||||
}
|
||||
|
||||
export function describeCronExpression(expr: string): string {
|
||||
const lower = expr.trim().toLowerCase();
|
||||
const specialDescs: Record<string, string> = {
|
||||
'@yearly': 'Every year on January 1st at midnight',
|
||||
'@annually': 'Every year on January 1st at midnight',
|
||||
'@monthly': 'Every month on the 1st at midnight',
|
||||
'@weekly': 'Every week on Sunday at midnight',
|
||||
'@daily': 'Every day at midnight',
|
||||
'@midnight': 'Every day at midnight',
|
||||
'@hourly': 'Every hour at :00',
|
||||
'@reboot': 'Once at system reboot',
|
||||
};
|
||||
if (lower in specialDescs) return specialDescs[lower];
|
||||
|
||||
const parsed = parseCronExpression(expr);
|
||||
if (!parsed) return 'Invalid cron expression';
|
||||
const { fields } = parsed;
|
||||
|
||||
const mVals = [...fields.minute.values].sort((a, b) => a - b);
|
||||
const hVals = [...fields.hour.values].sort((a, b) => a - b);
|
||||
const domVals = [...fields.dom.values].sort((a, b) => a - b);
|
||||
const monVals = [...fields.month.values].sort((a, b) => a - b);
|
||||
const dowVals = [...fields.dow.values].sort((a, b) => a - b);
|
||||
|
||||
const mWild = fields.minute.isWildcard;
|
||||
const hWild = fields.hour.isWildcard;
|
||||
const domWild = fields.dom.isWildcard;
|
||||
const monWild = fields.month.isWildcard;
|
||||
const dowWild = fields.dow.isWildcard;
|
||||
|
||||
// Time
|
||||
let when = '';
|
||||
if (mWild && hWild) {
|
||||
when = 'Every minute';
|
||||
} else if (hWild && isStepRaw(fields.minute.raw)) {
|
||||
const s = stepValue(fields.minute.raw);
|
||||
when = s === 1 ? 'Every minute' : `Every ${s} minutes`;
|
||||
} else if (mWild && isStepRaw(fields.hour.raw)) {
|
||||
const s = stepValue(fields.hour.raw);
|
||||
when = s === 1 ? 'Every hour' : `Every ${s} hours`;
|
||||
} else if (!mWild && hWild) {
|
||||
if (isStepRaw(fields.minute.raw)) {
|
||||
const s = stepValue(fields.minute.raw);
|
||||
when = `Every ${s} minutes`;
|
||||
} else if (mVals.length === 1 && mVals[0] === 0) {
|
||||
when = 'Every hour at :00';
|
||||
} else if (mVals.length === 1) {
|
||||
when = `Every hour at :${String(mVals[0]).padStart(2, '0')}`;
|
||||
} else {
|
||||
when = `Every hour at minutes ${mVals.join(', ')}`;
|
||||
}
|
||||
} else if (mWild && !hWild) {
|
||||
if (hVals.length === 1) when = `Every minute of ${formatHour(hVals[0])}`;
|
||||
else when = `Every minute of hours ${hVals.join(', ')}`;
|
||||
} else {
|
||||
if (hVals.length === 1 && mVals.length === 1) {
|
||||
when = `At ${formatTime12(hVals[0], mVals[0])}`;
|
||||
} else if (hVals.length === 1) {
|
||||
when = `${formatHour(hVals[0])}, at minutes ${mVals.join(', ')}`;
|
||||
} else if (mVals.length === 1 && mVals[0] === 0) {
|
||||
when = `At ${hVals.map(formatHour).join(' and ')}`;
|
||||
} else {
|
||||
when = `At hours ${hVals.join(', ')}, minutes ${mVals.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Day
|
||||
let day = '';
|
||||
if (!domWild || !dowWild) {
|
||||
if (!domWild && !dowWild) {
|
||||
day = `on day ${domVals.map(ordinal).join(', ')} or ${formatDowList(dowVals)}`;
|
||||
} else if (!domWild) {
|
||||
day = domVals.length === 1 ? `on the ${ordinal(domVals[0])}` : `on days ${domVals.map(ordinal).join(', ')}`;
|
||||
} else {
|
||||
const isWeekdays = dowVals.length === 5 && [1,2,3,4,5].every(v => dowVals.includes(v));
|
||||
const isWeekends = dowVals.length === 2 && dowVals.includes(0) && dowVals.includes(6);
|
||||
if (isWeekdays) day = 'on weekdays';
|
||||
else if (isWeekends) day = 'on weekends';
|
||||
else day = `on ${formatDowList(dowVals)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Month
|
||||
let month = '';
|
||||
if (!monWild) {
|
||||
month = `in ${monVals.map(v => MONTH_FULL_NAMES[v - 1]).join(', ')}`;
|
||||
}
|
||||
|
||||
let result = when;
|
||||
if (day) result += `, ${day}`;
|
||||
if (month) result += `, ${month}`;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateCronExpression(expr: string): { valid: boolean; error?: string } {
|
||||
const parsed = parseCronExpression(expr);
|
||||
if (!parsed) return { valid: false, error: 'Invalid cron expression' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function validateCronField(value: string, type: FieldType): { valid: boolean; error?: string } {
|
||||
if (!value.trim()) return { valid: false, error: 'Required' };
|
||||
const field = parseField(value, FIELD_CONFIGS[type]);
|
||||
if (!field) return { valid: false, error: `Invalid ${type} expression` };
|
||||
return { valid: true };
|
||||
}
|
||||
47
lib/cron/store.ts
Normal file
47
lib/cron/store.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface CronHistoryEntry {
|
||||
id: string;
|
||||
expression: string;
|
||||
label?: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
interface CronStore {
|
||||
expression: string;
|
||||
history: CronHistoryEntry[];
|
||||
setExpression: (expr: string) => void;
|
||||
addToHistory: (expr: string, label?: string) => void;
|
||||
removeFromHistory: (id: string) => void;
|
||||
clearHistory: () => void;
|
||||
}
|
||||
|
||||
export const useCronStore = create<CronStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
expression: '0 9 * * 1-5',
|
||||
history: [],
|
||||
|
||||
setExpression: (expression) => set({ expression }),
|
||||
|
||||
addToHistory: (expression, label) =>
|
||||
set((state) => {
|
||||
const entry: CronHistoryEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
expression,
|
||||
label,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
const filtered = state.history.filter((h) => h.expression !== expression);
|
||||
return { history: [entry, ...filtered].slice(0, 30) };
|
||||
}),
|
||||
|
||||
removeFromHistory: (id) =>
|
||||
set((state) => ({ history: state.history.filter((h) => h.id !== id) })),
|
||||
|
||||
clearHistory: () => set({ history: [] }),
|
||||
}),
|
||||
{ name: 'kit-cron-v1' },
|
||||
),
|
||||
);
|
||||
@@ -153,15 +153,6 @@ export const SUPPORTED_FORMATS: ConversionFormat[] = [
|
||||
converter: 'imagemagick',
|
||||
description: 'Tagged Image File Format',
|
||||
},
|
||||
{
|
||||
id: 'svg',
|
||||
name: 'SVG',
|
||||
extension: 'svg',
|
||||
mimeType: 'image/svg+xml',
|
||||
category: 'image',
|
||||
converter: 'imagemagick',
|
||||
description: 'Scalable Vector Graphics',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
118
lib/random/generators.ts
Normal file
118
lib/random/generators.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
const CHARSET = {
|
||||
uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
lowercase: 'abcdefghijklmnopqrstuvwxyz',
|
||||
numbers: '0123456789',
|
||||
symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?',
|
||||
hex: '0123456789abcdef',
|
||||
base62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
|
||||
};
|
||||
|
||||
export interface PasswordOpts {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
symbols: boolean;
|
||||
}
|
||||
|
||||
export interface ApiKeyOpts {
|
||||
length: number;
|
||||
format: 'hex' | 'base62' | 'base64url';
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface HashOpts {
|
||||
algorithm: 'SHA-1' | 'SHA-256' | 'SHA-512';
|
||||
input: string;
|
||||
}
|
||||
|
||||
export interface TokenOpts {
|
||||
bytes: number;
|
||||
format: 'hex' | 'base64url';
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const arr = new Uint8Array(n);
|
||||
crypto.getRandomValues(arr);
|
||||
return arr;
|
||||
}
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function toBase64url(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generatePassword(opts: PasswordOpts): string {
|
||||
let charset = '';
|
||||
if (opts.uppercase) charset += CHARSET.uppercase;
|
||||
if (opts.lowercase) charset += CHARSET.lowercase;
|
||||
if (opts.numbers) charset += CHARSET.numbers;
|
||||
if (opts.symbols) charset += CHARSET.symbols;
|
||||
if (!charset) charset = CHARSET.lowercase + CHARSET.numbers;
|
||||
|
||||
const bytes = randomBytes(opts.length * 4);
|
||||
let result = '';
|
||||
let i = 0;
|
||||
while (result.length < opts.length && i < bytes.length) {
|
||||
const idx = bytes[i] % charset.length;
|
||||
result += charset[idx];
|
||||
i++;
|
||||
}
|
||||
return result.slice(0, opts.length);
|
||||
}
|
||||
|
||||
export function passwordEntropy(opts: PasswordOpts): number {
|
||||
let size = 0;
|
||||
if (opts.uppercase) size += 26;
|
||||
if (opts.lowercase) size += 26;
|
||||
if (opts.numbers) size += 10;
|
||||
if (opts.symbols) size += CHARSET.symbols.length;
|
||||
if (size === 0) size = 36;
|
||||
return Math.round(Math.log2(size) * opts.length);
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function generateApiKey(opts: ApiKeyOpts): string {
|
||||
const bytes = randomBytes(opts.length * 2);
|
||||
let key: string;
|
||||
switch (opts.format) {
|
||||
case 'hex':
|
||||
key = toHex(bytes).slice(0, opts.length);
|
||||
break;
|
||||
case 'base64url':
|
||||
key = toBase64url(bytes).slice(0, opts.length);
|
||||
break;
|
||||
case 'base62': {
|
||||
const cs = CHARSET.base62;
|
||||
key = Array.from(bytes)
|
||||
.map((b) => cs[b % cs.length])
|
||||
.join('')
|
||||
.slice(0, opts.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return opts.prefix ? `${opts.prefix}_${key}` : key;
|
||||
}
|
||||
|
||||
export async function generateHash(opts: HashOpts): Promise<string> {
|
||||
const data = opts.input.trim() || toHex(randomBytes(32));
|
||||
const encoded = new TextEncoder().encode(data);
|
||||
const hashBuffer = await crypto.subtle.digest(opts.algorithm, encoded);
|
||||
return toHex(new Uint8Array(hashBuffer));
|
||||
}
|
||||
|
||||
export function generateToken(opts: TokenOpts): string {
|
||||
const bytes = randomBytes(opts.bytes);
|
||||
return opts.format === 'hex' ? toHex(bytes) : toBase64url(bytes);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon, CalculateIcon } from '@/components/AppIcons';
|
||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon, CalculateIcon, RandomIcon, CronIcon } from '@/components/AppIcons';
|
||||
|
||||
export interface Tool {
|
||||
/** Short display name (e.g. "Color") */
|
||||
@@ -97,14 +97,36 @@ export const tools: Tool[] = [
|
||||
icon: AnimateIcon,
|
||||
badges: ['CSS', 'Tailwind v4', '20+ Presets'],
|
||||
},
|
||||
{
|
||||
shortTitle: 'Random',
|
||||
title: 'Random Generator',
|
||||
navTitle: 'Random Generator',
|
||||
href: '/random',
|
||||
description: 'Generate secure passwords, UUIDs, API keys and tokens.',
|
||||
summary:
|
||||
'Cryptographically secure random generator. Create passwords, UUIDs, API keys, SHA hashes, and secure tokens — all using the browser Web Crypto API, nothing leaves your machine.',
|
||||
icon: RandomIcon,
|
||||
badges: ['Web Crypto', 'Passwords', 'UUID', 'Hashes'],
|
||||
},
|
||||
{
|
||||
shortTitle: 'Cron',
|
||||
title: 'Cron Editor',
|
||||
navTitle: 'Cron Editor',
|
||||
href: '/cron',
|
||||
description: 'Visual editor for cron expressions with live preview.',
|
||||
summary:
|
||||
'Build and validate cron expressions with an intuitive visual field editor. Get a human-readable description and preview the next upcoming scheduled runs.',
|
||||
icon: CronIcon,
|
||||
badges: ['Cron', 'Scheduler', 'Visual'],
|
||||
},
|
||||
{
|
||||
shortTitle: 'Calculate',
|
||||
title: 'Calculator & Grapher',
|
||||
title: 'Calculator',
|
||||
navTitle: 'Calculator',
|
||||
href: '/calculate',
|
||||
description: 'Advanced expression evaluator with interactive function graphing.',
|
||||
description: 'Advanced expression evaluator with function graphing.',
|
||||
summary:
|
||||
'Powerful mathematical calculator powered by Math.js. Evaluate complex expressions, define variables, and plot multiple functions simultaneously on an interactive graph with pan and zoom.',
|
||||
'Powerful mathematical calculator powered by Math.js. Evaluate complex expressions, define variables, and plot functions on an interactive graph.',
|
||||
icon: CalculateIcon,
|
||||
badges: ['Math.js', 'Graphing', 'Interactive'],
|
||||
},
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './urlSharing';
|
||||
export * from './animations';
|
||||
export * from './format';
|
||||
export * from './time';
|
||||
export * from './styles';
|
||||
|
||||
15
lib/utils/styles.ts
Normal file
15
lib/utils/styles.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Shared Tailwind class strings for consistent UI patterns across tools.
|
||||
*/
|
||||
|
||||
/** Smaller button for card title rows (copy, share, export icons next to a section label) */
|
||||
export const cardBtn =
|
||||
'flex items-center gap-1 px-2 py-1 text-[10px] font-mono 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';
|
||||
|
||||
/** Standard action button used throughout all tools (copy, download, share, apply…) */
|
||||
export const actionBtn =
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono glass rounded-lg 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';
|
||||
|
||||
/** Small square icon-only button (animate preview controls, timeline actions) */
|
||||
export const iconBtn =
|
||||
'flex items-center justify-center glass rounded-lg 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';
|
||||
@@ -25,7 +25,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"convert-units": "^2.3.4",
|
||||
"figlet": "^1.10.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@@ -41,9 +41,6 @@ importers:
|
||||
figlet:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0
|
||||
framer-motion:
|
||||
specifier: ^12.34.3
|
||||
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
fuse.js:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
@@ -2435,20 +2432,6 @@ packages:
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
framer-motion@12.34.3:
|
||||
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fresh@2.0.0:
|
||||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3127,12 +3110,6 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
motion-dom@12.34.3:
|
||||
resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==}
|
||||
|
||||
motion-utils@12.29.2:
|
||||
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -6585,15 +6562,6 @@ snapshots:
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.34.3
|
||||
motion-utils: 12.29.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fs-extra@11.3.3:
|
||||
@@ -7222,12 +7190,6 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
motion-dom@12.34.3:
|
||||
dependencies:
|
||||
motion-utils: 12.29.2
|
||||
|
||||
motion-utils@12.29.2: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3):
|
||||
|
||||
Reference in New Issue
Block a user