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() { export default function AnimatePage() {
return ( return (
<AppPage title={tool.title} description={tool.summary} icon={tool.icon}> <AppPage>
<AnimationEditor /> <AnimationEditor />
</AppPage> </AppPage>
); );

View File

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

View File

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

View File

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

19
app/(app)/cron/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { AppPage } from '@/components/layout/AppPage';
import { CronEditor } from '@/components/cron/CronEditor';
import { getToolByHref } from '@/lib/tools';
import { Metadata } from 'next';
const tool = getToolByHref('/cron')!;
export const metadata: Metadata = {
title: tool.title,
description: tool.summary,
};
export default function CronPage() {
return (
<AppPage>
<CronEditor />
</AppPage>
);
}

View File

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

View File

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

View File

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

16
app/(app)/random/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { RandomGenerator } from '@/components/random/RandomGenerator';
import { AppPage } from '@/components/layout/AppPage';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/random')!;
export const metadata: Metadata = { title: tool.title, description: tool.summary };
export default function RandomPage() {
return (
<AppPage>
<RandomGenerator />
</AppPage>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,73 +1,57 @@
'use client';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion';
import AnimatedBackground from '@/components/AnimatedBackground'; import AnimatedBackground from '@/components/AnimatedBackground';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { Button } from '@/components/ui/button'; import { ArrowLeft } from 'lucide-react';
import { Home } from 'lucide-react';
export default function NotFound() { export default function NotFound() {
return ( return (
<main className="relative min-h-screen dark text-foreground flex flex-col"> <main className="relative min-h-screen dark text-foreground flex flex-col">
<AnimatedBackground /> <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 */} <div className="flex-1 flex flex-col items-center justify-center px-6 py-20 relative z-10 text-center">
<motion.h1
className="text-7xl md:text-9xl font-bold mb-6 text-primary" {/* Logo */}
initial={{ opacity: 0, y: 20 }} <Logo size={52} />
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }} {/* 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 404
</motion.h1> </span>
</div>
{/* Subtitle */} {/* Divider */}
<motion.p <div
className="text-xl md:text-3xl font-medium mb-4" className="mt-6 w-12 h-px bg-gradient-to-r from-transparent via-primary/50 to-transparent"
initial={{ opacity: 0, y: 20 }} style={{ animation: 'fadeIn 0.5s ease-out 0.3s both' }}
animate={{ opacity: 1, y: 0 }} />
transition={{ duration: 0.8, delay: 0.4 }}
>
Page Not Found
</motion.p>
{/* Description */} {/* Message */}
<motion.p <div
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-md mx-auto" className="mt-6 space-y-2"
initial={{ opacity: 0, y: 20 }} style={{ animation: 'slideUp 0.5s ease-out 0.35s both' }}
animate={{ opacity: 1, y: 0 }} >
transition={{ duration: 0.8, delay: 0.6 }} <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 are looking for doesn&apos;t exist or has been moved. The tool or page you&apos;re looking for doesn&apos;t exist or has been moved.
</motion.p> </p>
</div>
{/* CTA Button */} {/* CTA */}
<motion.div <div
initial={{ opacity: 0, y: 20 }} className="mt-8"
animate={{ opacity: 1, y: 0 }} style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
transition={{ duration: 0.8, delay: 0.8 }} >
<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="/"> <ArrowLeft className="w-3.5 h-3.5 text-primary" />
<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"> Back to Home
<Home className="mr-2 h-5 w-5" /> </Link>
Back to Home
</Button>
</Link>
</motion.div>
</div> </div>
</div> </div>
</main> </main>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,68 +1,39 @@
'use client';
import { tools } from '@/lib/tools'; import { tools } from '@/lib/tools';
import { motion } from 'framer-motion'; import { Box, Code2, Globe } from 'lucide-react';
const stats = [ const stats = [
{ { value: tools.length, label: 'Tools available', icon: Box },
number: tools.length, { value: '100%', label: 'Open source', icon: Code2 },
label: 'Tools', { value: '100%', label: 'Browser-first', icon: Globe },
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>
),
},
]; ];
export default function Stats() { export default function Stats() {
return ( return (
<section className="relative py-16 px-4"> <section className="relative py-4 px-6">
<div className="max-w-6xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{stats.map((stat, index) => ( {stats.map((stat, i) => {
<motion.div const Icon = stat.icon;
key={stat.label} return (
className="glass rounded-2xl p-8 text-center" <div
initial={{ opacity: 0, y: 20 }} key={stat.label}
whileInView={{ opacity: 1, y: 0 }} className="glass rounded-2xl p-5 flex items-center gap-4 border border-white/[0.06]"
viewport={{ once: true }} style={{ animation: `slideUp 0.5s ease-out ${0.1 + i * 0.1}s both` }}
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 }}
> >
{stat.icon} <div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/15 flex items-center justify-center shrink-0">
</motion.div> <Icon className="w-4.5 h-4.5 text-primary" />
<div className="text-4xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400"> </div>
{stat.number} <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>
<div className="text-muted-foreground text-base font-medium"> );
{stat.label} })}
</div>
</motion.div>
))}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,91 +1,64 @@
'use client';
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
const MotionLink = motion.create(Link); import { ElementType } from 'react';
interface ToolCardProps { interface ToolCardProps {
title: string; title: string;
description: string; description: string;
icon: ReactNode; icon: ElementType;
url: string; url: string;
index: number; index: number;
badges?: string[]; 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 ( return (
<MotionLink <Link
href={url} href={url}
className="group relative block h-full" 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"
initial={{ opacity: 0, y: 50 }} style={{ animation: `slideUp 0.5s ease-out ${0.05 * index}s both` }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -10 }}
> >
<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"> {/* Top shimmer accent on hover */}
{/* Subtle hover overlay */} <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" />
<div className="absolute inset-0 opacity-0 group-hover:opacity-10 transition-opacity duration-300 bg-primary" />
{/* Icon */} {/* Radial glow on hover */}
<motion.div <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" />
className="mb-6 flex justify-center"
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<div className="p-4 rounded-xl bg-primary/10 text-primary shadow-lg shadow-black/5">
{icon}
</div>
</motion.div>
{/* Title */} {/* Icon */}
<h3 className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary"> <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)]">
{title} <Icon className="w-5 h-5 text-primary" />
</h3> </div>
{/* Badges */} {/* Title */}
{badges && badges.length > 0 && ( <h3 className="text-base font-semibold text-foreground/80 group-hover:text-foreground transition-colors duration-200 mb-2 leading-snug">
<div className="flex flex-wrap gap-2 mb-3"> {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) => ( {badges.map((badge) => (
<span <span
key={badge} 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} {badge}
</span> </span>
))} ))}
</div> </div>
) : (
<span />
)} )}
<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">
{/* Description */} <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" />
<p className="text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300"> </div>
{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> </div>
</MotionLink> </Link>
); );
} }

View File

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

View File

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

View File

@@ -1,10 +1,8 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; 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 { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { cn, iconBtn } from '@/lib/utils';
import { buildCSS } from '@/lib/animate/cssBuilder'; import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate'; import type { AnimationConfig, PreviewElement } from '@/types/animate';
@@ -23,13 +21,26 @@ const SPEEDS: { label: string; value: string }[] = [
{ label: '2×', value: '2' }, { 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) { export function AnimationPreview({ config, element, onElementChange }: Props) {
const styleRef = useRef<HTMLStyleElement | null>(null); const styleRef = useRef<HTMLStyleElement | null>(null);
const [restartKey, setRestartKey] = useState(0); const [restartKey, setRestartKey] = useState(0);
const [animState, setAnimState] = useState<AnimState>('playing'); const [animState, setAnimState] = useState<AnimState>('playing');
const [speed, setSpeed] = useState('1'); const [speed, setSpeed] = useState('1');
// Inject @keyframes CSS into document head
useEffect(() => { useEffect(() => {
if (!styleRef.current) { if (!styleRef.current) {
styleRef.current = document.createElement('style'); styleRef.current = document.createElement('style');
@@ -37,125 +48,113 @@ export function AnimationPreview({ config, element, onElementChange }: Props) {
document.head.appendChild(styleRef.current); document.head.appendChild(styleRef.current);
} }
styleRef.current.textContent = buildCSS(config); styleRef.current.textContent = buildCSS(config);
// Restart preview whenever config changes so changes are immediately visible
setAnimState('playing'); setAnimState('playing');
setRestartKey((k) => k + 1); setRestartKey((k) => k + 1);
}, [config]); }, [config]);
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { styleRef.current?.remove(); }; return () => { styleRef.current?.remove(); };
}, []); }, []);
const restart = () => { const restart = () => { setAnimState('playing'); setRestartKey((k) => k + 1); };
setAnimState('playing');
setRestartKey((k) => k + 1);
};
const handlePlay = () => {
if (animState === 'ended') {
// Animation finished — restart it
restart();
} else {
setAnimState('playing');
}
};
const scaledDuration = Math.round(config.duration / Number(speed)); const scaledDuration = Math.round(config.duration / Number(speed));
const isInfinite = config.iterationCount === 'infinite'; const isInfinite = config.iterationCount === 'infinite';
return ( return (
<Card className="h-full flex flex-col"> <div className="glass rounded-xl p-4 shrink-0 flex flex-col gap-3">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> {/* Header: speed pills */}
<CardTitle>Preview</CardTitle> <div className="flex items-center justify-between shrink-0">
<ToggleGroup type="single" value={speed} onValueChange={(v) => v && setSpeed(v)} variant="outline" size="sm"> <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) => ( {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} {s.label}
</ToggleGroupItem> </button>
))} ))}
</ToggleGroup> </div>
</CardHeader> </div>
<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',
}}
/>
{/* Animated element */} {/* Canvas */}
<div <div
key={restartKey} className="h-44 rounded-xl flex items-center justify-center relative overflow-hidden"
className="animated relative z-10" style={{
style={{ background: 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
animationDuration: `${scaledDuration}ms`, backgroundImage: [
animationPlayState: animState === 'paused' ? 'paused' : 'running', 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
}} 'linear-gradient(var(--border) 1px, transparent 1px)',
onAnimationEnd={() => !isInfinite && setAnimState('ended')} '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' && ( <Play className="w-3 h-3" />
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" /> </button>
)} <button
{element === 'circle' && ( onClick={() => setAnimState('paused')}
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" /> disabled={animState !== 'playing'}
)} title="Pause"
{element === 'text' && ( className={previewBtn}
<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 <Pause className="w-3 h-3" />
</span> </button>
)} <button onClick={restart} title="Restart" className={previewBtn}>
</div> <RotateCcw className="w-3 h-3" />
</button>
</div> </div>
</div>
{/* Controls */} </div>
<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>
); );
} }

View File

