Compare commits

...

51 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
37874e3eea polish: shorten hero description copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 09:09:00 +01:00
9126589de3 refactor: align landing page and 404 with Calculate blueprint
- Hero: remove framer-motion, CSS stagger animations, glass pill CTA button, refined typography and scroll indicator
- Stats: remove framer-motion, Lucide icons, tighter glass cards with mono labels
- ToolsGrid: remove framer-motion, editorial section heading, 4-col xl grid
- ToolCard: replace framer-motion motion.Link with plain Link + CSS hover, compact layout (icon→title→desc→badges+arrow), ElementType icon prop
- Footer: remove framer-motion, matches sidebar footer style
- BackToTop: remove framer-motion, JS scroll progress bar (1px primary line), compact glass button
- not-found: remove framer-motion and shadcn Button, glass pill CTA, 120px mono 404, CSS stagger
- page.tsx: remove unnecessary 'use client' directive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 09:07:18 +01:00
413c677173 refactor: align layout chrome with glass blueprint
AppHeader:
- Remove shadcn Button → native 8×8 rounded glass icon buttons
- Shrink to h-14 (from h-16) to match sidebar header
- Add current tool name breadcrumb (navTitle) next to collapse toggle;
  shows context when sidebar is collapsed or on mobile

AppSidebar:
- Remove shadcn Button → native button for mobile close
- Sidebar narrows to w-60 (from w-64); matches h-14 header
- Active state: slim absolute left-bar (0.5px) replaces harsh border-l-2;
  bg-primary/10 tint kept; no border on the link itself
- Nav item text refined: 13px font-medium title + 9px mono description
- Border opacity drops to border-border/20 throughout (from border-border)
- Footer: smaller mono text, lighter icon opacity

AppPage:
- Shrink from py-8 / text-2xl to py-5 / text-lg font-semibold
- Icon wrapped in 7×7 glass pill (bg-primary/10) matching tool cards
- Description moved inline under title as 10px mono, truncated
- border-b border-border/20 separator between header and tool content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:58:33 +01:00
002fa037b7 refactor: merge keyframes/export/presets into one tabbed card
Right column now has two elements: preview canvas (shrink-0) and a
single glass card with Keyframes|Export|Presets tabs (flex-1).
Defaults to Keyframes tab. Removes the standalone timeline card and
the redundant embedded timeline in the mobile edit panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:52:55 +01:00
ea464ef797 refactor: align animate tool with Calculate/Media blueprint
Layout:
- AnimationEditor: lg:grid-cols-5 (2/5 edit, 3/5 visual); full viewport
  height; mobile Edit|Preview glass pill tabs; timeline embedded in edit
  panel on mobile, standalone on desktop; Export|Presets custom tab
  panel at the bottom of the right column

Components (all shadcn removed):
- AnimationSettings: Card/Label/Input/Select/Button → native inputs;
  direction & fill mode as 4-pill selectors; easing as native <select>;
  ∞ iterations as icon pill toggle
- AnimationPreview: Card/ToggleGroup/Button → glass card; speed pills
  as inline glass pill group; element picker as compact icon pills;
  playback controls as glass icon buttons; subtle grid bg on canvas
- KeyframeTimeline: Card/Button → glass card; embedded prop for
  rendering inside another card on mobile without double glass
- KeyframeProperties: Card/Label/Input/Button → bare content section;
  SliderRow uses native number input; bg color toggle as pill button
- ExportPanel: Card/Tabs/Button → bare section; CSS|Tailwind custom
  tab switcher; dark terminal (#06060e) code blocks
- PresetLibrary: Card/Tabs → bare section; category pills replace Tabs;
  preset cards use glass border-border/20 bg-primary/3 styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:48:35 +01:00
50cf5823f9 refactor: align QR code tool with Calculate/Media blueprint
- QRCodeGenerator: lg:grid-cols-5 layout (2/5 options, 3/5 preview);
  full viewport height; mobile Configure|Preview glass pill tabs
- QRInput: remove shadcn Textarea/Card; native <textarea> in glass panel
  section; character counter in monospace
- QROptions: remove shadcn Card/Label/Input/Button/Select; EC level as
  4 pill buttons with recovery % label; native color inputs + pickers;
  transparent toggle as small pill; keep shadcn Slider for margin
- QRPreview: remove shadcn Card/Button/Skeleton/ToggleGroup/Tooltip/Empty;
  glass card fills full height; PNG button with inline size pill group
  (256/512/1k/2k); empty state and pulse skeleton match other tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:37:39 +01:00
7da20c37c1 fix: move generate button into App Details card, stretch to full height
App Details card is now flex-1 min-h-0 so it fills the remaining left
column height, matching the right panel. Generate/Reset buttons are
pinned at the bottom of the card with a border-t divider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:33:28 +01:00
4927fb9a93 refactor: align favicon tool with Calculate/Media blueprint
- FaviconGenerator: lg:grid-cols-5 layout (2/5 setup, 3/5 results);
  glass panels, native inputs, custom tab switcher (Icons/HTML/Manifest),
  native progress bar, empty state placeholder, mobile Setup|Results tabs
- FaviconFileUpload: remove shadcn Button; match media FileUpload styling
  with file card, metadata chips (size, dimensions)
- CodeSnippet: remove shadcn Button; dark terminal (#06060e) with hover
  copy button, consistent with ASCII/ExportMenu code blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:30:56 +01:00
84 changed files with 3681 additions and 3624 deletions

View File

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

View File

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

View File

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

View File

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

19
app/(app)/cron/page.tsx Normal file
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() {
return (
<AppPage
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<AppPage>
<FaviconGenerator />
</AppPage>
);

View File

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

View File

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

16
app/(app)/random/page.tsx Normal file
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() {
return (
<AppPage
title={tool.title}
description={tool.summary}
icon={tool.icon}
>
<AppPage>
<MainConverter />
</AppPage>
);

View File

@@ -84,6 +84,27 @@
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
}
@keyframes logoStamp {
0% { opacity: 0; transform: scale(2) rotate(15deg); }
38% { opacity: 1; transform: scale(0.82) rotate(-5deg); }
58% { transform: scale(1.14) rotate(3deg); }
74% { transform: scale(0.94) rotate(-1deg); }
88% { transform: scale(1.04) rotate(0.3deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes pathFlicker {
0% { opacity: 0; }
28%, 30% { opacity: 0; }
31%, 33% { opacity: 1; }
34%, 40% { opacity: 0; }
41%, 44% { opacity: 1; }
45%, 49% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 1; }
}
:root {

View File

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

View File

@@ -1,73 +1,57 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import AnimatedBackground from '@/components/AnimatedBackground';
import Logo from '@/components/Logo';
import { Button } from '@/components/ui/button';
import { Home } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
export default function NotFound() {
return (
<main className="relative min-h-screen dark text-foreground flex flex-col">
<AnimatedBackground />
<div className="flex-1 flex flex-col items-center justify-center px-4 py-20 relative z-10">
<div className="max-w-6xl mx-auto text-center">
{/* Logo */}
<motion.div
className="mb-8 flex justify-center"
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Logo size={100} />
</motion.div>
{/* 404 heading */}
<motion.h1
className="text-7xl md:text-9xl font-bold mb-6 text-primary"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
<div className="flex-1 flex flex-col items-center justify-center px-6 py-20 relative z-10 text-center">
{/* Logo */}
<Logo size={52} />
{/* 404 */}
<div
className="mt-10"
style={{ animation: 'slideUp 0.5s ease-out 0.15s both' }}
>
<span className="text-[80px] md:text-[120px] font-bold font-mono leading-none tabular-nums block bg-gradient-to-b from-foreground to-foreground/25 bg-clip-text text-transparent">
404
</motion.h1>
</span>
</div>
{/* Subtitle */}
<motion.p
className="text-xl md:text-3xl font-medium mb-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
>
Page Not Found
</motion.p>
{/* Divider */}
<div
className="mt-6 w-12 h-px bg-gradient-to-r from-transparent via-primary/50 to-transparent"
style={{ animation: 'fadeIn 0.5s ease-out 0.3s both' }}
/>
{/* Description */}
<motion.p
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-md mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }}
>
The tool or page you are looking for doesn&apos;t exist or has been moved.
</motion.p>
{/* Message */}
<div
className="mt-6 space-y-2"
style={{ animation: 'slideUp 0.5s ease-out 0.35s both' }}
>
<p className="text-sm font-medium text-foreground/70">Page not found</p>
<p className="text-[11px] text-muted-foreground/45 font-mono max-w-xs mx-auto leading-relaxed">
The tool or page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
</div>
{/* CTA Button */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8 }}
{/* CTA */}
<div
className="mt-8"
style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
>
<Link
href="/"
className="inline-flex items-center gap-2 px-5 py-2.5 glass rounded-xl border border-white/[0.06] hover:border-primary/40 hover:bg-primary/[0.07] text-sm font-medium text-foreground/60 hover:text-foreground transition-all duration-200"
>
<Link href="/">
<Button size="lg" className="rounded-full px-8 h-14 text-lg font-semibold bg-gradient-to-r from-purple-500 to-cyan-500 hover:from-purple-600 hover:to-cyan-600 border-none transition-all duration-300">
<Home className="mr-2 h-5 w-5" />
Back to Home
</Button>
</Link>
</motion.div>
<ArrowLeft className="w-3.5 h-3.5 text-primary" />
Back to Home
</Link>
</div>
</div>
</main>

View File

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

View File

@@ -67,6 +67,31 @@ export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
</svg>
);
export const RandomIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="3" strokeWidth={2} />
<circle cx="8.5" cy="8.5" r="1.25" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="8.5" r="1.25" fill="currentColor" stroke="none" />
<circle cx="8.5" cy="15.5" r="1.25" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="15.5" r="1.25" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.25" fill="currentColor" stroke="none" />
</svg>
);
export const CronIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Clock face */}
<circle cx="12" cy="12" r="8.5" strokeWidth={2} />
{/* Center */}
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
{/* Clock hands */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 7.5V12l3 2" />
{/* Repeat arrow arcing around the top */}
<path strokeLinecap="round" strokeWidth={1.5} d="M18.5 6.5a10.5 10.5 0 0 0-7-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.5 6.5l2-2M18.5 6.5l-1.5 2.5" />
</svg>
);
export const CalculateIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Y-axis */}

View File

@@ -1,77 +0,0 @@
'use client';
import { motion, useScroll, useSpring } from 'framer-motion';
import { useState, useEffect } from 'react';
export default function BackToTop() {
const [isVisible, setIsVisible] = useState(false);
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
useEffect(() => {
const toggleVisibility = () => {
if (window.pageYOffset > 300) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
window.addEventListener('scroll', toggleVisibility);
return () => window.removeEventListener('scroll', toggleVisibility);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<>
{/* Progress bar */}
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-gradient-to-r from-purple-500 to-cyan-500 transform origin-left z-50"
style={{ scaleX }}
/>
{/* Back to top button */}
{isVisible && (
<motion.button
onClick={scrollToTop}
className="fixed bottom-8 right-8 p-4 rounded-full glass hover:bg-accent/50 text-purple-400 hover:text-purple-300 transition-colors shadow-lg z-40 group"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Back to top"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
{/* Tooltip */}
<span className="absolute bottom-full right-0 mb-2 px-3 py-1 text-xs text-white bg-gray-900 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
Back to top
</span>
</motion.button>
)}
</>
);
}

View File

@@ -1,47 +1,35 @@
'use client';
import { motion } from 'framer-motion';
import { GitFork, Heart } from 'lucide-react';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="relative py-12 px-4">
<div className="max-w-6xl mx-auto border-t border-border pt-12">
<motion.div
className="flex flex-col md:flex-row items-center justify-between gap-6"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
{/* Copyright */}
<p className="text-sm text-muted-foreground flex items-center gap-1">
© {currentYear} Kit.
<Heart className="h-4 w-4 text-primary shrink-0 animate-pulse" fill="currentColor" />
<a
href="https://pivoine.art"
target="_blank"
rel="noopener noreferrer"
title="Pivoine.Art"
className="font-medium underline underline-offset-4 decoration-primary/0 hover:decoration-primary transition-all duration-300"
>
Valknar
</a>
<footer className="relative py-10 px-6">
<div className="max-w-5xl mx-auto border-t border-white/[0.06] pt-8">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground/35 font-mono">
<span>© {currentYear} Kit</span>
<Heart className="w-2.5 h-2.5 text-primary/60 shrink-0 animate-pulse" fill="currentColor" />
<a
href="https://pivoine.art"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground/60 transition-colors duration-200"
>
Valknar
</a>
</p>
{/* Source link */}
<a
href="https://dev.pivoine.art/valknar/kit-ui"
target="_blank"
rel="noopener noreferrer"
title="View source"
className="text-muted-foreground hover:text-primary transition-colors duration-300"
className="flex items-center gap-1.5 text-xs text-muted-foreground/30 font-mono hover:text-primary transition-colors duration-200"
>
<GitFork className="h-5 w-5" />
<GitFork className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Source</span>
</a>
</motion.div>
</div>
</div>
</footer>
);

View File

@@ -1,108 +1,74 @@
'use client';
import { motion } from 'framer-motion';
import { Toolbox } from 'lucide-react';
import { ArrowDown } from 'lucide-react';
import Logo from './Logo';
export default function Hero() {
/**
* Smoothly scrolls the window to the tools section without modifying the URL hash.
*/
const scrollToTools = () => {
const toolsSection = document.getElementById('tools');
if (toolsSection) {
toolsSection.scrollIntoView({ behavior: 'smooth' });
}
document.getElementById('tools')?.scrollIntoView({ behavior: 'smooth' });
};
return (
<section className="relative min-h-screen flex flex-col items-center justify-center px-4 py-20">
<div className="max-w-6xl mx-auto text-center">
<section className="relative min-h-screen flex flex-col items-center justify-center px-6 py-24">
<div className="flex flex-col items-center text-center max-w-2xl mx-auto">
{/* Logo */}
<motion.div
className="mb-8 flex justify-center"
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Logo size={130} />
</motion.div>
<Logo size={72} />
{/* Main heading */}
<motion.h1
className="text-6xl md:text-8xl font-bold mb-6 text-primary"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
{/* Badge */}
<div
className="mt-8 flex items-center gap-2 px-3 py-1.5 glass rounded-full border border-white/[0.06]"
style={{ animation: 'slideUp 0.5s ease-out 0.2s both' }}
>
Kit
</motion.h1>
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
<span className="text-[10px] font-mono text-muted-foreground/55 tracking-widest uppercase">
Browser-first
</span>
</div>
{/* Subtitle */}
<motion.p
className="text-xl md:text-2xl text-muted-foreground mb-4 max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
{/* Title */}
<h1
className="mt-6 font-bold tracking-tight leading-none"
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
>
Your Creative Toolkit
</motion.p>
<span className="text-6xl md:text-8xl text-foreground">Kit</span>
<span className="text-6xl md:text-8xl text-primary">.</span>
</h1>
{/* Description */}
<motion.p
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }}
<p
className="mt-6 text-sm text-muted-foreground/55 max-w-xs leading-relaxed"
style={{ animation: 'slideUp 0.5s ease-out 0.4s both' }}
>
A curated collection of creative and utility tools for developers and creators.
Simple, powerful, and always at your fingertips.
</motion.p>
A curated collection of browser-based tools for developers and creators.
Everything runs locally no data leaves your machine.
</p>
{/* CTA Buttons */}
<motion.div
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-16"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8 }}
{/* CTA */}
<div
className="mt-8"
style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
>
<motion.button
<button
onClick={scrollToTools}
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl border border-primary/30 bg-primary/[0.07] hover:border-primary/55 hover:bg-primary/[0.13] text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200"
>
<span className="relative z-10 inline-flex items-center gap-2">
<Toolbox className="h-5 w-5" />
Explore Tools
</span>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-purple-600 to-cyan-600"
initial={{ x: '100%' }}
whileHover={{ x: 0 }}
transition={{ duration: 0.3 }}
/>
</motion.button>
</motion.div>
Explore Tools
<ArrowDown className="w-3.5 h-3.5 text-primary" />
</button>
</div>
{/* Scroll indicator */}
<motion.button
<button
onClick={scrollToTools}
className="mx-auto flex flex-col items-center gap-2 cursor-pointer group"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 1 }}
className="mt-24 flex flex-col items-center gap-2 group"
style={{ animation: 'fadeIn 0.5s ease-out 0.9s both' }}
>
<span className="text-base text-gray-500 group-hover:text-gray-400 transition-colors">Scroll to explore</span>
<motion.div
className="w-6 h-10 border-2 border-gray-600 group-hover:border-purple-400 rounded-full p-1 transition-colors"
animate={{ y: [0, 10, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
</motion.div>
</motion.button>
<div className="w-px h-8 bg-gradient-to-b from-transparent via-primary/30 to-primary/60 group-hover:via-primary/50 group-hover:to-primary transition-colors duration-300" />
<span className="text-[9px] font-mono text-muted-foreground/25 uppercase tracking-widest group-hover:text-muted-foreground/50 transition-colors">
Scroll
</span>
</button>
</div>
</section>
);

View File

@@ -1,28 +1,20 @@
'use client';
import { motion } from 'framer-motion';
export default function Logo({ className = '', size = 120 }: { className?: string; size?: number }) {
return (
<motion.svg
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
style={{ animation: 'logoStamp 0.65s cubic-bezier(0.22, 1, 0.36, 1) both' }}
>
{/* Wrench (Lucide) - vertical */}
<motion.g
<g
transform="translate(32, 32) rotate(0) scale(3.15) translate(-12.5, -11.5)"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 1.2, ease: 'easeInOut' }}
style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
>
<motion.path
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
stroke="url(#wrenchGradient)"
strokeWidth="1.5"
@@ -31,16 +23,14 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
fill="none"
vectorEffect="non-scaling-stroke"
/>
</motion.g>
</g>
{/* Brush (Lucide) - horizontal flipped */}
<motion.g
<g
transform="translate(32, 30) rotate(90) scale(3.025) translate(-11.25, -11)"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 1.2, delay: 0.3, ease: 'easeInOut' }}
style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
>
<motion.path
<path
d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"
stroke="url(#brushGradient)"
strokeWidth="1.5"
@@ -49,7 +39,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
fill="none"
vectorEffect="non-scaling-stroke"
/>
<motion.path
<path
d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
stroke="url(#brushGradient)"
strokeWidth="1.5"
@@ -58,7 +48,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
fill="none"
vectorEffect="non-scaling-stroke"
/>
</motion.g>
</g>
{/* Gradient definitions */}
<defs>
@@ -71,6 +61,6 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
<stop offset="100%" stopColor="#ec4899" />
</linearGradient>
</defs>
</motion.svg>
</svg>
);
}

