Compare commits

...

43 Commits

Author SHA1 Message Date
ba118be485 fix: cron layout 2026-03-04 11:57:08 +01:00
df4db515d8 feat: add Cron Editor tool
Visual cron expression editor with field-by-field builder, presets
select, human-readable description, and live schedule preview showing
next occurrences. Registered in tools registry with CronIcon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 11:30:30 +01:00
e9927bf0f5 feat: add copy button with toast to units result field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 19:06:00 +01:00
d1092c7169 fix: remove emojis from units tool category list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 18:56:48 +01:00
6ecdc33933 feat: add cardBtn style for card title row buttons
Smaller variant for buttons that sit next to section labels in card headers
(Preview, Color, Results rows). Applied to QRPreview, FontPreview,
ColorManipulation, and FileConverter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:36:19 +01:00
3305b12c02 refactor: centralize action/icon button styles across all tools
Extract shared actionBtn and iconBtn constants into lib/utils/styles.ts
and replace all 11 local definitions across tool components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:26:53 +01:00
a1dcfa34dc chore: remove BackToTop component and scroll progress bar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:30:58 +01:00
3fffe96016 fix: further shorten Random tool description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:18:01 +01:00
36e99d0973 fix: shorten Random and Calculate tool descriptions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 17:15:31 +01:00
fe7dce1cde fix: reduce button size in RandomGenerator and ExpressionPanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 15:42:47 +01:00
b1e79e1808 fix: change tools grid from 4 to 3 columns on xl breakpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:13:05 +01:00
63b4823315 feat: add Random Generator tool
Cryptographically secure generator for 5 types:
- Password: configurable charset + entropy strength meter
- UUID: crypto.randomUUID()
- API Key: hex/base62/base64url with optional prefix
- Hash: SHA-1/256/512 of custom input or random data
- Token: variable byte-length in hex or base64url

All using Web Crypto API — nothing leaves the browser.
Registered in lib/tools.tsx with RandomIcon (dice).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:08:48 +01:00
bdbd123dd4 fix: use tool.title instead of tool.shortTitle in ToolsGrid
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:28:47 +01:00
3f46b46823 fix: shorten Calculate tool summary text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:27:11 +01:00
c686ad82b7 fix: shorten hero badge text to 'Browser-first'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:23:57 +01:00
cac75041db fix: remove SVG from image conversion options in media tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:23:01 +01:00
fbaefbf5b8 fix: replace misleading 'Data collected' stat with 'Browser-first'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:50:11 +01:00
075aa0b6c5 refine: landing page and 404 for clean consistent look
Hero: Kit. title with primary dot, arrow-down CTA, minimal line scroll
indicator. Stats: rounded-2xl + icon border matching cards. ToolsGrid:
proper h2 with gradient accent word. ToolCard: visible rest border,
radial glow, bigger icon+arrow. Footer: visible Source label, consistent
border. 404: fade gradient number, divider line, rounded-xl CTA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:48:04 +01:00
20406c5dcf feat: stamp+glitch logo animation, move keyframes outside @theme
logoStamp and pathFlicker defined at global CSS scope (outside @theme)
so they are always emitted. Logo uses sharp stamp+bounce entrance with
flickering path reveals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:38:28 +01:00
7424c2e899 chore: remove framer-motion, replace Logo animations with CSS
Use scaleIn/fadeIn keyframes from globals.css for the SVG entrance
animation and path group fade-ins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:31:38 +01:00
547753772c feat: style Sonner toasts to match glassmorphic app theme
Drop richColors, apply dark glass background with subtle per-type
border tints (primary/success, red/error, amber/warning, blue/info).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:28:47 +01:00
16e1ce4558 fix: reduce MobileTabs button padding from py-2.5 to py-1.5
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:16:47 +01:00
d476ffb613 refactor: extract MobileTabs shared component, replace in all 8 tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:14:56 +01:00
b5f698cf29 fix: reduce main layout height offset from 180px to 120px across all tools
Also restore scroll handling to ExportPanel and PresetLibrary, and
remove maxHeight cap from CodeSnippet in ExportPanel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:05:14 +01:00
25067bca30 fix: stack units input row on mobile for better usability
Value input now takes full width on its own row; unit selects and
swap button sit on a separate row below, each taking equal flex space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:41:40 +01:00
c545211cf7 refactor: use CodeSnippet in color ExportMenu, drop inline copy button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:13:41 +01:00
11d4207f72 fix: adjust comment style pill padding and AnimationEditor layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:08:22 +01:00
6d6505e5dc fix: reduce ExportPanel code snippet maxHeight to 13rem
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:04:17 +01:00
19cc44c102 fix: add scrollbar-thin to CodeSnippet pre element
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:52:05 +01:00
002edc1532 refactor: extract CodeSnippet to shared ui component
Move components/favicon/CodeSnippet.tsx → components/ui/code-snippet.tsx.
Update Favicon tool import path. Replace Animate tool's local CodeBlock
(with external copy/download buttons) with the shared CodeSnippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:42:12 +01:00
56c0d6403c refactor: go fully native — remove all remaining shadcn component usage
Replace shadcn Select → native <select>:
- ascii/FontPreview.tsx: comment-style picker → glass pill wrapper
  with MessageSquareCode icon + native select
- color/ExportMenu.tsx: format + color-space pickers → native select
  with shared selectCls
- units/MainConverter.tsx: from/to unit pickers → native select

Delete dead code:
- components/media/FormatSelector.tsx (not imported anywhere,
  used shadcn Input + Label + Card)
- components/ui/select.tsx  — now unused
- components/ui/input.tsx   — now unused
- components/ui/label.tsx   — now unused
- components/ui/card.tsx    — now unused

Remaining components/ui/:
  slider.tsx, tooltip.tsx (TooltipProvider in Providers.tsx),
  slider-row.tsx, color-input.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:25:02 +01:00
a0a0e6eaef chore: delete 10 unused shadcn UI components
Removed (0 imports anywhere in the codebase):
skeleton, empty, progress, dialog, button, badge,
tabs, textarea, toggle, toggle-group

Remaining (still actively imported):
slider (SliderRow + ManipulationPanel + QROptions)
tooltip (Providers.tsx — TooltipProvider)
select (ASCII FontPreview, Color ExportMenu, Units MainConverter)
input, label, card (Media FormatSelector)
color-input, slider-row (shared custom primitives)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:20:55 +01:00
8a909bc8aa fix: stack favicon color pickers vertically instead of side by side
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:17:39 +01:00
998ac641f9 refactor: externalize shared primitives, remove shadcn mixing in tools
Shared components (components/ui/):
- slider-row.tsx: SliderRow — label + display value + Slider, replaces
  inline slider blocks in FileConverter, QROptions
- color-input.tsx: ColorInput — color swatch + hex text input pair,
  replaces repeated inline patterns in QROptions, KeyframeProperties,
  FaviconGenerator

Media tool (FileConverter.tsx):
- Remove all shadcn Select/SelectTrigger/SelectContent/SelectItem
- Replace with native <select> + selectCls (matches AnimationSettings
  and all other tools)
- Use SliderRow for video/audio bitrate and image quality sliders
- Net: -6 shadcn Select trees, consistent with every other tool

QROptions.tsx:
- Use SliderRow for margin slider (remove raw Slider import)
- Use ColorInput for foreground + background color pairs

KeyframeProperties.tsx:
- Use ColorInput for background color pair (keep local SliderRow which
  has a different layout with number input)

FaviconGenerator.tsx:
- Use ColorInput for background + theme color pairs

AnimationSettings.tsx:
- Remove dead bg-[#1a1a2e] per-option className (global CSS handles
  select option styling via bg-popover)

Delete:
- components/media/ConversionOptions.tsx — dead code (no imports),
  contained the shadcn Label/Input/Select/Slider patterns being replaced

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:08:58 +01:00
1276a10e9a fix: keyframe timeline 2026-03-01 12:46:00 +01:00
f9db58122c fix: app page layout 2026-03-01 12:20:15 +01:00
2abbdf407f fix: app page layout 2026-03-01 12:14:55 +01:00
dc638ac4d3 chore: cleanup 2026-03-01 10:31:41 +01:00
9390c27f44 chore: cleanup 2026-03-01 10:20:00 +01:00
db37fb1ae2 fix: calculate 2026-03-01 10:11:52 +01:00
e12cc6592e fix: landing page stats grid 2026-03-01 10:04:30 +01:00
00c77ff3fe fix: remove heading and description 2026-03-01 10:01:28 +01:00
a4cc53d774 polish: make tool cards and landing page more prominent
ToolCard: larger icon (w-11 h-11) with violet glow on hover, top shimmer
accent line, primary-tinted badges, arrow in glass pill, stronger border/
shadow on hover, all badges shown, overflow-hidden for clean rendering

ToolsGrid: gap-4, section heading with module count callout, max-w-5xl

Stats: align to max-w-5xl, horizontal layout per stat (icon + value/label),
rounder icon container w-9 h-9

Hero: warm up CTA button with ambient bg-primary/[0.07] fill at rest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 09:41:32 +01:00
81 changed files with 2470 additions and 2274 deletions

View File