@@ -1,17 +1,7 @@
'use client'; '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 { Infinity } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { AnimationConfig } from '@/types/animate'; import type { AnimationConfig } from '@/types/animate';
interface Props { interface Props {
@@ -30,14 +20,38 @@ const EASINGS = [
{ value: 'steps(8, end)', label: 'Steps (8)' }, { 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) { export function AnimationSettings({ config, onChange }: Props) {
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) => const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
onChange({ ...config, [key]: value }); onChange({ ...config, [key]: value });
const isInfinite = config.iterationCount === 'infinite'; 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 cubicValues = (() => {
const m = config.easing.match(/cubic-bezier\(([^)]+)\)/); const m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
if (!m) return [0.25, 0.1, 0.25, 1.0]; 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(',')})`); set('easing', `cubic-bezier(${v.join(',')})`);
}; };
const easingSelectValue = isCubic ? 'cubic-bezier' : config.easing;
return ( return (
<Card className="h-full"> <div className="space-y-4">
<CardHeader>
<CardTitle>Settings</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
</CardHeader> Settings
<CardContent className="space-y-4"> </span>
{/* Name */}
<div className="space-y-1.5"> {/* Name */}
<Label className="text-xs">Name</Label> <div>
<Input <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Name</label>
value={config.name} <input
onChange={(e) => { type="text"
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, ''); value={config.name}
set('name', val || 'myAnimation'); onChange={(e) => {
}} const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
className="font-mono text-xs" 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>
<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 */} {/* Easing */}
<div className="grid grid-cols-2 gap-3"> <div>
<div className="space-y-1.5"> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Easing</label>
<Label className="text-xs">Duration</Label> <select
<div className="flex items-center gap-1"> value={easingSelectValue}
<Input onChange={(e) => {
type="number" const v = e.target.value;
min={50} set('easing', v === 'cubic-bezier' ? 'cubic-bezier(0.25,0.1,0.25,1)' : v);
max={10000} }}
step={50} 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"
value={config.duration} >
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))} {EASINGS.map((e) => (
<option key={e.value} value={e.value}>
/> {e.label}
<span className="text-xs text-muted-foreground shrink-0">ms</span> </option>
</div> ))}
</div> </select>
<div className="space-y-1.5"> </div>
<Label className="text-xs">Delay</Label>
<div className="flex items-center gap-1"> {/* Cubic-bezier inputs */}
<Input {isCubic && (
type="number" <div>
min={0} <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">
max={5000} cubic-bezier(P1x, P1y, P2x, P2y)
step={50} </label>
value={config.delay} <div className="grid grid-cols-4 gap-1.5">
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))} {(['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>
<span className="text-xs text-muted-foreground shrink-0">ms</span> <input
</div> 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>
</div> </div>
)}
{/* Easing */} {/* Iterations */}
<div className="space-y-1.5"> <div>
<Label className="text-xs">Easing</Label> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Iterations</label>
<Select <div className="flex gap-1.5">
value={isCubic ? 'cubic-bezier' : config.easing} <input
onValueChange={(v) => { type="number"
if (v === 'cubic-bezier') { min={1}
set('easing', 'cubic-bezier(0.25,0.1,0.25,1)'); max={999}
} else { value={isInfinite ? '' : (config.iterationCount as number)}
set('easing', v); 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"> <Infinity className="w-3.5 h-3.5" />
<SelectValue /> </button>
</SelectTrigger>
<SelectContent>
{EASINGS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div>
{/* Cubic-bezier inputs */} {/* Direction */}
{isCubic && ( <div>
<div className="space-y-1.5"> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Direction</label>
<Label className="text-xs text-muted-foreground">cubic-bezier(P1x, P1y, P2x, P2y)</Label> <div className="flex gap-1">
<div className="grid grid-cols-4 gap-1.5"> {DIRECTIONS.map(({ value, label }) => (
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => ( <button key={value} onClick={() => set('direction', value)} className={pillCls(config.direction === value)}>
<div key={label} className="space-y-0.5"> {label}
<Label className="text-[10px] text-muted-foreground">{label}</Label> </button>
<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>
</div> </div>
</div>
{/* Direction */} {/* Fill Mode */}
<div className="space-y-1.5"> <div>
<Label className="text-xs">Direction</Label> <label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Fill Mode</label>
<Select value={config.direction} onValueChange={(v) => set('direction', v as AnimationConfig['direction'])}> <div className="flex gap-1">
<SelectTrigger className="w-full"> {FILL_MODES.map(({ value, label }) => (
<SelectValue /> <button key={value} onClick={() => set('fillMode', value)} className={pillCls(config.fillMode === value)}>
</SelectTrigger> {label}
<SelectContent> </button>
<SelectItem value="normal">Normal</SelectItem> ))}
<SelectItem value="reverse">Reverse</SelectItem>
<SelectItem value="alternate">Alternate</SelectItem>
<SelectItem value="alternate-reverse">Alternate Reverse</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div>
{/* Fill Mode */} </div>
<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>
); );
} }

View File

@@ -1,79 +1,43 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from '@/lib/utils/cn';
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 { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder'; import { buildCSS, buildTailwindCSS } from '@/lib/animate/cssBuilder';
import { CodeSnippet } from '@/components/ui/code-snippet';
import type { AnimationConfig } from '@/types/animate'; import type { AnimationConfig } from '@/types/animate';
interface Props { interface Props {
config: AnimationConfig; config: AnimationConfig;
} }
function CodeBlock({ code, filename }: { code: string; filename: string }) { type ExportTab = 'css' | 'tailwind';
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>
);
}
export function ExportPanel({ config }: Props) { export function ExportPanel({ config }: Props) {
const [tab, setTab] = useState<ExportTab>('css');
const css = useMemo(() => buildCSS(config), [config]); const css = useMemo(() => buildCSS(config), [config]);
const tailwind = useMemo(() => buildTailwindCSS(config), [config]); const tailwind = useMemo(() => buildTailwindCSS(config), [config]);
return ( return (
<Card className="h-full"> <div className="space-y-3 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle>Export</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span>
</CardHeader> <div className="flex glass rounded-lg p-0.5 gap-0.5">
<CardContent> {(['css', 'tailwind'] as ExportTab[]).map((t) => (
<Tabs defaultValue="css"> <button
<TabsList className="mb-4"> key={t}
<TabsTrigger value="css" className="text-xs">Plain CSS</TabsTrigger> onClick={() => setTab(t)}
<TabsTrigger value="tailwind" className="text-xs">Tailwind v4</TabsTrigger> className={cn(
</TabsList> 'px-2.5 py-1 rounded-md text-[10px] font-mono transition-all',
<TabsContent value="css"> tab === t ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
<CodeBlock code={css} filename={`${config.name}.css`} /> )}
</TabsContent> >
<TabsContent value="tailwind"> {t === 'css' ? 'Plain CSS' : 'Tailwind v4'}
<CodeBlock code={tailwind} filename={`${config.name}.tailwind.css`} /> </button>
</TabsContent> ))}
</Tabs> </div>
</CardContent> </div>
</Card> {tab === 'css' && <CodeSnippet code={css} />}
{tab === 'tailwind' && <CodeSnippet code={tailwind} />}
</div>
); );
} }

View File

@@ -1,10 +1,7 @@
'use client'; '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 { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button'; import { ColorInput } from '@/components/ui/color-input';
import { MousePointerClick } from 'lucide-react'; import { MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate'; import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
@@ -28,26 +25,20 @@ interface SliderRowProps {
function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) { function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) {
return ( return (
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center"> <div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-1"> <div className="space-y-1.5">
<Label className="text-[10px] text-muted-foreground"> <label className="text-[9px] text-muted-foreground/50 font-mono">
{label}{unit && <span className="text-muted-foreground/50"> ({unit})</span>} {label}{unit && <span className="opacity-50"> ({unit})</span>}
</Label> </label>
<Slider <Slider min={min} max={max} step={step} value={[value]} onValueChange={([v]) => onChange(v)} />
min={min}
max={max}
step={step}
value={[value]}
onValueChange={([v]) => onChange(v)}
/>
</div> </div>
<Input <input
type="number" type="number"
min={min} min={min}
max={max} max={max}
step={step} step={step}
value={value} value={value}
onChange={(e) => onChange(Number(e.target.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> </div>
); );
@@ -56,15 +47,12 @@ function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderR
export function KeyframeProperties({ keyframe, onChange }: Props) { export function KeyframeProperties({ keyframe, onChange }: Props) {
if (!keyframe) { if (!keyframe) {
return ( return (
<Card className="h-full"> <div className="flex flex-col items-center justify-center py-12 text-center gap-3">
<CardHeader> <MousePointerClick className="w-7 h-7 text-muted-foreground/20" />
<CardTitle>Properties</CardTitle> <p className="text-[10px] text-muted-foreground/40 font-mono leading-relaxed max-w-[180px]">
</CardHeader> Select a keyframe on the timeline to edit its properties
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> </p>
<MousePointerClick className="h-8 w-8 mx-auto mb-3 opacity-20" /> </div>
<p className="text-xs text-muted-foreground">Select a keyframe on the timeline to edit its properties</p>
</CardContent>
</Card>
); );
} }
@@ -72,10 +60,7 @@ export function KeyframeProperties({ keyframe, onChange }: Props) {
const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform }; const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform };
const setTransform = (key: keyof TransformValue, value: number) => { const setTransform = (key: keyof TransformValue, value: number) => {
onChange(keyframe.id, { onChange(keyframe.id, { ...props, transform: { ...t, [key]: value } });
...props,
transform: { ...t, [key]: value },
});
}; };
const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => { 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'; const hasBg = props.backgroundColor && props.backgroundColor !== 'none';
return ( return (
<Card className="h-full overflow-auto"> <div className="space-y-5">
<CardHeader> <div className="flex items-center gap-2">
<CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Properties Properties
<span className="text-muted-foreground font-normal text-sm ml-2">{keyframe.offset}%</span> </span>
</CardTitle> <span className="text-[9px] text-primary/60 font-mono bg-primary/10 px-1.5 py-0.5 rounded">
</CardHeader> {keyframe.offset}%
<CardContent className="space-y-5"> </span>
</div>
{/* Transform */} {/* Transform */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Transform</p> <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 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="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="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 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="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 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)} /> <SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
</div> </div>
{/* Visual */} {/* Visual */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Visual</p> <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 {/* Background color */}
label="Opacity" <div>
value={props.opacity ?? 1} <div className="flex items-center justify-between mb-1.5">
min={0} max={1} step={0.01} <label className="text-[9px] text-muted-foreground/50 font-mono">Background Color</label>
onChange={(v) => setProp('opacity', v)} <button
/> onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
className={cn(
{/* Background color */} 'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
<div className="space-y-1.5"> hasBg
<Label className="text-[10px] text-muted-foreground">Background Color</Label> ? 'border-primary/40 text-primary bg-primary/10'
<div className="flex items-center gap-2"> : 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
<Input )}
type="color" >
value={hasBg ? props.backgroundColor! : '#8b5cf6'} {hasBg ? 'On' : 'Off'}
onChange={(e) => setProp('backgroundColor', e.target.value)} </button>
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>
</div> </div>
<ColorInput
<SliderRow value={hasBg ? props.backgroundColor! : '#8b5cf6'}
label="Border Radius" onChange={(v) => setProp('backgroundColor', v)}
unit="px" disabled={!hasBg}
value={props.borderRadius ?? 0}
min={0} max={200}
onChange={(v) => setProp('borderRadius', v)}
/> />
</div> </div>
{/* Filters */} <SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} />
<div className="space-y-3"> </div>
<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>
</CardContent> {/* Filters */}
</Card> <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'; 'use client';
import { useRef } from 'react'; 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 { Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, iconBtn } from '@/lib/utils';
import type { Keyframe } from '@/types/animate'; import type { Keyframe } from '@/types/animate';
interface Props { interface Props {
@@ -14,11 +12,14 @@ interface Props {
onAdd: (offset: number) => void; onAdd: (offset: number) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onMove: (id: string, newOffset: number) => 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 trackRef = useRef<HTMLDivElement>(null);
const getOffsetFromEvent = (clientX: number): number => { const getOffsetFromEvent = (clientX: number): number => {
@@ -29,7 +30,6 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
}; };
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => { 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; if ((e.target as HTMLElement).closest('[data-keyframe-marker]')) return;
onAdd(getOffsetFromEvent(e.clientX)); onAdd(getOffsetFromEvent(e.clientX));
}; };
@@ -39,16 +39,11 @@ export function KeyframeTimeline({ keyframes, selectedId, onSelect, onAdd, onDel
onSelect(id); onSelect(id);
const el = e.currentTarget as HTMLElement; const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId); el.setPointerCapture(e.pointerId);
const handleMove = (me: PointerEvent) => onMove(id, getOffsetFromEvent(me.clientX));
const handleMove = (me: PointerEvent) => {
onMove(id, getOffsetFromEvent(me.clientX));
};
const handleUp = () => { const handleUp = () => {
el.removeEventListener('pointermove', handleMove); el.removeEventListener('pointermove', handleMove);
el.removeEventListener('pointerup', handleUp); el.removeEventListener('pointerup', handleUp);
}; };
el.addEventListener('pointermove', handleMove); el.addEventListener('pointermove', handleMove);
el.addEventListener('pointerup', handleUp); 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 sorted = [...keyframes].sort((a, b) => a.offset - b.offset);
const selectedKf = keyframes.find((k) => k.id === selectedId); const selectedKf = keyframes.find((k) => k.id === selectedId);
return ( const content = (
<Card> <div className="space-y-2">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> {/* Header */}
<CardTitle>Keyframes</CardTitle> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{keyframes.length} keyframe{keyframes.length !== 1 ? 's' : ''} Keyframes
{selectedKf ? ` · selected: ${selectedKf.offset}%` : ''}
</span> </span>
<Button <span className="text-[9px] text-muted-foreground/40 font-mono">
size="icon-xs" {keyframes.length} kf{selectedKf ? ` · ${selectedKf.offset}%` : ''}
variant="outline" </span>
onClick={() => onAdd(50)} </div>
title="Add keyframe at 50%" <div className="flex items-center gap-1">
> <button onClick={() => onAdd(50)} title="Add at 50%" className={timelineBtn}>
<Plus className="h-3 w-3" /> <Plus className="w-3 h-3" />
</Button> </button>
<Button <button
size="icon-xs"
variant="outline"
disabled={!selectedId || keyframes.length <= 2}
onClick={() => selectedId && onDelete(selectedId)} onClick={() => selectedId && onDelete(selectedId)}
title="Delete selected keyframe" disabled={!selectedId || keyframes.length <= 2}
title="Delete selected"
className={timelineBtn}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="w-3 h-3" />
</Button> </button>
</div> </div>
</CardHeader> </div>
<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" />
{/* Tick marks */} {/* Track */}
{TICKS.map((tick) => ( <div
<div ref={trackRef}
key={tick} className="relative h-14 bg-white/3 rounded-lg border border-border/25 cursor-crosshair select-none mx-4"
className="absolute top-0 bottom-0 flex flex-col items-center pointer-events-none" onClick={handleTrackClick}
style={{ left: `${tick}%` }} >
> <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-border/30" />
<div className="w-px h-2 bg-muted-foreground/30 mt-0" /> {TICKS.map((tick) => (
<span className="text-[9px] text-muted-foreground/50 mt-auto mb-1">{tick}%</span> <div
</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 */} {/* Offset labels */}
{sorted.map((kf) => ( <div className="relative h-4 mx-4">
<button {sorted.map((kf) => (
key={kf.id} <span
data-keyframe-marker key={kf.id}
className={cn( 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', 'absolute -translate-x-1/2 text-[9px] font-mono transition-colors',
kf.id === selectedId kf.id === selectedId ? 'text-primary font-medium' : 'text-muted-foreground/40'
? 'bg-primary shadow-lg shadow-primary/40 scale-125' )}
: 'bg-muted-foreground/60 hover:bg-primary/70' style={{ left: `${kf.offset}%` }}
)} >
style={{ left: `${kf.offset}%` }} {kf.offset}%
onClick={(e) => { e.stopPropagation(); onSelect(kf.id); }} </span>
onPointerDown={(e) => handlePointerDown(e, kf.id)} ))}
title={`${kf.offset}% — drag to move`} </div>
/> </div>
))} );
</div>
{/* Offset labels below */} if (embedded) return <div>{content}</div>;
<div className="relative h-5 mt-1">
{sorted.map((kf) => ( return (
<span <div className="glass rounded-xl px-4 pt-4 pb-3 shrink-0">
key={kf.id} {content}
className={cn( </div>
'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>
); );
} }

View File

@@ -1,24 +1,20 @@
'use client'; 'use client';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from '@/lib/utils/cn';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets'; import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
import { buildKeyframesOnly } from '@/lib/animate/cssBuilder'; import { buildKeyframesOnly } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, AnimationPreset } from '@/types/animate'; import type { AnimationConfig, AnimationPreset, PresetCategory } from '@/types/animate';
interface Props { interface Props {
onSelect: (config: AnimationConfig) => void; onSelect: (config: AnimationConfig) => void;
} }
function PresetCard({ preset, onSelect }: { function PresetCard({ preset, onSelect }: { preset: AnimationPreset; onSelect: () => void }) {
preset: AnimationPreset;
onSelect: () => void;
}) {
const styleRef = useRef<HTMLStyleElement | null>(null); const styleRef = useRef<HTMLStyleElement | null>(null);
const animName = `preview-${preset.id}`; 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(() => { useEffect(() => {
const renamedConfig = { ...preset.config, name: animName }; const renamedConfig = { ...preset.config, name: animName };
if (!styleRef.current) { if (!styleRef.current) {
@@ -26,25 +22,18 @@ function PresetCard({ preset, onSelect }: {
document.head.appendChild(styleRef.current); document.head.appendChild(styleRef.current);
} }
styleRef.current.textContent = buildKeyframesOnly(renamedConfig); styleRef.current.textContent = buildKeyframesOnly(renamedConfig);
return () => { return () => { styleRef.current?.remove(); styleRef.current = null; };
styleRef.current?.remove();
styleRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 ( return (
<button <button
onClick={onSelect} 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-12 flex items-center justify-center rounded-lg bg-white/3 overflow-hidden">
<div className="w-full h-14 flex items-center justify-center rounded-lg bg-muted/30 overflow-hidden">
<div <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={{ style={{
animationName: animName, animationName: animName,
animationDuration: `${thumbDuration}ms`, animationDuration: `${thumbDuration}ms`,
@@ -55,7 +44,7 @@ function PresetCard({ preset, onSelect }: {
}} }}
/> />
</div> </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} {preset.name}
</span> </span>
</button> </button>
@@ -63,35 +52,32 @@ function PresetCard({ preset, onSelect }: {
} }
export function PresetLibrary({ onSelect }: Props) { export function PresetLibrary({ onSelect }: Props) {
const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]);
return ( return (
<Card> <div className="space-y-3 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<CardHeader> <div className="flex items-center justify-between">
<CardTitle>Presets</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Presets</span>
</CardHeader> <div className="flex glass rounded-lg p-0.5 gap-0.5">
<CardContent>
<Tabs defaultValue="Entrance">
<TabsList className="mb-4">
{PRESET_CATEGORIES.map((cat) => (
<TabsTrigger key={cat} value={cat} className="text-xs">
{cat}
</TabsTrigger>
))}
</TabsList>
{PRESET_CATEGORIES.map((cat) => ( {PRESET_CATEGORIES.map((cat) => (
<TabsContent key={cat} value={cat}> <button
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2"> key={cat}
{PRESETS.filter((p) => p.category === cat).map((preset) => ( onClick={() => setCategory(cat)}
<PresetCard className={cn(
key={preset.id} 'px-2 py-1 rounded-md text-[10px] font-mono transition-all',
preset={preset} category === cat ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
onSelect={() => onSelect(preset.config)} )}
/> >
))} {cat}
</div> </button>
</TabsContent>
))} ))}
</Tabs> </div>
</CardContent> </div>
</Card> <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 { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii'; import type { ASCIIFont } from '@/types/ascii';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'editor' | 'preview'; type Tab = 'editor' | 'preview';
@@ -102,28 +103,16 @@ export function ASCIIConverter() {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ────────────────────────────────── */} <MobileTabs
<div className="flex lg:hidden glass rounded-xl p-1 gap-1"> tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
{(['editor', 'preview'] as Tab[]).map((t) => ( active={tab}
<button onChange={(v) => setTab(v as Tab)}
key={t} />
onClick={() => setTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
tab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'editor' ? 'Editor' : 'Preview'}
</button>
))}
</div>
{/* ── Main layout ────────────────────────────────────────── */} {/* ── Main layout ────────────────────────────────────────── */}
<div <div
className="grid grid-cols-1 lg:grid-cols-5 gap-4" className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }} style={{ height: 'calc(100svh - 120px)' }}
> >
{/* Left panel: text input + font selector */} {/* Left panel: text input + font selector */}
<div <div

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,372 @@
'use client';
import { useState, useMemo, useCallback, useRef } from 'react';
import {
Copy, Check, BookmarkPlus, Clock, Trash2, ChevronRight,
AlertCircle, CalendarClock,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
import { cardBtn } from '@/lib/utils/styles';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import { CronFieldEditor } from './CronFieldEditor';
import { CronPresets } from './CronPresets';
import { useCronStore } from '@/lib/cron/store';
import {
FIELD_CONFIGS,
splitCronFields,
buildCronExpression,
describeCronExpression,
validateCronExpression,
getNextOccurrences,
type FieldType,
type CronFields,
} from '@/lib/cron/cron-engine';
const FIELD_ORDER: FieldType[] = ['minute', 'hour', 'dom', 'month', 'dow'];
function getFieldValue(fields: CronFields, type: FieldType): string {
switch (type) {
case 'minute': return fields.minute;
case 'hour': return fields.hour;
case 'dom': return fields.dom;
case 'month': return fields.month;
case 'dow': return fields.dow;
case 'second': return fields.second ?? '*';
}
}
function formatOccurrence(d: Date): { relative: string; absolute: string; dow: string } {
const now = new Date();
const diffMs = d.getTime() - now.getTime();
const diffMins = Math.round(diffMs / 60_000);
const diffH = Math.round(diffMs / 3_600_000);
const diffD = Math.round(diffMs / 86_400_000);
let relative: string;
if (diffMins < 60) relative = `in ${diffMins}m`;
else if (diffH < 24) relative = `in ${diffH}h`;
else if (diffD === 1) relative = 'tomorrow';
else relative = `in ${diffD}d`;
const absolute = d.toLocaleString('en-US', {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true,
});
const dow = d.toLocaleDateString('en-US', { weekday: 'short' });
return { relative, absolute, dow };
}
// ── Schedule list ─────────────────────────────────────────────────────────────
function ScheduleList({ schedule, isValid }: { schedule: Date[]; isValid: boolean }) {
if (!isValid) return (
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
Fix the expression to see upcoming runs
</p>
);
if (schedule.length === 0) return (
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
No occurrences in the next 5 years
</p>
);
return (
<div className="flex flex-col">
{schedule.map((d, i) => {
const { relative, absolute, dow } = formatOccurrence(d);
const isFirst = i === 0;
return (
<div
key={i}
className={cn(
'flex items-center gap-2.5 py-2.5 border-b border-border/10 last:border-0',
)}
>
<span className={cn(
'font-mono text-[10px] px-1.5 py-0.5 rounded border shrink-0 w-[36px] text-center',
isFirst
? 'bg-primary/20 text-primary border-primary/30'
: 'bg-muted/15 text-muted-foreground/50 border-border/10',
)}>
{dow}
</span>
<span className={cn(
'text-xs font-mono flex-1',
isFirst ? 'text-foreground font-medium' : 'text-muted-foreground',
)}>
{absolute}
</span>
<span className="text-[10px] font-mono text-muted-foreground/35 shrink-0">
{relative}
</span>
</div>
);
})}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CronEditor() {
const { expression, setExpression, addToHistory, history, removeFromHistory, clearHistory } =
useCronStore();
const [activeField, setActiveField] = useState<FieldType>('minute');
const [mobileTab, setMobileTab] = useState<'editor' | 'preview'>('editor');
const [copied, setCopied] = useState(false);
const [editingRaw, setEditingRaw] = useState(false);
const [rawExpr, setRawExpr] = useState('');
const rawInputRef = useRef<HTMLInputElement>(null);
const isValid = useMemo(() => validateCronExpression(expression).valid, [expression]);
const fields = useMemo(() => splitCronFields(expression), [expression]);
const description = useMemo(() => describeCronExpression(expression), [expression]);
const schedule = useMemo(
() => (isValid ? getNextOccurrences(expression, 7) : []),
[expression, isValid],
);
const handleFieldChange = useCallback(
(type: FieldType, value: string) => {
if (!fields) return;
const updated: CronFields = { ...fields, [type]: value };
setExpression(buildCronExpression(updated));
},
[fields, setExpression],
);
const handleCopy = async () => {
await navigator.clipboard.writeText(expression);
setCopied(true);
toast.success('Copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
const handleSave = () => {
addToHistory(expression);
toast.success('Saved to history');
};
const handleRawKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (validateCronExpression(rawExpr).valid) setExpression(rawExpr);
setEditingRaw(false);
}
if (e.key === 'Escape') setEditingRaw(false);
};
const startEditRaw = () => {
setRawExpr(expression);
setEditingRaw(true);
setTimeout(() => rawInputRef.current?.focus(), 0);
};
// ── Expression bar (rendered inside right panel) ──────────────────────────
const expressionBar = (
<div className="glass rounded-xl border border-border/40 p-4">
{/* Row 1: Field chips + actions */}
<div className="flex items-center gap-1.5 flex-wrap mb-3">
{FIELD_ORDER.map((type) => {
const active = activeField === type;
const fValue = fields ? getFieldValue(fields, type) : '*';
return (
<button
key={type}
onClick={() => { setActiveField(type); setMobileTab('editor'); }}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md border transition-all',
active
? 'bg-primary/15 border-primary/50 shadow-[0_0_8px_rgba(139,92,246,0.2)]'
: 'glass border-border/25 hover:border-primary/30 hover:bg-primary/5',
)}
>
<span className={cn(
'text-[8px] font-mono uppercase tracking-[0.1em]',
active ? 'text-primary/60' : 'text-muted-foreground/40',
)}>
{FIELD_CONFIGS[type].shortLabel}
</span>
<span className={cn(
'font-mono text-[10px] font-semibold',
active ? 'text-primary' : fValue === '*' ? 'text-muted-foreground/50' : 'text-foreground',
)}>
{fValue}
</span>
</button>
);
})}
<div className="ml-auto flex items-center gap-1.5">
<button onClick={handleCopy} className={cardBtn}>
{copied
? <><Check className="w-3 h-3" /> Copied</>
: <><Copy className="w-3 h-3" /> Copy</>}
</button>
<button onClick={handleSave} className={cardBtn}>
<BookmarkPlus className="w-3 h-3" /> Save
</button>
</div>
</div>
{/* Row 2: Expression + description (stacked on mobile, inline on lg) */}
<div className="flex flex-col gap-1 min-w-0">
<div
className={cn(
'cursor-text font-mono text-sm tracking-[0.15em] rounded px-1 -mx-1 py-0.5 transition-colors w-full',
!editingRaw && 'hover:bg-white/3',
!isValid && !editingRaw && 'text-destructive/70',
)}
onClick={!editingRaw ? startEditRaw : undefined}
>
{editingRaw ? (
<input
ref={rawInputRef}
value={rawExpr}
onChange={(e) => setRawExpr(e.target.value)}
onKeyDown={handleRawKey}
onBlur={() => setEditingRaw(false)}
className={cn(
'w-full bg-transparent font-mono text-sm tracking-[0.15em] focus:outline-none',
validateCronExpression(rawExpr).valid ? 'text-foreground' : 'text-destructive/80',
)}
/>
) : (
expression
)}
</div>
<div className="flex items-center gap-1.5 min-w-0">
{isValid
? <CalendarClock className="w-3 h-3 text-muted-foreground/30 shrink-0" />
: <AlertCircle className="w-3 h-3 text-destructive/50 shrink-0" />}
<p className={cn(
'text-xs truncate',
isValid ? 'text-muted-foreground' : 'text-destructive/60',
)}>
{description}
</p>
</div>
</div>
{/* Row 3: Presets select */}
<div className="mt-3 pt-3 border-t border-border/10">
<CronPresets onSelect={(expr) => setExpression(expr)} current={expression} />
</div>
</div>
);
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as 'editor' | 'preview')}
/>
{/* Main layout — side-by-side on lg, tabbed on mobile */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: Field editor + Presets ──────────────────────────────── */}
<div className={cn(
'lg:col-span-3 flex flex-col gap-4',
mobileTab === 'preview' && 'hidden lg:flex',
)}>
{/* Field selector tabs */}
<div className="flex glass rounded-lg p-0.5 gap-0.5">
{FIELD_ORDER.map((type) => (
<button
key={type}
onClick={() => setActiveField(type)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
activeField === type
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{FIELD_CONFIGS[type].shortLabel}
</button>
))}
</div>
{/* Field editor panel */}
<div className="glass rounded-xl p-5 border border-border/40 flex-1 min-h-0 overflow-hidden">
{fields ? (
<CronFieldEditor
fieldType={activeField}
value={getFieldValue(fields, activeField)}
onChange={(v) => handleFieldChange(activeField, v)}
/>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
Invalid expression fix it above to edit fields
</p>
)}
</div>
</div>
{/* Right: Expression bar + Schedule preview ───────────────────── */}
<div className={cn(
'lg:col-span-2 flex flex-col gap-4 flex-1 min-h-0',
mobileTab === 'editor' && 'hidden lg:flex',
)}>
{expressionBar}
<div className="glass rounded-xl p-4 border border-border/40 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent overflow-auto flex-1">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-3.5 h-3.5 text-muted-foreground/40" />
<span className="text-[9px] font-mono text-muted-foreground/50 uppercase tracking-widest">
Next Occurrences
</span>
</div>
<ScheduleList schedule={schedule} isValid={isValid} />
</div>
{/* Saved history */}
{history.length > 0 && (
<div className="glass rounded-xl p-4 border border-border/40 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent overflow-auto">
<div className="flex items-center justify-between mb-3">
<span className="text-[9px] font-mono text-muted-foreground/40 uppercase tracking-widest">
Saved
</span>
<button onClick={clearHistory} className={cardBtn}>
<Trash2 className="w-2.5 h-2.5" /> Clear
</button>
</div>
<div className="flex flex-col gap-1">
{history.slice(0, 8).map((entry) => (
<div key={entry.id} className="flex items-center gap-2 group">
<button
onClick={() => setExpression(entry.expression)}
className={cn(
'flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-left',
entry.expression === expression
? 'bg-primary/10 border-primary/30 text-primary'
: 'glass border-border/20 text-muted-foreground hover:border-primary/30 hover:text-foreground',
)}
>
{entry.expression === expression && <ChevronRight className="w-3 h-3 shrink-0" />}
<span className="font-mono text-xs truncate">{entry.expression}</span>
</button>
<button
onClick={() => removeFromHistory(entry.id)}
className="w-6 h-6 flex items-center justify-center text-muted-foreground/40 hover:text-destructive transition-all rounded"
>
×
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils/cn';
import {
parseField,
rebuildFieldFromValues,
validateCronField,
FIELD_CONFIGS,
MONTH_SHORT_NAMES,
DOW_SHORT_NAMES,
type FieldType,
} from '@/lib/cron/cron-engine';
// ── Per-field presets ─────────────────────────────────────────────────────────
interface Preset { label: string; value: string }
const FIELD_PRESETS: Record<FieldType, Preset[]> = {
second: [
{ label: 'Any (*)', value: '*' },
{ label: '*/5', value: '*/5' },
{ label: '*/10', value: '*/10' },
{ label: '*/15', value: '*/15' },
{ label: '*/30', value: '*/30' },
],
minute: [
{ label: 'Any (*)', value: '*' },
{ label: ':00', value: '0' },
{ label: ':30', value: '30' },
{ label: '*/5', value: '*/5' },
{ label: '*/10', value: '*/10' },
{ label: '*/15', value: '*/15' },
{ label: '*/30', value: '*/30' },
],
hour: [
{ label: 'Any (*)', value: '*' },
{ label: 'Midnight', value: '0' },
{ label: '6 AM', value: '6' },
{ label: '9 AM', value: '9' },
{ label: 'Noon', value: '12' },
{ label: '6 PM', value: '18' },
{ label: 'Every 4h', value: '*/4' },
{ label: 'Every 6h', value: '*/6' },
{ label: '917', value: '9-17' },
],
dom: [
{ label: 'Any (*)', value: '*' },
{ label: '1st', value: '1' },
{ label: '10th', value: '10' },
{ label: '15th', value: '15' },
{ label: '20th', value: '20' },
{ label: '1,15', value: '1,15' },
{ label: '17', value: '1-7' },
],
month: [
{ label: 'Any (*)', value: '*' },
{ label: 'Q1', value: '1-3' },
{ label: 'Q2', value: '4-6' },
{ label: 'Q3', value: '7-9' },
{ label: 'Q4', value: '10-12' },
{ label: 'H1', value: '1-6' },
{ label: 'H2', value: '7-12' },
],
dow: [
{ label: 'Any (*)', value: '*' },
{ label: 'Weekdays', value: '1-5' },
{ label: 'Weekends', value: '0,6' },
{ label: 'Mon', value: '1' },
{ label: 'Wed', value: '3' },
{ label: 'Fri', value: '5' },
{ label: 'Sun', value: '0' },
],
};
// ── Grid configuration ────────────────────────────────────────────────────────
const GRID_COLS: Record<FieldType, string> = {
second: 'grid-cols-10',
minute: 'grid-cols-10',
hour: 'grid-cols-8',
dom: 'grid-cols-7',
month: 'grid-cols-4',
dow: 'grid-cols-7',
};
// ── Component ─────────────────────────────────────────────────────────────────
interface CronFieldEditorProps {
fieldType: FieldType;
value: string;
onChange: (value: string) => void;
}
export function CronFieldEditor({ fieldType, value, onChange }: CronFieldEditorProps) {
const [rawInput, setRawInput] = useState('');
const [showRaw, setShowRaw] = useState(false);
const [rawError, setRawError] = useState('');
const config = FIELD_CONFIGS[fieldType];
const parsed = useMemo(() => parseField(value, config), [value, config]);
const presets = FIELD_PRESETS[fieldType];
const isWildcard = parsed?.isWildcard ?? false;
const isSelected = (v: number) => parsed?.values.has(v) ?? false;
const cellLabel = (v: number): string => {
if (fieldType === 'month') return MONTH_SHORT_NAMES[v - 1];
if (fieldType === 'dow') return DOW_SHORT_NAMES[v];
return String(v).padStart(fieldType === 'second' || fieldType === 'minute' ? 2 : 1, '0');
};
const handleCellClick = (v: number) => {
if (!parsed) return;
if (isWildcard) { onChange(String(v)); return; }
const next = new Set(parsed.values);
if (next.has(v)) {
next.delete(v);
if (next.size === 0) { onChange('*'); return; }
} else {
next.add(v);
if (next.size === config.max - config.min + 1) { onChange('*'); return; }
}
onChange(rebuildFieldFromValues(next, config));
};
const handleRawSubmit = () => {
const { valid, error } = validateCronField(rawInput, fieldType);
if (valid) {
onChange(rawInput);
setShowRaw(false);
setRawInput('');
setRawError('');
} else {
setRawError(error ?? 'Invalid');
}
};
const cells = Array.from({ length: config.max - config.min + 1 }, (_, i) => i + config.min);
// Pad to complete rows for DOM (31 cells, 7 cols → pad to 35)
const colCount = parseInt(GRID_COLS[fieldType].replace('grid-cols-', ''), 10);
const rem = cells.length % colCount;
const padded: (number | null)[] = [...cells, ...(rem === 0 ? [] : Array<null>(colCount - rem).fill(null))];
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-2">
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
{config.label}
</span>
<span className="text-[10px] text-muted-foreground/50 font-mono">
{config.min}{config.max}
</span>
</div>
<div className="flex items-center gap-2">
{isWildcard && (
<span className="text-[10px] font-mono text-primary/60 bg-primary/5 px-2 py-0.5 rounded border border-primary/15">
any value
</span>
)}
<span className="font-mono text-sm text-primary bg-primary/10 px-2.5 py-1 rounded-lg border border-primary/25">
{value}
</span>
</div>
</div>
{/* Presets */}
<div className="flex flex-wrap gap-1.5">
{presets.map((preset) => (
<button
key={preset.value}
onClick={() => onChange(preset.value)}
className={cn(
'px-2.5 py-1 text-[11px] font-mono rounded-lg border transition-all',
value === preset.value
? 'bg-primary/20 border-primary/50 text-primary shadow-[0_0_8px_rgba(139,92,246,0.2)]'
: 'glass border-border/30 text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-primary/5',
)}
>
{preset.label}
</button>
))}
</div>
{/* Value grid */}
<div className={cn('grid gap-1', GRID_COLS[fieldType])}>
{padded.map((v, i) => {
if (v === null) return <div key={`pad-${i}`} />;
const selected = isSelected(v);
return (
<button
key={v}
onClick={() => handleCellClick(v)}
title={fieldType === 'month' ? MONTH_SHORT_NAMES[v - 1] : fieldType === 'dow' ? DOW_SHORT_NAMES[v] : String(v)}
className={cn(
'flex items-center justify-center text-[10px] font-mono rounded-md border transition-all',
fieldType === 'month' || fieldType === 'dow'
? 'py-2 px-1'
: 'aspect-square',
isWildcard
? 'bg-primary/8 border-primary/20 text-primary/50 hover:bg-primary/15 hover:border-primary/40 hover:text-primary'
: selected
? 'bg-primary/25 border-primary/55 text-primary font-semibold shadow-[0_0_6px_rgba(139,92,246,0.25)]'
: 'glass border-border/20 text-muted-foreground/50 hover:border-primary/35 hover:text-foreground hover:bg-primary/5',
)}
>
{cellLabel(v)}
</button>
);
})}
</div>
{/* Custom raw input */}
<div className="pt-1 border-t border-border/10">
{showRaw ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
value={rawInput}
onChange={(e) => { setRawInput(e.target.value); setRawError(''); }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRawSubmit();
if (e.key === 'Escape') { setShowRaw(false); setRawError(''); }
}}
placeholder={`e.g. ${fieldType === 'minute' ? '*/15 or 0,30' : fieldType === 'hour' ? '9-17' : fieldType === 'dow' ? '1-5' : '*'}`}
className={cn(
'flex-1 px-3 py-1.5 text-xs font-mono bg-muted/20 border rounded-lg focus:outline-none transition-colors',
rawError ? 'border-destructive/50 focus:border-destructive' : 'border-border/30 focus:border-primary/50',
)}
autoFocus
/>
<button
onClick={handleRawSubmit}
className="px-3 py-1.5 text-xs font-mono bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-all"
>
Set
</button>
<button
onClick={() => { setShowRaw(false); setRawError(''); }}
className="px-3 py-1.5 text-xs font-mono glass border-border/30 text-muted-foreground rounded-lg hover:text-foreground transition-all"
>
Cancel
</button>
</div>
{rawError && (
<p className="text-[10px] text-destructive font-mono">{rawError}</p>
)}
</div>
) : (
<button
onClick={() => { setRawInput(value); setShowRaw(true); }}
className="text-[11px] font-mono text-muted-foreground/40 hover:text-primary/70 transition-colors"
>
Enter custom expression
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { ChevronDown } from 'lucide-react';
interface Preset {
label: string;
expr: string;
}
interface PresetGroup {
label: string;
items: Preset[];
}
const PRESET_GROUPS: PresetGroup[] = [
{
label: 'Common',
items: [
{ label: 'Every minute', expr: '* * * * *' },
{ label: 'Every 5 min', expr: '*/5 * * * *' },
{ label: 'Every 15 min', expr: '*/15 * * * *' },
{ label: 'Every 30 min', expr: '*/30 * * * *' },
{ label: 'Every hour', expr: '0 * * * *' },
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
],
},
{
label: 'Daily',
items: [
{ label: 'Midnight', expr: '0 0 * * *' },
{ label: '6 AM', expr: '0 6 * * *' },
{ label: '9 AM', expr: '0 9 * * *' },
{ label: 'Noon', expr: '0 12 * * *' },
{ label: 'Twice daily', expr: '0 6,18 * * *' },
],
},
{
label: 'Weekly',
items: [
{ label: 'Weekdays 9 AM', expr: '0 9 * * 1-5' },
{ label: 'Monday 9 AM', expr: '0 9 * * 1' },
{ label: 'Friday 5 PM', expr: '0 17 * * 5' },
{ label: 'Sunday 0 AM', expr: '0 0 * * 0' },
{ label: 'Weekends 9 AM', expr: '0 9 * * 0,6' },
],
},
{
label: 'Periodic',
items: [
{ label: 'Monthly 1st', expr: '0 0 1 * *' },
{ label: '1st & 15th', expr: '0 0 1,15 * *' },
{ label: 'Quarterly', expr: '0 0 1 */3 *' },
{ label: 'Bi-annual', expr: '0 0 1 1,7 *' },
{ label: 'January 1st', expr: '0 0 1 1 *' },
],
},
];
interface CronPresetsProps {
onSelect: (expr: string) => void;
current: string;
}
export function CronPresets({ onSelect, current }: CronPresetsProps) {
const allExprs = PRESET_GROUPS.flatMap(g => g.items.map(i => i.expr));
const isPreset = allExprs.includes(current);
return (
<div className="relative">
<select
value={isPreset ? current : ''}
onChange={(e) => { if (e.target.value) onSelect(e.target.value); }}
className="w-full appearance-none bg-muted/20 border border-border/30 rounded-lg px-3 py-1.5 pr-8 text-xs font-mono text-muted-foreground focus:border-primary/50 focus:outline-none transition-colors cursor-pointer hover:border-border/50"
>
<option value="" disabled>
{isPreset ? '' : 'Quick preset…'}
</option>
{PRESET_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((preset) => (
<option key={preset.expr} value={preset.expr}>
{preset.label}
</option>
))}
</optgroup>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none text-muted-foreground/40" />
</div>
);
}

View File

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

View File

@@ -1,24 +1,34 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { Download, Loader2, Code2, Globe, Layout } from 'lucide-react'; import { Download, Loader2, Code2, Globe, Layout, FileImage } 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 { FaviconFileUpload } from './FaviconFileUpload'; import { FaviconFileUpload } from './FaviconFileUpload';
import { CodeSnippet } from './CodeSnippet'; import { ColorInput } from '@/components/ui/color-input';
import { CodeSnippet } from '@/components/ui/code-snippet';
import { generateFaviconSet } from '@/lib/favicon/faviconService'; import { generateFaviconSet } from '@/lib/favicon/faviconService';
import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils'; import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils';
import type { FaviconSet, FaviconOptions } from '@/types/favicon'; import type { FaviconSet, FaviconOptions } from '@/types/favicon';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn, 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() { export function FaviconGenerator() {
const [sourceFile, setSourceFile] = React.useState<File | null>(null); const [sourceFile, setSourceFile] = React.useState<File | null>(null);
const [options, setOptions] = React.useState<FaviconOptions>({ const [options, setOptions] = React.useState<FaviconOptions>({
name: 'My Awesome App', name: 'My App',
shortName: 'App', shortName: 'App',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
themeColor: '#3b82f6', themeColor: '#3b82f6',
@@ -26,22 +36,18 @@ export function FaviconGenerator() {
const [isGenerating, setIsGenerating] = React.useState(false); const [isGenerating, setIsGenerating] = React.useState(false);
const [progress, setProgress] = React.useState(0); const [progress, setProgress] = React.useState(0);
const [result, setResult] = React.useState<FaviconSet | null>(null); 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 () => { const handleGenerate = async () => {
if (!sourceFile) { if (!sourceFile) { toast.error('Please upload a source image'); return; }
toast.error('Please upload a source image');
return;
}
setIsGenerating(true); setIsGenerating(true);
setProgress(0); setProgress(0);
try { try {
const resultSet = await generateFaviconSet(sourceFile, options, (p) => { const resultSet = await generateFaviconSet(sourceFile, options, (p) => setProgress(p));
setProgress(p);
});
setResult(resultSet); setResult(resultSet);
toast.success('Favicon set generated successfully!'); setMobileTab('results');
toast.success('Favicon set generated!');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error('Failed to generate favicons'); toast.error('Failed to generate favicons');
@@ -52,229 +58,217 @@ export function FaviconGenerator() {
const handleDownloadAll = async () => { const handleDownloadAll = async () => {
if (!result) return; if (!result) return;
const files = result.icons.map((icon) => ({ blob: icon.blob!, filename: icon.name }));
const files = result.icons.map((icon) => ({
blob: icon.blob!,
filename: icon.name,
}));
// Add manifest to ZIP
const manifestBlob = new Blob([result.manifest], { type: 'application/json' }); const manifestBlob = new Blob([result.manifest], { type: 'application/json' });
files.push({ files.push({ blob: manifestBlob, filename: 'site.webmanifest' });
blob: manifestBlob,
filename: 'site.webmanifest',
});
await downloadBlobsAsZip(files, 'favicons.zip'); await downloadBlobsAsZip(files, 'favicons.zip');
toast.success('Downloading favicons ZIP...'); toast.success('Downloading favicons ZIP');
}; };
const handleReset = () => { const handleReset = () => {
setSourceFile(null); setSourceFile(null);
setResult(null); setResult(null);
setProgress(0); setProgress(0);
setMobileTab('setup');
}; };
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6"> <div className="flex flex-col gap-4">
{/* 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>
<Card> <MobileTabs
<CardHeader> tabs={[{ value: 'setup', label: 'Setup' }, { value: 'results', label: 'Results' }]}
<CardTitle>Theme Colors</CardTitle> active={mobileTab}
</CardHeader> onChange={(v) => setMobileTab(v as MobileTab)}
<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>
<Card className="overflow-hidden"> {/* ── Main layout ─────────────────────────────────────── */}
<CardContent> <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 <FaviconFileUpload
selectedFile={sourceFile} selectedFile={sourceFile}
onFileSelect={setSourceFile} onFileSelect={setSourceFile}
onFileRemove={() => setSourceFile(null)} onFileRemove={() => setSourceFile(null)}
disabled={isGenerating} disabled={isGenerating}
/> />
<Button </div>
className="w-full mt-4"
disabled={!sourceFile || isGenerating} {/* App config */}
onClick={handleGenerate} <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 ? ( {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" /> {tab === 'icons' && (
Generating... {progress}% <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> </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> </div>
) : null} </div>
</div> </div>
</div> </div>
); );

View File

@@ -1,42 +1,60 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; 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 { useSidebar } from './SidebarProvider';
import { getToolByHref } from '@/lib/tools';
import Logo from '@/components/Logo'; 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() { export function AppHeader() {
const { toggle, isOpen, isCollapsed, toggleCollapse } = useSidebar(); const { toggle, isOpen, isCollapsed, toggleCollapse } = useSidebar();
const pathname = usePathname();
const tool = getToolByHref(pathname);
return ( 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)]"> <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-2"> <div className="flex items-center gap-1.5 min-w-0">
<Button {/* Desktop: sidebar collapse toggle */}
variant="ghost" <button
size="icon"
className="hidden lg:inline-flex text-muted-foreground hover:text-foreground"
onClick={toggleCollapse} onClick={toggleCollapse}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(iconBtn, 'hidden lg:flex shrink-0')}
> >
{isCollapsed ? ( {isCollapsed
<PanelLeftOpen className="h-5 w-5" /> ? <PanelLeftOpen className="w-4 h-4" />
) : ( : <PanelLeftClose className="w-4 h-4" />
<PanelLeftClose className="h-5 w-5" /> }
)} </button>
</Button>
{/* Mobile: logo home link */}
<Link href="/" className="lg:hidden shrink-0 ml-2"> <Link href="/" className="lg:hidden shrink-0 ml-2">
<Logo size={24} /> <Logo size={20} />
</Link> </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> </div>
<Button {/* Mobile: open/close sidebar */}
variant="ghost" <button
size="icon"
className="lg:hidden text-muted-foreground hover:text-foreground"
onClick={toggle} 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" />} {isOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</Button> </button>
</header> </header>
); );
} }

View File

@@ -2,28 +2,14 @@ import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface AppPageProps { interface AppPageProps {
title: string;
description?: string;
icon?: React.ElementType;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} }
export function AppPage({ title, description, icon: Icon, children, className }: AppPageProps) { export function AppPage({ children, className }: AppPageProps) {
return ( return (
<div className={cn("min-h-screen py-8", className)}> <div className={cn('overflow-y-auto', className)}>
<div className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in"> <div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in py-6 lg:py-8">
<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>
{children} {children}
</div> </div>
</div> </div>

View File

@@ -6,7 +6,6 @@ import { X, GitFork, Heart } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import { useSidebar } from './SidebarProvider'; import { useSidebar } from './SidebarProvider';
import { Button } from '@/components/ui/button';
import { tools } from '@/lib/tools'; import { tools } from '@/lib/tools';
export function AppSidebar() { export function AppSidebar() {
@@ -15,7 +14,7 @@ export function AppSidebar() {
return ( return (
<> <>
{/* Mobile Overlay Backdrop */} {/* Mobile backdrop */}
{isOpen && ( {isOpen && (
<div <div
className="fixed inset-0 bg-transparent backdrop-blur-sm z-40 lg:hidden" className="fixed inset-0 bg-transparent backdrop-blur-sm z-40 lg:hidden"
@@ -24,94 +23,100 @@ export function AppSidebar() {
)} )}
<aside className={cn( <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", '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", isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
isCollapsed ? "lg:w-14" : "w-64" isCollapsed ? 'lg:w-14' : 'w-60'
)}> )}>
{/* Sidebar Header */}
{/* Header */}
<div className={cn( <div className={cn(
"flex h-16 items-center shrink-0 border-b border-border", 'flex h-14 items-center shrink-0 border-b border-border/20',
isCollapsed ? "justify-center px-2" : "justify-between px-5" isCollapsed ? 'justify-center px-2' : 'justify-between px-4'
)}> )}>
<Link href="/" className={cn( <Link
"flex items-center group overflow-hidden", href="/"
isCollapsed ? "justify-center" : "gap-3" className={cn(
)}> 'flex items-center group overflow-hidden',
isCollapsed ? 'justify-center' : 'gap-2.5'
)}
>
<div className="shrink-0"> <div className="shrink-0">
<Logo size={isCollapsed ? 20 : 28} /> <Logo size={isCollapsed ? 18 : 24} />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="min-w-0"> <div className="min-w-0">
<span className="font-bold text-lg leading-tight block text-foreground"> <span className="font-semibold text-base leading-tight block text-foreground">Kit</span>
Kit <span className="text-[9px] leading-tight text-muted-foreground/50 block font-mono tracking-wider">
</span>
<span className="text-[10px] leading-tight text-muted-foreground block">
Browser-first toolkit Browser-first toolkit
</span> </span>
</div> </div>
)} )}
</Link> </Link>
{!isCollapsed && ( {!isCollapsed && (
<Button <button
variant="ghost"
size="icon"
className="lg:hidden text-muted-foreground"
onClick={close} 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" /> <X className="w-3.5 h-3.5" />
</Button> </button>
)} )}
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className={cn( <nav className={cn(
"flex-1 overflow-y-auto py-2 space-y-6 mt-4 overflow-hidden scrollbar", 'flex-1 overflow-y-auto py-3 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/10 scrollbar-track-transparent',
isCollapsed ? "px-2" : "px-4" isCollapsed ? 'px-2' : 'px-3'
)}> )}>
<div className="space-y-0.5"> {tools.map((tool) => {
{tools.map((tool) => { const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href)); const Icon = tool.icon;
const Icon = tool.icon;
return ( return (
<div key={tool.href} className="space-y-1"> <Link
<Link key={tool.href}
href={tool.href} href={tool.href}
onClick={() => { if (window.innerWidth < 1024) close(); }} onClick={() => { if (window.innerWidth < 1024) close(); }}
className={cn( title={isCollapsed ? tool.navTitle : undefined}
"flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-300 relative group/item", className={cn(
isActive 'relative flex items-center rounded-lg text-sm transition-all duration-200 group/item',
? "bg-primary/10 text-primary border-l-2 border-primary" isActive
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground", ? 'bg-primary/10 text-primary'
isCollapsed ? "justify-center" : "justify-between" : 'text-foreground/55 hover:bg-white/4 hover:text-foreground',
)} isCollapsed ? 'justify-center p-2' : 'gap-3 px-3 py-2'
title={isCollapsed ? tool.navTitle : undefined} )}
> >
<div className="flex items-center gap-3 min-w-0"> {/* Active left bar */}
<span className={cn( {isActive && (
"transition-colors duration-300 shrink-0", <span className="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-primary" />
isActive ? "text-primary" : "text-foreground/80 group-hover/item:text-foreground" )}
)}>
<Icon className="h-4 w-4" /> <span className={cn(
</span> 'shrink-0 transition-colors duration-200',
{!isCollapsed && ( isActive ? 'text-primary' : 'text-foreground/40 group-hover/item:text-foreground/70'
<div className="min-w-0"> )}>
<span className="whitespace-nowrap block">{tool.navTitle}</span> <Icon className="w-4 h-4" />
<span className="text-[10px] text-muted-foreground leading-tight block line-clamp-2">{tool.description}</span> </span>
</div>
)} {!isCollapsed && (
</div> <div className="min-w-0">
</Link> <span className="whitespace-nowrap block text-[13px] font-medium leading-tight">
</div> {tool.navTitle}
); </span>
})} <span className="text-[9px] text-muted-foreground/40 leading-tight block font-mono mt-0.5">
</div> {tool.description}
</span>
</div>
)}
</Link>
);
})}
</nav> </nav>
{/* Sidebar Footer */} {/* Footer */}
<div className={cn( <div className={cn(
"shrink-0 border-t border-border py-3", 'shrink-0 border-t border-border/20 py-3',
isCollapsed ? "flex justify-center px-2" : "px-4" isCollapsed ? 'flex justify-center px-2' : 'px-4'
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<a <a
@@ -119,21 +124,20 @@ export function AppSidebar() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View source" 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> </a>
) : ( ) : (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="flex items-center gap-1 text-[10px] text-muted-foreground"> <p className="flex items-center gap-1 text-[9px] text-muted-foreground/40 font-mono">
© {new Date().getFullYear()} Kit. © {new Date().getFullYear()} Kit
<Heart className="h-2.5 w-2.5 text-primary shrink-0 animate-pulse" fill="currentColor" /> <Heart className="w-2 h-2 text-primary/70 shrink-0 animate-pulse" fill="currentColor" />
<a <a
href="https://pivoine.art" href="https://pivoine.art"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="Pivoine.Art" className="hover:text-foreground/70 transition-colors"
className="font-medium underline underline-offset-4 decoration-primary/0 hover:decoration-primary transition-all duration-300"
> >
Valknar Valknar
</a> </a>
@@ -143,14 +147,13 @@ export function AppSidebar() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title="View source" 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> </a>
</div> </div>
)} )}
</div> </div>
</aside> </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 * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react'; import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn, actionBtn } from '@/lib/utils';
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils'; import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob } from '@/types/media'; import type { ConversionJob } from '@/types/media';
@@ -11,9 +11,6 @@ export interface ConversionPreviewProps {
onRetry?: () => void; onRetry?: () => void;
} }
const actionBtn =
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) { export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null); const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0); const [elapsedTime, setElapsedTime] = React.useState(0);
@@ -171,7 +168,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
})()} })()}
{/* Download */} {/* Download */}
<button onClick={handleDownload} className={cn(actionBtn, 'w-full')}> <button onClick={handleDownload} className={cn(actionBtn, 'w-full justify-center')}>
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
<span className="truncate min-w-0">{filename}</span> <span className="truncate min-w-0">{filename}</span>
</button> </button>
@@ -187,7 +184,7 @@ export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
</div> </div>
)} )}
{onRetry && ( {onRetry && (
<button onClick={onRetry} className={cn(actionBtn, 'w-full')}> <button onClick={onRetry} className={cn(actionBtn, 'w-full justify-center')}>
<RefreshCw className="w-3 h-3" /> <RefreshCw className="w-3 h-3" />
Retry Retry
</button> </button>

View File

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

View File

@@ -1,137 +0,0 @@
'use client';
import * as React from 'react';
import Fuse from 'fuse.js';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import type { ConversionFormat } from '@/types/media';
export interface FormatSelectorProps {
formats: ConversionFormat[];
selectedFormat?: ConversionFormat;
onFormatSelect: (format: ConversionFormat) => void;
label?: string;
disabled?: boolean;
}
export function FormatSelector({
formats,
selectedFormat,
onFormatSelect,
label = 'Select format',
disabled = false,
}: FormatSelectorProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [filteredFormats, setFilteredFormats] = React.useState<ConversionFormat[]>(formats);
// Set up Fuse.js for fuzzy search
const fuse = React.useMemo(() => {
return new Fuse(formats, {
keys: ['name', 'extension', 'description'],
threshold: 0.3,
includeScore: true,
});
}, [formats]);
// Filter formats based on search query
React.useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFormats(formats);
return;
}
const results = fuse.search(searchQuery);
setFilteredFormats(results.map((result) => result.item));
}, [searchQuery, formats, fuse]);
// Group formats by category
const groupedFormats = React.useMemo(() => {
const groups: Record<string, ConversionFormat[]> = {};
filteredFormats.forEach((format) => {
if (!groups[format.category]) {
groups[format.category] = [];
}
groups[format.category].push(format);
});
return groups;
}, [filteredFormats]);
return (
<div className="w-full">
<Label className="mb-2">{label}</Label>
{/* Search input */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search formats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={disabled}
className="pl-10"
/>
</div>
{/* Format list */}
<Card className="max-h-64 overflow-y-auto custom-scrollbar">
{Object.entries(groupedFormats).length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No formats found matching &quot;{searchQuery}&quot;
</div>
) : (
<div className="p-2">
{Object.entries(groupedFormats).map(([category, categoryFormats]) => (
<div key={category} className="mb-3 last:mb-0">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2 px-2">
{category}
</h3>
<div className="space-y-1">
{categoryFormats.map((format) => (
<button
key={format.id}
onClick={() => !disabled && onFormatSelect(format)}
disabled={disabled}
className={cn(
'w-full text-left px-3 py-2 rounded-md transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary text-primary-foreground hover:bg-primary/90':
selectedFormat?.id === format.id,
}
)}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{format.name}</p>
{format.description && (
<p className="text-xs opacity-75 mt-0.5">{format.description}</p>
)}
</div>
<span className="text-xs font-mono opacity-75">.{format.extension}</span>
</div>
</button>
))}
</div>
</div>
))}
</div>
)}
</Card>
{/* Selected format display */}
{selectedFormat && (
<div className="mt-2 text-xs text-muted-foreground">
Selected: <span className="font-medium text-foreground">{selectedFormat.name}</span> (.
{selectedFormat.extension})
</div>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -1,34 +1,29 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; const MAX_LENGTH = 2048;
import { Textarea } from '@/components/ui/textarea';
interface QRInputProps { interface QRInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
const MAX_LENGTH = 2048;
export function QRInput({ value, onChange }: QRInputProps) { export function QRInput({ value, onChange }: QRInputProps) {
return ( return (
<Card> <div>
<CardHeader> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
<CardTitle>Text</CardTitle> Content
</CardHeader> </span>
<CardContent className="space-y-2"> <textarea
<Textarea value={value}
value={value} onChange={(e) => onChange(e.target.value)}
onChange={(e) => onChange(e.target.value)} placeholder="Enter text or URL…"
placeholder="Enter text or URL..." maxLength={MAX_LENGTH}
maxLength={MAX_LENGTH} rows={4}
rows={3} 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"
className="resize-none font-mono text-sm" />
/> <div className="text-[9px] text-muted-foreground/30 font-mono text-right mt-1 tabular-nums">
<div className="text-[10px] text-muted-foreground text-right"> {value.length} / {MAX_LENGTH}
{value.length} / {MAX_LENGTH} </div>
</div> </div>
</CardContent>
</Card>
); );
} }

View File

@@ -1,17 +1,8 @@
'use client'; 'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { SliderRow } from '@/components/ui/slider-row';
import { Label } from '@/components/ui/label'; import { ColorInput } from '@/components/ui/color-input';
import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ErrorCorrectionLevel } from '@/types/qrcode'; import type { ErrorCorrectionLevel } from '@/types/qrcode';
interface QROptionsProps { interface QROptionsProps {
@@ -25,11 +16,11 @@ interface QROptionsProps {
onMarginChange: (margin: number) => void; onMarginChange: (margin: number) => void;
} }
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string }[] = [ const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
{ value: 'L', label: 'Low (7%)' }, { value: 'L', label: 'L', desc: '7%' },
{ value: 'M', label: 'Medium (15%)' }, { value: 'M', label: 'M', desc: '15%' },
{ value: 'Q', label: 'Quartile (25%)' }, { value: 'Q', label: 'Q', desc: '25%' },
{ value: 'H', label: 'High (30%)' }, { value: 'H', label: 'H', desc: '30%' },
]; ];
export function QROptions({ export function QROptions({
@@ -45,93 +36,78 @@ export function QROptions({
const isTransparent = backgroundColor === '#00000000'; const isTransparent = backgroundColor === '#00000000';
return ( return (
<Card> <div className="space-y-5">
<CardHeader>
<CardTitle>Options</CardTitle> {/* Error Correction */}
</CardHeader> <div>
<CardContent className="space-y-4"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
{/* Error Correction */} Error Correction
<div className="space-y-1.5"> </span>
<Label className="text-xs">Error Correction</Label> <div className="flex gap-1.5">
<Select value={errorCorrection} onValueChange={(v) => onErrorCorrectionChange(v as ErrorCorrectionLevel)}> {EC_OPTIONS.map((opt) => (
<SelectTrigger className="w-full"> <button
<SelectValue /> key={opt.value}
</SelectTrigger> onClick={() => onErrorCorrectionChange(opt.value)}
<SelectContent> className={cn(
{EC_OPTIONS.map((opt) => ( 'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
<SelectItem key={opt.value} value={opt.value}> errorCorrection === opt.value
{opt.label} ? 'bg-primary/10 border-primary/40 text-primary'
</SelectItem> : 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
))} )}
</SelectContent> >
</Select> <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> </div>
{/* Colors */} {/* Background */}
<div className="space-y-3"> <div>
<div className="space-y-1.5"> <div className="flex items-center justify-between mb-1.5">
<Label className="text-xs">Foreground</Label> <label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
<div className="flex gap-2"> <button
<Input onClick={() => onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')}
type="color" className={cn(
className="w-9 p-1 h-9 shrink-0" 'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
value={foregroundColor} isTransparent
onChange={(e) => onForegroundColorChange(e.target.value)} ? 'border-primary/40 text-primary bg-primary/10'
/> : 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
<Input )}
className="font-mono text-xs" >
value={foregroundColor} Transparent
onChange={(e) => onForegroundColorChange(e.target.value)} </button>
/>
</div>
</div> </div>
<div className="space-y-1.5"> <ColorInput
<div className="flex items-center justify-between"> value={isTransparent ? '#ffffff' : backgroundColor}
<Label className="text-xs">Background</Label> onChange={onBackgroundColorChange}
<Button disabled={isTransparent}
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}
/> />
</div> </div>
</CardContent> </div>
</Card>
{/* 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'; '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 { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import type { ExportSize } from '@/types/qrcode'; import type { ExportSize } from '@/types/qrcode';
interface QRPreviewProps { interface QRPreviewProps {
@@ -30,6 +15,14 @@ interface QRPreviewProps {
onDownloadSvg: () => void; 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({ export function QRPreview({
svgString, svgString,
isGenerating, isGenerating,
@@ -41,92 +34,81 @@ export function QRPreview({
onDownloadSvg, onDownloadSvg,
}: QRPreviewProps) { }: QRPreviewProps) {
return ( return (
<Card className="h-full flex flex-col"> <div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<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>
<Tooltip> {/* Action bar */}
<TooltipTrigger asChild> <div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
<Button variant="outline" size="xs" onClick={onShare} disabled={!svgString}> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
<Share2 className="h-3 w-3 mr-1" /> Preview
Share </span>
</Button>
</TooltipTrigger>
<TooltipContent>Copy shareable URL</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1"> <button onClick={onCopyImage} disabled={!svgString} className={cardBtn}>
<Tooltip> <Copy className="w-3 h-3" />Copy
<TooltipTrigger asChild> </button>
<Button variant="outline" size="xs" onClick={onDownloadPng} disabled={!svgString}>
<ImageIcon className="h-3 w-3 mr-1" /> <button onClick={onShare} disabled={!svgString} className={cardBtn}>
PNG <Share2 className="w-3 h-3" />Share
</Button> </button>
</TooltipTrigger>
<TooltipContent>Download as PNG</TooltipContent> {/* PNG + inline size selector */}
</Tooltip> <div className="flex items-center glass rounded-md border border-border/30">
<ToggleGroup <button
type="single" onClick={onDownloadPng}
value={String(exportSize)} disabled={!svgString}
onValueChange={(v) => v && onExportSizeChange(Number(v) as ExportSize)} 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"
variant="outline" >
size="sm" <ImageIcon className="w-3 h-3" />PNG
> </button>
<ToggleGroupItem value="256" className="h-6 px-1.5 min-w-0 text-[10px]">256</ToggleGroupItem> <div className="flex items-center px-1 gap-0.5">
<ToggleGroupItem value="512" className="h-6 px-1.5 min-w-0 text-[10px]">512</ToggleGroupItem> {EXPORT_SIZES.map(({ value, label }) => (
<ToggleGroupItem value="1024" className="h-6 px-1.5 min-w-0 text-[10px]">1k</ToggleGroupItem> <button
<ToggleGroupItem value="2048" className="h-6 px-1.5 min-w-0 text-[10px]">2k</ToggleGroupItem> key={value}
</ToggleGroup> 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>
</div>
<Tooltip> <button onClick={onDownloadSvg} disabled={!svgString} className={cardBtn}>
<TooltipTrigger asChild> <FileCode className="w-3 h-3" />SVG
<Button variant="outline" size="xs" onClick={onDownloadSvg} disabled={!svgString}> </button>
<FileCode className="h-3 w-3 mr-1" /> </div>
SVG
</Button> {/* QR canvas */}
</TooltipTrigger> <div
<TooltipContent>Download as SVG</TooltipContent> className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
</Tooltip> style={{
</div> backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
</CardHeader> backgroundSize: '16px 16px',
<CardContent className="flex-1 flex flex-col"> }}
<div className="flex-1 min-h-[200px] rounded-lg p-4 flex items-center justify-center" >
style={{ {isGenerating ? (
backgroundImage: 'repeating-conic-gradient(hsl(var(--muted)) 0% 25%, transparent 0% 50%)', <div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
backgroundSize: '16px 16px', ) : svgString ? (
}} <div
> className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
{isGenerating ? ( dangerouslySetInnerHTML={{ __html: svgString }}
<Skeleton className="h-[200px] w-[200px]" /> />
) : svgString ? ( ) : (
<div <div className="flex flex-col items-center gap-3 text-center">
className="w-full max-w-[400px] aspect-square [&>svg]:w-full [&>svg]:h-full" <div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
dangerouslySetInnerHTML={{ __html: svgString }} <QrCode className="w-6 h-6 text-primary/40" />
/> </div>
) : ( <div>
<Empty> <p className="text-sm font-medium text-foreground/40">No QR code yet</p>
<EmptyHeader> <p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
<EmptyMedia variant="icon"> </div>
<QrCode /> </div>
</EmptyMedia> )}
<EmptyTitle>Enter text to generate a QR code</EmptyTitle> </div>
<EmptyDescription>Type text or a URL in the input field above</EmptyDescription> </div>
</EmptyHeader>
</Empty>
)}
</div>
</CardContent>
</Card>
); );
} }

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

463
lib/cron/cron-engine.ts Normal file
View File

@@ -0,0 +1,463 @@
// Cron expression parser, scheduler, and describer
export type FieldType = 'second' | 'minute' | 'hour' | 'dom' | 'month' | 'dow';
export interface CronFieldConfig {
min: number;
max: number;
label: string;
shortLabel: string;
names?: readonly string[];
aliases?: Record<string, number>;
}
export const FIELD_CONFIGS: Record<FieldType, CronFieldConfig> = {
second: { min: 0, max: 59, label: 'Second', shortLabel: 'SEC' },
minute: { min: 0, max: 59, label: 'Minute', shortLabel: 'MIN' },
hour: { min: 0, max: 23, label: 'Hour', shortLabel: 'HOUR' },
dom: { min: 1, max: 31, label: 'Day of Month', shortLabel: 'DOM' },
month: {
min: 1, max: 12, label: 'Month', shortLabel: 'MON',
names: ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'],
aliases: { JAN:1,FEB:2,MAR:3,APR:4,MAY:5,JUN:6,JUL:7,AUG:8,SEP:9,OCT:10,NOV:11,DEC:12 },
},
dow: {
min: 0, max: 6, label: 'Day of Week', shortLabel: 'DOW',
names: ['SUN','MON','TUE','WED','THU','FRI','SAT'],
aliases: { SUN:0,MON:1,TUE:2,WED:3,THU:4,FRI:5,SAT:6 },
},
};
export const MONTH_FULL_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
export const DOW_FULL_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
export const MONTH_SHORT_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
export const DOW_SHORT_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
export interface ParsedCronField {
raw: string;
values: Set<number>;
isWildcard: boolean;
}
export interface ParsedCron {
hasSeconds: boolean;
fields: {
second?: ParsedCronField;
minute: ParsedCronField;
hour: ParsedCronField;
dom: ParsedCronField;
month: ParsedCronField;
dow: ParsedCronField;
};
}
export interface CronFields {
second?: string;
minute: string;
hour: string;
dom: string;
month: string;
dow: string;
hasSeconds: boolean;
}
// ── Special expressions ───────────────────────────────────────────────────────
const SPECIAL_EXPRESSIONS: Record<string, string | null> = {
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *',
'@monthly': '0 0 1 * *',
'@weekly': '0 0 * * 0',
'@daily': '0 0 * * *',
'@midnight': '0 0 * * *',
'@hourly': '0 * * * *',
'@reboot': null,
};
// ── Low-level field parser ────────────────────────────────────────────────────
function resolveAlias(val: string, config: CronFieldConfig): number {
const n = parseInt(val, 10);
if (!isNaN(n)) return n;
if (config.aliases) {
const upper = val.toUpperCase();
if (upper in config.aliases) return config.aliases[upper];
}
return NaN;
}
function parsePart(part: string, config: CronFieldConfig, values: Set<number>): boolean {
// Step: */5 or 0-30/5 or 5/15
const stepMatch = part.match(/^(.+)\/(\d+)$/);
if (stepMatch) {
const step = parseInt(stepMatch[2], 10);
if (isNaN(step) || step < 1) return false;
let start: number, end: number;
if (stepMatch[1] === '*') {
start = config.min; end = config.max;
} else {
const rm = stepMatch[1].match(/^(.+)-(.+)$/);
if (rm) {
start = resolveAlias(rm[1], config);
end = resolveAlias(rm[2], config);
} else {
start = resolveAlias(stepMatch[1], config);
end = config.max;
}
}
if (isNaN(start) || isNaN(end) || start < config.min || end > config.max) return false;
for (let i = start; i <= end; i += step) values.add(i);
return true;
}
// Range: 1-5
const rangeMatch = part.match(/^(.+)-(.+)$/);
if (rangeMatch) {
const start = resolveAlias(rangeMatch[1], config);
const end = resolveAlias(rangeMatch[2], config);
if (isNaN(start) || isNaN(end) || start > end || start < config.min || end > config.max) return false;
for (let i = start; i <= end; i++) values.add(i);
return true;
}
// Single
const n = resolveAlias(part, config);
if (isNaN(n)) return false;
const adjusted = (config === FIELD_CONFIGS.dow && n === 7) ? 0 : n;
if (adjusted < config.min || adjusted > config.max) return false;
values.add(adjusted);
return true;
}
export function parseField(expr: string, config: CronFieldConfig): ParsedCronField | null {
if (!expr) return null;
const values = new Set<number>();
if (expr === '*') {
for (let i = config.min; i <= config.max; i++) values.add(i);
return { raw: expr, values, isWildcard: true };
}
for (const part of expr.split(',')) {
if (!parsePart(part.trim(), config, values)) return null;
}
return { raw: expr, values, isWildcard: false };
}
// ── Expression parser ─────────────────────────────────────────────────────────
export function parseCronExpression(expr: string): ParsedCron | null {
expr = expr.trim();
const lower = expr.toLowerCase();
if (lower.startsWith('@')) {
const resolved = SPECIAL_EXPRESSIONS[lower];
if (resolved === undefined) return null;
if (resolved === null) return null;
expr = resolved;
}
const parts = expr.split(/\s+/);
if (parts.length < 5 || parts.length > 6) return null;
const hasSeconds = parts.length === 6;
const o = hasSeconds ? 1 : 0;
let secondField: ParsedCronField | undefined;
if (hasSeconds) {
const f = parseField(parts[0], FIELD_CONFIGS.second);
if (!f) return null;
secondField = f;
}
const minute = parseField(parts[o + 0], FIELD_CONFIGS.minute);
const hour = parseField(parts[o + 1], FIELD_CONFIGS.hour);
const dom = parseField(parts[o + 2], FIELD_CONFIGS.dom);
const month = parseField(parts[o + 3], FIELD_CONFIGS.month);
const dow = parseField(parts[o + 4], FIELD_CONFIGS.dow);
if (!minute || !hour || !dom || !month || !dow) return null;
return { hasSeconds, fields: { second: secondField, minute, hour, dom, month, dow } };
}
// ── Field value reconstruction ────────────────────────────────────────────────
export function rebuildFieldFromValues(values: Set<number>, config: CronFieldConfig): string {
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 0) return '*';
if (sorted.length === config.max - config.min + 1) return '*';
// Regular step from min → */N
if (sorted.length > 1) {
const step = sorted[1] - sorted[0];
if (step > 0 && sorted.every((v, i) => v === sorted[0] + i * step)) {
if (sorted[0] === config.min) return `*/${step}`;
return `${sorted[0]}-${sorted[sorted.length - 1]}/${step}`;
}
// Consecutive range
if (sorted.every((v, i) => i === 0 || v === sorted[i - 1] + 1)) {
return `${sorted[0]}-${sorted[sorted.length - 1]}`;
}
}
return sorted.join(',');
}
// ── Split / build ─────────────────────────────────────────────────────────────
export function splitCronFields(expr: string): CronFields | null {
const lower = expr.trim().toLowerCase();
const resolved = SPECIAL_EXPRESSIONS[lower];
if (resolved !== undefined) {
if (resolved === null) return null;
expr = resolved;
}
const parts = expr.trim().split(/\s+/);
if (parts.length === 5) {
return { minute: parts[0], hour: parts[1], dom: parts[2], month: parts[3], dow: parts[4], hasSeconds: false };
}
if (parts.length === 6) {
return { second: parts[0], minute: parts[1], hour: parts[2], dom: parts[3], month: parts[4], dow: parts[5], hasSeconds: true };
}
return null;
}
export function buildCronExpression(fields: CronFields): string {
const base = `${fields.minute} ${fields.hour} ${fields.dom} ${fields.month} ${fields.dow}`;
return fields.hasSeconds && fields.second ? `${fields.second} ${base}` : base;
}
// ── Day matching ──────────────────────────────────────────────────────────────
function checkDay(d: Date, parsed: ParsedCron): boolean {
const domWild = parsed.fields.dom.isWildcard;
const dowWild = parsed.fields.dow.isWildcard;
if (domWild && dowWild) return true;
if (domWild) return parsed.fields.dow.values.has(d.getDay());
if (dowWild) return parsed.fields.dom.values.has(d.getDate());
return parsed.fields.dom.values.has(d.getDate()) || parsed.fields.dow.values.has(d.getDay());
}
// ── Smart advance algorithm ───────────────────────────────────────────────────
function advanceToNext(date: Date, parsed: ParsedCron): Date | null {
const d = new Date(date);
const maxDate = new Date(date.getTime() + 5 * 366 * 24 * 60 * 60 * 1000);
let guard = 0;
while (d < maxDate && guard++ < 200_000) {
// Month
const m = d.getMonth() + 1;
if (!parsed.fields.month.values.has(m)) {
const sorted = [...parsed.fields.month.values].sort((a, b) => a - b);
const next = sorted.find(v => v > m);
if (next !== undefined) {
d.setMonth(next - 1, 1);
} else {
d.setFullYear(d.getFullYear() + 1, sorted[0] - 1, 1);
}
d.setHours(0, 0, 0, 0);
continue;
}
// Day
if (!checkDay(d, parsed)) {
d.setDate(d.getDate() + 1);
d.setHours(0, 0, 0, 0);
continue;
}
// Hour
const h = d.getHours();
const sortedH = [...parsed.fields.hour.values].sort((a, b) => a - b);
if (!parsed.fields.hour.values.has(h)) {
const next = sortedH.find(v => v > h);
if (next !== undefined) {
d.setHours(next, 0, 0, 0);
} else {
d.setDate(d.getDate() + 1);
d.setHours(sortedH[0], 0, 0, 0);
}
continue;
}
// Minute
const min = d.getMinutes();
const sortedM = [...parsed.fields.minute.values].sort((a, b) => a - b);
if (!parsed.fields.minute.values.has(min)) {
const next = sortedM.find(v => v > min);
if (next !== undefined) {
d.setMinutes(next, 0, 0);
} else {
const nextH = sortedH.find(v => v > h);
if (nextH !== undefined) {
d.setHours(nextH, sortedM[0], 0, 0);
} else {
d.setDate(d.getDate() + 1);
d.setHours(sortedH[0], sortedM[0], 0, 0);
}
}
continue;
}
return new Date(d);
}
return null;
}
export function getNextOccurrences(
expr: string,
count: number = 8,
from: Date = new Date(),
): Date[] {
const parsed = parseCronExpression(expr);
if (!parsed) return [];
const results: Date[] = [];
let current = new Date(from);
current.setSeconds(0, 0);
current.setTime(current.getTime() + 60_000); // start from next minute
for (let i = 0; i < count; i++) {
const next = advanceToNext(current, parsed);
if (!next) break;
results.push(next);
current = new Date(next.getTime() + 60_000);
}
return results;
}
// ── Human-readable description ────────────────────────────────────────────────
function isStepRaw(raw: string): boolean {
return /^(\*|\d+)\/\d+$/.test(raw);
}
function stepValue(raw: string): number | null {
const m = raw.match(/\/(\d+)$/);
return m ? parseInt(m[1], 10) : null;
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function formatTime12(hour: number, minute: number): string {
const ampm = hour < 12 ? 'AM' : 'PM';
const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${h}:${String(minute).padStart(2, '0')} ${ampm}`;
}
function formatHour(h: number): string {
const ampm = h < 12 ? 'AM' : 'PM';
const d = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${d}:00 ${ampm}`;
}
function formatDowList(vals: number[]): string {
if (vals.length === 1) return DOW_FULL_NAMES[vals[0]];
if (vals.length === 7) return 'every day';
return vals.map(v => DOW_FULL_NAMES[v]).join(', ');
}
export function describeCronExpression(expr: string): string {
const lower = expr.trim().toLowerCase();
const specialDescs: Record<string, string> = {
'@yearly': 'Every year on January 1st at midnight',
'@annually': 'Every year on January 1st at midnight',
'@monthly': 'Every month on the 1st at midnight',
'@weekly': 'Every week on Sunday at midnight',
'@daily': 'Every day at midnight',
'@midnight': 'Every day at midnight',
'@hourly': 'Every hour at :00',
'@reboot': 'Once at system reboot',
};
if (lower in specialDescs) return specialDescs[lower];
const parsed = parseCronExpression(expr);
if (!parsed) return 'Invalid cron expression';
const { fields } = parsed;
const mVals = [...fields.minute.values].sort((a, b) => a - b);
const hVals = [...fields.hour.values].sort((a, b) => a - b);
const domVals = [...fields.dom.values].sort((a, b) => a - b);
const monVals = [...fields.month.values].sort((a, b) => a - b);
const dowVals = [...fields.dow.values].sort((a, b) => a - b);
const mWild = fields.minute.isWildcard;
const hWild = fields.hour.isWildcard;
const domWild = fields.dom.isWildcard;
const monWild = fields.month.isWildcard;
const dowWild = fields.dow.isWildcard;
// Time
let when = '';
if (mWild && hWild) {
when = 'Every minute';
} else if (hWild && isStepRaw(fields.minute.raw)) {
const s = stepValue(fields.minute.raw);
when = s === 1 ? 'Every minute' : `Every ${s} minutes`;
} else if (mWild && isStepRaw(fields.hour.raw)) {
const s = stepValue(fields.hour.raw);
when = s === 1 ? 'Every hour' : `Every ${s} hours`;
} else if (!mWild && hWild) {
if (isStepRaw(fields.minute.raw)) {
const s = stepValue(fields.minute.raw);
when = `Every ${s} minutes`;
} else if (mVals.length === 1 && mVals[0] === 0) {
when = 'Every hour at :00';
} else if (mVals.length === 1) {
when = `Every hour at :${String(mVals[0]).padStart(2, '0')}`;
} else {
when = `Every hour at minutes ${mVals.join(', ')}`;
}
} else if (mWild && !hWild) {
if (hVals.length === 1) when = `Every minute of ${formatHour(hVals[0])}`;
else when = `Every minute of hours ${hVals.join(', ')}`;
} else {
if (hVals.length === 1 && mVals.length === 1) {
when = `At ${formatTime12(hVals[0], mVals[0])}`;
} else if (hVals.length === 1) {
when = `${formatHour(hVals[0])}, at minutes ${mVals.join(', ')}`;
} else if (mVals.length === 1 && mVals[0] === 0) {
when = `At ${hVals.map(formatHour).join(' and ')}`;
} else {
when = `At hours ${hVals.join(', ')}, minutes ${mVals.join(', ')}`;
}
}
// Day
let day = '';
if (!domWild || !dowWild) {
if (!domWild && !dowWild) {
day = `on day ${domVals.map(ordinal).join(', ')} or ${formatDowList(dowVals)}`;
} else if (!domWild) {
day = domVals.length === 1 ? `on the ${ordinal(domVals[0])}` : `on days ${domVals.map(ordinal).join(', ')}`;
} else {
const isWeekdays = dowVals.length === 5 && [1,2,3,4,5].every(v => dowVals.includes(v));
const isWeekends = dowVals.length === 2 && dowVals.includes(0) && dowVals.includes(6);
if (isWeekdays) day = 'on weekdays';
else if (isWeekends) day = 'on weekends';
else day = `on ${formatDowList(dowVals)}`;
}
}
// Month
let month = '';
if (!monWild) {
month = `in ${monVals.map(v => MONTH_FULL_NAMES[v - 1]).join(', ')}`;
}
let result = when;
if (day) result += `, ${day}`;
if (month) result += `, ${month}`;
return result;
}
export function validateCronExpression(expr: string): { valid: boolean; error?: string } {
const parsed = parseCronExpression(expr);
if (!parsed) return { valid: false, error: 'Invalid cron expression' };
return { valid: true };
}
export function validateCronField(value: string, type: FieldType): { valid: boolean; error?: string } {
if (!value.trim()) return { valid: false, error: 'Required' };
const field = parseField(value, FIELD_CONFIGS[type]);
if (!field) return { valid: false, error: `Invalid ${type} expression` };
return { valid: true };
}

47
lib/cron/store.ts Normal file
View File

@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface CronHistoryEntry {
id: string;
expression: string;
label?: string;
savedAt: number;
}
interface CronStore {
expression: string;
history: CronHistoryEntry[];
setExpression: (expr: string) => void;
addToHistory: (expr: string, label?: string) => void;
removeFromHistory: (id: string) => void;
clearHistory: () => void;
}
export const useCronStore = create<CronStore>()(
persist(
(set) => ({
expression: '0 9 * * 1-5',
history: [],
setExpression: (expression) => set({ expression }),
addToHistory: (expression, label) =>
set((state) => {
const entry: CronHistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
expression,
label,
savedAt: Date.now(),
};
const filtered = state.history.filter((h) => h.expression !== expression);
return { history: [entry, ...filtered].slice(0, 30) };
}),
removeFromHistory: (id) =>
set((state) => ({ history: state.history.filter((h) => h.id !== id) })),
clearHistory: () => set({ history: [] }),
}),
{ name: 'kit-cron-v1' },
),
);

View File

@@ -153,15 +153,6 @@ export const SUPPORTED_FORMATS: ConversionFormat[] = [
converter: 'imagemagick', converter: 'imagemagick',
description: 'Tagged Image File Format', description: 'Tagged Image File Format',
}, },
{
id: 'svg',
name: 'SVG',
extension: 'svg',
mimeType: 'image/svg+xml',
category: 'image',
converter: 'imagemagick',
description: 'Scalable Vector Graphics',
},
]; ];
/** /**

118
lib/random/generators.ts Normal file
View File

@@ -0,0 +1,118 @@
const CHARSET = {
uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
lowercase: 'abcdefghijklmnopqrstuvwxyz',
numbers: '0123456789',
symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?',
hex: '0123456789abcdef',
base62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
};
export interface PasswordOpts {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
symbols: boolean;
}
export interface ApiKeyOpts {
length: number;
format: 'hex' | 'base62' | 'base64url';
prefix: string;
}
export interface HashOpts {
algorithm: 'SHA-1' | 'SHA-256' | 'SHA-512';
input: string;
}
export interface TokenOpts {
bytes: number;
format: 'hex' | 'base64url';
}
function randomBytes(n: number): Uint8Array {
const arr = new Uint8Array(n);
crypto.getRandomValues(arr);
return arr;
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function toBase64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generatePassword(opts: PasswordOpts): string {
let charset = '';
if (opts.uppercase) charset += CHARSET.uppercase;
if (opts.lowercase) charset += CHARSET.lowercase;
if (opts.numbers) charset += CHARSET.numbers;
if (opts.symbols) charset += CHARSET.symbols;
if (!charset) charset = CHARSET.lowercase + CHARSET.numbers;
const bytes = randomBytes(opts.length * 4);
let result = '';
let i = 0;
while (result.length < opts.length && i < bytes.length) {
const idx = bytes[i] % charset.length;
result += charset[idx];
i++;
}
return result.slice(0, opts.length);
}
export function passwordEntropy(opts: PasswordOpts): number {
let size = 0;
if (opts.uppercase) size += 26;
if (opts.lowercase) size += 26;
if (opts.numbers) size += 10;
if (opts.symbols) size += CHARSET.symbols.length;
if (size === 0) size = 36;
return Math.round(Math.log2(size) * opts.length);
}
export function generateUUID(): string {
return crypto.randomUUID();
}
export function generateApiKey(opts: ApiKeyOpts): string {
const bytes = randomBytes(opts.length * 2);
let key: string;
switch (opts.format) {
case 'hex':
key = toHex(bytes).slice(0, opts.length);
break;
case 'base64url':
key = toBase64url(bytes).slice(0, opts.length);
break;
case 'base62': {
const cs = CHARSET.base62;
key = Array.from(bytes)
.map((b) => cs[b % cs.length])
.join('')
.slice(0, opts.length);
break;
}
}
return opts.prefix ? `${opts.prefix}_${key}` : key;
}
export async function generateHash(opts: HashOpts): Promise<string> {
const data = opts.input.trim() || toHex(randomBytes(32));
const encoded = new TextEncoder().encode(data);
const hashBuffer = await crypto.subtle.digest(opts.algorithm, encoded);
return toHex(new Uint8Array(hashBuffer));
}
export function generateToken(opts: TokenOpts): string {
const bytes = randomBytes(opts.bytes);
return opts.format === 'hex' ? toHex(bytes) : toBase64url(bytes);
}

View File

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

View File

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

15
lib/utils/styles.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Shared Tailwind class strings for consistent UI patterns across tools.
*/
/** Smaller button for card title rows (copy, share, export icons next to a section label) */
export const cardBtn =
'flex items-center gap-1 px-2 py-1 text-[10px] font-mono glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
/** Standard action button used throughout all tools (copy, download, share, apply…) */
export const actionBtn =
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-mono glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
/** Small square icon-only button (animate preview controls, timeline actions) */
export const iconBtn =
'flex items-center justify-center glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';

View File

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

38
pnpm-lock.yaml generated
View File

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