View File

@@ -1,68 +1,39 @@
'use client';
import { tools } from '@/lib/tools';
import { motion } from 'framer-motion';
import { Box, Code2, Globe } from 'lucide-react';
const stats = [
{
number: tools.length,
label: 'Tools',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
number: '100%',
label: 'Open Source',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
},
{
number: '∞',
label: 'Privacy First',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
),
},
{ value: tools.length, label: 'Tools available', icon: Box },
{ value: '100%', label: 'Open source', icon: Code2 },
{ value: '100%', label: 'Browser-first', icon: Globe },
];
export default function Stats() {
return (
<section className="relative py-16 px-4">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{stats.map((stat, index) => (
<motion.div
key={stat.label}
className="glass rounded-2xl p-8 text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
>
<motion.div
className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-primary/10 text-primary"
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
<section className="relative py-4 px-6">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{stats.map((stat, i) => {
const Icon = stat.icon;
return (
<div
key={stat.label}
className="glass rounded-2xl p-5 flex items-center gap-4 border border-white/[0.06]"
style={{ animation: `slideUp 0.5s ease-out ${0.1 + i * 0.1}s both` }}
>
{stat.icon}
</motion.div>
<div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
{stat.number}
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/15 flex items-center justify-center shrink-0">
<Icon className="w-4.5 h-4.5 text-primary" />
</div>
<div>
<span className="text-2xl font-bold tabular-nums text-foreground block leading-none">
{stat.value}
</span>
<span className="text-[10px] font-mono text-muted-foreground/40 uppercase tracking-widest mt-1 block">
{stat.label}
</span>
</div>
</div>
<div className="text-muted-foreground text-base font-medium">
{stat.label}
</div>
</motion.div>
))}
);
})}
</div>
</div>
</section>

View File

@@ -1,91 +1,64 @@
'use client';
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
import Link from 'next/link';
const MotionLink = motion.create(Link);
import { ArrowRight } from 'lucide-react';
import { ElementType } from 'react';
interface ToolCardProps {
title: string;
description: string;
icon: ReactNode;
icon: ElementType;
url: string;
index: number;
badges?: string[];
}
export default function ToolCard({ title, description, icon, url, index, badges }: ToolCardProps) {
export default function ToolCard({ title, description, icon: Icon, url, index, badges }: ToolCardProps) {
return (
<MotionLink
<Link
href={url}
className="group relative block h-full"
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -10 }}
className="group relative glass rounded-2xl p-6 flex flex-col h-full transition-all duration-300 border border-white/[0.06] hover:border-primary/35 hover:shadow-[0_12px_48px_rgba(139,92,246,0.11)] overflow-hidden"
style={{ animation: `slideUp 0.5s ease-out ${0.05 * index}s both` }}
>
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl group-hover:bg-card/80">
{/* Subtle hover overlay */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-10 transition-opacity duration-300 bg-primary" />
{/* Top shimmer accent on hover */}
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-primary/70 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
{/* Icon */}
<motion.div
className="mb-6 flex justify-center"
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<div className="p-4 rounded-xl bg-primary/10 text-primary shadow-lg shadow-black/5">
{icon}
</div>
</motion.div>
{/* Radial glow on hover */}
<div className="absolute top-0 left-0 w-36 h-36 rounded-full bg-primary/[0.07] blur-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none -translate-x-6 -translate-y-6" />
{/* Title */}
<h3 className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary">
{title}
</h3>
{/* Icon */}
<div className="w-12 h-12 rounded-2xl bg-primary/10 border border-primary/15 flex items-center justify-center mb-5 shrink-0 transition-all duration-300 group-hover:bg-primary/20 group-hover:border-primary/30 group-hover:shadow-[0_0_24px_rgba(139,92,246,0.22)]">
<Icon className="w-5 h-5 text-primary" />
</div>
{/* Badges */}
{badges && badges.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{/* Title */}
<h3 className="text-base font-semibold text-foreground/80 group-hover:text-foreground transition-colors duration-200 mb-2 leading-snug">
{title}
</h3>
{/* Description */}
<p className="text-[13px] text-muted-foreground/50 leading-relaxed flex-1 mb-5">
{description}
</p>
{/* Footer: badges + arrow */}
<div className="flex items-end justify-between gap-2">
{badges && badges.length > 0 ? (
<div className="flex flex-wrap gap-1">
{badges.map((badge) => (
<span
key={badge}
className="text-xs px-2 py-1 rounded-full bg-primary/5 border border-primary/10 text-muted-foreground font-medium"
className="text-[9px] font-mono px-1.5 py-0.5 rounded-md bg-primary/[0.07] border border-primary/20 text-primary/55 transition-colors duration-200 group-hover:border-primary/35 group-hover:text-primary/75"
>
{badge}
</span>
))}
</div>
) : (
<span />
)}
{/* Description */}
<p className="text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300">
{description}
</p>
{/* Arrow icon */}
<motion.div
className="absolute bottom-8 right-8 text-muted-foreground group-hover:text-primary transition-colors duration-300"
initial={{ x: 0 }}
whileHover={{ x: 5 }}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</motion.div>
<div className="w-7 h-7 rounded-xl glass border border-white/[0.06] flex items-center justify-center shrink-0 transition-all duration-200 group-hover:border-primary/30 group-hover:bg-primary/10">
<ArrowRight className="w-3.5 h-3.5 text-muted-foreground/30 group-hover:text-primary group-hover:translate-x-0.5 transition-all duration-200" />
</div>
</div>
</MotionLink>
</Link>
);
}

View File

@@ -1,45 +1,40 @@
'use client';
import { motion } from 'framer-motion';
import ToolCard from './ToolCard';
import { tools } from '@/lib/tools';
export default function ToolsGrid() {
return (
<section id="tools" className="relative py-20 px-4">
<div className="max-w-6xl mx-auto">
<section id="tools" className="relative py-16 px-6">
<div className="max-w-5xl mx-auto">
{/* Section heading */}
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
<div
className="mb-10"
style={{ animation: 'fadeIn 0.5s ease-out both' }}
>
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
Available Tools
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
Available{' '}
<span className="bg-gradient-to-r from-primary via-violet-400 to-pink-400 bg-clip-text text-transparent">
Tools
</span>
</h2>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Explore our collection of carefully crafted tools designed to boost your productivity and creativity
<p className="text-sm text-muted-foreground/40 mt-2">
{tools.length} tools &mdash; everything runs in your browser, no data leaves your machine
</p>
</motion.div>
</div>
{/* Tools grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{tools.map((tool, index) => {
const Icon = tool.icon;
return (
<ToolCard
key={tool.href}
title={tool.title}
description={tool.summary}
icon={<Icon className="w-12 h-12" />}
url={tool.href}
badges={tool.badges}
index={index}
/>
);
})}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{tools.map((tool, index) => (
<ToolCard
key={tool.href}
title={tool.title}
description={tool.summary}
icon={tool.icon}
url={tool.href}
badges={tool.badges}
index={index}
/>
))}
</div>
</div>
</section>

View File

@@ -8,14 +8,21 @@ import { KeyframeProperties } from './KeyframeProperties';
import { PresetLibrary } from './PresetLibrary';
import { ExportPanel } from './ExportPanel';
import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults';
import { cn } from '@/lib/utils/cn';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate';
type MobileTab = 'edit' | 'preview';
type RightTab = 'keyframes' | 'export' | 'presets';
export function AnimationEditor() {
const [config, setConfig] = useState<AnimationConfig>(DEFAULT_CONFIG);
const [selectedId, setSelectedId] = useState<string | null>(
DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id
);
const [previewElement, setPreviewElement] = useState<PreviewElement>('box');
const [mobileTab, setMobileTab] = useState<MobileTab>('edit');
const [rightTab, setRightTab] = useState<RightTab>('export');
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
@@ -35,8 +42,7 @@ export function AnimationEditor() {
const deleteKeyframe = useCallback((id: string) => {
setConfig((c) => {
if (c.keyframes.length <= 2) return c;
const next = c.keyframes.filter((k) => k.id !== id);
return { ...c, keyframes: next };
return { ...c, keyframes: c.keyframes.filter((k) => k.id !== id) };
});
setSelectedId((prev) => {
if (prev !== id) return prev;
@@ -58,47 +64,77 @@ export function AnimationEditor() {
setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id);
}, []);
return (
<div className="space-y-6">
{/* Row 1: Settings + Preview */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
<div className="lg:col-span-1">
<AnimationSettings config={config} onChange={setConfig} />
</div>
<div className="lg:col-span-2">
<AnimationPreview
config={config}
element={previewElement}
onElementChange={setPreviewElement}
/>
</div>
</div>
const timelineProps = {
keyframes: config.keyframes,
selectedId,
onSelect: setSelectedId,
onAdd: addKeyframe,
onDelete: deleteKeyframe,
onMove: moveKeyframe,
};
{/* Row 2: Keyframe Timeline */}
<KeyframeTimeline
keyframes={config.keyframes}
selectedId={selectedId}
onSelect={setSelectedId}
onAdd={addKeyframe}
onDelete={deleteKeyframe}
onMove={moveKeyframe}
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'edit', label: 'Edit' }, { value: 'preview', label: 'Preview' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* Row 3: Keyframe Properties + Export */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
<div className="lg:col-span-1">
<KeyframeProperties
keyframe={selectedKeyframe}
onChange={updateKeyframeProps}
/>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: Settings + Properties */}
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'edit' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
<AnimationSettings config={config} onChange={setConfig} />
<div className="border-t border-border/25" />
<KeyframeTimeline {...timelineProps} embedded />
<KeyframeProperties keyframe={selectedKeyframe} onChange={updateKeyframeProps} />
</div>
</div>
</div>
<div className="lg:col-span-2">
<ExportPanel config={config} />
{/* Right: Preview + tabbed panel */}
<div className={cn('lg:col-span-3 flex flex-col gap-3 overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
{/* Preview canvas */}
<AnimationPreview config={config} element={previewElement} onElementChange={setPreviewElement} />
{/* Keyframes / Export / Presets tab panel */}
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Tab switcher */}
<div className="flex glass rounded-lg p-0.5 gap-0.5 mb-4 shrink-0">
{(['export', 'presets'] as RightTab[]).map((t) => (
<button
key={t}
onClick={() => setRightTab(t)}
className={cn(
'flex-1 py-1.5 rounded-md text-xs font-medium capitalize transition-all',
rightTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'export' ? 'Export' : 'Presets'}
</button>
))}
</div>
{/* Content */}
{rightTab === 'export' && <ExportPanel config={config} />}
{rightTab === 'presets' && <PresetLibrary onSelect={loadPreset} />}
</div>
</div>
</div>
{/* Row 4: Preset Library */}
<PresetLibrary onSelect={loadPreset} />
</div>
);
}

View File

@@ -1,10 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { cn, iconBtn } from '@/lib/utils';
import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate';
@@ -23,13 +21,26 @@ const SPEEDS: { label: string; value: string }[] = [
{ label: '2×', value: '2' },
];
const ELEMENTS: { value: PreviewElement; icon: React.ReactNode; title: string }[] = [
{ value: 'box', icon: <Square className="w-3 h-3" />, title: 'Box' },
{ value: 'circle', icon: <Circle className="w-3 h-3" />, title: 'Circle' },
{ value: 'text', icon: <Type className="w-3 h-3" />, title: 'Text' },
];
const previewBtn = cn(iconBtn, 'w-7 h-7');
const pillCls = (active: boolean) =>
cn(
'px-2 py-0.5 rounded text-[10px] font-mono transition-all',
active ? 'text-primary bg-primary/10' : 'text-muted-foreground/50 hover:text-muted-foreground'
);
export function AnimationPreview({ config, element, onElementChange }: Props) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const [restartKey, setRestartKey] = useState(0);
const [animState, setAnimState] = useState<AnimState>('playing');
const [speed, setSpeed] = useState('1');
// Inject @keyframes CSS into document head
useEffect(() => {
if (!styleRef.current) {
styleRef.current = document.createElement('style');
@@ -37,125 +48,113 @@ export function AnimationPreview({ config, element, onElementChange }: Props) {
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildCSS(config);
// Restart preview whenever config changes so changes are immediately visible
setAnimState('playing');
setRestartKey((k) => k + 1);
}, [config]);
// Cleanup on unmount
useEffect(() => {
return () => { styleRef.current?.remove(); };
}, []);
const restart = () => {
setAnimState('playing');
setRestartKey((k) => k + 1);
};
const handlePlay = () => {
if (animState === 'ended') {
// Animation finished — restart it
restart();
} else {
setAnimState('playing');
}
};
const restart = () => { setAnimState('playing'); setRestartKey((k) => k + 1); };
const scaledDuration = Math.round(config.duration / Number(speed));
const isInfinite = config.iterationCount === 'infinite';
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Preview</CardTitle>
<ToggleGroup type="single" value={speed} onValueChange={(v) => v && setSpeed(v)} variant="outline" size="sm">
<div className="glass rounded-xl p-4 shrink-0 flex flex-col gap-3">
{/* Header: speed pills */}
<div className="flex items-center justify-between shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Preview</span>
<div className="flex items-center glass rounded-md border border-border/30 px-1 gap-0.5">
{SPEEDS.map((s) => (
<ToggleGroupItem key={s.value} value={s.value} className="h-6 px-1.5 min-w-0 text-[10px]">
<button key={s.value} onClick={() => setSpeed(s.value)} className={pillCls(speed === s.value)}>
{s.label}
</ToggleGroupItem>
</button>
))}
</ToggleGroup>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4">
{/* Preview canvas */}
<div className="flex-1 min-h-52 flex items-center justify-center rounded-xl bg-gradient-to-br from-muted/20 to-muted/5 border border-border relative overflow-hidden">
{/* Grid overlay */}
<div
className="absolute inset-0 opacity-5 pointer-events-none"
style={{
backgroundImage: 'linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)',
backgroundSize: '32px 32px',
}}
/>
</div>
</div>
{/* Animated element */}
<div
key={restartKey}
className="animated relative z-10"
style={{
animationDuration: `${scaledDuration}ms`,
animationPlayState: animState === 'paused' ? 'paused' : 'running',
}}
onAnimationEnd={() => !isInfinite && setAnimState('ended')}
{/* Canvas */}
<div
className="h-44 rounded-xl flex items-center justify-center relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
backgroundImage: [
'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
'linear-gradient(var(--border) 1px, transparent 1px)',
'linear-gradient(90deg, var(--border) 1px, transparent 1px)',
].join(', '),
backgroundSize: 'auto, 32px 32px, 32px 32px',
}}
>
<div
key={restartKey}
className="animated relative z-10"
style={{
animationDuration: `${scaledDuration}ms`,
animationPlayState: animState === 'paused' ? 'paused' : 'running',
}}
onAnimationEnd={() => !isInfinite && setAnimState('ended')}
>
{element === 'box' && (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
)}
{element === 'circle' && (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
)}
{element === 'text' && (
<span className="text-3xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
Hello
</span>
)}
</div>
</div>
{/* Controls: element selector + playback */}
<div className="flex items-center justify-between shrink-0">
{/* Element picker */}
<div className="flex items-center glass rounded-md border border-border/30 p-0.5 gap-0.5">
{ELEMENTS.map(({ value, icon, title }) => (
<button
key={value}
onClick={() => onElementChange(value)}
title={title}
className={cn(
'w-7 h-7 flex items-center justify-center rounded transition-all',
element === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{icon}
</button>
))}
</div>
{/* Playback */}
<div className="flex items-center gap-1">
<button
onClick={() => animState === 'ended' ? restart() : setAnimState('playing')}
disabled={animState === 'playing'}
title={animState === 'ended' ? 'Replay' : 'Play'}
className={previewBtn}
>
{element === 'box' && (
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
)}
{element === 'circle' && (
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
)}
{element === 'text' && (
<span className="text-4xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
Hello
</span>
)}
</div>
<Play className="w-3 h-3" />
</button>
<button
onClick={() => setAnimState('paused')}
disabled={animState !== 'playing'}
title="Pause"
className={previewBtn}
>
<Pause className="w-3 h-3" />
</button>
<button onClick={restart} title="Restart" className={previewBtn}>
<RotateCcw className="w-3 h-3" />
</button>
</div>
{/* Controls */}
<div className="flex items-center justify-between gap-3">
<ToggleGroup type="single" value={element} onValueChange={(v) => v && onElementChange(v as PreviewElement)} variant="outline" size="sm">
<ToggleGroupItem value="box" className="h-6 px-1.5 min-w-0" title="Box">
<Square className="h-3 w-3" />
</ToggleGroupItem>
<ToggleGroupItem value="circle" className="h-6 px-1.5 min-w-0" title="Circle">
<Circle className="h-3 w-3" />
</ToggleGroupItem>
<ToggleGroupItem value="text" className="h-6 px-1.5 min-w-0" title="Text">
<Type className="h-3 w-3" />
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center gap-1.5">
<Button
size="icon-xs"
variant="outline"
onClick={handlePlay}
disabled={animState === 'playing'}
title={animState === 'ended' ? 'Replay' : 'Play'}
>
<Play className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
onClick={() => setAnimState('paused')}
disabled={animState !== 'playing'}
title="Pause"
>
<Pause className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
onClick={restart}
title="Restart"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,17 +1,7 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Infinity } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { AnimationConfig } from '@/types/animate';
interface Props {
@@ -30,14 +20,38 @@ const EASINGS = [
{ value: 'steps(8, end)', label: 'Steps (8)' },
];
const DIRECTIONS: { value: AnimationConfig['direction']; label: string }[] = [
{ value: 'normal', label: 'Normal' },
{ value: 'reverse', label: 'Reverse' },
{ value: 'alternate', label: 'Alt' },
{ value: 'alternate-reverse', label: 'Alt-Rev' },
];
const FILL_MODES: { value: AnimationConfig['fillMode']; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'forwards', label: 'Fwd' },
{ value: 'backwards', label: 'Bwd' },
{ value: 'both', label: 'Both' },
];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
const pillCls = (active: boolean) =>
cn(
'flex-1 py-1.5 rounded-lg border text-[10px] font-mono transition-all',
active
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
);
export function AnimationSettings({ config, onChange }: Props) {
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
onChange({ ...config, [key]: value });
const isInfinite = config.iterationCount === 'infinite';
const isCubic = config.easing === 'cubic-bezier';
const isCubic = config.easing.startsWith('cubic-bezier');
// Parse cubic-bezier values from string like "cubic-bezier(x1,y1,x2,y2)"
const cubicValues = (() => {
const m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
if (!m) return [0.25, 0.1, 0.25, 1.0];
@@ -50,167 +64,153 @@ export function AnimationSettings({ config, onChange }: Props) {
set('easing', `cubic-bezier(${v.join(',')})`);
};
const easingSelectValue = isCubic ? 'cubic-bezier' : config.easing;
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={config.name}
onChange={(e) => {
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
set('name', val || 'myAnimation');
}}
className="font-mono text-xs"
<div className="space-y-4">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
Settings
</span>
{/* Name */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Name</label>
<input
type="text"
value={config.name}
onChange={(e) => {
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
set('name', val || 'myAnimation');
}}
className={inputCls}
/>
</div>
{/* Duration + Delay */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Duration (ms)</label>
<input
type="number"
min={50}
max={10000}
step={50}
value={config.duration}
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
className={inputCls}
/>
</div>
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Delay (ms)</label>
<input
type="number"
min={0}
max={5000}
step={50}
value={config.delay}
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
className={inputCls}
/>
</div>
</div>
{/* Duration + Delay */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Duration</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={50}
max={10000}
step={50}
value={config.duration}
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
/>
<span className="text-xs text-muted-foreground shrink-0">ms</span>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Delay</Label>
<div className="flex items-center gap-1">
<Input
type="number"
min={0}
max={5000}
step={50}
value={config.delay}
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
/>
<span className="text-xs text-muted-foreground shrink-0">ms</span>
</div>
{/* Easing */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Easing</label>
<select
value={easingSelectValue}
onChange={(e) => {
const v = e.target.value;
set('easing', v === 'cubic-bezier' ? 'cubic-bezier(0.25,0.1,0.25,1)' : v);
}}
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{EASINGS.map((e) => (
<option key={e.value} value={e.value}>
{e.label}
</option>
))}
</select>
</div>
{/* Cubic-bezier inputs */}
{isCubic && (
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">
cubic-bezier(P1x, P1y, P2x, P2y)
</label>
<div className="grid grid-cols-4 gap-1.5">
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
<div key={label}>
<label className="text-[9px] text-muted-foreground/40 font-mono block mb-1">{label}</label>
<input
type="number"
min={i % 2 === 0 ? 0 : -1}
max={i % 2 === 0 ? 1 : 2}
step={0.01}
value={cubicValues[i] ?? 0}
onChange={(e) => setCubic(i, Number(e.target.value))}
className="w-full bg-transparent border border-border/40 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 text-center"
/>
</div>
))}
</div>
</div>
)}
{/* Easing */}
<div className="space-y-1.5">
<Label className="text-xs">Easing</Label>
<Select
value={isCubic ? 'cubic-bezier' : config.easing}
onValueChange={(v) => {
if (v === 'cubic-bezier') {
set('easing', 'cubic-bezier(0.25,0.1,0.25,1)');
} else {
set('easing', v);
}
}}
{/* Iterations */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Iterations</label>
<div className="flex gap-1.5">
<input
type="number"
min={1}
max={999}
value={isInfinite ? '' : (config.iterationCount as number)}
disabled={isInfinite}
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
placeholder="1"
className={cn(inputCls, 'flex-1', isInfinite && 'opacity-30')}
/>
<button
onClick={() => set('iterationCount', isInfinite ? 1 : 'infinite')}
title="Toggle infinite"
className={cn(
'w-9 h-9 flex items-center justify-center rounded-lg border text-xs transition-all shrink-0',
isInfinite
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/40 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EASINGS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Infinity className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Cubic-bezier inputs */}
{isCubic && (
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">cubic-bezier(P1x, P1y, P2x, P2y)</Label>
<div className="grid grid-cols-4 gap-1.5">
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
<div key={label} className="space-y-0.5">
<Label className="text-[10px] text-muted-foreground">{label}</Label>
<Input
type="number"
min={i % 2 === 0 ? 0 : -1}
max={i % 2 === 0 ? 1 : 2}
step={0.01}
value={cubicValues[i] ?? 0}
onChange={(e) => setCubic(i, Number(e.target.value))}
className="text-xs px-1.5"
/>
</div>
))}
</div>
</div>
)}
{/* Iteration */}
<div className="space-y-1.5">
<Label className="text-xs">Iterations</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={999}
value={isInfinite ? '' : config.iterationCount}
disabled={isInfinite}
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
className="text-xs flex-1"
placeholder="1"
/>
<Button
size="icon-xs"
variant={isInfinite ? 'default' : 'outline'}
onClick={() =>
set('iterationCount', isInfinite ? 1 : 'infinite')
}
title="Toggle infinite"
>
<Infinity className="h-3 w-3" />
</Button>
</div>
{/* Direction */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Direction</label>
<div className="flex gap-1">
{DIRECTIONS.map(({ value, label }) => (
<button key={value} onClick={() => set('direction', value)} className={pillCls(config.direction === value)}>
{label}
</button>
))}
</div>
</div>
{/* Direction */}
<div className="space-y-1.5">
<Label className="text-xs">Direction</Label>
<Select value={config.direction} onValueChange={(v) => set('direction', v as AnimationConfig['direction'])}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="reverse">Reverse</SelectItem>
<SelectItem value="alternate">Alternate</SelectItem>
<SelectItem value="alternate-reverse">Alternate Reverse</SelectItem>
</SelectContent>
</Select>
{/* Fill Mode */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Fill Mode</label>
<div className="flex gap-1">
{FILL_MODES.map(({ value, label }) => (
<button key={value} onClick={() => set('fillMode', value)} className={pillCls(config.fillMode === value)}>
{label}
</button>
))}
</div>
{/* Fill Mode */}
<div className="space-y-1.5">
<Label className="text-xs">Fill Mode</Label>
<Select value={config.fillMode} onValueChange={(v) => set('fillMode', v as AnimationConfig['fillMode'])}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="forwards">Forwards</SelectItem>
<SelectItem value="backwards">Backwards</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,79 +1,43 @@
'use client';
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Copy, Download } from 'lucide-react';
import { toast } from 'sonner';
import { useMemo, useState } from 'react';
import { cn } from '@/lib/utils/cn';
import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
import { CodeSnippet } from '@/components/ui/code-snippet';
import type { AnimationConfig } from '@/types/animate';
interface Props {
config: AnimationConfig;
}
function CodeBlock({ code, filename }: { code: string; filename: string }) {
const copy = () => {
navigator.clipboard.writeText(code);
toast.success('Copied to clipboard!');
};
const download = () => {
const blob = new Blob([code], { type: 'text/css' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
toast.success(`Downloaded ${filename}`);
};
return (
<div className="space-y-3">
<div className="relative">
<pre className="p-4 rounded-xl bg-muted/30 border border-border text-xs font-mono leading-relaxed overflow-auto max-h-72 text-foreground/90 whitespace-pre scrollbar">
<code>{code}</code>
</pre>
</div>
<div className="flex flex-col md:flex-row gap-3">
<Button variant="outline" onClick={copy} className="w-full md:flex-1">
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</Button>
<Button onClick={download} className="w-full md:flex-1">
<Download className="h-3.5 w-3.5 mr-1.5" />
Download .css
</Button>
</div>
</div>
);
}
type ExportTab = 'css' | 'tailwind';
export function ExportPanel({ config }: Props) {
const [tab, setTab] = useState<ExportTab>('css');
const css = useMemo(() => buildCSS(config), [config]);
const tailwind = useMemo(() => buildTailwindCSS(config), [config]);
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Export</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="css">
<TabsList className="mb-4">
<TabsTrigger value="css" className="text-xs">Plain CSS</TabsTrigger>
<TabsTrigger value="tailwind" className="text-xs">Tailwind v4</TabsTrigger>
</TabsList>
<TabsContent value="css">
<CodeBlock code={css} filename={`${config.name}.css`} />
</TabsContent>
<TabsContent value="tailwind">
<CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
<div className="space-y-3 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span>
<div className="flex glass rounded-lg p-0.5 gap-0.5">
{(['css', 'tailwind'] as ExportTab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'px-2.5 py-1 rounded-md text-[10px] font-mono transition-all',
tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'css' ? 'Plain CSS' : 'Tailwind v4'}
</button>
))}
</div>
</div>
{tab === 'css' && <CodeSnippet code={css} />}
{tab === 'tailwind' && <CodeSnippet code={tailwind} />}
</div>
);
}

View File

@@ -1,10 +1,7 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { ColorInput } from '@/components/ui/color-input';
import { MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
@@ -28,26 +25,20 @@ interface SliderRowProps {
function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) {
return (
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
{label}{unit && <span className="text-muted-foreground/50"> ({unit})</span>}
</Label>
<Slider
min={min}
max={max}
step={step}
value={[value]}
onValueChange={([v]) => onChange(v)}
/>
<div className="space-y-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">
{label}{unit && <span className="opacity-50"> ({unit})</span>}
</label>
<Slider min={min} max={max} step={step} value={[value]} onValueChange={([v]) => onChange(v)} />
</div>
<Input
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-16 text-xs px-1.5 h-7 mt-4"
className="w-14 bg-transparent border border-border/40 rounded-md px-1.5 py-1 text-[10px] font-mono text-center outline-none focus:border-primary/50 transition-colors text-foreground/80 mt-4"
/>
</div>
);
@@ -56,15 +47,12 @@ function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderR
export function KeyframeProperties({ keyframe, onChange }: Props) {
if (!keyframe) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Properties</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<MousePointerClick className="h-8 w-8 mx-auto mb-3 opacity-20" />
<p className="text-xs text-muted-foreground">Select a keyframe on the timeline to edit its properties</p>
</CardContent>
</Card>
<div className="flex flex-col items-center justify-center py-12 text-center gap-3">
<MousePointerClick className="w-7 h-7 text-muted-foreground/20" />
<p className="text-[10px] text-muted-foreground/40 font-mono leading-relaxed max-w-[180px]">
Select a keyframe on the timeline to edit its properties
</p>
</div>
);
}
@@ -72,10 +60,7 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform };
const setTransform = (key: keyof TransformValue, value: number) => {
onChange(keyframe.id, {
...props,
transform: { ...t, [key]: value },
});
onChange(keyframe.id, { ...props, transform: { ...t, [key]: value } });
};
const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => {
@@ -85,96 +70,65 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
const hasBg = props.backgroundColor && props.backgroundColor !== 'none';
return (
<Card className="h-full overflow-auto">
<CardHeader>
<CardTitle>
<div className="space-y-5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Properties
<span className="text-muted-foreground font-normal text-sm ml-2">{keyframe.offset}%</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
</span>
<span className="text-[9px] text-primary/60 font-mono bg-primary/10 px-1.5 py-0.5 rounded">
{keyframe.offset}%
</span>
</div>
{/* Transform */}
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Transform</p>
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
</div>
{/* Transform */}
<div className="space-y-3">
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Transform</p>
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
</div>
{/* Visual */}
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Visual</p>
{/* Visual */}
<div className="space-y-3">
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Visual</p>
<SliderRow label="Opacity" value={props.opacity ?? 1} min={0} max={1} step={0.01} onChange={(v) => setProp('opacity', v)} />
<SliderRow
label="Opacity"
value={props.opacity ?? 1}
min={0} max={1} step={0.01}
onChange={(v) => setProp('opacity', v)}
/>
{/* Background color */}
<div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground">Background Color</Label>
<div className="flex items-center gap-2">
<Input
type="color"
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
onChange={(e) => setProp('backgroundColor', e.target.value)}
disabled={!hasBg}
className={cn('w-9 h-9 p-1 shrink-0 cursor-pointer', !hasBg && 'opacity-30')}
/>
<Input
type="text"
value={hasBg ? props.backgroundColor! : ''}
onChange={(e) => setProp('backgroundColor', e.target.value)}
disabled={!hasBg}
placeholder="none"
className="font-mono text-xs flex-1"
/>
<Button
size="xs"
variant={hasBg ? 'default' : 'outline'}
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
className="shrink-0"
>
{hasBg ? 'On' : 'Off'}
</Button>
</div>
{/* Background color */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">Background Color</label>
<button
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
hasBg
? 'border-primary/40 text-primary bg-primary/10'
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
{hasBg ? 'On' : 'Off'}
</button>
</div>
<SliderRow
label="Border Radius"
unit="px"
value={props.borderRadius ?? 0}
min={0} max={200}
onChange={(v) => setProp('borderRadius', v)}
<ColorInput
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
onChange={(v) => setProp('backgroundColor', v)}
disabled={!hasBg}
/>
</div>
{/* Filters */}
<div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Filter</p>
<SliderRow
label="Blur"
unit="px"
value={props.blur ?? 0}
min={0} max={50}
onChange={(v) => setProp('blur', v)}
/>
<SliderRow
label="Brightness"
value={props.brightness ?? 1}
min={0} max={3} step={0.01}
onChange={(v) => setProp('brightness', v)}
/>
</div>
<SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} />
</div>
</CardContent>
</Card>
{/* Filters */}
<div className="space-y-3">
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Filter</p>
<SliderRow label="Blur" unit="px" value={props.blur ?? 0} min={0} max={50} onChange={(v) => setProp('blur', v)} />
<SliderRow label="Brightness" value={props.brightness ?? 1} min={0} max={3} step={0.01} onChange={(v) => setProp('brightness', v)} />
</div>
</div>
);
}

View File

@@ -1,10 +1,8 @@
'use client';
import { useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { cn, iconBtn } from '@/lib/utils';
import type { Keyframe } from '@/types/animate';
interface Props {
@@ -14,11 +12,14 @@ interface Props {
onAdd: (offset: number) => void;
onDelete: (id: string) => void;
onMove: (id: string, newOffset: number) => void;
embedded?: boolean; // when true, no glass card wrapper (use inside another card)
}
const TICKS = [0, 25, 50, 75, 100];
const TICKS = [25, 50, 75];
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove }: Props) {
const timelineBtn = cn(iconBtn, 'w-6 h-6');
export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDelete, onMove, embedded = false }: Props) {
const trackRef = useRef<HTMLDivElement>(null);
const getOffsetFromEvent = (clientX: number): number => {
@@ -29,7 +30,6 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
};
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Ignore clicks that land directly on a keyframe marker
if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return;
onAdd(getOffsetFromEvent(e.clientX));
};
@@ -39,16 +39,11 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
onSelect(id);
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
const handleMove = (me: PointerEvent) => {
onMove(id, getOffsetFromEvent(me.clientX));
};
const handleMove = (me: PointerEvent) => onMove(id, getOffsetFromEvent(me.clientX));
const handleUp = () => {
el.removeEventListener('pointermove', handleMove);
el.removeEventListener('pointerup', handleUp);
};
el.addEventListener('pointermove', handleMove);
el.addEventListener('pointerup', handleUp);
};
@@ -56,91 +51,91 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
const sorted = [...keyframes].sort((a, b) => a.offset - b.offset);
const selectedKf = keyframes.find((k) => k.id === selectedId);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Keyframes</CardTitle>
const content = (
<div className="space-y-2">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''}
{selectedKf ? ` · selected: ${selectedKf.offset}%` : ''}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Keyframes
</span>
<Button
size="icon-xs"
variant="outline"
onClick={() => onAdd(50)}
title="Add keyframe at 50%"
>
<Plus className="h-3 w-3" />
</Button>
<Button
size="icon-xs"
variant="outline"
disabled={!selectedId || keyframes.length <= 2}
<span className="text-[9px] text-muted-foreground/40 font-mono">
{keyframes.length} kf{selectedKf ? ` · ${selectedKf.offset}%` : ''}
</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => onAdd(50)} title="Add at 50%" className={timelineBtn}>
<Plus className="w-3 h-3" />
</button>
<button
onClick={() => selectedId && onDelete(selectedId)}
title="Delete selected keyframe"
disabled={!selectedId || keyframes.length <= 2}
title="Delete selected"
className={timelineBtn}
>
<Trash2 className="h-3 w-3" />
</Button>
<Trash2 className="w-3 h-3" />
</button>
</div>
</CardHeader>
<CardContent>
{/* Track */}
<div
ref={trackRef}
className="relative h-16 bg-muted/30 rounded-lg border border-border cursor-crosshair select-none"
onClick={handleTrackClick}
>
{/* Center line */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border" />
</div>
{/* Tick marks */}
{TICKS.map((tick) => (
<div
key={tick}
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none"
style={{ left: `${tick}%` }}
>
<div className="w-px h-2 bg-muted-foreground/30 mt-0" />
<span className="text-[9px] text-muted-foreground/50 mt-auto mb-1">{tick}%</span>
</div>
))}
{/* Track */}
<div
ref={trackRef}
className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none mx-4"
onClick={handleTrackClick}
>
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
{TICKS.map((tick) => (
<div
key={tick}
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none -ml-1.5"
style={{ left: `${tick}%` }}
>
<div className="w-px h-2 bg-muted-foreground/20" />
<span className="text-[8px] text-muted-foreground/30 mt-auto mb-1 font-mono">{tick}%</span>
</div>
))}
{sorted.map((kf) => (
<button
key={kf.id}
data-keyframe-marker
className={cn(
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rotate-45 rounded-sm transition-all duration-150 touch-none',
kf.id === selectedId
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
: 'bg-muted-foreground/40 hover:bg-primary/70'
)}
style={{ left: `${kf.offset}%` }}
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
onPointerDown={(e) => handlePointerDown(e, kf.id)}
title={`${kf.offset}% — drag to move`}
/>
))}
</div>
{/* Keyframe markers */}
{sorted.map((kf) => (
<button
key={kf.id}
data-keyframe-marker
className={cn(
'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 rounded-sm transition-all duration-150 touch-none',
kf.id === selectedId
? 'bg-primary shadow-lg shadow-primary/40 scale-125'
: 'bg-muted-foreground/60 hover:bg-primary/70'
)}
style={{ left: `${kf.offset}%` }}
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }}
onPointerDown={(e) => handlePointerDown(e, kf.id)}
title={`${kf.offset}% — drag to move`}
/>
))}
</div>
{/* Offset labels */}
<div className="relative h-4 mx-4">
{sorted.map((kf) => (
<span
key={kf.id}
className={cn(
'absolute -translate-x-1/2 text-[9px] font-mono transition-colors',
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground/40'
)}
style={{ left: `${kf.offset}%` }}
>
{kf.offset}%
</span>
))}
</div>
</div>
);
{/* Offset labels below */}
<div className="relative h-5 mt-1">
{sorted.map((kf) => (
<span
key={kf.id}
className={cn(
'absolute -translate-x-1/2 text-[10px] transition-colors',
kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground'
)}
style={{ left: `${kf.offset}%` }}
>
{kf.offset}%
</span>
))}
</div>
</CardContent>
</Card>
if (embedded) return <div>{content}</div>;
return (
<div className="glass rounded-xl px-4 pt-4 pb-3 shrink-0">
{content}
</div>
);
}

View File

@@ -1,24 +1,20 @@
'use client';
import { useEffect, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils/cn';
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
import { buildKeyframesOnly } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, AnimationPreset } from '@/types/animate';
import type { AnimationConfig, AnimationPreset, PresetCategory } from '@/types/animate';
interface Props {
onSelect: (config: AnimationConfig) => void;
}
function PresetCard({ preset, onSelect }: {
preset: AnimationPreset;
onSelect: () => void;
}) {
function PresetCard({ preset, onSelect }: { preset: AnimationPreset; onSelect: () => void }) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const animName = `preview-${preset.id}`;
const thumbDuration = Math.min(preset.config.duration, 1200);
// Inject only the @keyframes block under a unique name — no .animated class rule
useEffect(() => {
const renamedConfig = { ...preset.config, name: animName };
if (!styleRef.current) {
@@ -26,25 +22,18 @@ function PresetCard({ preset, onSelect }: {
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildKeyframesOnly(renamedConfig);
return () => {
styleRef.current?.remove();
styleRef.current = null;
};
return () => { styleRef.current?.remove(); styleRef.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Cap thumbnail duration so fast presets loop nicely; slow ones cap at 1.2s
const thumbDuration = Math.min(preset.config.duration, 1200);
return (
<button
onClick={onSelect}
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border bg-card/50 transition-all duration-200 hover:border-primary/50 hover:bg-accent/30 hover:shadow-sm"
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border/20 bg-primary/3 transition-all hover:border-primary/40 hover:bg-primary/8 group"
>
{/* Mini preview — animation driven entirely by inline style, not .animated class */}
<div className="w-full h-14 flex items-center justify-center rounded-lg bg-muted/30 overflow-hidden">
<div className="w-full h-12 flex items-center justify-center rounded-lg bg-white/3 overflow-hidden">
<div
className="w-8 h-8 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
className="w-7 h-7 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
style={{
animationName: animName,
animationDuration: `${thumbDuration}ms`,
@@ -55,7 +44,7 @@ function PresetCard({ preset, onSelect }: {
}}
/>
</div>
<span className="text-[11px] font-medium text-center leading-tight text-foreground/80">
<span className="text-[10px] font-mono text-center leading-tight text-foreground/60 group-hover:text-foreground/80 transition-colors">
{preset.name}
</span>
</button>
@@ -63,35 +52,32 @@ function PresetCard({ preset, onSelect }: {
}
export function PresetLibrary({ onSelect }: Props) {
const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]);
return (
<Card>
<CardHeader>
<CardTitle>Presets</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="Entrance">
<TabsList className="mb-4">
{PRESET_CATEGORIES.map((cat) => (
<TabsTrigger key={cat} value={cat} className="text-xs">
{cat}
</TabsTrigger>
))}
</TabsList>
<div className="space-y-3 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Presets</span>
<div className="flex glass rounded-lg p-0.5 gap-0.5">
{PRESET_CATEGORIES.map((cat) => (
<TabsContent key={cat} value={cat}>
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
{PRESETS.filter((p) => p.category === cat).map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
onSelect={() => onSelect(preset.config)}
/>
))}
</div>
</TabsContent>
<button
key={cat}
onClick={() => setCategory(cat)}
className={cn(
'px-2 py-1 rounded-md text-[10px] font-mono transition-all',
category === cat ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{cat}
</button>
))}
</Tabs>
</CardContent>
</Card>
</div>
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{PRESETS.filter((p) => p.category === category).map((preset) => (
<PresetCard key={preset.id} preset={preset} onSelect={() => onSelect(preset.config)} />
))}
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharin
import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii';
import { cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'editor' | 'preview';
@@ -102,28 +103,16 @@ export function ASCIIConverter() {
return (
<div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */}
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
{(['editor', 'preview'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
tab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'editor' ? 'Editor' : 'Preview'}
</button>
))}
</div>
<MobileTabs
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
active={tab}
onChange={(v) => setTab(v as Tab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: text input + font selector */}
<div

View File

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

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { cn } from '@/lib/utils';
import { ExpressionPanel } from './ExpressionPanel';
import { GraphPanel } from './GraphPanel';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'calc' | 'graph';
@@ -13,28 +14,16 @@ export default function Calculator() {
return (
<div className="flex flex-col gap-4">
{/* Mobile tab switcher — hidden on lg+ */}
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
{(['calc', 'graph'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
tab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'calc' ? 'Calculator' : 'Graph'}
</button>
))}
</div>
<MobileTabs
tabs={[{ value: 'calc', label: 'Calculator' }, { value: 'graph', label: 'Graph' }]}
active={tab}
onChange={(v) => setTab(v as Tab)}
/>
{/* Main layout — side-by-side on lg, tabbed on mobile */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Expression panel */}
<div

View File

@@ -162,7 +162,7 @@ export function ExpressionPanel() {
onClick={handleSubmit}
disabled={!expression.trim()}
className={cn(
'mt-2 w-full py-2 rounded-lg text-sm font-medium transition-all',
'mt-2 w-full py-2 rounded-lg text-xs font-medium transition-all',
'bg-primary/90 text-primary-foreground hover:bg-primary',
'disabled:opacity-30 disabled:cursor-not-allowed'
)}

View File

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

View File

@@ -10,7 +10,8 @@ import { ExportMenu } from '@/components/color/ExportMenu';
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type HarmonyType = 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient';
@@ -31,8 +32,6 @@ const RIGHT_TABS: { value: RightTab; label: string }[] = [
{ value: 'gradient', label: 'Gradient' },
];
const actionBtn =
'flex items-center gap-1 px-2.5 py-1 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
function ColorManipulationContent() {
const searchParams = useSearchParams();
@@ -102,28 +101,16 @@ function ColorManipulationContent() {
return (
<div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */}
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
{(['pick', 'explore'] as MobileTab[]).map((t) => (
<button
key={t}
onClick={() => setMobileTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
mobileTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'pick' ? 'Pick' : 'Explore'}
</button>
))}
</div>
<MobileTabs
tabs={[{ value: 'pick', label: 'Pick' }, { value: 'explore', label: 'Explore' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: Picker + ColorInfo */}
@@ -139,7 +126,7 @@ function ColorManipulationContent() {
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Color
</span>
<button onClick={handleShare} className={actionBtn}>
<button onClick={handleShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share
</button>
</div>

View File

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

View File

@@ -12,15 +12,13 @@ import {
} from '@/lib/color/api/queries';
import { toast } from 'sonner';
import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { cn, actionBtn } from '@/lib/utils';
interface ManipulationPanelProps {
color: string;
onColorChange: (color: string) => void;
}
const actionBtn =
'shrink-0 px-3 py-1 text-[10px] font-mono glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2);
@@ -118,7 +116,7 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
onValueChange={(vals) => row.setValue(vals[0])}
className="flex-1"
/>
<button onClick={row.onApply} disabled={isLoading} className={actionBtn}>
<button onClick={row.onApply} disabled={isLoading} className={cn(actionBtn, 'shrink-0')}>
Apply
</button>
</div>
@@ -137,7 +135,7 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
} catch { toast.error('Failed'); }
}}
disabled={isLoading}
className={cn(actionBtn, 'w-full justify-center flex items-center gap-1.5 py-2')}
className={cn(actionBtn, 'w-full justify-center py-2')}
>
<ArrowLeftRight className="w-3 h-3" />
Complementary Color

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

@@ -1,40 +0,0 @@
'use client';
import * as React from 'react';
import { Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
interface CodeSnippetProps {
code: string;
language?: string;
}
export function CodeSnippet({ code, language }: CodeSnippetProps) {
const [copied, setCopied] = React.useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
toast.success('Copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group">
<div className="absolute right-4 top-4 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="secondary"
size="icon-xs"
onClick={handleCopy}
className="bg-background/50 backdrop-blur-md border border-border"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<pre className="p-4 rounded-lg bg-input backdrop-blur-sm border border-border overflow-x-auto font-mono text-xs text-muted-foreground leading-relaxed">
<code>{code}</code>
</pre>
</div>
);
}

View File

@@ -1,9 +1,8 @@
'use client';
import * as React from 'react';
import { Upload, X, FileImage, HardDrive } from 'lucide-react';
import { Upload, X, FileImage, HardDrive, Film } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
export interface FaviconFileUploadProps {
onFileSelect: (file: File) => void;
@@ -26,7 +25,7 @@ export function FaviconFileUpload({
if (selectedFile) {
const img = new Image();
img.onload = () => {
setDimensions(`${img.width} × ${img.height}`);
setDimensions(`${img.width}×${img.height}`);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(selectedFile);
@@ -35,49 +34,22 @@ export function FaviconFileUpload({
}
}, [selectedFile]);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0 && files[0].type.startsWith('image/')) {
onFileSelect(files[0]);
}
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0 && files[0].type.startsWith('image/')) {
onFileSelect(files[0]);
}
};
const handleClick = () => {
if (!disabled) fileInputRef.current?.click();
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
};
return (
<div className="w-full space-y-3">
<div className="w-full">
<input
ref={fileInputRef}
type="file"
@@ -88,66 +60,64 @@ export function FaviconFileUpload({
/>
{selectedFile ? (
<div className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
<FileImage className="h-5 w-5 text-primary" />
<div className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<FileImage className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-mono text-foreground/80 truncate" title={selectedFile.name}>
{selectedFile.name}
</p>
<button
onClick={onFileRemove}
disabled={disabled}
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground truncate" title={selectedFile.name}>
{selectedFile.name}
</p>
<Button
variant="ghost"
size="icon-xs"
onClick={onFileRemove}
disabled={disabled}
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="mt-1.5 flex gap-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{(selectedFile.size / 1024).toFixed(1)} KB</span>
</div>
{dimensions && (
<div className="flex items-center gap-1">
<FileImage className="h-3 w-3" />
<span>{dimensions}</span>
</div>
)}
</div>
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
<span className="flex items-center gap-1">
<HardDrive className="w-2.5 h-2.5" />
{selectedFile.size < 1024 * 1024
? `${(selectedFile.size / 1024).toFixed(1)} KB`
: `${(selectedFile.size / (1024 * 1024)).toFixed(1)} MB`}
</span>
{dimensions && (
<span className="flex items-center gap-1">
<Film className="w-2.5 h-2.5" />{dimensions}
</span>
)}
</div>
</div>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !disabled && fileInputRef.current?.click()}
onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
'hover:border-primary/40 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border/50': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
'flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer text-center select-none py-8',
isDragging
? 'border-primary bg-primary/10 scale-[0.99]'
: 'border-border/35 hover:border-primary/40 hover:bg-primary/5',
disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
)}
>
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
<Upload className="h-6 w-6 text-primary" />
<div className={cn(
'w-14 h-14 rounded-full flex items-center justify-center mb-4 transition-colors',
isDragging ? 'bg-primary/25' : 'bg-primary/10'
)}>
<Upload className={cn('w-6 h-6 transition-colors', isDragging ? 'text-primary' : 'text-primary/60')} />
</div>
<p className="text-sm font-medium text-foreground mb-0.5">
Drop icon source here
<p className="text-sm font-medium text-foreground/70 mb-1">
{isDragging ? 'Drop to upload' : 'Drop icon here or click to browse'}
</p>
<p className="text-[10px] text-muted-foreground">
512x512 PNG or SVG recommended
<p className="text-[10px] text-muted-foreground/35 font-mono">
PNG · SVG · 512×512 recommended
</p>
</div>
)}

View File

@@ -1,24 +1,34 @@
'use client';
import * as React from 'react';
import { Download, Loader2, Code2, Globe, Layout } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react';
import { FaviconFileUpload } from './FaviconFileUpload';
import { CodeSnippet } from './CodeSnippet';
import { ColorInput } from '@/components/ui/color-input';
import { CodeSnippet } from '@/components/ui/code-snippet';
import { generateFaviconSet } from '@/lib/favicon/faviconService';
import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils';
import type { FaviconSet, FaviconOptions } from '@/types/favicon';
import { toast } from 'sonner';
import { cn, actionBtn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'icons' | 'html' | 'manifest';
type MobileTab = 'setup' | 'results';
const TABS: { value: Tab; label: string; icon: React.ReactNode }[] = [
{ value: 'icons', label: 'Icons', icon: <Layout className="w-3 h-3" /> },
{ value: 'html', label: 'HTML', icon: <Code2 className="w-3 h-3" /> },
{ value: 'manifest', label: 'Manifest', icon: <Globe className="w-3 h-3" /> },
];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
export function FaviconGenerator() {
const [sourceFile, setSourceFile] = React.useState<File | null>(null);
const [options, setOptions] = React.useState<FaviconOptions>({
name: 'My Awesome App',
name: 'My App',
shortName: 'App',
backgroundColor: '#ffffff',
themeColor: '#3b82f6',
@@ -26,22 +36,18 @@ export function FaviconGenerator() {
const [isGenerating, setIsGenerating] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const [result, setResult] = React.useState<FaviconSet | null>(null);
const [tab, setTab] = React.useState<Tab>('icons');
const [mobileTab, setMobileTab] = React.useState<MobileTab>('setup');
const handleGenerate = async () => {
if (!sourceFile) {
toast.error('Please upload a source image');
return;
}
if (!sourceFile) { toast.error('Please upload a source image'); return; }
setIsGenerating(true);
setProgress(0);
try {
const resultSet = await generateFaviconSet(sourceFile, options, (p) => {
setProgress(p);
});
const resultSet = await generateFaviconSet(sourceFile, options, (p) => setProgress(p));
setResult(resultSet);
toast.success('Favicon set generated successfully!');
setMobileTab('results');
toast.success('Favicon set generated!');
} catch (error) {
console.error(error);
toast.error('Failed to generate favicons');
@@ -52,229 +58,217 @@ export function FaviconGenerator() {
const handleDownloadAll = async () => {
if (!result) return;
const files = result.icons.map((icon) => ({
blob: icon.blob!,
filename: icon.name,
}));
// Add manifest to ZIP
const files = result.icons.map((icon) => ({ blob: icon.blob!, filename: icon.name }));
const manifestBlob = new Blob([result.manifest], { type: 'application/json' });
files.push({
blob: manifestBlob,
filename: 'site.webmanifest',
});
files.push({ blob: manifestBlob, filename: 'site.webmanifest' });
await downloadBlobsAsZip(files, 'favicons.zip');
toast.success('Downloading favicons ZIP...');
toast.success('Downloading favicons ZIP');
};
const handleReset = () => {
setSourceFile(null);
setResult(null);
setProgress(0);
setMobileTab('setup');
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Settings Column */}
<div className="lg:col-span-4 space-y-6">
<Card>
<CardHeader>
<CardTitle>App Details</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="app-name" className="text-xs">Application Name</Label>
<Input
id="app-name"
value={options.name}
onChange={(e) => setOptions({ ...options, name: e.target.value })}
placeholder="e.g. My Awesome Website"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="short-name" className="text-xs">Short Name</Label>
<Input
id="short-name"
value={options.shortName}
onChange={(e) => setOptions({ ...options, shortName: e.target.value })}
placeholder="e.g. My App"
/>
<p className="text-[10px] text-muted-foreground">Used for mobile home screen labels</p>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Theme Colors</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="bg-color" className="text-xs">Background</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
className="w-9 p-1 h-9 shrink-0"
value={options.backgroundColor}
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
/>
<Input
className="font-mono text-xs"
value={options.backgroundColor}
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="theme-color" className="text-xs">Theme</Label>
<div className="flex gap-2">
<Input
id="theme-color"
type="color"
className="w-9 p-1 h-9 shrink-0"
value={options.themeColor}
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
/>
<Input
className="font-mono text-xs"
value={options.themeColor}
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
/>
</div>
</div>
</div>
</CardContent>
</Card>
<MobileTabs
tabs={[{ value: 'setup', label: 'Setup' }, { value: 'results', label: 'Results' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
<Card className="overflow-hidden">
<CardContent>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: Setup */}
<div className={cn('lg:col-span-2 flex flex-col gap-3 overflow-hidden', mobileTab !== 'setup' && 'hidden lg:flex')}>
{/* Upload zone */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
Source Image
</span>
<FaviconFileUpload
selectedFile={sourceFile}
onFileSelect={setSourceFile}
onFileRemove={() => setSourceFile(null)}
disabled={isGenerating}
/>
<Button
className="w-full mt-4"
disabled={!sourceFile || isGenerating}
onClick={handleGenerate}
>
</div>
{/* App config */}
<div className="glass rounded-xl p-4 flex-1 min-h-0 flex flex-col overflow-hidden">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3 shrink-0">
App Details
</span>
<div className="space-y-3 flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">App Name</label>
<input
type="text"
value={options.name}
onChange={(e) => setOptions({ ...options, name: e.target.value })}
placeholder="My Awesome App"
className={inputCls}
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Short Name</label>
<input
type="text"
value={options.shortName}
onChange={(e) => setOptions({ ...options, shortName: e.target.value })}
placeholder="App"
className={inputCls}
/>
<p className="text-[9px] text-muted-foreground/30 font-mono mt-1">Used for mobile home screen labels</p>
</div>
<div className="space-y-3">
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label>
<ColorInput
value={options.backgroundColor}
onChange={(v) => setOptions({ ...options, backgroundColor: v })}
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label>
<ColorInput
value={options.themeColor}
onChange={(v) => setOptions({ ...options, themeColor: v })}
/>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2 shrink-0 pt-3 mt-3 border-t border-border/25">
{result && (
<button onClick={handleReset} className={cn(actionBtn, 'px-4')}>
Reset
</button>
)}
<button
onClick={handleGenerate}
disabled={!sourceFile || isGenerating}
className={cn(actionBtn, 'flex-1 justify-center')}
>
{isGenerating
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating {progress}%</>
: 'Generate Favicons'
}
</button>
</div>
</div>
</div>
{/* Right: Results */}
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'results' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Tab bar + download button */}
<div className="flex items-center gap-2 mb-4 shrink-0">
<div className="flex glass rounded-lg p-0.5 gap-0.5 flex-1">
{TABS.map(({ value, label, icon }) => (
<button
key={value}
onClick={() => setTab(value)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
tab === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{icon}{label}
</button>
))}
</div>
{result && (
<button onClick={handleDownloadAll} className={cn(actionBtn, 'shrink-0 px-3')}>
<Download className="w-3 h-3" />
ZIP
</button>
)}
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
{isGenerating ? (
<div className="flex flex-col items-center justify-center h-full gap-4">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="w-full max-w-xs space-y-2">
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground/50">
<span>Processing</span>
<span className="tabular-nums">{progress}%</span>
</div>
<div className="w-full h-1 rounded-full overflow-hidden bg-white/5">
<div
className="h-full bg-primary/65 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
) : result ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Generating... {progress}%
{tab === 'icons' && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{result.icons.map((icon) => (
<div
key={icon.name}
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border/20 bg-primary/3 group"
>
<div className="w-14 h-14 rounded-xl border border-border/25 bg-white/4 flex items-center justify-center group-hover:scale-105 transition-transform">
{icon.previewUrl ? (
<img src={icon.previewUrl} alt={icon.name} className="max-w-full max-h-full object-contain" />
) : (
<FileImage className="w-6 h-6 text-muted-foreground/30" />
)}
</div>
<div className="text-center w-full">
<p className="text-[10px] font-mono text-foreground/70 truncate" title={icon.name}>{icon.name}</p>
<p className="text-[9px] font-mono text-muted-foreground/40">{icon.width}×{icon.height} · {(icon.size / 1024).toFixed(1)} KB</p>
</div>
</div>
))}
</div>
)}
{tab === 'html' && (
<div className="space-y-3">
<CodeSnippet code={result.htmlCode} />
<div className="rounded-lg border border-primary/15 bg-primary/5 p-3">
<p className="text-[10px] text-muted-foreground/60 font-mono leading-relaxed">
Place generated files in your site root or update the href paths.
</p>
</div>
</div>
)}
{tab === 'manifest' && (
<CodeSnippet code={result.manifest} />
)}
</>
) : (
'Generate Favicons'
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<FileImage className="w-6 h-6 text-primary/40" />
</div>
<div>
<p className="text-sm font-medium text-foreground/40">No assets yet</p>
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Upload an image and generate favicons</p>
</div>
</div>
)}
</Button>
{result && (
<Button
variant="outline"
className="w-full mt-2"
onClick={handleReset}
>
Reset
</Button>
)}
</CardContent>
</Card>
</div>
{/* Results Column */}
<div className="lg:col-span-8 space-y-6">
{isGenerating ? (
<Card className="h-full flex flex-col items-center justify-center p-10 space-y-4">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
<div className="w-full max-w-xs space-y-2">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium">Processing...</span>
</div>
<span className="tabular-nums">{progress}%</span>
</div>
<Progress value={progress} className="h-1" />
</div>
</Card>
) : result ? (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between gap-4">
<h2 className="text-lg font-bold">Generated Assets</h2>
<Button onClick={handleDownloadAll}>
<Download className="mr-1.5 h-3.5 w-3.5" />
Download ZIP
</Button>
</div>
<Tabs defaultValue="icons" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="icons" className="flex items-center gap-1.5">
<Layout className="h-3.5 w-3.5" />
Icons
</TabsTrigger>
<TabsTrigger value="html" className="flex items-center gap-1.5">
<Code2 className="h-3.5 w-3.5" />
HTML
</TabsTrigger>
<TabsTrigger value="manifest" className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5" />
Manifest
</TabsTrigger>
</TabsList>
<TabsContent value="icons" className="mt-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{result?.icons.map((icon) => (
<Card key={icon.name} className="group overflow-hidden">
<div className="p-3 flex flex-col items-center text-center space-y-2">
<div className="relative h-16 w-16 flex items-center justify-center bg-muted/50 rounded-lg p-1.5 border border-border/50 group-hover:scale-105 transition-transform duration-200">
{icon.previewUrl && (
<img
src={icon.previewUrl}
alt={icon.name}
className="max-w-full max-h-full object-contain"
/>
)}
</div>
<div className="space-y-0.5 w-full">
<p className="text-[10px] font-medium text-foreground truncate" title={icon.name}>
{icon.name}
</p>
<p className="text-[10px] text-muted-foreground">
{icon.width}x{icon.height} · {(icon.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="html" className="mt-4 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Embed in your &lt;head&gt;</Label>
{result && <CodeSnippet code={result.htmlCode} />}
</div>
<div className="p-3 rounded-lg bg-primary/5 border border-primary/10">
<p className="text-[11px] text-muted-foreground leading-relaxed">
Place generated files in your site root or update the <code className="text-primary">href</code> paths.
</p>
</div>
</TabsContent>
<TabsContent value="manifest" className="mt-4">
<div className="space-y-1.5">
<Label className="text-xs">site.webmanifest</Label>
{result && <CodeSnippet code={result.manifest} />}
</div>
</TabsContent>
</Tabs>
</div>
) : null}
</div>
</div>
</div>
);

View File

@@ -1,42 +1,60 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { useSidebar } from './SidebarProvider';
import { getToolByHref } from '@/lib/tools';
import Logo from '@/components/Logo';
const iconBtn =
'w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground/50 hover:text-foreground hover:bg-white/5 transition-all';
export function AppHeader() {
const { toggle, isOpen, isCollapsed, toggleCollapse } = useSidebar();
const pathname = usePathname();
const tool = getToolByHref(pathname);
return (
<header className="h-16 border-b border-border bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-6 shadow-[0_1px_3px_0_rgb(0_0_0/0.05)]">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="hidden lg:inline-flex text-muted-foreground hover:text-foreground"
<header className="h-14 border-b border-border/20 bg-background/8 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-4 gap-3">
<div className="flex items-center gap-1.5 min-w-0">
{/* Desktop: sidebar collapse toggle */}
<button
onClick={toggleCollapse}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(iconBtn, 'hidden lg:flex shrink-0')}
>
{isCollapsed ? (
<PanelLeftOpen className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</Button>
{isCollapsed
? <PanelLeftOpen className="w-4 h-4" />
: <PanelLeftClose className="w-4 h-4" />
}
</button>
{/* Mobile: logo home link */}
<Link href="/" className="lg:hidden shrink-0 ml-2">
<Logo size={24} />
<Logo size={20} />
</Link>
{/* Current tool breadcrumb */}
{tool && (
<div className="flex items-center gap-1.5 min-w-0 ml-1">
<span className="text-border/50 text-xs select-none">/</span>
<span className="text-sm text-foreground/60 truncate font-mono">
{tool.navTitle}
</span>
</div>
)}
</div>
<Button
variant="ghost"
size="icon"
className="lg:hidden text-muted-foreground hover:text-foreground"
{/* Mobile: open/close sidebar */}
<button
onClick={toggle}
title={isOpen ? 'Close menu' : 'Open menu'}
className={cn(iconBtn, 'lg:hidden shrink-0')}
>
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{isOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</button>
</header>
);
}

View File

@@ -2,28 +2,14 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
interface AppPageProps {
title: string;
description?: string;
icon?: React.ElementType;
children: React.ReactNode;
className?: string;
}
export function AppPage({ title, description, icon: Icon, children, className }: AppPageProps) {
export function AppPage({ children, className }: AppPageProps) {
return (
<div className={cn("min-h-screen py-8", className)}>
<div className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in">
<div>
<div className="flex items-center gap-3 mb-1">
{Icon && <Icon className="h-6 w-6 text-primary shrink-0" />}
<h1 className="text-2xl font-bold">{title}</h1>
</div>
{description && (
<p className="text-sm text-muted-foreground max-w-2xl">
{description}
</p>
)}
</div>
<div className={cn('overflow-y-auto', className)}>
<div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in py-6 lg:py-8">
{children}
</div>
</div>

View File

@@ -6,7 +6,6 @@ import { X, GitFork, Heart } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import Logo from '@/components/Logo';
import { useSidebar } from './SidebarProvider';
import { Button } from '@/components/ui/button';
import { tools } from '@/lib/tools';
export function AppSidebar() {
@@ -15,7 +14,7 @@ export function AppSidebar() {
return (
<>
{/* Mobile Overlay Backdrop */}
{/* Mobile backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-transparent backdrop-blur-sm z-40 lg:hidden"
@@ -24,94 +23,100 @@ export function AppSidebar() {
)}
<aside className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full",
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
isCollapsed ? "lg:w-14" : "w-64"
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border/20 bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full',
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
isCollapsed ? 'lg:w-14' : 'w-60'
)}>
{/* Sidebar Header */}
{/* Header */}
<div className={cn(
"flex h-16 items-center shrink-0 border-b border-border",
isCollapsed ? "justify-center px-2" : "justify-between px-5"
'flex h-14 items-center shrink-0 border-b border-border/20',
isCollapsed ? 'justify-center px-2' : 'justify-between px-4'
)}>
<Link href="/" className={cn(
"flex items-center group overflow-hidden",
isCollapsed ? "justify-center" : "gap-3"
)}>
<Link
href="/"
className={cn(
'flex items-center group overflow-hidden',
isCollapsed ? 'justify-center' : 'gap-2.5'
)}
>
<div className="shrink-0">
<Logo size={isCollapsed ? 20 : 28} />
<Logo size={isCollapsed ? 18 : 24} />
</div>
{!isCollapsed && (
<div className="min-w-0">
<span className="font-bold text-lg leading-tight block text-foreground">
Kit
</span>
<span className="text-[10px] leading-tight text-muted-foreground block">
<span className="font-semibold text-base leading-tight block text-foreground">Kit</span>
<span className="text-[9px] leading-tight text-muted-foreground/50 block font-mono tracking-wider">
Browser-first toolkit
</span>
</div>
)}
</Link>
{!isCollapsed && (
<Button
variant="ghost"
size="icon"
className="lg:hidden text-muted-foreground"
<button
onClick={close}
className="lg:hidden w-7 h-7 flex items-center justify-center rounded-lg text-muted-foreground/40 hover:text-foreground hover:bg-white/5 transition-all"
>
<X className="h-5 w-5" />
</Button>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Navigation */}
<nav className={cn(
"flex-1 overflow-y-auto py-2 space-y-6 mt-4 overflow-hidden scrollbar",
isCollapsed ? "px-2" : "px-4"
'flex-1 overflow-y-auto py-3 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/10 scrollbar-track-transparent',
isCollapsed ? 'px-2' : 'px-3'
)}>
<div className="space-y-0.5">
{tools.map((tool) => {
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
const Icon = tool.icon;
{tools.map((tool) => {
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
const Icon = tool.icon;
return (
<div key={tool.href} className="space-y-1">
<Link
href={tool.href}
onClick={() => { if (window.innerWidth < 1024) close(); }}
className={cn(
"flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-300 relative group/item",
isActive
? "bg-primary/10 text-primary border-l-2 border-primary"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
isCollapsed ? "justify-center" : "justify-between"
)}
title={isCollapsed ? tool.navTitle : undefined}
>
<div className="flex items-center gap-3 min-w-0">
<span className={cn(
"transition-colors duration-300 shrink-0",
isActive ? "text-primary" : "text-foreground/80 group-hover/item:text-foreground"
)}>
<Icon className="h-4 w-4" />
</span>
{!isCollapsed && (
<div className="min-w-0">
<span className="whitespace-nowrap block">{tool.navTitle}</span>
<span className="text-[10px] text-muted-foreground leading-tight block line-clamp-2">{tool.description}</span>
</div>
)}
</div>
</Link>
</div>
);
})}
</div>
return (
<Link
key={tool.href}
href={tool.href}
onClick={() => { if (window.innerWidth < 1024) close(); }}
title={isCollapsed ? tool.navTitle : undefined}
className={cn(
'relative flex items-center rounded-lg text-sm transition-all duration-200 group/item',
isActive
? 'bg-primary/10 text-primary'
: 'text-foreground/55 hover:bg-white/4 hover:text-foreground',
isCollapsed ? 'justify-center p-2' : 'gap-3 px-3 py-2'
)}
>
{/* Active left bar */}
{isActive && (
<span className="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-primary" />
)}
<span className={cn(
'shrink-0 transition-colors duration-200',
isActive ? 'text-primary' : 'text-foreground/40 group-hover/item:text-foreground/70'
)}>
<Icon className="w-4 h-4" />
</span>
{!isCollapsed && (
<div className="min-w-0">
<span className="whitespace-nowrap block text-[13px] font-medium leading-tight">
{tool.navTitle}
</span>
<span className="text-[9px] text-muted-foreground/40 leading-tight block font-mono mt-0.5">
{tool.description}
</span>
</div>
)}
</Link>
);
})}
</nav>
{/* Sidebar Footer */}
{/* Footer */}
<div className={cn(
"shrink-0 border-t border-border py-3",
isCollapsed ? "flex justify-center px-2" : "px-4"
'shrink-0 border-t border-border/20 py-3',
isCollapsed ? 'flex justify-center px-2' : 'px-4'
)}>
{isCollapsed ? (
<a
@@ -119,21 +124,20 @@ export function AppSidebar() {
target="_blank"
rel="noopener noreferrer"
title="View source"
className="text-muted-foreground hover:text-primary transition-colors duration-300"
className="text-muted-foreground/40 hover:text-primary transition-colors"
>
<GitFork className="h-4 w-4" />
<GitFork className="w-3.5 h-3.5" />
</a>
) : (
<div className="flex items-center justify-between">
<p className="flex items-center gap-1 text-[10px] text-muted-foreground">
© {new Date().getFullYear()} Kit.
<Heart className="h-2.5 w-2.5 text-primary shrink-0 animate-pulse" fill="currentColor" />
<p className="flex items-center gap-1 text-[9px] text-muted-foreground/40 font-mono">
© {new Date().getFullYear()} Kit
<Heart className="w-2 h-2 text-primary/70 shrink-0 animate-pulse" fill="currentColor" />
<a
href="https://pivoine.art"
target="_blank"
rel="noopener noreferrer"
title="Pivoine.Art"
className="font-medium underline underline-offset-4 decoration-primary/0 hover:decoration-primary transition-all duration-300"
className="hover:text-foreground/70 transition-colors"
>
Valknar
</a>
@@ -143,14 +147,13 @@ export function AppSidebar() {
target="_blank"
rel="noopener noreferrer"
title="View source"
className="text-muted-foreground hover:text-primary transition-colors duration-300"
className="text-muted-foreground/30 hover:text-primary transition-colors"
>
<GitFork className="h-3.5 w-3.5" />
<GitFork className="w-3 h-3" />
</a>
</div>
)}
</div>
</aside>
</>
);

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 { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { cn, actionBtn } from '@/lib/utils';
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob } from '@/types/media';
@@ -11,9 +11,6 @@ export interface ConversionPreviewProps {
onRetry?: () => void;
}
const actionBtn =
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0);
@@ -171,7 +168,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
})()}
{/* Download */}
<button onClick={handleDownload} className={cn(actionBtn, 'w-full')}>
<button onClick={handleDownload} className={cn(actionBtn, 'w-full justify-center')}>
<Download className="w-3 h-3" />
<span className="truncate min-w-0">{filename}</span>
</button>
@@ -187,7 +184,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
</div>
)}
{onRetry && (
<button onClick={onRetry} className={cn(actionBtn, 'w-full')}>
<button onClick={onRetry} className={cn(actionBtn, 'w-full justify-center')}>
<RefreshCw className="w-3 h-3" />
Retry
</button>

View File

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

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 />
{children}
</TooltipProvider>
<Toaster position="top-right" richColors />
<Toaster
theme="dark"
position="bottom-right"
toastOptions={{
classNames: {
toast:
'!bg-[#13131f] !border !border-white/8 !text-white/85 !rounded-xl !shadow-2xl !font-sans',
title: '!text-sm !font-medium !text-white/85',
description: '!text-xs !text-white/45',
icon: '!mt-px',
success: '!border-primary/25',
error: '!border-red-500/25',
warning: '!border-amber-400/25',
info: '!border-blue-400/25',
},
}}
/>
</QueryClientProvider>
);
}

View File

@@ -9,8 +9,12 @@ import { decodeQRFromUrl, updateQRUrl, getQRShareableUrl } from '@/lib/qrcode/ur
import { downloadBlob } from '@/lib/media/utils/fileUtils';
import { debounce } from '@/lib/utils/debounce';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
type MobileTab = 'configure' | 'preview';
export function QRCodeGenerator() {
const [text, setText] = React.useState('https://kit.pivoine.art');
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
@@ -20,6 +24,7 @@ export function QRCodeGenerator() {
const [exportSize, setExportSize] = React.useState<ExportSize>(512);
const [svgString, setSvgString] = React.useState('');
const [isGenerating, setIsGenerating] = React.useState(false);
const [mobileTab, setMobileTab] = React.useState<MobileTab>('configure');
// Load state from URL on mount
React.useEffect(() => {
@@ -37,11 +42,7 @@ export function QRCodeGenerator() {
const generate = React.useMemo(
() =>
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
if (!t) {
setSvgString('');
setIsGenerating(false);
return;
}
if (!t) { setSvgString(''); setIsGenerating(false); return; }
setIsGenerating(true);
try {
const svg = await generateSvg(t, ec, fg, bg, m);
@@ -57,13 +58,11 @@ export function QRCodeGenerator() {
[],
);
// Regenerate on changes
React.useEffect(() => {
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
// Export: PNG download
const handleDownloadPng = async () => {
if (!text) return;
try {
@@ -71,74 +70,82 @@ export function QRCodeGenerator() {
const res = await fetch(dataUrl);
const blob = await res.blob();
downloadBlob(blob, `qrcode-${Date.now()}.png`);
} catch {
toast.error('Failed to export PNG');
}
} catch { toast.error('Failed to export PNG'); }
};
// Export: SVG download
const handleDownloadSvg = () => {
if (!svgString) return;
const blob = new Blob([svgString], { type: 'image/svg+xml' });
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
};
// Copy image to clipboard
const handleCopyImage = async () => {
if (!text) return;
try {
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
toast.success('Image copied to clipboard!');
} catch {
toast.error('Failed to copy image');
}
} catch { toast.error('Failed to copy image'); }
};
// Share URL
const handleShare = async () => {
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
try {
await navigator.clipboard.writeText(shareUrl);
toast.success('Shareable URL copied!');
} catch {
toast.error('Failed to copy URL');
}
} catch { toast.error('Failed to copy URL'); }
};
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
{/* Left Column - Input and Options */}
<div className="lg:col-span-1 space-y-6 overflow-y-auto custom-scrollbar">
<QRInput value={text} onChange={setText} />
<QROptions
errorCorrection={errorCorrection}
foregroundColor={foregroundColor}
backgroundColor={backgroundColor}
margin={margin}
onErrorCorrectionChange={setErrorCorrection}
onForegroundColorChange={setForegroundColor}
onBackgroundColorChange={setBackgroundColor}
onMarginChange={setMargin}
/>
</div>
<div className="flex flex-col gap-4">
{/* Right Column - Preview */}
<div className="lg:col-span-2 h-full">
<QRPreview
svgString={svgString}
isGenerating={isGenerating}
exportSize={exportSize}
onExportSizeChange={setExportSize}
onCopyImage={handleCopyImage}
onShare={handleShare}
onDownloadPng={handleDownloadPng}
onDownloadSvg={handleDownloadSvg}
/>
<MobileTabs
tabs={[{ value: 'configure', label: 'Configure' }, { value: 'preview', label: 'Preview' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: Input + Options */}
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'configure' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
<QRInput value={text} onChange={setText} />
<div className="border-t border-border/25" />
<QROptions
errorCorrection={errorCorrection}
foregroundColor={foregroundColor}
backgroundColor={backgroundColor}
margin={margin}
onErrorCorrectionChange={setErrorCorrection}
onForegroundColorChange={setForegroundColor}
onBackgroundColorChange={setBackgroundColor}
onMarginChange={setMargin}
/>
</div>
</div>
</div>
{/* Right: Preview */}
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
<QRPreview
svgString={svgString}
isGenerating={isGenerating}
exportSize={exportSize}
onExportSizeChange={setExportSize}
onCopyImage={handleCopyImage}
onShare={handleShare}
onDownloadPng={handleDownloadPng}
onDownloadSvg={handleDownloadSvg}
/>
</div>
</div>
</div>
);

View File

@@ -1,34 +1,29 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
const MAX_LENGTH = 2048;
interface QRInputProps {
value: string;
onChange: (value: string) => void;
}
const MAX_LENGTH = 2048;
export function QRInput({ value, onChange }: QRInputProps) {
return (
<Card>
<CardHeader>
<CardTitle>Text</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter text or URL..."
maxLength={MAX_LENGTH}
rows={3}
className="resize-none font-mono text-sm"
/>
<div className="text-[10px] text-muted-foreground text-right">
{value.length} / {MAX_LENGTH}
</div>
</CardContent>
</Card>
<div>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Content
</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter text or URL…"
maxLength={MAX_LENGTH}
rows={4}
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 resize-none"
/>
<div className="text-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
{value.length} / {MAX_LENGTH}
</div>
</div>
);
}

View File

@@ -1,17 +1,8 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SliderRow } from '@/components/ui/slider-row';
import { ColorInput } from '@/components/ui/color-input';
import { cn } from '@/lib/utils/cn';
import type { ErrorCorrectionLevel } from '@/types/qrcode';
interface QROptionsProps {
@@ -25,11 +16,11 @@ interface QROptionsProps {
onMarginChange: (margin: number) => void;
}
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string }[] = [
{ value: 'L', label: 'Low (7%)' },
{ value: 'M', label: 'Medium (15%)' },
{ value: 'Q', label: 'Quartile (25%)' },
{ value: 'H', label: 'High (30%)' },
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
{ value: 'L', label: 'L', desc: '7%' },
{ value: 'M', label: 'M', desc: '15%' },
{ value: 'Q', label: 'Q', desc: '25%' },
{ value: 'H', label: 'H', desc: '30%' },
];
export function QROptions({
@@ -45,93 +36,78 @@ export function QROptions({
const isTransparent = backgroundColor === '#00000000';
return (
<Card>
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Error Correction */}
<div className="space-y-1.5">
<Label className="text-xs">Error Correction</Label>
<Select value={errorCorrection} onValueChange={(v) => onErrorCorrectionChange(v as ErrorCorrectionLevel)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EC_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-5">
{/* Error Correction */}
<div>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Error Correction
</span>
<div className="flex gap-1.5">
{EC_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onErrorCorrectionChange(opt.value)}
className={cn(
'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
errorCorrection === opt.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
)}
>
<span className="font-semibold">{opt.label}</span>
<span className="text-[9px] opacity-50 mt-0.5">{opt.desc}</span>
</button>
))}
</div>
</div>
{/* Colors */}
<div className="space-y-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
Colors
</span>
{/* Foreground */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
<ColorInput value={foregroundColor} onChange={onForegroundColorChange} />
</div>
{/* Colors */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Foreground</Label>
<div className="flex gap-2">
<Input
type="color"
className="w-9 p-1 h-9 shrink-0"
value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)}
/>
<Input
className="font-mono text-xs"
value={foregroundColor}
onChange={(e) => onForegroundColorChange(e.target.value)}
/>
</div>
{/* Background */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
<button
onClick={() => onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
isTransparent
? 'border-primary/40 text-primary bg-primary/10'
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
Transparent
</button>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Background</Label>
<Button
variant={isTransparent ? 'default' : 'outline'}
size="xs"
className="h-5 text-[10px] px-1.5"
onClick={() =>
onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')
}
>
Transparent
</Button>
</div>
<div className="flex gap-2">
<Input
type="color"
className="w-9 p-1 h-9 shrink-0"
disabled={isTransparent}
value={backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
/>
<Input
className="font-mono text-xs"
disabled={isTransparent}
value={backgroundColor}
onChange={(e) => onBackgroundColorChange(e.target.value)}
/>
</div>
</div>
</div>
{/* Margin */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Margin</Label>
<span className="text-xs text-muted-foreground">{margin}</span>
</div>
<Slider
value={[margin]}
onValueChange={([v]) => onMarginChange(v)}
min={0}
max={8}
step={1}
<ColorInput
value={isTransparent ? '#ffffff' : backgroundColor}
onChange={onBackgroundColorChange}
disabled={isTransparent}
/>
</div>
</CardContent>
</Card>
</div>
{/* Margin */}
<SliderRow
label="Margin"
display={String(margin)}
value={margin}
min={0}
max={8}
step={1}
onChange={onMarginChange}
/>
</div>
);
}

View File

@@ -1,22 +1,7 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import type { ExportSize } from '@/types/qrcode';
interface QRPreviewProps {
@@ -30,6 +15,14 @@ interface QRPreviewProps {
onDownloadSvg: () => void;
}
const EXPORT_SIZES: { value: ExportSize; label: string }[] = [
{ value: 256, label: '256' },
{ value: 512, label: '512' },
{ value: 1024, label: '1k' },
{ value: 2048, label: '2k' },
];
export function QRPreview({
svgString,
isGenerating,
@@ -41,92 +34,81 @@ export function QRPreview({
onDownloadSvg,
}: QRPreviewProps) {
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<CardTitle>Preview</CardTitle>
<div className="flex items-center gap-1.5 flex-wrap">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="xs" onClick={onCopyImage} disabled={!svgString}>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</TooltipTrigger>
<TooltipContent>Copy image to clipboard</TooltipContent>
</Tooltip>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="xs" onClick={onShare} disabled={!svgString}>
<Share2 className="h-3 w-3 mr-1" />
Share
</Button>
</TooltipTrigger>
<TooltipContent>Copy shareable URL</TooltipContent>
</Tooltip>
{/* Action bar */}
<div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
Preview
</span>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="xs" onClick={onDownloadPng} disabled={!svgString}>
<ImageIcon className="h-3 w-3 mr-1" />
PNG
</Button>
</TooltipTrigger>
<TooltipContent>Download as PNG</TooltipContent>
</Tooltip>
<ToggleGroup
type="single"
value={String(exportSize)}
onValueChange={(v) => v && onExportSizeChange(Number(v) as ExportSize)}
variant="outline"
size="sm"
>
<ToggleGroupItem value="256" className="h-6 px-1.5 min-w-0 text-[10px]">256</ToggleGroupItem>
<ToggleGroupItem value="512" className="h-6 px-1.5 min-w-0 text-[10px]">512</ToggleGroupItem>
<ToggleGroupItem value="1024" className="h-6 px-1.5 min-w-0 text-[10px]">1k</ToggleGroupItem>
<ToggleGroupItem value="2048" className="h-6 px-1.5 min-w-0 text-[10px]">2k</ToggleGroupItem>
</ToggleGroup>
<button onClick={onCopyImage} disabled={!svgString} className={cardBtn}>
<Copy className="w-3 h-3" />Copy
</button>
<button onClick={onShare} disabled={!svgString} className={cardBtn}>
<Share2 className="w-3 h-3" />Share
</button>
{/* PNG + inline size selector */}
<div className="flex items-center glass rounded-md border border-border/30">
<button
onClick={onDownloadPng}
disabled={!svgString}
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-xs text-muted-foreground hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed border-r border-border/20"
>
<ImageIcon className="w-3 h-3" />PNG
</button>
<div className="flex items-center px-1 gap-0.5">
{EXPORT_SIZES.map(({ value, label }) => (
<button
key={value}
onClick={() => onExportSizeChange(value)}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded transition-all',
exportSize === value
? 'text-primary bg-primary/10'
: 'text-muted-foreground/40 hover:text-muted-foreground'
)}
>
{label}
</button>
))}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="xs" onClick={onDownloadSvg} disabled={!svgString}>
<FileCode className="h-3 w-3 mr-1" />
SVG
</Button>
</TooltipTrigger>
<TooltipContent>Download as SVG</TooltipContent>
</Tooltip>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col">
<div className="flex-1 min-h-[200px] rounded-lg p-4 flex items-center justify-center"
style={{
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)',
backgroundSize: '16px 16px',
}}
>
{isGenerating ? (
<Skeleton className="h-[200px] w-[200px]" />
) : svgString ? (
<div
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full"
dangerouslySetInnerHTML={{ __html: svgString }}
/>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<QrCode />
</EmptyMedia>
<EmptyTitle>Enter text to generate a QR code</EmptyTitle>
<EmptyDescription>Type text or a URL in the input field above</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</CardContent>
</Card>
<button onClick={onDownloadSvg} disabled={!svgString} className={cardBtn}>
<FileCode className="w-3 h-3" />SVG
</button>
</div>
{/* QR canvas */}
<div
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
style={{
backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
backgroundSize: '16px 16px',
}}
>
{isGenerating ? (
<div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
) : svgString ? (
<div
className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
dangerouslySetInnerHTML={{ __html: svgString }}
/>
) : (
<div className="flex flex-col items-center gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<QrCode className="w-6 h-6 text-primary/40" />
</div>
<div>
<p className="text-sm font-medium text-foreground/40">No QR code yet</p>
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
</div>
</div>
)}
</div>
</div>
);
}

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

@@ -0,0 +1,39 @@
'use client';
import * as React from 'react';
import { Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
interface CodeSnippetProps {
code: string;
maxHeight?: string;
}
export function CodeSnippet({ code, maxHeight }: CodeSnippetProps) {
const [copied, setCopied] = React.useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
toast.success('Copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
<button
onClick={handleCopy}
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md border border-white/10 bg-white/5 text-white/40 hover:text-white/70 hover:border-white/20 transition-all z-10"
>
{copied ? <Check className="w-2.5 h-2.5" /> : <Copy className="w-2.5 h-2.5" />}
{copied ? 'Copied' : 'Copy'}
</button>
<pre
className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed"
style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined}
>
<code>{code}</code>
</pre>
</div>
);
}

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';
import { useState, useEffect, useCallback } from 'react';
import { ArrowLeftRight, BarChart3, Grid3X3 } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeftRight, BarChart3, Grid3X3, Copy } from 'lucide-react';
import { toast } from 'sonner';
import SearchUnits from './SearchUnits';
import VisualComparison from './VisualComparison';
import {
@@ -21,14 +15,10 @@ import {
type ConversionResult,
} from '@/lib/units/units';
import { parseNumberInput, formatNumber, cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'category' | 'convert';
const CATEGORY_ICONS: Partial<Record<Measure, string>> = {
length: '📏', mass: '⚖️', temperature: '🌡️', speed: '⚡', time: '⏱️',
area: '⬛', volume: '🧊', digital: '💾', energy: '⚡', pressure: '🔵',
power: '🔆', frequency: '〰️', angle: '📐', current: '⚡', voltage: '🔌',
};
export default function MainConverter() {
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
@@ -94,28 +84,16 @@ export default function MainConverter() {
return (
<div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */}
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
{(['category', 'convert'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
tab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'category' ? 'Category' : 'Convert'}
</button>
))}
</div>
<MobileTabs
tabs={[{ value: 'category', label: 'Category' }, { value: 'convert', label: 'Convert' }]}
active={tab}
onChange={(v) => setTab(v as Tab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: search + categories */}
@@ -154,15 +132,11 @@ export default function MainConverter() {
onClick={() => handleCategorySelect(measure)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all text-left',
'border-l-2',
isSelected
? 'bg-primary/10 border-primary text-primary'
: 'border-transparent text-foreground/65 hover:bg-primary/8 hover:text-foreground'
? 'bg-primary/10 text-primary'
: 'text-foreground/65 hover:bg-primary/8 hover:text-foreground'
)}
>
<span className="text-xs leading-none shrink-0 opacity-70">
{CATEGORY_ICONS[measure] ?? '📦'}
</span>
<span className="flex-1 text-xs font-mono truncate">{formatMeasureName(measure)}</span>
<span
className={cn(
@@ -195,7 +169,7 @@ export default function MainConverter() {
</span>
{/* Input row */}
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2">
{/* Value input */}
<input
type="text"
@@ -203,51 +177,61 @@ export default function MainConverter() {
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="0"
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 tabular-nums"
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2.5 text-base font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30 tabular-nums"
/>
{/* From unit */}
<Select value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger className="w-28 h-9 shrink-0 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* Unit selectors + swap */}
<div className="flex items-center gap-2">
{/* From unit */}
<select
value={selectedUnit}
onChange={(e) => setSelectedUnit(e.target.value)}
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{units.map((unit) => (
<SelectItem key={unit} value={unit} className="font-mono text-xs">
{unit}
</SelectItem>
<option key={unit} value={unit}>{unit}</option>
))}
</SelectContent>
</Select>
</select>
{/* Swap */}
<button
onClick={handleSwapUnits}
title="Swap units"
className="shrink-0 w-8 h-8 flex items-center justify-center glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all"
>
<ArrowLeftRight className="w-3.5 h-3.5" />
</button>
{/* Swap */}
<button
onClick={handleSwapUnits}
title="Swap units"
className="shrink-0 w-8 h-8 flex items-center justify-center glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all"
>
<ArrowLeftRight className="w-3.5 h-3.5" />
</button>
{/* To unit */}
<Select value={targetUnit} onValueChange={setTargetUnit}>
<SelectTrigger className="w-28 h-9 shrink-0 text-xs border-border/30 bg-transparent hover:border-primary/30 transition-colors font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* To unit */}
<select
value={targetUnit}
onChange={(e) => setTargetUnit(e.target.value)}
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{units.map((unit) => (
<SelectItem key={unit} value={unit} className="font-mono text-xs">
{unit}
</SelectItem>
<option key={unit} value={unit}>{unit}</option>
))}
</SelectContent>
</Select>
</select>
</div>
</div>
{/* Result display */}
{resultValue !== null && (
<div className="mt-3 px-3 py-2.5 rounded-lg bg-primary/5 border border-primary/15">
<div className="text-[10px] text-muted-foreground/50 font-mono mb-0.5">Result</div>
<div className="flex items-center justify-between mb-0.5">
<div className="text-[10px] text-muted-foreground/50 font-mono">Result</div>
<button
onClick={() => {
const text = `${formatNumber(resultValue)} ${targetUnit}`;
navigator.clipboard.writeText(text);
toast.success('Copied', { description: text, duration: 2000 });
}}
title="Copy result"
className="w-5 h-5 flex items-center justify-center rounded text-muted-foreground/40 hover:text-primary transition-colors"
>
<Copy className="w-3 h-3" />
</button>
</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tabular-nums font-mono bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent">
{formatNumber(resultValue)}

463
lib/cron/cron-engine.ts Normal file
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',
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 {
/** Short display name (e.g. "Color") */
@@ -97,14 +97,36 @@ export const tools: Tool[] = [
icon: AnimateIcon,
badges: ['CSS', 'Tailwind v4', '20+ Presets'],
},
{
shortTitle: 'Random',
title: 'Random Generator',
navTitle: 'Random Generator',
href: '/random',
description: 'Generate secure passwords, UUIDs, API keys and tokens.',
summary:
'Cryptographically secure random generator. Create passwords, UUIDs, API keys, SHA hashes, and secure tokens — all using the browser Web Crypto API, nothing leaves your machine.',
icon: RandomIcon,
badges: ['Web Crypto', 'Passwords', 'UUID', 'Hashes'],
},
{
shortTitle: 'Cron',
title: 'Cron Editor',
navTitle: 'Cron Editor',
href: '/cron',
description: 'Visual editor for cron expressions with live preview.',
summary:
'Build and validate cron expressions with an intuitive visual field editor. Get a human-readable description and preview the next upcoming scheduled runs.',
icon: CronIcon,
badges: ['Cron', 'Scheduler', 'Visual'],
},
{
shortTitle: 'Calculate',
title: 'Calculator & Grapher',
title: 'Calculator',
navTitle: 'Calculator',
href: '/calculate',
description: 'Advanced expression evaluator with interactive function graphing.',
description: 'Advanced expression evaluator with function graphing.',
summary:
'Powerful mathematical calculator powered by Math.js. Evaluate complex expressions, define variables, and plot multiple functions simultaneously on an interactive graph with pan and zoom.',
'Powerful mathematical calculator powered by Math.js. Evaluate complex expressions, define variables, and plot functions on an interactive graph.',
icon: CalculateIcon,
badges: ['Math.js', 'Graphing', 'Interactive'],
},

View File

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

15
lib/utils/styles.ts Normal file
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",
"convert-units": "^2.3.4",
"figlet": "^1.10.0",
"framer-motion": "^12.34.3",
"fuse.js": "^7.1.0",
"html-to-image": "^1.11.13",
"jszip": "^3.10.1",

38
pnpm-lock.yaml generated
View File

@@ -41,9 +41,6 @@ importers:
figlet:
specifier: ^1.10.0
version: 1.10.0
framer-motion:
specifier: ^12.34.3
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
fuse.js:
specifier: ^7.1.0
version: 7.1.0
@@ -2435,20 +2432,6 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.34.3:
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -3127,12 +3110,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.34.3:
resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==}
motion-utils@12.29.2:
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -6585,15 +6562,6 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.34.3
motion-utils: 12.29.2
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
fresh@2.0.0: {}
fs-extra@11.3.3:
@@ -7222,12 +7190,6 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.34.3:
dependencies:
motion-utils: 12.29.2
motion-utils@12.29.2: {}
ms@2.1.3: {}
msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3):