@@ -9,7 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function AnimatePage() { export default function AnimatePage() {
return ( return (
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}> <AppPage>
<AnimationEditor /> <AnimationEditor />
</AppPage> </AppPage>
); );

View File

@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function ASCIIPage() { export default function ASCIIPage() {
return ( return (
<AppPage <AppPage>
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<ASCIIConverter /> <ASCIIConverter />
</AppPage> </AppPage>
); );

View File

@@ -9,7 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function CalculatePage() { export default function CalculatePage() {
return ( return (
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}> <AppPage>
<Calculator /> <Calculator />
</AppPage> </AppPage>
); );

View File

@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function ColorPage() { export default function ColorPage() {
return ( return (
<AppPage <AppPage>
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<ColorManipulation /> <ColorManipulation />
</AppPage> </AppPage>
); );

19
app/(app)/cron/page.tsx Normal file
View 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>
);
}

View File

@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function FaviconPage() { export default function FaviconPage() {
return ( return (
<AppPage <AppPage>
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<FaviconGenerator /> <FaviconGenerator />
</AppPage> </AppPage>
); );

View File

@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function MediaPage() { export default function MediaPage() {
return ( return (
<AppPage <AppPage>
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<FileConverter /> <FileConverter />
</AppPage> </AppPage>
); );

View File

@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function QRCodePage() { export default function QRCodePage() {
return ( return (
<AppPage <AppPage>
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<QRCodeGenerator /> <QRCodeGenerator />
</AppPage> </AppPage>
); );

16
app/(app)/random/page.tsx Normal file
View 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>
);
}

View File

@@ -9,11 +9,7 @@ export const metadata: Metadata = { title: tool.title, description: tool.summary
export default function UnitsPage() { export default function UnitsPage() {
return ( return (
<AppPage <AppPage>
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<MainConverter /> <MainConverter />
</AppPage> </AppPage>
); );

View File

@@ -84,6 +84,27 @@
from { transform: scale(0.95); opacity: 0; } from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; } 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 { :root {

View File

@@ -39,7 +39,7 @@ export default function RootLayout({
const isProd = process.env.NODE_ENV === 'production'; const isProd = process.env.NODE_ENV === 'production';
return ( return (
<html lang="en"> <html lang="en" className="scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />

View File

@@ -11,27 +11,31 @@ export default function NotFound() {
<div className="flex-1 flex flex-col items-center justify-center px-6 py-20 relative z-10 text-center"> <div className="flex-1 flex flex-col items-center justify-center px-6 py-20 relative z-10 text-center">
{/* Logo */} {/* Logo */}
<div style={{ animation: 'fadeIn 0.5s ease-out both' }}> <Logo size={52} />
<Logo size={52} />
</div>
{/* 404 */} {/* 404 */}
<div <div
className="mt-8" className="mt-10"
style={{ animation: 'slideUp 0.5s ease-out 0.15s both' }} style={{ animation: 'slideUp 0.5s ease-out 0.15s both' }}
> >
<span className="text-[80px] md:text-[120px] font-bold font-mono text-primary leading-none tabular-nums block"> <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 404
</span> </span>
</div> </div>
{/* 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' }}
/>
{/* Message */} {/* Message */}
<div <div
className="mt-4 space-y-1" className="mt-6 space-y-2"
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }} 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-sm font-medium text-foreground/70">Page not found</p>
<p className="text-[11px] text-muted-foreground/50 font-mono max-w-xs mx-auto leading-relaxed"> <p className="text-[11px] text-muted-foreground/45 font-mono max-w-xs mx-auto leading-relaxed">
The tool or page you&apos;re looking for doesn&apos;t exist or has been moved. The tool or page you&apos;re looking for doesn&apos;t exist or has been moved.
</p> </p>
</div> </div>
@@ -39,11 +43,11 @@ export default function NotFound() {
{/* CTA */} {/* CTA */}
<div <div
className="mt-8" className="mt-8"
style={{ animation: 'slideUp 0.5s ease-out 0.45s both' }} style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
> >
<Link <Link
href="/" href="/"
className="inline-flex items-center gap-2 px-5 py-2.5 glass rounded-lg border border-primary/30 hover:border-primary/60 hover:bg-primary/10 text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200" 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"
> >
<ArrowLeft className="w-3.5 h-3.5 text-primary" /> <ArrowLeft className="w-3.5 h-3.5 text-primary" />
Back to Home Back to Home

View File

@@ -3,13 +3,11 @@ import Hero from '@/components/Hero';
import Stats from '@/components/Stats'; import Stats from '@/components/Stats';
import ToolsGrid from '@/components/ToolsGrid'; import ToolsGrid from '@/components/ToolsGrid';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import BackToTop from '@/components/BackToTop';
export default function Home() { export default function Home() {
return ( return (
<main className="relative min-h-screen text-foreground"> <main className="relative min-h-screen text-foreground">
<AnimatedBackground /> <AnimatedBackground />
<BackToTop />
<Hero /> <Hero />
<Stats /> <Stats />
<ToolsGrid /> <ToolsGrid />

View File

@@ -67,6 +67,31 @@ export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
</svg> </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>) => ( export const CalculateIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Y-axis */} {/* Y-axis */}

View File

@@ -1,45 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { ChevronUp } from 'lucide-react';
export default function BackToTop() {
const [isVisible, setIsVisible] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onScroll = () => {
setIsVisible(window.scrollY > 300);
if (barRef.current) {
const el = document.documentElement;
const scrolled = el.scrollTop / (el.scrollHeight - el.clientHeight);
barRef.current.style.transform = `scaleX(${scrolled})`;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<>
{/* Scroll progress bar */}
<div
ref={barRef}
className="fixed top-0 left-0 right-0 h-px bg-primary z-50 origin-left"
style={{ transform: 'scaleX(0)', transition: 'transform 0.1s linear' }}
/>
{/* Back to top button */}
{isVisible && (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed bottom-6 right-6 w-8 h-8 glass rounded-lg flex items-center justify-center text-muted-foreground/40 hover:text-primary hover:border-primary/40 transition-all duration-200 z-40"
aria-label="Back to top"
style={{ animation: 'fadeIn 0.2s ease-out both' }}
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
)}
</>
);
}

View File

@@ -5,16 +5,16 @@ export default function Footer() {
return ( return (
<footer className="relative py-10 px-6"> <footer className="relative py-10 px-6">
<div className="max-w-5xl mx-auto border-t border-border/20 pt-8"> <div className="max-w-5xl mx-auto border-t border-white/[0.06] pt-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="flex items-center gap-1 text-[9px] text-muted-foreground/40 font-mono"> <p className="flex items-center gap-1.5 text-xs text-muted-foreground/35 font-mono">
© {currentYear} Kit <span>© {currentYear} Kit</span>
<Heart className="w-2 h-2 text-primary/70 shrink-0 animate-pulse" fill="currentColor" /> <Heart className="w-2.5 h-2.5 text-primary/60 shrink-0 animate-pulse" fill="currentColor" />
<a <a
href="https://pivoine.art" href="https://pivoine.art"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-foreground/70 transition-colors" className="hover:text-foreground/60 transition-colors duration-200"
> >
Valknar Valknar
</a> </a>
@@ -24,9 +24,10 @@ export default function Footer() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View source" title="View source"
className="text-muted-foreground/30 hover:text-primary transition-colors" className="flex items-center gap-1.5 text-xs text-muted-foreground/30 font-mono hover:text-primary transition-colors duration-200"
> >
<GitFork className="w-3.5 h-3.5" /> <GitFork className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Source</span>
</a> </a>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { Toolbox } from 'lucide-react'; import { ArrowDown } from 'lucide-react';
import Logo from './Logo'; import Logo from './Logo';
export default function Hero() { export default function Hero() {
@@ -13,36 +13,35 @@ export default function Hero() {
<div className="flex flex-col items-center text-center max-w-2xl mx-auto"> <div className="flex flex-col items-center text-center max-w-2xl mx-auto">
{/* Logo */} {/* Logo */}
<div style={{ animation: 'fadeIn 0.6s ease-out both' }}> <Logo size={72} />
<Logo size={64} />
</div>
{/* Badge */} {/* Badge */}
<div <div
className="mt-8 flex items-center gap-2 px-3 py-1 glass rounded-full" 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' }} style={{ animation: 'slideUp 0.5s ease-out 0.2s both' }}
> >
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
<span className="text-[10px] font-mono text-muted-foreground/60 tracking-widest uppercase"> <span className="text-[10px] font-mono text-muted-foreground/55 tracking-widest uppercase">
Browser-first toolkit Browser-first
</span> </span>
</div> </div>
{/* Title */} {/* Title */}
<h1 <h1
className="mt-5 text-6xl md:text-8xl font-bold text-foreground tracking-tight leading-none" className="mt-6 font-bold tracking-tight leading-none"
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }} style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
> >
Kit <span className="text-6xl md:text-8xl text-foreground">Kit</span>
<span className="text-6xl md:text-8xl text-primary">.</span>
</h1> </h1>
{/* Description */} {/* Description */}
<p <p
className="mt-5 text-sm text-muted-foreground/60 max-w-sm leading-relaxed" className="mt-6 text-sm text-muted-foreground/55 max-w-xs leading-relaxed"
style={{ animation: 'slideUp 0.5s ease-out 0.4s both' }} style={{ animation: 'slideUp 0.5s ease-out 0.4s both' }}
> >
A curated collection of browser-based tools for developers and creators. A curated collection of browser-based tools for developers and creators.
Everything runs locally. Everything runs locally no data leaves your machine.
</p> </p>
{/* CTA */} {/* CTA */}
@@ -52,28 +51,23 @@ export default function Hero() {
> >
<button <button
onClick={scrollToTools} onClick={scrollToTools}
className="flex items-center gap-2 px-5 py-2.5 glass rounded-lg border border-primary/30 hover:border-primary/60 hover:bg-primary/10 text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200" 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"
> >
<Toolbox className="w-3.5 h-3.5 text-primary" />
Explore Tools Explore Tools
<ArrowDown className="w-3.5 h-3.5 text-primary" />
</button> </button>
</div> </div>
{/* Scroll indicator */} {/* Scroll indicator */}
<button <button
onClick={scrollToTools} onClick={scrollToTools}
className="mt-20 flex flex-col items-center gap-2 group" className="mt-24 flex flex-col items-center gap-2 group"
style={{ animation: 'fadeIn 0.5s ease-out 0.9s both' }} style={{ animation: 'fadeIn 0.5s ease-out 0.9s both' }}
> >
<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"> <span className="text-[9px] font-mono text-muted-foreground/25 uppercase tracking-widest group-hover:text-muted-foreground/50 transition-colors">
Scroll Scroll
</span> </span>
<div className="w-4 h-7 border border-muted-foreground/15 rounded-full flex items-start justify-center pt-1.5 group-hover:border-primary/30 transition-colors">
<div
className="w-0.5 h-1.5 bg-primary/50 rounded-full"
style={{ animation: 'float 1.5s ease-in-out infinite' }}
/>
</div>
</button> </button>
</div> </div>
</section> </section>

View File

@@ -1,28 +1,20 @@
'use client';
import { motion } from 'framer-motion';
export default function Logo({ className = '', size = 120 }: { className?: string; size?: number }) { export default function Logo({ className = '', size = 120 }: { className?: string; size?: number }) {
return ( return (
<motion.svg <svg
width={size} width={size}
height={size} height={size}
viewBox="0 0 64 64" viewBox="0 0 64 64"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={className} className={className}
initial={{ opacity: 0, scale: 0.9 }} style={{ animation: 'logoStamp 0.65s cubic-bezier(0.22, 1, 0.36, 1) both' }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
> >
{/* Wrench (Lucide) - vertical */} {/* Wrench (Lucide) - vertical */}
<motion.g <g
transform="translate(32, 32) rotate(0) scale(3.15) translate(-12.5, -11.5)" transform="translate(32, 32) rotate(0) scale(3.15) translate(-12.5, -11.5)"
initial={{ pathLength: 0, opacity: 0 }} style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 1.2, ease: 'easeInOut' }}
> >
<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" 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)" stroke="url(#wrenchGradient)"
strokeWidth="1.5" strokeWidth="1.5"
@@ -31,16 +23,14 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
fill="none" fill="none"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</motion.g> </g>
{/* Brush (Lucide) - horizontal flipped */} {/* Brush (Lucide) - horizontal flipped */}
<motion.g <g
transform="translate(32, 30) rotate(90) scale(3.025) translate(-11.25, -11)" transform="translate(32, 30) rotate(90) scale(3.025) translate(-11.25, -11)"
initial={{ pathLength: 0, opacity: 0 }} style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 1.2, delay: 0.3, ease: 'easeInOut' }}
> >
<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" 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)" stroke="url(#brushGradient)"
strokeWidth="1.5" strokeWidth="1.5"
@@ -49,7 +39,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
fill="none" fill="none"
vectorEffect="non-scaling-stroke" 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" d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
stroke="url(#brushGradient)" stroke="url(#brushGradient)"
strokeWidth="1.5" strokeWidth="1.5"
@@ -58,7 +48,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
fill="none" fill="none"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</motion.g> </g>
{/* Gradient definitions */} {/* Gradient definitions */}
<defs> <defs>
@@ -71,6 +61,6 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
<stop offset="100%" stopColor="#ec4899" /> <stop offset="100%" stopColor="#ec4899" />
</linearGradient> </linearGradient>
</defs> </defs>
</motion.svg> </svg>
); );
} }

View File

@@ -1,32 +1,36 @@
import { tools } from '@/lib/tools'; import { tools } from '@/lib/tools';
import { Box, Code2, Shield } from 'lucide-react'; import { Box, Code2, Globe } from 'lucide-react';
const stats = [ const stats = [
{ value: tools.length, label: 'Tools', icon: Box }, { value: tools.length, label: 'Tools available', icon: Box },
{ value: '100%', label: 'Open Source', icon: Code2 }, { value: '100%', label: 'Open source', icon: Code2 },
{ value: '', label: 'Privacy First', icon: Shield }, { value: '100%', label: 'Browser-first', icon: Globe },
]; ];
export default function Stats() { export default function Stats() {
return ( return (
<section className="relative py-8 px-6"> <section className="relative py-4 px-6">
<div className="max-w-xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{stats.map((stat, i) => { {stats.map((stat, i) => {
const Icon = stat.icon; const Icon = stat.icon;
return ( return (
<div <div
key={stat.label} key={stat.label}
className="glass rounded-xl p-5 flex flex-col items-center text-center" 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` }} style={{ animation: `slideUp 0.5s ease-out ${0.1 + i * 0.1}s both` }}
> >
<div className="w-7 h-7 rounded-md bg-primary/10 flex items-center justify-center mb-3"> <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-3.5 h-3.5 text-primary" /> <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>
<span className="text-2xl font-bold tabular-nums text-foreground">{stat.value}</span>
<span className="text-[10px] font-mono text-muted-foreground/40 uppercase tracking-widest mt-1">
{stat.label}
</span>
</div> </div>
); );
})} })}

View File

@@ -15,21 +15,27 @@ export default function ToolCard({ title, description, icon: Icon, url, index, b
return ( return (
<Link <Link
href={url} href={url}
className="group glass rounded-xl p-4 flex flex-col h-full transition-all duration-200 hover:border-primary/30 hover:bg-primary/3" 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` }} style={{ animation: `slideUp 0.5s ease-out ${0.05 * index}s both` }}
> >
{/* 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" />
{/* 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" />
{/* Icon */} {/* Icon */}
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center mb-3 shrink-0 group-hover:bg-primary/15 transition-colors"> <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-4 h-4 text-primary" /> <Icon className="w-5 h-5 text-primary" />
</div> </div>
{/* Title */} {/* Title */}
<h3 className="text-sm font-semibold text-foreground/80 group-hover:text-foreground transition-colors mb-1.5"> <h3 className="text-base font-semibold text-foreground/80 group-hover:text-foreground transition-colors duration-200 mb-2 leading-snug">
{title} {title}
</h3> </h3>
{/* Description */} {/* Description */}
<p className="text-[11px] text-muted-foreground/50 leading-relaxed flex-1 mb-3"> <p className="text-[13px] text-muted-foreground/50 leading-relaxed flex-1 mb-5">
{description} {description}
</p> </p>
@@ -37,10 +43,10 @@ export default function ToolCard({ title, description, icon: Icon, url, index, b
<div className="flex items-end justify-between gap-2"> <div className="flex items-end justify-between gap-2">
{badges && badges.length > 0 ? ( {badges && badges.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{badges.slice(0, 2).map((badge) => ( {badges.map((badge) => (
<span <span
key={badge} key={badge}
className="text-[9px] font-mono px-1.5 py-0.5 rounded border border-border/30 text-muted-foreground/35" 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} {badge}
</span> </span>
@@ -49,7 +55,9 @@ export default function ToolCard({ title, description, icon: Icon, url, index, b
) : ( ) : (
<span /> <span />
)} )}
<ArrowRight className="w-3 h-3 text-muted-foreground/25 group-hover:text-primary group-hover:translate-x-0.5 transition-all duration-200 shrink-0" /> <div 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> </div>
</Link> </Link>
); );

View File

@@ -8,23 +8,26 @@ export default function ToolsGrid() {
{/* Section heading */} {/* Section heading */}
<div <div
className="mb-8" className="mb-10"
style={{ animation: 'fadeIn 0.5s ease-out both' }} style={{ animation: 'fadeIn 0.5s ease-out both' }}
> >
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-1"> <h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
Available Tools Available{' '}
</span> <span className="bg-gradient-to-r from-primary via-violet-400 to-pink-400 bg-clip-text text-transparent">
<p className="text-xs text-muted-foreground/40"> Tools
Carefully crafted tools for your workflow </span>
</h2>
<p className="text-sm text-muted-foreground/40 mt-2">
{tools.length} tools &mdash; everything runs in your browser, no data leaves your machine
</p> </p>
</div> </div>
{/* Tools grid */} {/* Tools grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{tools.map((tool, index) => ( {tools.map((tool, index) => (
<ToolCard <ToolCard
key={tool.href} key={tool.href}
title={tool.shortTitle} title={tool.title}
description={tool.summary} description={tool.summary}
icon={tool.icon} icon={tool.icon}
url={tool.href} url={tool.href}

View File

@@ -9,6 +9,7 @@ import { PresetLibrary } from './PresetLibrary';
import { ExportPanel } from './ExportPanel'; import { ExportPanel } from './ExportPanel';
import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults'; import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate'; import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate';
type MobileTab = 'edit' | 'preview'; type MobileTab = 'edit' | 'preview';
@@ -21,7 +22,7 @@ export function AnimationEditor() {
); );
const [previewElement, setPreviewElement] = useState<PreviewElement>('box'); const [previewElement, setPreviewElement] = useState<PreviewElement>('box');
const [mobileTab, setMobileTab] = useState<MobileTab>('edit'); const [mobileTab, setMobileTab] = useState<MobileTab>('edit');
const [rightTab, setRightTab] = useState<RightTab>('keyframes'); const [rightTab, setRightTab] = useState<RightTab>('export');
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null; const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
@@ -75,28 +76,16 @@ export function AnimationEditor() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ─────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'edit', label: 'Edit' }, { value: 'preview', label: 'Preview' }]}
{(['edit', 'preview'] as MobileTab[]).map((t) => ( active={mobileTab}
<button onChange={(v) => setMobileTab(v as MobileTab)}
key={t} />
onClick={() => setMobileTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
mobileTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'edit' ? 'Edit' : 'Preview'}
</button>
))}
</div>
{/* ── Main layout ─────────────────────────────────────── */} {/* ── Main layout ─────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '660px' }} style={{ height: 'calc(100svh - 120px)' }}
> >
{/* Left: Settings + Properties */} {/* Left: Settings + Properties */}
@@ -108,6 +97,8 @@ export function AnimationEditor() {
<div className="border-t border-border/25" /> <div className="border-t border-border/25" />
<KeyframeTimeline {...timelineProps} embedded />
<KeyframeProperties keyframe={selectedKeyframe} onChange={updateKeyframeProps} /> <KeyframeProperties keyframe={selectedKeyframe} onChange={updateKeyframeProps} />
</div> </div>
</div> </div>
@@ -123,7 +114,7 @@ export function AnimationEditor() {
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Tab switcher */} {/* Tab switcher */}
<div className="flex glass rounded-lg p-0.5 gap-0.5 mb-4 shrink-0"> <div className="flex glass rounded-lg p-0.5 gap-0.5 mb-4 shrink-0">
{(['keyframes', 'export', 'presets'] as RightTab[]).map((t) => ( {(['export', 'presets'] as RightTab[]).map((t) => (
<button <button
key={t} key={t}
onClick={() => setRightTab(t)} onClick={() => setRightTab(t)}
@@ -134,16 +125,13 @@ export function AnimationEditor() {
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
)} )}
> >
{t === 'keyframes' ? 'Keyframes' : t === 'export' ? 'Export' : 'Presets'} {t === 'export' ? 'Export' : 'Presets'}
</button> </button>
))} ))}
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5"> {rightTab === 'export' && <ExportPanel config={config} />}
{rightTab === 'keyframes' && <KeyframeTimeline {...timelineProps} embedded />} {rightTab === 'presets' && <PresetLibrary onSelect={loadPreset} />}
{rightTab === 'export' && <ExportPanel config={config} />}
{rightTab === 'presets' && <PresetLibrary onSelect={loadPreset} />}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react'; import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, iconBtn } from '@/lib/utils';
import { buildCSS } from '@/lib/animate/cssBuilder'; import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate'; import type { AnimationConfig, PreviewElement } from '@/types/animate';
@@ -27,7 +27,7 @@ const ELEMENTS: { value: PreviewElement; icon: React.ReactNode; title: string }[
{ value: 'text', icon: <Type className="w-3 h-3" />, title: 'Text' }, { value: 'text', icon: <Type className="w-3 h-3" />, title: 'Text' },
]; ];
const actionBtn = 'flex items-center justify-center w-7 h-7 glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 transition-all disabled:opacity-40 disabled:cursor-not-allowed'; const previewBtn = cn(iconBtn, 'w-7 h-7');
const pillCls = (active: boolean) => const pillCls = (active: boolean) =>
cn( cn(
@@ -138,7 +138,7 @@ export function AnimationPreview({ config, element, onElementChange }: Props) {
onClick={() => animState === 'ended' ? restart() : setAnimState('playing')} onClick={() => animState === 'ended' ? restart() : setAnimState('playing')}
disabled={animState === 'playing'} disabled={animState === 'playing'}
title={animState === 'ended' ? 'Replay' : 'Play'} title={animState === 'ended' ? 'Replay' : 'Play'}
className={actionBtn} className={previewBtn}
> >
<Play className="w-3 h-3" /> <Play className="w-3 h-3" />
</button> </button>
@@ -146,11 +146,11 @@ export function AnimationPreview({ config, element, onElementChange }: Props) {
onClick={() => setAnimState('paused')} onClick={() => setAnimState('paused')}
disabled={animState !== 'playing'} disabled={animState !== 'playing'}
title="Pause" title="Pause"
className={actionBtn} className={previewBtn}
> >
<Pause className="w-3 h-3" /> <Pause className="w-3 h-3" />
</button> </button>
<button onClick={restart} title="Restart" className={actionBtn}> <button onClick={restart} title="Restart" className={previewBtn}>
<RotateCcw className="w-3 h-3" /> <RotateCcw className="w-3 h-3" />
</button> </button>
</div> </div>

View File

@@ -127,7 +127,7 @@ export function AnimationSettings({ config, onChange }: Props) {
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" 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) => ( {EASINGS.map((e) => (
<option key={e.value} value={e.value} className="bg-[#1a1a2e]"> <option key={e.value} value={e.value}>
{e.label} {e.label}
</option> </option>
))} ))}

View File

@@ -1,10 +1,9 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder'; import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
import { CodeSnippet } from '@/components/ui/code-snippet';
import type { AnimationConfig } from '@/types/animate'; import type { AnimationConfig } from '@/types/animate';
interface Props { interface Props {
@@ -13,52 +12,13 @@ interface Props {
type ExportTab = 'css' | 'tailwind'; type ExportTab = 'css' | 'tailwind';
const actionBtn =
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all';
function CodeBlock({ code, filename }: { code: string; filename: string }) {
const copy = () => {
navigator.clipboard.writeText(code);
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-2">
<div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
<pre className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed max-h-64">
<code>{code}</code>
</pre>
</div>
<div className="flex gap-2">
<button onClick={copy} className={cn(actionBtn, 'flex-1')}>
<Copy className="w-3 h-3" />Copy
</button>
<button onClick={download} className={cn(actionBtn, 'flex-1')}>
<Download className="w-3 h-3" />Download .css
</button>
</div>
</div>
);
}
export function ExportPanel({ config }: Props) { export function ExportPanel({ config }: Props) {
const [tab, setTab] = useState<ExportTab>('css'); const [tab, setTab] = useState<ExportTab>('css');
const css = useMemo(() => buildCSS(config), [config]); const css = useMemo(() => buildCSS(config), [config]);
const tailwind = useMemo(() => buildTailwindCSS(config), [config]); const tailwind = useMemo(() => buildTailwindCSS(config), [config]);
return ( return (
<div className="space-y-3"> <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"> <div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span> <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"> <div className="flex glass rounded-lg p-0.5 gap-0.5">
@@ -76,8 +36,8 @@ export function ExportPanel({ config }: Props) {
))} ))}
</div> </div>
</div> </div>
{tab === 'css' && <CodeBlock code={css} filename={`${config.name}.css`} />} {tab === 'css' && <CodeSnippet code={css} />}
{tab === 'tailwind' && <CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} />} {tab === 'tailwind' && <CodeSnippet code={tailwind} />}
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { ColorInput } from '@/components/ui/color-input';
import { MousePointerClick } from 'lucide-react'; import { MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate'; import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
@@ -112,23 +113,11 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
{hasBg ? 'On' : 'Off'} {hasBg ? 'On' : 'Off'}
</button> </button>
</div> </div>
<div className="flex gap-1.5"> <ColorInput
<input value={hasBg ? props.backgroundColor! : '#8b5cf6'}
type="color" onChange={(v) => setProp('backgroundColor', v)}
value={hasBg ? props.backgroundColor! : '#8b5cf6'} disabled={!hasBg}
onChange={(e) => setProp('backgroundColor', e.target.value)} />
disabled={!hasBg}
className={cn('w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5', !hasBg && 'opacity-30 cursor-not-allowed')}
/>
<input
type="text"
value={hasBg ? props.backgroundColor! : ''}
onChange={(e) => setProp('backgroundColor', e.target.value)}
disabled={!hasBg}
placeholder="none"
className="flex-1 bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 disabled:opacity-30"
/>
</div>
</div> </div>
<SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} /> <SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} />

View File

@@ -2,7 +2,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, iconBtn } from '@/lib/utils';
import type { Keyframe } from '@/types/animate'; import type { Keyframe } from '@/types/animate';
interface Props { interface Props {
@@ -15,13 +15,9 @@ interface Props {
embedded?: boolean; // when true, no glass card wrapper (use inside another card) embedded?: boolean; // when true, no glass card wrapper (use inside another card)
} }
const TICKS = [0, 25, 50, 75, 100]; const TICKS = [25, 50, 75];
const iconBtn = (disabled?: boolean) => const timelineBtn = cn(iconBtn, 'w-6 h-6');
cn(
'w-6 h-6 flex items-center justify-center rounded-md glass border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 transition-all',
disabled && 'opacity-30 cursor-not-allowed pointer-events-none'
);
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) { export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) {
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
@@ -68,14 +64,14 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={() => onAdd(50)} title="Add at 50%" className={iconBtn()}> <button onClick={() => onAdd(50)} title="Add at 50%" className={timelineBtn}>
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
</button> </button>
<button <button
onClick={() => selectedId && onDelete(selectedId)} onClick={() => selectedId && onDelete(selectedId)}
disabled={!selectedId || keyframes.length <= 2} disabled={!selectedId || keyframes.length <= 2}
title="Delete selected" title="Delete selected"
className={iconBtn(!selectedId || keyframes.length <= 2)} className={timelineBtn}
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
@@ -85,14 +81,14 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
{/* Track */} {/* Track */}
<div <div
ref={trackRef} ref={trackRef}
className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none" className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none mx-4"
onClick={handleTrackClick} onClick={handleTrackClick}
> >
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" /> <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
{TICKS.map((tick) => ( {TICKS.map((tick) => (
<div <div
key={tick} key={tick}
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none" className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none -ml-1.5"
style={{ left: `${tick}%` }} style={{ left: `${tick}%` }}
> >
<div className="w-px h-2 bg-muted-foreground/20" /> <div className="w-px h-2 bg-muted-foreground/20" />
@@ -118,7 +114,7 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
</div> </div>
{/* Offset labels */} {/* Offset labels */}
<div className="relative h-4"> <div className="relative h-4 mx-4">
{sorted.map((kf) => ( {sorted.map((kf) => (
<span <span
key={kf.id} key={kf.id}

View File

@@ -55,7 +55,7 @@ export function PresetLibrary({ onSelect }: Props) {
const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]); const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]);
return ( return (
<div className="space-y-3"> <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"> <div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Presets</span> <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"> <div className="flex glass rounded-lg p-0.5 gap-0.5">

View File

@@ -12,6 +12,7 @@ import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharin
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii'; import type { ASCIIFont } from '@/types/ascii';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'editor' | 'preview'; type Tab = 'editor' | 'preview';
@@ -102,28 +103,16 @@ export function ASCIIConverter() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
{(['editor', 'preview'] as Tab[]).map((t) => ( active={tab}
<button onChange={(v) => setTab(v as Tab)}
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>
{/* ── Main layout ────────────────────────────────────────── */} {/* ── Main layout ────────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" 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 */} {/* Left panel: text input + font selector */}
<div <div

View File

@@ -2,13 +2,6 @@
import * as React from 'react'; import * as React from 'react';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { import {
Copy, Copy,
Download, Download,
@@ -20,7 +13,7 @@ import {
MessageSquareCode, MessageSquareCode,
Type, Type,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, actionBtn, cardBtn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- -->' | '"""'; 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 ( return (
<div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}> <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>
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{onCopy && ( {onCopy && (
<button onClick={onCopy} className={actionBtn}> <button onClick={onCopy} className={cardBtn}>
<Copy className="w-3 h-3" /> Copy <Copy className="w-3 h-3" /> Copy
</button> </button>
)} )}
{onShare && ( {onShare && (
<button onClick={onShare} className={actionBtn}> <button onClick={onShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share <Share2 className="w-3 h-3" /> Share
</button> </button>
)} )}
<button onClick={handleExportPNG} className={actionBtn}> <button onClick={handleExportPNG} className={cardBtn}>
<ImageIcon className="w-3 h-3" /> PNG <ImageIcon className="w-3 h-3" /> PNG
</button> </button>
{onDownload && ( {onDownload && (
<button onClick={onDownload} className={actionBtn}> <button onClick={onDownload} className={cardBtn}>
<Download className="w-3 h-3" /> TXT <Download className="w-3 h-3" /> TXT
</button> </button>
)} )}
@@ -174,7 +164,7 @@ export function FontPreview({
disabled={commentStyle !== 'none'} disabled={commentStyle !== 'none'}
title={label} title={label}
className={cn( 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' textAlign === value && commentStyle === 'none'
? 'bg-primary/10 border-primary/30 text-primary' ? 'bg-primary/10 border-primary/30 text-primary'
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40', : 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40',
@@ -205,19 +195,18 @@ export function FontPreview({
</div> </div>
{/* Comment style */} {/* Comment style */}
<Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}> <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">
<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 shrink-0" />
<MessageSquareCode className="w-3 h-3 text-muted-foreground/60 shrink-0" /> <select
<SelectValue /> value={commentStyle}
</SelectTrigger> onChange={(e) => setCommentStyle(e.target.value as CommentStyle)}
<SelectContent> className="bg-transparent outline-none text-[10px] font-mono cursor-pointer"
>
{COMMENT_STYLES.map((s) => ( {COMMENT_STYLES.map((s) => (
<SelectItem key={s.value} value={s.value}> <option key={s.value} value={s.value}>{s.label}</option>
{s.label}
</SelectItem>
))} ))}
</SelectContent> </select>
</Select> </div>
{/* Stats */} {/* Stats */}
{!isLoading && text && ( {!isLoading && text && (

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ExpressionPanel } from './ExpressionPanel'; import { ExpressionPanel } from './ExpressionPanel';
import { GraphPanel } from './GraphPanel'; import { GraphPanel } from './GraphPanel';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'calc' | 'graph'; type Tab = 'calc' | 'graph';
@@ -13,28 +14,16 @@ export default function Calculator() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Mobile tab switcher — hidden on lg+ */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'calc', label: 'Calculator' }, { value: 'graph', label: 'Graph' }]}
{(['calc', 'graph'] as Tab[]).map((t) => ( active={tab}
<button onChange={(v) => setTab(v as Tab)}
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>
{/* Main layout — side-by-side on lg, tabbed on mobile */} {/* Main layout — side-by-side on lg, tabbed on mobile */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" 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 */} {/* Expression panel */}
<div <div

View File

@@ -162,7 +162,7 @@ export function ExpressionPanel() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={!expression.trim()} disabled={!expression.trim()}
className={cn( 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', 'bg-primary/90 text-primary-foreground hover:bg-primary',
'disabled:opacity-30 disabled:cursor-not-allowed' 'disabled:opacity-30 disabled:cursor-not-allowed'
)} )}

View File

@@ -107,11 +107,6 @@ export function GraphPanel() {
<GraphCanvas functions={graphFunctions} variables={variables} /> <GraphCanvas functions={graphFunctions} variables={variables} />
</div> </div>
{/* ── Hint bar ─────────────────────────────────────────── */}
<p className="text-[10px] text-muted-foreground/35 text-center shrink-0 pb-1">
Drag to pan &nbsp;·&nbsp; Scroll to zoom &nbsp;·&nbsp; Hover for coordinates
</p>
</div> </div>
); );
} }

View File

@@ -10,7 +10,8 @@ import { ExportMenu } from '@/components/color/ExportMenu';
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries'; import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react'; import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react';
import { toast } from 'sonner'; 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 HarmonyType = 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient'; type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient';
@@ -31,8 +32,6 @@ const RIGHT_TABS: { value: RightTab; label: string }[] = [
{ value: 'gradient', label: 'Gradient' }, { 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() { function ColorManipulationContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -102,28 +101,16 @@ function ColorManipulationContent() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'pick', label: 'Pick' }, { value: 'explore', label: 'Explore' }]}
{(['pick', 'explore'] as MobileTab[]).map((t) => ( active={mobileTab}
<button onChange={(v) => setMobileTab(v as MobileTab)}
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>
{/* ── Main layout ────────────────────────────────────────── */} {/* ── Main layout ────────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" 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 */} {/* Left panel: Picker + ColorInfo */}
@@ -139,7 +126,7 @@ function ColorManipulationContent() {
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Color Color
</span> </span>
<button onClick={handleShare} className={actionBtn}> <button onClick={handleShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share <Share2 className="w-3 h-3" /> Share
</button> </button>
</div> </div>

View File

@@ -1,14 +1,7 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import { Download, Loader2 } from 'lucide-react';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Download, Copy, Check, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
exportAsCSS, exportAsCSS,
@@ -20,7 +13,8 @@ import {
type ExportColor, type ExportColor,
} from '@/lib/color/utils/export'; } from '@/lib/color/utils/export';
import { colorAPI } from '@/lib/color/api/client'; 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 { interface ExportMenuProps {
colors: string[]; colors: string[];
@@ -30,15 +24,15 @@ interface ExportMenuProps {
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript'; type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
type ColorSpace = 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklab' | 'lch' | 'oklch'; type ColorSpace = 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklab' | 'lch' | 'oklch';
const actionBtn = const selectCls =
'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'; '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) { export function ExportMenu({ colors, className }: ExportMenuProps) {
const [format, setFormat] = useState<ExportFormat>('css'); const [format, setFormat] = useState<ExportFormat>('css');
const [colorSpace, setColorSpace] = useState<ColorSpace>('hex'); const [colorSpace, setColorSpace] = useState<ColorSpace>('hex');
const [convertedColors, setConvertedColors] = useState<string[]>(colors); const [convertedColors, setConvertedColors] = useState<string[]>(colors);
const [isConverting, setIsConverting] = useState(false); const [isConverting, setIsConverting] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => { useEffect(() => {
async function convertColors() { 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 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 = () => { const handleDownload = () => {
downloadAsFile(getContent(), `palette.${getExt()}`, 'text/plain'); downloadAsFile(getContent(), `palette.${getExt()}`, 'text/plain');
toast.success('Downloaded!'); toast.success('Downloaded!');
@@ -92,56 +79,43 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
{/* Selectors */} {/* Selectors */}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}> <select
<SelectTrigger className="flex-1 h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors"> value={format}
<SelectValue /> onChange={(e) => setFormat(e.target.value as ExportFormat)}
</SelectTrigger> className={selectCls}
<SelectContent> >
<SelectItem value="css">CSS Vars</SelectItem> <option value="css">CSS Vars</option>
<SelectItem value="scss">SCSS</SelectItem> <option value="scss">SCSS</option>
<SelectItem value="tailwind">Tailwind</SelectItem> <option value="tailwind">Tailwind</option>
<SelectItem value="json">JSON</SelectItem> <option value="json">JSON</option>
<SelectItem value="javascript">JS Array</SelectItem> <option value="javascript">JS Array</option>
</SelectContent> </select>
</Select> <select
<Select value={colorSpace} onValueChange={(v) => setColorSpace(v as ColorSpace)}> value={colorSpace}
<SelectTrigger className="flex-1 h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors"> onChange={(e) => setColorSpace(e.target.value as ColorSpace)}
<SelectValue /> className={selectCls}
</SelectTrigger> >
<SelectContent> {['hex', 'rgb', 'hsl', 'lab', 'oklab', 'lch', 'oklch'].map((s) => (
{['hex', 'rgb', 'hsl', 'lab', 'oklab', 'lch', 'oklch'].map((s) => ( <option key={s} value={s}>{s}</option>
<SelectItem key={s} value={s} className="font-mono text-xs">{s}</SelectItem> ))}
))} </select>
</SelectContent>
</Select>
</div> </div>
{/* Code preview */} {/* Code preview */}
<div <div className="relative">
className="relative rounded-xl overflow-hidden border border-white/5 min-h-[80px]"
style={{ background: '#06060e' }}
>
{isConverting && ( {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" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div> </div>
)} )}
<pre className="p-3 text-[10px] font-mono text-white/60 overflow-x-auto leading-relaxed"> <CodeSnippet code={getContent()} />
<code>{getContent()}</code>
</pre>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex gap-2"> <button onClick={handleDownload} disabled={isConverting} className={cn(actionBtn, 'w-full justify-center')}>
<button onClick={handleCopy} disabled={isConverting} className={cn(actionBtn, 'flex-1 justify-center')}> <Download className="w-3 h-3" />
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />} Download
{copied ? 'Copied' : 'Copy'} </button>
</button>
<button onClick={handleDownload} disabled={isConverting} className={cn(actionBtn, 'flex-1 justify-center')}>
<Download className="w-3 h-3" />
Download
</button>
</div>
</div> </div>
); );
} }

View File

@@ -12,15 +12,13 @@ import {
} from '@/lib/color/api/queries'; } from '@/lib/color/api/queries';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react'; import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, actionBtn } from '@/lib/utils';
interface ManipulationPanelProps { interface ManipulationPanelProps {
color: string; color: string;
onColorChange: (color: string) => void; 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) { export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2); const [lightenAmount, setLightenAmount] = useState(0.2);
@@ -118,7 +116,7 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
onValueChange={(vals) => row.setValue(vals[0])} onValueChange={(vals) => row.setValue(vals[0])}
className="flex-1" className="flex-1"
/> />
<button onClick={row.onApply} disabled={isLoading} className={actionBtn}> <button onClick={row.onApply} disabled={isLoading} className={cn(actionBtn, 'shrink-0')}>
Apply Apply
</button> </button>
</div> </div>
@@ -137,7 +135,7 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
} catch { toast.error('Failed'); } } catch { toast.error('Failed'); }
}} }}
disabled={isLoading} 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" /> <ArrowLeftRight className="w-3 h-3" />
Complementary Color Complementary Color

View 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>
);
}

View 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: '917', 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: '17', 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>
);
}

View 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>
);
}

View File

@@ -3,12 +3,14 @@
import * as React from 'react'; import * as React from 'react';
import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react'; import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react';
import { FaviconFileUpload } from './FaviconFileUpload'; 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 { generateFaviconSet } from '@/lib/favicon/faviconService';
import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils'; import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils';
import type { FaviconSet, FaviconOptions } from '@/types/favicon'; import type { FaviconSet, FaviconOptions } from '@/types/favicon';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn'; import { cn, actionBtn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'icons' | 'html' | 'manifest'; type Tab = 'icons' | 'html' | 'manifest';
type MobileTab = 'setup' | 'results'; type MobileTab = 'setup' | 'results';
@@ -19,8 +21,6 @@ const TABS: { value: Tab; label: string; icon: React.ReactNode }[] = [
{ value: 'manifest', label: 'Manifest', icon: <Globe className="w-3 h-3" /> }, { value: 'manifest', label: 'Manifest', icon: <Globe className="w-3 h-3" /> },
]; ];
const actionBtn =
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
const inputCls = 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'; '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';
@@ -75,28 +75,16 @@ export function FaviconGenerator() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ─────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'setup', label: 'Setup' }, { value: 'results', label: 'Results' }]}
{(['setup', 'results'] as MobileTab[]).map((t) => ( active={mobileTab}
<button onChange={(v) => setMobileTab(v as MobileTab)}
key={t} />
onClick={() => setMobileTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
mobileTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'setup' ? 'Setup' : 'Results'}
</button>
))}
</div>
{/* ── Main layout ─────────────────────────────────────── */} {/* ── Main layout ─────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }} style={{ height: 'calc(100svh - 120px)' }}
> >
{/* Left: Setup */} {/* Left: Setup */}
@@ -142,40 +130,20 @@ export function FaviconGenerator() {
/> />
<p className="text-[9px] text-muted-foreground/30 font-mono mt-1">Used for mobile home screen labels</p> <p className="text-[9px] text-muted-foreground/30 font-mono mt-1">Used for mobile home screen labels</p>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="space-y-3">
<div> <div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label> <label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label>
<div className="flex gap-1.5"> <ColorInput
<input value={options.backgroundColor}
type="color" onChange={(v) => setOptions({ ...options, backgroundColor: v })}
value={options.backgroundColor} />
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/>
<input
type="text"
value={options.backgroundColor}
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
className={cn(inputCls, 'py-1')}
/>
</div>
</div> </div>
<div> <div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label> <label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label>
<div className="flex gap-1.5"> <ColorInput
<input value={options.themeColor}
type="color" onChange={(v) => setOptions({ ...options, themeColor: v })}
value={options.themeColor} />
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/>
<input
type="text"
value={options.themeColor}
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
className={cn(inputCls, 'py-1')}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -190,7 +158,7 @@ export function FaviconGenerator() {
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={!sourceFile || isGenerating} disabled={!sourceFile || isGenerating}
className={cn(actionBtn, 'flex-1 py-2.5')} className={cn(actionBtn, 'flex-1 justify-center')}
> >
{isGenerating {isGenerating
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating {progress}%</> ? <><Loader2 className="w-3 h-3 animate-spin" /> Generating {progress}%</>

View File

@@ -32,7 +32,7 @@ export function AppHeader() {
</button> </button>
{/* Mobile: logo home link */} {/* Mobile: logo home link */}
<Link href="/" className="lg:hidden shrink-0"> <Link href="/" className="lg:hidden shrink-0 ml-2">
<Logo size={20} /> <Logo size={20} />
</Link> </Link>

View File

@@ -2,40 +2,15 @@ import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface AppPageProps { interface AppPageProps {
title: string;
description?: string;
icon?: React.ElementType;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
export function AppPage({ title, description, icon: Icon, children, className }: AppPageProps) { export function AppPage({ children, className }: AppPageProps) {
return ( return (
<div className={cn('min-h-screen', className)}> <div className={cn('overflow-y-auto', className)}>
<div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in"> <div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in py-6 lg:py-8">
{children}
{/* Page header */}
<div className="py-5 border-b border-border/20 mb-6">
<div className="flex items-center gap-3">
{Icon && (
<div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="w-3.5 h-3.5 text-primary" />
</div>
)}
<div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground leading-tight">{title}</h1>
{description && (
<p className="text-[10px] text-muted-foreground/50 font-mono mt-0.5 truncate">
{description}
</p>
)}
</div>
</div>
</div>
<div className="pb-8">
{children}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -103,7 +103,7 @@ export function AppSidebar() {
<span className="whitespace-nowrap block text-[13px] font-medium leading-tight"> <span className="whitespace-nowrap block text-[13px] font-medium leading-tight">
{tool.navTitle} {tool.navTitle}
</span> </span>
<span className="text-[9px] text-muted-foreground/40 leading-tight block truncate font-mono mt-0.5"> <span className="text-[9px] text-muted-foreground/40 leading-tight block font-mono mt-0.5">
{tool.description} {tool.description}
</span> </span>
</div> </div>

View File

@@ -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()}
</>
);
}

View File

@@ -2,7 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-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 { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob } from '@/types/media'; import type { ConversionJob } from '@/types/media';
@@ -11,9 +11,6 @@ export interface ConversionPreviewProps {
onRetry?: () => void; 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) { export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null); const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0); const [elapsedTime, setElapsedTime] = React.useState(0);
@@ -171,7 +168,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
})()} })()}
{/* Download */} {/* 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" /> <Download className="w-3 h-3" />
<span className="truncate min-w-0">{filename}</span> <span className="truncate min-w-0">{filename}</span>
</button> </button>
@@ -187,7 +184,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
</div> </div>
)} )}
{onRetry && ( {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" /> <RefreshCw className="w-3 h-3" />
Retry Retry
</button> </button>

View File

@@ -1,14 +1,8 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { import { SliderRow } from '@/components/ui/slider-row';
Select, import { MobileTabs } from '@/components/ui/mobile-tabs';
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { FileUpload } from './FileUpload'; import { FileUpload } from './FileUpload';
import { ConversionPreview } from './ConversionPreview'; import { ConversionPreview } from './ConversionPreview';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -23,12 +17,12 @@ import { addToHistory } from '@/lib/media/storage/history';
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils'; import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media'; import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media';
import { ShieldCheck, Download, RotateCcw, Loader2 } from 'lucide-react'; import { ShieldCheck, Download, RotateCcw, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn, actionBtn, cardBtn } from '@/lib/utils';
type MobileTab = 'upload' | 'convert'; type MobileTab = 'upload' | 'convert';
const actionBtn = const selectCls =
'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'; '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() { export function FileConverter() {
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]); const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
@@ -58,7 +52,6 @@ export function FileConverter() {
setCompatibleFormats(compat); setCompatibleFormats(compat);
if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]); if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]);
toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`); toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`);
// Auto-advance to convert tab on mobile
setMobileTab('convert'); setMobileTab('convert');
} else { } else {
toast.error('Could not detect file format'); toast.error('Could not detect file format');
@@ -187,28 +180,16 @@ export function FileConverter() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ─────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'upload', label: 'Upload' }, { value: 'convert', label: 'Convert' }]}
{(['upload', 'convert'] as MobileTab[]).map((t) => ( active={mobileTab}
<button onChange={(v) => setMobileTab(v as MobileTab)}
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>
{/* ── Main layout ─────────────────────────────────────── */} {/* ── Main layout ─────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" 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 */} {/* Left: upload zone */}
@@ -246,7 +227,6 @@ export function FileConverter() {
mobileTab !== 'convert' && 'hidden lg:flex' mobileTab !== 'convert' && 'hidden lg:flex'
)} )}
> >
{/* Options panel */}
{inputFormat && compatibleFormats.length > 0 ? ( {inputFormat && compatibleFormats.length > 0 ? (
<div className="glass rounded-xl p-4 shrink-0"> <div className="glass rounded-xl p-4 shrink-0">
{/* Detected format */} {/* Detected format */}
@@ -290,90 +270,70 @@ export function FileConverter() {
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Codec</span> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Codec</span>
<Select <select
value={conversionOptions.videoCodec || 'default'} 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} 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"> <option value="default">Auto (Recommended)</option>
<SelectValue /> <option value="libx264">H.264</option>
</SelectTrigger> <option value="libx265">H.265</option>
<SelectContent> <option value="libvpx">VP8 (WebM)</option>
<SelectItem value="default">Auto (Recommended)</SelectItem> <option value="libvpx-vp9">VP9 (WebM)</option>
<SelectItem value="libx264">H.264</SelectItem> </select>
<SelectItem value="libx265">H.265</SelectItem>
<SelectItem value="libvpx">VP8 (WebM)</SelectItem>
<SelectItem value="libvpx-vp9">VP9 (WebM)</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5"> <SliderRow
<div className="flex items-center justify-between"> label="Video Bitrate"
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Bitrate</span> display={conversionOptions.videoBitrate || '2M'}
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.videoBitrate || '2M'}</span> value={parseFloat(conversionOptions.videoBitrate?.replace('M', '') || '2')}
</div> min={0.5} max={10} step={0.5}
<Slider onChange={(v) => setOpt({ videoBitrate: `${v}M` })}
min={0.5} max={10} step={0.5} disabled={isConverting}
value={[parseFloat(conversionOptions.videoBitrate?.replace('M', '') || '2')]} />
onValueChange={(v) => setOpt({ videoBitrate: `${v[0]}M` })}
disabled={isConverting}
/>
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Resolution</span> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Resolution</span>
<Select <select
value={conversionOptions.videoResolution || 'original'} 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} disabled={isConverting}
className={selectCls}
> >
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono"> <option value="original">Original</option>
<SelectValue /> <option value="1920x-1">1080p</option>
</SelectTrigger> <option value="1280x-1">720p</option>
<SelectContent> <option value="854x-1">480p</option>
<SelectItem value="original">Original</SelectItem> <option value="640x-1">360p</option>
<SelectItem value="1920x-1">1080p</SelectItem> </select>
<SelectItem value="1280x-1">720p</SelectItem>
<SelectItem value="854x-1">480p</SelectItem>
<SelectItem value="640x-1">360p</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">FPS</span> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">FPS</span>
<Select <select
value={conversionOptions.videoFps?.toString() || 'original'} 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} disabled={isConverting}
className={selectCls}
> >
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono"> <option value="original">Original</option>
<SelectValue /> <option value="60">60 fps</option>
</SelectTrigger> <option value="30">30 fps</option>
<SelectContent> <option value="24">24 fps</option>
<SelectItem value="original">Original</SelectItem> <option value="15">15 fps</option>
<SelectItem value="60">60 fps</SelectItem> </select>
<SelectItem value="30">30 fps</SelectItem>
<SelectItem value="24">24 fps</SelectItem>
<SelectItem value="15">15 fps</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
<div className="space-y-1.5"> <SliderRow
<div className="flex items-center justify-between"> label="Audio Bitrate"
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Audio Bitrate</span> display={conversionOptions.audioBitrate || '128k'}
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.audioBitrate || '128k'}</span> value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '128')}
</div> min={64} max={320} step={32}
<Slider onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
min={64} max={320} step={32} disabled={isConverting}
value={[parseInt(conversionOptions.audioBitrate?.replace('k', '') || '128')]} />
onValueChange={(v) => setOpt({ audioBitrate: `${v[0]}k` })}
disabled={isConverting}
/>
</div>
</> </>
)} )}
@@ -382,73 +342,57 @@ export function FileConverter() {
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Codec</span> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Codec</span>
<Select <select
value={conversionOptions.audioCodec || 'default'} 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} disabled={isConverting}
className={selectCls}
> >
<SelectTrigger className="h-7 w-full text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono"> <option value="default">Auto</option>
<SelectValue /> <option value="libmp3lame">MP3 (LAME)</option>
</SelectTrigger> <option value="aac">AAC</option>
<SelectContent> <option value="libvorbis">Vorbis</option>
<SelectItem value="default">Auto</SelectItem> <option value="libopus">Opus</option>
<SelectItem value="libmp3lame">MP3 (LAME)</SelectItem> <option value="flac">FLAC</option>
<SelectItem value="aac">AAC</SelectItem> </select>
<SelectItem value="libvorbis">Vorbis</SelectItem>
<SelectItem value="libopus">Opus</SelectItem>
<SelectItem value="flac">FLAC</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5"> <SliderRow
<div className="flex items-center justify-between"> label="Bitrate"
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Bitrate</span> display={conversionOptions.audioBitrate || '192k'}
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.audioBitrate || '192k'}</span> value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '192')}
</div> min={64} max={320} step={32}
<Slider onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
min={64} max={320} step={32} disabled={isConverting}
value={[parseInt(conversionOptions.audioBitrate?.replace('k', '') || '192')]} />
onValueChange={(v) => setOpt({ audioBitrate: `${v[0]}k` })}
disabled={isConverting}
/>
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Sample Rate</span> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Sample Rate</span>
<Select <select
value={conversionOptions.audioSampleRate?.toString() || 'original'} 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} disabled={isConverting}
className={selectCls}
> >
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono"> <option value="original">Original</option>
<SelectValue /> <option value="48000">48 kHz</option>
</SelectTrigger> <option value="44100">44.1 kHz</option>
<SelectContent> <option value="22050">22 kHz</option>
<SelectItem value="original">Original</SelectItem> </select>
<SelectItem value="48000">48 kHz</SelectItem>
<SelectItem value="44100">44.1 kHz</SelectItem>
<SelectItem value="22050">22 kHz</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Channels</span> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Channels</span>
<Select <select
value={conversionOptions.audioChannels?.toString() || 'original'} 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} disabled={isConverting}
className={selectCls}
> >
<SelectTrigger className="h-7 text-xs border-border/30 bg-transparent hover:border-primary/30 font-mono"> <option value="original">Original</option>
<SelectValue /> <option value="2">Stereo</option>
</SelectTrigger> <option value="1">Mono</option>
<SelectContent> </select>
<SelectItem value="original">Original</SelectItem>
<SelectItem value="2">Stereo</SelectItem>
<SelectItem value="1">Mono</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
</> </>
@@ -457,18 +401,14 @@ export function FileConverter() {
{/* Image options */} {/* Image options */}
{outputFormat.category === 'image' && ( {outputFormat.category === 'image' && (
<> <>
<div className="space-y-1.5"> <SliderRow
<div className="flex items-center justify-between"> label="Quality"
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Quality</span> display={`${conversionOptions.imageQuality ?? 85}%`}
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{conversionOptions.imageQuality ?? 85}%</span> value={conversionOptions.imageQuality ?? 85}
</div> min={1} max={100} step={1}
<Slider onChange={(v) => setOpt({ imageQuality: v })}
min={1} max={100} step={1} disabled={isConverting}
value={[conversionOptions.imageQuality ?? 85]} />
onValueChange={(v) => setOpt({ imageQuality: v[0] })}
disabled={isConverting}
/>
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{(['imageWidth', 'imageHeight'] as const).map((key) => ( {(['imageWidth', 'imageHeight'] as const).map((key) => (
@@ -496,11 +436,7 @@ export function FileConverter() {
<button <button
onClick={handleConvert} onClick={handleConvert}
disabled={!selectedFiles.length || !outputFormat || isConverting} disabled={!selectedFiles.length || !outputFormat || isConverting}
className={cn(actionBtn, 'flex-1 py-2 text-sm font-medium', className={cn(actionBtn, 'flex-1 justify-center py-2')}
!isConverting && selectedFiles.length && outputFormat
? 'hover:text-primary'
: ''
)}
> >
{isConverting {isConverting
? <><Loader2 className="w-3 h-3 animate-spin" />Converting</> ? <><Loader2 className="w-3 h-3 animate-spin" />Converting</>
@@ -534,7 +470,7 @@ export function FileConverter() {
Results Results
</span> </span>
{completedCount > 0 && ( {completedCount > 0 && (
<button onClick={handleDownloadAll} className={actionBtn}> <button onClick={handleDownloadAll} className={cardBtn}>
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
{completedCount > 1 ? `Download all (${completedCount}) as ZIP` : 'Download'} {completedCount > 1 ? `Download all (${completedCount}) as ZIP` : 'Download'}
</button> </button>

View File

@@ -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 &quot;{searchQuery}&quot;
</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>
);
}

View File

@@ -25,7 +25,23 @@ export function Providers({ children }: { children: React.ReactNode }) {
<SWRegistration /> <SWRegistration />
{children} {children}
</TooltipProvider> </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> </QueryClientProvider>
); );
} }

View File

@@ -10,6 +10,7 @@ import { downloadBlob } from '@/lib/media/utils/fileUtils';
import { debounce } from '@/lib/utils/debounce'; import { debounce } from '@/lib/utils/debounce';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode'; import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
type MobileTab = 'configure' | 'preview'; type MobileTab = 'configure' | 'preview';
@@ -100,28 +101,16 @@ export function QRCodeGenerator() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ─────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'configure', label: 'Configure' }, { value: 'preview', label: 'Preview' }]}
{(['configure', 'preview'] as MobileTab[]).map((t) => ( active={mobileTab}
<button onChange={(v) => setMobileTab(v as MobileTab)}
key={t} />
onClick={() => setMobileTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
mobileTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'configure' ? 'Configure' : 'Preview'}
</button>
))}
</div>
{/* ── Main layout ─────────────────────────────────────── */} {/* ── Main layout ─────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }} style={{ height: 'calc(100svh - 120px)' }}
> >
{/* Left: Input + Options */} {/* Left: Input + Options */}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Slider } from '@/components/ui/slider'; import { SliderRow } from '@/components/ui/slider-row';
import { ColorInput } from '@/components/ui/color-input';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import type { ErrorCorrectionLevel } from '@/types/qrcode'; import type { ErrorCorrectionLevel } from '@/types/qrcode';
@@ -22,9 +23,6 @@ const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[]
{ value: 'H', label: 'H', desc: '30%' }, { value: 'H', label: 'H', desc: '30%' },
]; ];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
export function QROptions({ export function QROptions({
errorCorrection, errorCorrection,
foregroundColor, foregroundColor,
@@ -73,20 +71,7 @@ export function QROptions({
{/* Foreground */} {/* Foreground */}
<div> <div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
<div className="flex gap-1.5"> <ColorInput value={foregroundColor} onChange={onForegroundColorChange} />
<input
type="color"
value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/>
<input
type="text"
value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)}
className={inputCls}
/>
</div>
</div> </div>
{/* Background */} {/* Background */}
@@ -105,44 +90,24 @@ export function QROptions({
Transparent Transparent
</button> </button>
</div> </div>
<div className="flex gap-1.5"> <ColorInput
<input value={isTransparent ? '#ffffff' : backgroundColor}
type="color" onChange={onBackgroundColorChange}
disabled={isTransparent} disabled={isTransparent}
value={isTransparent ? '#ffffff' : backgroundColor} />
onChange={(e) => onBackgroundColorChange(e.target.value)}
className={cn(
'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity',
isTransparent && 'opacity-30 cursor-not-allowed'
)}
/>
<input
type="text"
disabled={isTransparent}
value={isTransparent ? 'transparent' : backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
className={cn(inputCls, isTransparent && 'opacity-30')}
/>
</div>
</div> </div>
</div> </div>
{/* Margin */} {/* Margin */}
<div> <SliderRow
<div className="flex items-center justify-between mb-2"> label="Margin"
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest"> display={String(margin)}
Margin value={margin}
</span> min={0}
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{margin}</span> max={8}
</div> step={1}
<Slider onChange={onMarginChange}
value={[margin]} />
onValueChange={([v]) => onMarginChange(v)}
min={0}
max={8}
step={1}
/>
</div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react'; import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, actionBtn, cardBtn } from '@/lib/utils';
import type { ExportSize } from '@/types/qrcode'; import type { ExportSize } from '@/types/qrcode';
interface QRPreviewProps { interface QRPreviewProps {
@@ -22,8 +22,6 @@ const EXPORT_SIZES: { value: ExportSize; label: string }[] = [
{ value: 2048, label: '2k' }, { value: 2048, label: '2k' },
]; ];
const actionBtn =
'flex items-center gap-1 px-2.5 py-1 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
export function QRPreview({ export function QRPreview({
svgString, svgString,
@@ -44,11 +42,11 @@ export function QRPreview({
Preview Preview
</span> </span>
<button onClick={onCopyImage} disabled={!svgString} className={actionBtn}> <button onClick={onCopyImage} disabled={!svgString} className={cardBtn}>
<Copy className="w-3 h-3" />Copy <Copy className="w-3 h-3" />Copy
</button> </button>
<button onClick={onShare} disabled={!svgString} className={actionBtn}> <button onClick={onShare} disabled={!svgString} className={cardBtn}>
<Share2 className="w-3 h-3" />Share <Share2 className="w-3 h-3" />Share
</button> </button>
@@ -79,7 +77,7 @@ export function QRPreview({
</div> </div>
</div> </div>
<button onClick={onDownloadSvg} disabled={!svgString} className={actionBtn}> <button onClick={onDownloadSvg} disabled={!svgString} className={cardBtn}>
<FileCode className="w-3 h-3" />SVG <FileCode className="w-3 h-3" />SVG
</button> </button>
</div> </div>

View 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: 'AZ', hint: 'Uppercase' },
{ key: 'lowercase', label: 'az', hint: 'Lowercase' },
{ key: 'numbers', label: '09', 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&apos;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>
);
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -6,9 +6,10 @@ import { toast } from 'sonner';
interface CodeSnippetProps { interface CodeSnippetProps {
code: string; code: string;
maxHeight?: string;
} }
export function CodeSnippet({ code }: CodeSnippetProps) { export function CodeSnippet({ code, maxHeight }: CodeSnippetProps) {
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const handleCopy = () => { const handleCopy = () => {
@@ -22,12 +23,15 @@ export function CodeSnippet({ code }: CodeSnippetProps) {
<div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}> <div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
<button <button
onClick={handleCopy} 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" 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 ? <Check className="w-2.5 h-2.5" /> : <Copy className="w-2.5 h-2.5" />}
{copied ? 'Copied' : 'Copy'} {copied ? 'Copied' : 'Copy'}
</button> </button>
<pre className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed"> <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> <code>{code}</code>
</pre> </pre>
</div> </div>

View 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>
);
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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 }

View 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>
);
}

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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 }

View 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>
);
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -1,14 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ArrowLeftRight, BarChart3, Grid3X3 } from 'lucide-react'; import { ArrowLeftRight, BarChart3, Grid3X3, Copy } from 'lucide-react';
import { import { toast } from 'sonner';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import SearchUnits from './SearchUnits'; import SearchUnits from './SearchUnits';
import VisualComparison from './VisualComparison'; import VisualComparison from './VisualComparison';
import { import {
@@ -21,14 +15,10 @@ import {
type ConversionResult, type ConversionResult,
} from '@/lib/units/units'; } from '@/lib/units/units';
import { parseNumberInput, formatNumber, cn } from '@/lib/utils'; import { parseNumberInput, formatNumber, cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'category' | 'convert'; 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() { export default function MainConverter() {
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length'); const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
@@ -94,28 +84,16 @@ export default function MainConverter() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'category', label: 'Category' }, { value: 'convert', label: 'Convert' }]}
{(['category', 'convert'] as Tab[]).map((t) => ( active={tab}
<button onChange={(v) => setTab(v as Tab)}
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>
{/* ── Main layout ────────────────────────────────────────── */} {/* ── Main layout ────────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" 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 */} {/* Left panel: search + categories */}
@@ -154,15 +132,11 @@ export default function MainConverter() {
onClick={() => handleCategorySelect(measure)} onClick={() => handleCategorySelect(measure)}
className={cn( className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all text-left', 'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all text-left',
'border-l-2',
isSelected isSelected
? 'bg-primary/10 border-primary text-primary' ? 'bg-primary/10 text-primary'
: 'border-transparent text-foreground/65 hover:bg-primary/8 hover:text-foreground' : '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="flex-1 text-xs font-mono truncate">{formatMeasureName(measure)}</span>
<span <span
className={cn( className={cn(
@@ -195,7 +169,7 @@ export default function MainConverter() {
</span> </span>
{/* Input row */} {/* Input row */}
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2">
{/* Value input */} {/* Value input */}
<input <input
type="text" type="text"
@@ -203,51 +177,61 @@ export default function MainConverter() {
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
placeholder="0" 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 */} {/* Unit selectors + swap */}
<Select value={selectedUnit} onValueChange={setSelectedUnit}> <div className="flex items-center gap-2">
<SelectTrigger className="w-28 h-9 shrink-0 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono"> {/* From unit */}
<SelectValue /> <select
</SelectTrigger> value={selectedUnit}
<SelectContent> 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) => ( {units.map((unit) => (
<SelectItem key={unit} value={unit} className="font-mono text-xs"> <option key={unit} value={unit}>{unit}</option>
{unit}
</SelectItem>
))} ))}
</SelectContent> </select>
</Select>
{/* Swap */} {/* Swap */}
<button <button
onClick={handleSwapUnits} onClick={handleSwapUnits}
title="Swap units" 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" 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" /> <ArrowLeftRight className="w-3.5 h-3.5" />
</button> </button>
{/* To unit */} {/* To unit */}
<Select value={targetUnit} onValueChange={setTargetUnit}> <select
<SelectTrigger className="w-28 h-9 shrink-0 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono"> value={targetUnit}
<SelectValue /> onChange={(e) => setTargetUnit(e.target.value)}
</SelectTrigger> 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"
<SelectContent> >
{units.map((unit) => ( {units.map((unit) => (
<SelectItem key={unit} value={unit} className="font-mono text-xs"> <option key={unit} value={unit}>{unit}</option>
{unit}
</SelectItem>
))} ))}
</SelectContent> </select>
</Select> </div>
</div> </div>
{/* Result display */} {/* Result display */}
{resultValue !== null && ( {resultValue !== null && (
<div className="mt-3 px-3 py-2.5 rounded-lg bg-primary/5 border border-primary/15"> <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"> <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"> <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)} {formatNumber(resultValue)}

463
lib/cron/cron-engine.ts Normal file
View 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
View 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' },
),
);

View File

@@ -153,15 +153,6 @@ export const SUPPORTED_FORMATS: ConversionFormat[] = [
converter: 'imagemagick', converter: 'imagemagick',
description: 'Tagged Image File Format', 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
View 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);
}

View File

@@ -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 { export interface Tool {
/** Short display name (e.g. "Color") */ /** Short display name (e.g. "Color") */
@@ -97,14 +97,36 @@ export const tools: Tool[] = [
icon: AnimateIcon, icon: AnimateIcon,
badges: ['CSS', 'Tailwind v4', '20+ Presets'], 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', shortTitle: 'Calculate',
title: 'Calculator & Grapher', title: 'Calculator',
navTitle: 'Calculator', navTitle: 'Calculator',
href: '/calculate', href: '/calculate',
description: 'Advanced expression evaluator with interactive function graphing.', description: 'Advanced expression evaluator with function graphing.',
summary: 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, icon: CalculateIcon,
badges: ['Math.js', 'Graphing', 'Interactive'], badges: ['Math.js', 'Graphing', 'Interactive'],
}, },

View File

@@ -4,3 +4,4 @@ export * from './urlSharing';
export * from './animations'; export * from './animations';
export * from './format'; export * from './format';
export * from './time'; export * from './time';
export * from './styles';

15
lib/utils/styles.ts Normal file
View 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';

View File

@@ -25,7 +25,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"convert-units": "^2.3.4", "convert-units": "^2.3.4",
"figlet": "^1.10.0", "figlet": "^1.10.0",
"framer-motion": "^12.34.3",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"jszip": "^3.10.1", "jszip": "^3.10.1",

38
pnpm-lock.yaml generated
View File

@@ -41,9 +41,6 @@ importers:
figlet: figlet:
specifier: ^1.10.0 specifier: ^1.10.0
version: 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: fuse.js:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
@@ -2435,20 +2432,6 @@ packages:
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} 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: fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3127,12 +3110,6 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 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: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -6585,15 +6562,6 @@ snapshots:
fraction.js@5.3.4: {} 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: {} fresh@2.0.0: {}
fs-extra@11.3.3: fs-extra@11.3.3:
@@ -7222,12 +7190,6 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
motion-dom@12.34.3:
dependencies:
motion-utils: 12.29.2
motion-utils@12.29.2: {}
ms@2.1.3: {} ms@2.1.3: {}
msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3): msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3):