Compare commits

...

63 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
2763b76abe refactor: refactor media tool to match calculate blueprint
Rewrites all three media components to use the glass panel design
language, fixed-height two-panel layout, and glass action buttons.

- FileConverter: lg:grid-cols-5 split — left 2/5 is the upload zone;
  right 3/5 has output format pill grid + codec/quality options +
  convert/reset buttons + scrollable results panel; mobile 'Upload |
  Convert' tab switcher auto-advances on file selection; removed all
  Card/Button/Label/Input shadcn imports; keeps Select+Slider for
  codec/quality controls
- FileUpload: large flex-1 drop zone fills the left panel; file list
  shows glass item cards with metadata chips; native buttons; removes
  shadcn Button dependency
- ConversionPreview: glass card replaces Card; native progress bar
  (div with bg-primary/65) replaces shadcn Progress; size reduction
  shown as emerald/muted badge; media previews in dark-bordered
  containers; all buttons are glass action buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:22:17 +01:00
0727ec7675 refactor: refactor color tool to match calculate blueprint
Rewrites all color components to use the glass panel design language,
fixed-height two-panel layout, and tab-based navigation.

- ColorManipulation: lg:grid-cols-5 split — left 2/5 shows ColorPicker
  + ColorInfo always; right 3/5 has Info/Adjust/Harmony/Gradient tabs;
  mobile 'Pick | Explore' switcher
- ColorPicker: removes shadcn Input/Label, native input with dynamic
  contrast color matching the picked hue
- ColorInfo: removes shadcn Button, native copy buttons on hover,
  metadata chips with bg-primary/5 background
- ManipulationPanel: keeps Slider, replaces Button with glass action
  buttons, tighter spacing and muted labels
- ExportMenu: keeps Select, replaces Buttons with glass action buttons,
  code preview in dark terminal box (#06060e)
- ColorSwatch: rectangular full-width design for palette grids,
  hover reveals copy icon, hex label at bottom
- PaletteGrid: denser grid (4→5 cols), smaller swatch height

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 08:15:33 +01:00
50dc009fdf fix: use bg-popover for search dropdown (glass is near-transparent) 2026-03-01 08:02:35 +01:00
d8a568076d fix: use Tailwind class for bar fill (--primary is hex, not HSL) 2026-03-01 08:00:32 +01:00
7eb28851b7 refactor: refactor units tool to match calculate blueprint
Rewrites all three units components to use the same glass panel
layout, lg:grid-cols-5 two-panel split, and interactive patterns
established by the calculate tool.

- MainConverter: category sidebar (left 2/5) replaces Select dropdown;
  converter card + scrollable conversion grid (right 3/5); mobile
  'Category | Convert' tab switcher; clickable conversion cards set
  target unit; glass Grid/Chart toggle
- SearchUnits: native input with glass border, glass dropdown panel,
  compact result rows matching font selector style
- VisualComparison: polished gradient bars, tighter spacing, cleaner
  value display; all drag logic preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 07:57:29 +01:00
141ab1f4e3 refactor(ascii): align layout and UX with Calculate blueprint
Rewrites all four ASCII tool components to share the same design
language and spatial structure as the Calculator & Grapher tool.

Layout
- New responsive 2/5–3/5 grid (was fixed 2+1 col); matches Calculate
- Left panel: text input card + font selector filling remaining height
- Right panel: preview as the dominant full-height element
- Mobile: tabbed Editor / Preview switcher (same pattern as Calculator)

TextInput
- Replace shadcn Textarea with native <textarea>
- Glass border pattern (border-border/40, focus:border-primary/50)
- Monospace font, consistent counter styling

FontSelector
- Replace Card + shadcn Tabs + Button + Input + Empty with native elements
- Glass panel (glass rounded-xl) matching Calculate panel style
- Custom tab strip mirrors Calculator mobile tab pattern
- Native search input with glass border
- Font list items: border-l-2 left accent for selected state,
  hover:bg-primary/8, rose heart for favorites
- Auto-scrolls selected item into view on external changes
- Simplified empty state to single italic line

FontPreview
- Replace Card + Button + Badge + ToggleGroup + Tooltip + Empty
- Glass panel with header row (label + font tag + action buttons)
- Controls row: native toggle buttons with primary/10 active state
- Terminal window: dark #06060e background, macOS-style chrome
  (rose/amber/emerald dots), font name watermark — the hero element
- PNG export captures entire terminal including chrome at 2x
- Inline skeleton loader with pulse animation replaces Skeleton import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 07:46:21 +01:00
d161aeba72 docs: update README with Calculate tool and 8-tool count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:48:31 +01:00
9efa783ca3 feat: add Calculator & Grapher tool
Adds a full-featured mathematical calculator and interactive function
grapher at /calculate. Powered by Math.js v15 with a HiDPI Canvas
renderer for the graph.

- Evaluates arbitrary math expressions (trig, log, complex, matrices,
  factorials, combinatorics, and more) with named variable scope
- Persists history (50 entries) and variables via localStorage
- 32 quick-insert buttons for constants and functions
- Interactive graph: pan (drag), zoom (scroll), crosshair tooltip
  showing cursor coords and f₁(x)…f₈(x) values simultaneously
- Up to 8 color-coded functions with inline color pickers and
  visibility toggles
- Discontinuity detection for functions like tan(x)
- Adaptive grid labels that rescale with zoom
- Responsive layout: 2/5–3/5 split on desktop, tabbed on mobile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:44:53 +01:00
aa890a0d55 docs: update README with all 7 tools and current project structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 20:16:07 +01:00
e4fafeb7b7 refactor: replace generic badges with tool-specific ones
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:58:05 +01:00
83f071ec6b feat: animate heart icon, dynamic tools count in stats, trim tool summaries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:53:33 +01:00
d6e01e4bf5 fix: make all tool grid cards the same height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:53:02 +01:00
97 changed files with 6635 additions and 5827 deletions

View File

@@ -5,7 +5,7 @@
[![React](https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react)](https://react.dev) [![React](https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react)](https://react.dev)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-4-38B2AC?style=for-the-badge&logo=tailwind-css)](https://tailwindcss.com) [![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-4-38B2AC?style=for-the-badge&logo=tailwind-css)](https://tailwindcss.com)
**Kit UI** is a high-performance, aesthetically pleasing toolkit for developers and designers. It consolidates essential creative tools—from advanced color manipulation to ASCII art generation—into a single, unified workspace. **Kit UI** is a high-performance, aesthetically pleasing toolkit for developers and designers. It consolidates essential creative tools—from color manipulation and CSS animation editing to ASCII art generation—into a single, unified workspace.
Built with **Next.js 16**, **React 19**, and **Tailwind CSS 4**, Kit UI delivers a lightning-fast, glassmorphic experience with a focus on precision and accessibility. Built with **Next.js 16**, **React 19**, and **Tailwind CSS 4**, Kit UI delivers a lightning-fast, glassmorphic experience with a focus on precision and accessibility.
@@ -13,42 +13,58 @@ Built with **Next.js 16**, **React 19**, and **Tailwind CSS 4**, Kit UI delivers
## 🚀 The Toolkit ## 🚀 The Toolkit
Kit UI is divided into five core specialized applications: Kit UI currently ships **8 tools**:
### 🎨 [Color](./app/(app)/color) — Professional Color Toolkit ### 🎨 [Color](./app/(app)/color) — Color Manipulation
A comprehensive suite for color theory, manipulation, and accessibility. Modern color manipulation toolkit with palette generation and format conversion.
- **Color Playground**: Interactive HSL/RGB/HEX manipulation with real-time analysis. - **Color Playground**: Interactive HSL/RGB/HEX manipulation with real-time analysis.
- **Accessibility Suite**: WCAG 2.1 Contrast Checker and a real-time Colorblindness Simulator. - **Accessibility Suite**: WCAG 2.1 Contrast Checker and a real-time Colorblindness Simulator.
- **Generative Tools**: Harmony generator (Analogous, Triadic, etc.), Palette Generator, and Gradient Architect. - **Generative Tools**: Harmony generator (Analogous, Triadic, etc.), Palette Generator, and Gradient Architect.
- **Batch Processing**: Perform mass color operations and exports.
- **WASM Powered**: Utilizes `@valknarthing/pastel-wasm` for high-performance color calculations. - **WASM Powered**: Utilizes `@valknarthing/pastel-wasm` for high-performance color calculations.
### 📐 [Units](./app/(app)/units) — Smart Unit Converter ### 📐 [Units](./app/(app)/units) — Units Converter
A powerful, intuitive converter that understands the way you work. Smart unit converter with 187 units across 23 categories.
- **187+ Units**: Supporting 23 categories including Length, Mass, Temperature, Force, and more. - **187+ Units**: Length, Mass, Temperature, Force, Digital Storage, and more.
- **Smart Search**: Quickly find units via a fuzzy-search command palette. - **Real-time Bidirectional Conversion**: Instant results as you type with fuzzy search.
- **Visual Comparison**: Dynamic chart views for comparing scale across different units.
- **Favorites & History**: Save your most-used conversions for instant access. - **Favorites & History**: Save your most-used conversions for instant access.
### ✍️ [ASCII](./app/(app)/ascii) — ASCII Art Generator ### ✍️ [ASCII](./app/(app)/ascii) — ASCII Art Generator
Retro-inspired text banners for terminals and documentation. Create stunning text banners, terminal art, and retro designs.
- **373 Fonts**: From classic `Standard` to complex 3D and cursive styles. - **373 Fonts**: From classic `Standard` to complex 3D and cursive styles.
- **Real-time Preview**: See your ASCII art transform as you type. - **Real-time Preview**: See your ASCII art transform as you type.
- **Multi-Export**: Copy as raw text, download `.txt` files, or export as `.png` images. - **Multi-Export**: Copy as raw text, download `.txt` files, or export as `.png` images.
### 🎬 [Media](./app/(app)/media) — Browser-Based File Converter ### 🎬 [Media](./app/(app)/media) — Media Converter
Privacy-first, local-only media conversion powered by WebAssembly. Privacy-first, local-only media conversion powered by WebAssembly.
- **Video & Audio**: Transcode between MP4, WebM, MP3, WAV, and more using FFmpeg. - **Video & Audio**: Transcode between MP4, WebM, MP3, WAV, and more using FFmpeg.
- **Image Processing**: Convert and resize PNG, JPG, WebP, and SVG via ImageMagick. - **Image Processing**: Convert and resize PNG, JPG, WebP, and SVG via ImageMagick.
- **Zero Server Uploads**: All processing happens locally in your browser for maximum privacy. - **Zero Server Uploads**: All processing happens locally in your browser.
- **Advanced Options**: Fine-tune bitrates, codecs, resolutions, and quality presets.
### 🌐 [Favicon](./app/(app)/favicon) — Favicon & PWA Generator ### 🌐 [Favicon](./app/(app)/favicon) — Favicon Generator
Complete asset generation for modern web presence. Complete favicon and PWA asset generation for modern web presence.
- **Complete Icon Set**: Generates standard favicons, Apple Touch icons, and Android Chrome icons. - **Complete Icon Set**: Standard favicons, Apple Touch icons, and Android Chrome icons.
- **PWA Manifest**: Automatically generates a standards-compliant `site.webmanifest`. - **PWA Manifest**: Automatically generates a standards-compliant `site.webmanifest`.
- **HTML Snippets**: Copy-paste ready `<head>` tags for easy integration. - **HTML Snippets**: Copy-paste ready `<head>` tags for easy integration.
- **Privacy-First**: Powered by ImageMagick WASM—no server-side processing.
### 🔲 [QR Code](./app/(app)/qrcode) — QR Code Generator
Generate QR codes with live preview and full customization.
- **Custom Colors**: Foreground, background, and logo overlay support.
- **Error Correction**: Configurable L / M / Q / H error correction levels.
- **Export**: Download as PNG or SVG directly from the browser.
### 🎞️ [Animate](./app/(app)/animate) — CSS Animation Editor
Visual editor for CSS `@keyframe` animations with live preview and export.
- **Visual Keyframe Timeline**: Drag keyframes, set per-frame transforms and visual properties.
- **20+ Built-in Presets**: Entrance, exit, attention seekers, and special effects.
- **Live Preview**: Real-time preview with speed control and element selector.
- **Export**: Plain CSS or Tailwind v4 `@utility` format.
### 🧮 [Calculate](./app/(app)/calculate) — Calculator & Grapher
Advanced mathematical expression evaluator with an interactive function grapher.
- **Full Math.js Engine**: Trig, logarithms, complex numbers, matrices, factorials, combinatorics, and more.
- **Named Variables**: Define and reuse variables (`x = 5`) across expressions and graph functions.
- **32 Quick-Insert Keys**: One-click constants (π, e, φ) and functions (sin, ln, gcd, nCr…).
- **Interactive Graph**: Plot up to 8 simultaneous color-coded functions with pan (drag) and zoom (scroll); crosshair tooltip shows coordinates and per-function values.
--- ---
@@ -71,6 +87,7 @@ Complete asset generation for modern web presence.
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com) (CSS-first configuration) - **Styling**: [Tailwind CSS 4](https://tailwindcss.com) (CSS-first configuration)
- **Animations**: [Framer Motion](https://www.framer.com/motion/) - **Animations**: [Framer Motion](https://www.framer.com/motion/)
- **State Management**: [Zustand](https://github.com/pmndrs/zustand) & [React Query](https://tanstack.com/query) - **State Management**: [Zustand](https://github.com/pmndrs/zustand) & [React Query](https://tanstack.com/query)
- **Math Engine**: [Math.js 15](https://mathjs.org) (expression evaluation, compilation cache)
- **Icons**: [Lucide React](https://lucide.dev) - **Icons**: [Lucide React](https://lucide.dev)
- **Type Safety**: [TypeScript 5](https://www.typescriptlang.org) - **Type Safety**: [TypeScript 5](https://www.typescriptlang.org)
@@ -81,22 +98,28 @@ Complete asset generation for modern web presence.
```bash ```bash
. .
├── app/ # Next.js App Router (Pages & Layouts) ├── app/ # Next.js App Router (Pages & Layouts)
│ ├── (app)/ # Core Tool Pages (Color, Units, ASCII, Media, Favicon) │ ├── (app)/ # Tool pages (color, units, ascii, media, favicon, qrcode, animate, calculate)
│ ├── manifest.ts # PWA manifest generation │ ├── manifest.ts # PWA manifest generation
│ └── api/ # Backend API routes │ └── api/ # Backend API routes
├── components/ # Reusable UI & Logic Components ├── components/ # Reusable UI & Logic Components
│ ├── color/ # Color-specific components │ ├── color/ # Color-specific components
│ ├── units/ # Converter-specific components │ ├── units/ # Converter-specific components
│ ├── ascii/ # ASCII-specific components │ ├── ascii/ # ASCII-specific components
│ ├── media/ # Media conversion components │ ├── media/ # Media conversion components
│ ├── favicon/ # Favicon-specific components │ ├── favicon/ # Favicon-specific components
│ ├── qrcode/ # QR code components
│ ├── animate/ # CSS animation editor components
│ ├── calculate/ # Calculator & grapher components
│ └── ui/ # Base Atomic Components (Buttons, Cards, etc.) │ └── ui/ # Base Atomic Components (Buttons, Cards, etc.)
├── lib/ # Business Logic & Utilities ├── lib/ # Business Logic & Utilities
│ ├── color/ # WASM wrappers & Color logic │ ├── color/ # WASM wrappers & Color logic
│ ├── units/ # Conversion algorithms │ ├── units/ # Conversion algorithms
│ ├── ascii/ # Font loading & ASCII generation │ ├── ascii/ # Font loading & ASCII generation
│ ├── media/ # FFmpeg & ImageMagick WASM orchestration │ ├── media/ # FFmpeg & ImageMagick WASM orchestration
── favicon/ # Favicon generation logic ── favicon/ # Favicon generation logic
│ ├── qrcode/ # QR code generation logic
│ ├── animate/ # CSS builder, presets, and defaults
│ └── calculate/ # Math.js engine, graph sampler, Zustand store
├── public/ # Static assets & ASCII fonts ├── public/ # Static assets & ASCII fonts
├── Dockerfile # Multi-stage Docker build ├── Dockerfile # Multi-stage Docker build
└── nginx.conf # Production Nginx configuration └── nginx.conf # Production Nginx configuration

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

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import Calculator from '@/components/calculate/Calculator';
import { AppPage } from '@/components/layout/AppPage';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/calculate')!;
export const metadata: Metadata = { title: tool.title, description: tool.summary };
export default function CalculatePage() {
return (
<AppPage>
<Calculator />
</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

@@ -66,3 +66,40 @@ export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
<line x1="18" y1="14" x2="18" y2="17" strokeWidth={2} /> <line x1="18" y1="14" x2="18" y2="17" strokeWidth={2} />
</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>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Y-axis */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 20V4" />
{/* X-axis */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 20h16" />
{/* Smooth curve resembling sin/cos */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 14c1.5-3 3-7 5-5s2 8 4 6 3-6 5-5" />
</svg>
);

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" 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,67 +1,39 @@
'use client'; import { tools } from '@/lib/tools';
import { Box, Code2, Globe } from 'lucide-react';
import { motion } from 'framer-motion';
const stats = [ const stats = [
{ { value: tools.length, label: 'Tools available', icon: Box },
number: '5', { 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" 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

@@ -11,7 +11,10 @@ import { addRecentFont } from '@/lib/storage/favorites';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing'; import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii'; import type { ASCIIFont } from '@/types/ascii';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'editor' | 'preview';
export function ASCIIConverter() { export function ASCIIConverter() {
const [text, setText] = React.useState('ASCII'); const [text, setText] = React.useState('ASCII');
@@ -19,13 +22,11 @@ export function ASCIIConverter() {
const [asciiArt, setAsciiArt] = React.useState(''); const [asciiArt, setAsciiArt] = React.useState('');
const [fonts, setFonts] = React.useState<ASCIIFont[]>([]); const [fonts, setFonts] = React.useState<ASCIIFont[]>([]);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [tab, setTab] = React.useState<Tab>('editor');
const commentedTextRef = React.useRef(''); const commentedTextRef = React.useRef('');
// Load fonts and check URL params on mount
React.useEffect(() => { React.useEffect(() => {
getFontList().then(setFonts); getFontList().then(setFonts);
// Check for URL parameters
const urlState = decodeFromUrl(); const urlState = decodeFromUrl();
if (urlState) { if (urlState) {
if (urlState.text) setText(urlState.text); if (urlState.text) setText(urlState.text);
@@ -33,57 +34,45 @@ export function ASCIIConverter() {
} }
}, []); }, []);
// Generate ASCII art
const generateAsciiArt = React.useMemo( const generateAsciiArt = React.useMemo(
() => debounce(async (inputText: string, fontName: string) => { () =>
if (!inputText) { debounce(async (inputText: string, fontName: string) => {
setAsciiArt(''); if (!inputText) {
setIsLoading(false); setAsciiArt('');
return; setIsLoading(false);
} return;
}
setIsLoading(true); setIsLoading(true);
try { try {
const result = await textToAscii(inputText, fontName); const result = await textToAscii(inputText, fontName);
setAsciiArt(result); setAsciiArt(result);
} catch (error) { } catch {
console.error('Error generating ASCII art:', error); setAsciiArt('Error generating ASCII art. Please try a different font.');
setAsciiArt('Error generating ASCII art. Please try a different font.'); } finally {
} finally { setIsLoading(false);
setIsLoading(false); }
} }, 300),
}, 300),
[] []
); );
// Trigger generation when text or font changes
React.useEffect(() => { React.useEffect(() => {
generateAsciiArt(text, selectedFont); generateAsciiArt(text, selectedFont);
// Track recent fonts if (selectedFont) addRecentFont(selectedFont);
if (selectedFont) {
addRecentFont(selectedFont);
}
// Update URL
updateUrl(text, selectedFont); updateUrl(text, selectedFont);
}, [text, selectedFont, generateAsciiArt]); }, [text, selectedFont, generateAsciiArt]);
// Copy to clipboard
const handleCopy = async () => { const handleCopy = async () => {
if (!asciiArt) return; if (!asciiArt) return;
try { try {
await navigator.clipboard.writeText(commentedTextRef.current || asciiArt); await navigator.clipboard.writeText(commentedTextRef.current || asciiArt);
toast.success('Copied to clipboard!'); toast.success('Copied to clipboard!');
} catch (error) { } catch {
console.error('Failed to copy:', error);
toast.error('Failed to copy'); toast.error('Failed to copy');
} }
}; };
// Download as text file
const handleDownload = () => { const handleDownload = () => {
if (!asciiArt) return; if (!asciiArt) return;
const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' }); const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -95,69 +84,89 @@ export function ASCIIConverter() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
// Share (copy URL to clipboard)
const handleShare = async () => { const handleShare = async () => {
const shareUrl = getShareableUrl(text, selectedFont);
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(getShareableUrl(text, selectedFont));
toast.success('Shareable URL copied!'); toast.success('Shareable URL copied!');
} catch (error) { } catch {
console.error('Failed to copy URL:', error);
toast.error('Failed to copy URL'); toast.error('Failed to copy URL');
} }
}; };
// Random font
const handleRandomFont = () => { const handleRandomFont = () => {
if (fonts.length === 0) return; if (!fonts.length) return;
const randomIndex = Math.floor(Math.random() * fonts.length); const font = fonts[Math.floor(Math.random() * fonts.length)];
setSelectedFont(fonts[randomIndex].name); setSelectedFont(font.name);
toast.info(`Random font: ${fonts[randomIndex].name}`); toast.info(`Font: ${font.name}`);
}; };
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 Preview */}
<div className="lg:col-span-2 space-y-6 overflow-y-auto custom-scrollbar"> <MobileTabs
<Card> tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
<CardHeader> active={tab}
<CardTitle>Text</CardTitle> onChange={(v) => setTab(v as Tab)}
</CardHeader> />
<CardContent>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: text input + font selector */}
<div
className={cn(
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
tab !== 'editor' && 'hidden lg:flex'
)}
>
{/* Text input */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Text
</span>
<TextInput <TextInput
value={text} value={text}
onChange={setText} onChange={setText}
placeholder="Type your text here..." placeholder="Type your text here"
/> />
</CardContent> </div>
</Card>
<FontPreview {/* Font selector — fills remaining height */}
text={asciiArt} <div className="flex-1 min-h-0 overflow-hidden">
font={selectedFont} <FontSelector
isLoading={isLoading} fonts={fonts}
onCopy={handleCopy} selectedFont={selectedFont}
onDownload={handleDownload} onSelectFont={setSelectedFont}
onShare={handleShare} onRandomFont={handleRandomFont}
onCommentedTextChange={React.useCallback((t: string) => { commentedTextRef.current = t; }, [])} className="h-full"
/> />
</div> </div>
</div>
{/* Right Column - Font Selector */} {/* Right panel: preview */}
<div className="lg:col-span-1 h-[500px] lg:h-auto relative"> <div
<div className="lg:absolute lg:inset-0 h-full"> className={cn(
<FontSelector 'lg:col-span-3 flex flex-col overflow-hidden',
fonts={fonts} tab !== 'preview' && 'hidden lg:flex'
selectedFont={selectedFont} )}
onSelectFont={setSelectedFont} >
onRandomFont={handleRandomFont} <FontPreview
className="h-full" text={asciiArt}
font={selectedFont}
isLoading={isLoading}
onCopy={handleCopy}
onDownload={handleDownload}
onShare={handleShare}
onCommentedTextChange={React.useCallback(
(t: string) => { commentedTextRef.current = t; },
[]
)}
/> />
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -2,44 +2,30 @@
import * as React from 'react'; import * as React from 'react';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { import {
Select, Copy,
SelectContent, Download,
SelectItem, Share2,
SelectTrigger, Image as ImageIcon,
SelectValue, AlignLeft,
} from '@/components/ui/select'; AlignCenter,
import { AlignRight,
Tooltip, MessageSquareCode,
TooltipContent, Type,
TooltipTrigger, } from 'lucide-react';
} from '@/components/ui/tooltip'; import { cn, actionBtn, cardBtn } from '@/lib/utils';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type, MessageSquareCode } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { toast } from 'sonner'; import { toast } from 'sonner';
export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- -->' | '"""'; export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- -->' | '"""';
const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [ const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
{ value: '//', label: '// C, JS, Go' }, { value: '//', label: '// C / JS / Go' },
{ value: '#', label: '# Python, Shell' }, { value: '#', label: '# Python / Shell' },
{ value: '--', label: '-- SQL, Lua' }, { value: '--', label: '-- SQL / Lua' },
{ value: ';', label: '; Lisp, ASM' }, { value: ';', label: '; Lisp / ASM' },
{ value: '/* */', label: '/* */ Block' }, { value: '/* */', label: '/* Block */' },
{ value: '<!-- -->', label: '<!-- --> HTML' }, { value: '<!-- -->', label: '<!-- HTML -->' },
{ value: '"""', label: '""" Docstring' }, { value: '"""', label: '""" Docstring' },
]; ];
@@ -51,9 +37,9 @@ function applyCommentStyle(text: string, style: CommentStyle): string {
case '#': case '#':
case '--': case '--':
case ';': case ';':
return lines.map(line => `${style} ${line}`).join('\n'); return lines.map((l) => `${style} ${l}`).join('\n');
case '/* */': case '/* */':
return ['/*', ...lines.map(line => ` * ${line}`), ' */'].join('\n'); return ['/*', ...lines.map((l) => ` * ${l}`), ' */'].join('\n');
case '<!-- -->': case '<!-- -->':
return ['<!--', ...lines, '-->'].join('\n'); return ['<!--', ...lines, '-->'].join('\n');
case '"""': case '"""':
@@ -73,14 +59,39 @@ export interface FontPreviewProps {
} }
type TextAlign = 'left' | 'center' | 'right'; type TextAlign = 'left' | 'center' | 'right';
type FontSize = 'xs' | 'sm' | 'base';
export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, onCommentedTextChange, className }: FontPreviewProps) { const ALIGN_OPTS: { value: TextAlign; icon: React.ElementType; label: string }[] = [
const previewRef = React.useRef<HTMLDivElement>(null); { value: 'left', icon: AlignLeft, label: 'Left' },
{ value: 'center', icon: AlignCenter, label: 'Center' },
{ value: 'right', icon: AlignRight, label: 'Right' },
];
const SIZE_OPTS: { value: FontSize; label: string }[] = [
{ value: 'xs', label: 'xs' },
{ value: 'sm', label: 'sm' },
{ value: 'base', label: 'md' },
];
export function FontPreview({
text,
font,
isLoading,
onCopy,
onDownload,
onShare,
onCommentedTextChange,
className,
}: FontPreviewProps) {
const terminalRef = React.useRef<HTMLDivElement>(null);
const [textAlign, setTextAlign] = React.useState<TextAlign>('left'); const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm'); const [fontSize, setFontSize] = React.useState<FontSize>('sm');
const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none'); const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none');
const commentedText = React.useMemo(() => applyCommentStyle(text, commentStyle), [text, commentStyle]); const commentedText = React.useMemo(
() => applyCommentStyle(text, commentStyle),
[text, commentStyle]
);
const lineCount = commentedText ? commentedText.split('\n').length : 0; const lineCount = commentedText ? commentedText.split('\n').length : 0;
const charCount = commentedText ? commentedText.length : 0; const charCount = commentedText ? commentedText.length : 0;
@@ -89,183 +100,177 @@ export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare
}, [commentedText, onCommentedTextChange]); }, [commentedText, onCommentedTextChange]);
const handleExportPNG = async () => { const handleExportPNG = async () => {
if (!previewRef.current || !text) return; if (!terminalRef.current || !text) return;
try { try {
const dataUrl = await toPng(previewRef.current, { const dataUrl = await toPng(terminalRef.current, {
backgroundColor: getComputedStyle(previewRef.current).backgroundColor, backgroundColor: '#06060e',
pixelRatio: 2, pixelRatio: 2,
}); });
const link = document.createElement('a'); const link = document.createElement('a');
link.download = `ascii-${font || 'export'}-${Date.now()}.png`; link.download = `ascii-${font || 'export'}-${Date.now()}.png`;
link.href = dataUrl; link.href = dataUrl;
link.click(); link.click();
toast.success('Exported as PNG!'); toast.success('Exported as PNG!');
} catch (error) { } catch {
console.error('Failed to export PNG:', error);
toast.error('Failed to export PNG'); toast.error('Failed to export PNG');
} }
}; };
return ( return (
<Card className={cn('relative', className)}> <div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
{/* ── Header: label + font tag + export actions ─────────── */}
<div className="flex items-center justify-between gap-2 shrink-0 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CardTitle>Preview</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Preview
</span>
{font && ( {font && (
<Badge className="text-[10px] font-mono"> <span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
{font} {font}
</Badge> </span>
)} )}
</div> </div>
<div className="flex gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{onCopy && ( {onCopy && (
<Tooltip> <button onClick={onCopy} className={cardBtn}>
<TooltipTrigger asChild> <Copy className="w-3 h-3" /> Copy
<Button variant="outline" size="xs" onClick={onCopy}> </button>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
</TooltipTrigger>
<TooltipContent>Copy to clipboard</TooltipContent>
</Tooltip>
)} )}
{onShare && ( {onShare && (
<Tooltip> <button onClick={onShare} className={cardBtn}>
<TooltipTrigger asChild> <Share2 className="w-3 h-3" /> Share
<Button variant="outline" size="xs" onClick={onShare}> </button>
<Share2 className="h-3 w-3 mr-1" />
Share
</Button>
</TooltipTrigger>
<TooltipContent>Copy shareable URL</TooltipContent>
</Tooltip>
)} )}
<Tooltip> <button onClick={handleExportPNG} className={cardBtn}>
<TooltipTrigger asChild> <ImageIcon className="w-3 h-3" /> PNG
<Button variant="outline" size="xs" onClick={handleExportPNG}> </button>
<ImageIcon className="h-3 w-3 mr-1" />
PNG
</Button>
</TooltipTrigger>
<TooltipContent>Export as PNG</TooltipContent>
</Tooltip>
{onDownload && ( {onDownload && (
<Tooltip> <button onClick={onDownload} className={cardBtn}>
<TooltipTrigger asChild> <Download className="w-3 h-3" /> TXT
<Button variant="outline" size="xs" onClick={onDownload}> </button>
<Download className="h-3 w-3 mr-1" />
TXT
</Button>
</TooltipTrigger>
<TooltipContent>Download as text file</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</CardHeader> </div>
<CardContent className="space-y-3">
{/* Controls */} {/* ── Controls: alignment · size · comment style ─────────── */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 shrink-0 flex-wrap">
<ToggleGroup {/* Alignment */}
type="single" <div className="flex items-center gap-0.5">
value={textAlign} {ALIGN_OPTS.map(({ value, icon: Icon, label }) => (
onValueChange={(v) => v && setTextAlign(v as TextAlign)} <button
variant="outline" key={value}
size="sm" onClick={() => setTextAlign(value)}
disabled={commentStyle !== 'none'} disabled={commentStyle !== 'none'}
title={label}
className={cn(
'px-2 py-1 h-6 rounded-md transition-all border text-xs',
textAlign === value && commentStyle === 'none'
? 'bg-primary/10 border-primary/30 text-primary'
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40',
commentStyle !== 'none' && 'opacity-30 cursor-not-allowed'
)}
>
<Icon className="w-3 h-3" />
</button>
))}
</div>
{/* Font size */}
<div className="flex items-center gap-0.5">
{SIZE_OPTS.map(({ value, label }) => (
<button
key={value}
onClick={() => setFontSize(value)}
className={cn(
'px-2 py-1 text-[10px] font-mono rounded-md transition-all border uppercase',
fontSize === value
? 'bg-primary/10 border-primary/30 text-primary'
: 'glass border-transparent text-muted-foreground/55 hover:text-foreground hover:border-border/40'
)}
>
{label}
</button>
))}
</div>
{/* Comment style */}
<div className="flex items-center gap-1 px-2 py-1.25 glass rounded-md border border-border/30 text-muted-foreground hover:border-primary/30 hover:text-primary transition-colors">
<MessageSquareCode className="w-3 h-3 shrink-0" />
<select
value={commentStyle}
onChange={(e) => setCommentStyle(e.target.value as CommentStyle)}
className="bg-transparent outline-none text-[10px] font-mono cursor-pointer"
> >
<ToggleGroupItem value="left" aria-label="Align left" className="px-1.5"> {COMMENT_STYLES.map((s) => (
<AlignLeft className="h-3 w-3" /> <option key={s.value} value={s.value}>{s.label}</option>
</ToggleGroupItem> ))}
<ToggleGroupItem value="center" aria-label="Align center" className="px-1.5"> </select>
<AlignCenter className="h-3 w-3" /> </div>
</ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="Align right" className="px-1.5">
<AlignRight className="h-3 w-3" />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup {/* Stats */}
type="single" {!isLoading && text && (
value={fontSize} <span className="ml-auto text-[10px] text-muted-foreground/30 font-mono tabular-nums">
onValueChange={(v) => v && setFontSize(v as 'xs' | 'sm' | 'base')} {lineCount}L · {charCount}C
variant="outline" </span>
size="sm" )}
> </div>
<ToggleGroupItem value="xs" aria-label="Extra small font" className="px-1.5 text-[10px] uppercase">
xs
</ToggleGroupItem>
<ToggleGroupItem value="sm" aria-label="Small font" className="px-1.5 text-[10px] uppercase">
sm
</ToggleGroupItem>
<ToggleGroupItem value="base" aria-label="Medium font" className="px-1.5 text-[10px] uppercase">
md
</ToggleGroupItem>
</ToggleGroup>
<Select value={commentStyle} onValueChange={(v) => setCommentStyle(v as CommentStyle)}> {/* ── Terminal window ────────────────────────────────────── */}
<SelectTrigger size="sm" className="h-8 w-auto gap-1 text-xs"> <div
<MessageSquareCode className="h-3 w-3 text-foreground shrink-0" /> ref={terminalRef}
<SelectValue /> className="flex-1 min-h-0 flex flex-col rounded-xl overflow-hidden border border-white/5"
</SelectTrigger> style={{ background: '#06060e' }}
<SelectContent> >
{COMMENT_STYLES.map((s) => ( {/* Terminal chrome */}
<SelectItem key={s.value} value={s.value}> <div className="flex items-center gap-1.5 px-3.5 py-2 border-b border-white/5 shrink-0">
{s.label} <div className="w-2.5 h-2.5 rounded-full bg-rose-500/55" />
</SelectItem> <div className="w-2.5 h-2.5 rounded-full bg-amber-400/55" />
))} <div className="w-2.5 h-2.5 rounded-full bg-emerald-500/55" />
</SelectContent> {font && (
</Select> <span className="ml-2 text-[10px] font-mono text-white/20 tracking-wider select-none">
{font}
{!isLoading && text && ( </span>
<div className="flex gap-2 text-[10px] text-muted-foreground ml-auto">
<span>{lineCount} lines</span>
<span>{charCount} chars</span>
</div>
)} )}
</div> </div>
{/* Content */}
<div <div
ref={previewRef} className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-white/8 scrollbar-track-transparent"
className={cn( style={{ textAlign: commentStyle === 'none' ? textAlign : 'left' }}
'relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto',
commentStyle === 'none' && textAlign === 'center' && 'text-center',
commentStyle === 'none' && textAlign === 'right' && 'text-right'
)}
> >
{isLoading ? ( {isLoading ? (
<div className="space-y-3"> <div className="space-y-2 animate-pulse">
<Skeleton className="h-6 w-3/4" /> {[0.7, 1, 0.85, 0.55, 1, 0.9, 0.75].map((w, i) => (
<Skeleton className="h-6 w-full" /> <div
<Skeleton className="h-6 w-5/6" /> key={i}
<Skeleton className="h-6 w-2/3" /> className="h-3.5 rounded-sm bg-white/5"
<Skeleton className="h-6 w-full" /> style={{ width: `${w * 100}%` }}
<Skeleton className="h-6 w-4/5" /> />
))}
</div> </div>
) : text ? ( ) : text ? (
<pre className={cn( <pre
'font-mono whitespace-pre overflow-x-auto animate-in', className={cn(
fontSize === 'xs' && 'text-[10px]', 'font-mono whitespace-pre text-white/85 leading-snug',
fontSize === 'sm' && 'text-xs sm:text-sm', fontSize === 'xs' && 'text-[9px]',
fontSize === 'base' && 'text-sm sm:text-base' fontSize === 'sm' && 'text-[11px] sm:text-xs',
)}> fontSize === 'base' && 'text-xs sm:text-sm'
)}
>
{commentedText} {commentedText}
</pre> </pre>
) : ( ) : (
<Empty> <div className="h-full flex flex-col items-center justify-center gap-2 text-center">
<EmptyHeader> <Type className="w-6 h-6 text-white/10" />
<EmptyMedia variant="icon"> <p className="text-xs text-white/20 font-mono">
<Type /> Start typing to see your ASCII art
</EmptyMedia> </p>
<EmptyTitle>Start typing to see your ASCII art</EmptyTitle> </div>
<EmptyDescription>Enter text in the input field above to generate ASCII art with the selected font</EmptyDescription>
</EmptyHeader>
</Empty>
)} )}
</div> </div>
</CardContent> </div>
</Card>
</div>
); );
} }

View File

@@ -2,19 +2,8 @@
import * as React from 'react'; import * as React from 'react';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react'; import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { ASCIIFont } from '@/types/ascii'; import type { ASCIIFont } from '@/types/ascii';
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites'; import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
@@ -28,62 +17,52 @@ export interface FontSelectorProps {
type FilterType = 'all' | 'favorites' | 'recent'; type FilterType = 'all' | 'favorites' | 'recent';
const FILTERS: { value: FilterType; icon: React.ElementType; label: string }[] = [
{ value: 'all', icon: List, label: 'All' },
{ value: 'favorites', icon: Heart, label: 'Fav' },
{ value: 'recent', icon: Clock, label: 'Recent' },
];
export function FontSelector({ export function FontSelector({
fonts, fonts,
selectedFont, selectedFont,
onSelectFont, onSelectFont,
onRandomFont, onRandomFont,
className className,
}: FontSelectorProps) { }: FontSelectorProps) {
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [filter, setFilter] = React.useState<FilterType>('all'); const [filter, setFilter] = React.useState<FilterType>('all');
const [favorites, setFavorites] = React.useState<string[]>([]); const [favorites, setFavorites] = React.useState<string[]>([]);
const [recentFonts, setRecentFonts] = React.useState<string[]>([]); const [recentFonts, setRecentFonts] = React.useState<string[]>([]);
const searchInputRef = React.useRef<HTMLInputElement>(null); const selectedRef = React.useRef<HTMLButtonElement>(null);
// Load favorites and recent fonts
React.useEffect(() => { React.useEffect(() => {
setFavorites(getFavorites()); setFavorites(getFavorites());
setRecentFonts(getRecentFonts()); setRecentFonts(getRecentFonts());
}, []); }, []);
// Initialize Fuse.js for fuzzy search // Keep selected item in view when font changes externally (e.g. random)
const fuse = React.useMemo(() => { React.useEffect(() => {
return new Fuse(fonts, { selectedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
keys: ['name', 'fileName'], }, [selectedFont]);
threshold: 0.3,
includeScore: true, const fuse = React.useMemo(
}); () => new Fuse(fonts, { keys: ['name', 'fileName'], threshold: 0.3, includeScore: true }),
}, [fonts]); [fonts]
);
const filteredFonts = React.useMemo(() => { const filteredFonts = React.useMemo(() => {
let fontsToFilter = fonts; let base = fonts;
// Apply category filter
if (filter === 'favorites') { if (filter === 'favorites') {
fontsToFilter = fonts.filter(f => favorites.includes(f.name)); base = fonts.filter((f) => favorites.includes(f.name));
} else if (filter === 'recent') { } else if (filter === 'recent') {
fontsToFilter = fonts.filter(f => recentFonts.includes(f.name)); base = [...fonts.filter((f) => recentFonts.includes(f.name))].sort(
// Sort by recent order (a, b) => recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name)
fontsToFilter.sort((a, b) => { );
return recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name);
});
} }
if (!searchQuery) return base;
// Apply search query const hits = fuse.search(searchQuery).map((r) => r.item);
if (!searchQuery) return fontsToFilter; return filter === 'all' ? hits : hits.filter((f) => base.includes(f));
const results = fuse.search(searchQuery);
const searchResults = results.map(result => result.item);
// Filter search results by category
if (filter === 'favorites') {
return searchResults.filter(f => favorites.includes(f.name));
} else if (filter === 'recent') {
return searchResults.filter(f => recentFonts.includes(f.name));
}
return searchResults;
}, [fonts, searchQuery, fuse, filter, favorites, recentFonts]); }, [fonts, searchQuery, fuse, filter, favorites, recentFonts]);
const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => { const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => {
@@ -92,134 +71,140 @@ export function FontSelector({
setFavorites(getFavorites()); setFavorites(getFavorites());
}; };
return ( const emptyMessage =
<Card className={cn("flex flex-col min-h-0 overflow-hidden", className)}> filter === 'favorites'
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2 space-y-0"> ? 'No favorites yet — click ♥ to save'
<CardTitle>Fonts</CardTitle> : filter === 'recent'
{onRandomFont && ( ? 'No recent fonts'
<Button : searchQuery
variant="outline" ? 'No fonts match your search'
size="xs" : 'Loading fonts…';
onClick={onRandomFont}
title="Random font"
>
<Shuffle className="h-3 w-3 mr-1" />
Random
</Button>
)}
</CardHeader>
<CardContent className="flex flex-col flex-1 min-h-0 pt-0">
<Tabs
value={filter}
onValueChange={(v) => setFilter(v as FilterType)}
className="mb-3 shrink-0"
>
<TabsList className="w-full">
<TabsTrigger value="all" className="flex-1">
<List className="h-3 w-3" />
All
</TabsTrigger>
<TabsTrigger value="favorites" className="flex-1">
<Heart className="h-3 w-3" />
Fav
</TabsTrigger>
<TabsTrigger value="recent" className="flex-1">
<Clock className="h-3 w-3" />
Recent
</TabsTrigger>
</TabsList>
</Tabs>
{/* Search Input */} return (
<div className="relative mb-3 shrink-0"> <div className={cn('glass rounded-xl p-3 flex flex-col min-h-0 overflow-hidden', className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input {/* ── Header ────────────────────────────────────────────── */}
ref={searchInputRef} <div className="flex items-center justify-between mb-3 shrink-0">
type="text" <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
placeholder="Search fonts..." Fonts
value={searchQuery} </span>
onChange={(e) => setSearchQuery(e.target.value)} <div className="flex items-center gap-2.5">
className="pl-8 pr-8 h-8 text-sm" <span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
/> {fonts.length}
{searchQuery && ( </span>
{onRandomFont && (
<button <button
onClick={() => setSearchQuery('')} onClick={onRandomFont}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="text-muted-foreground/50 hover:text-primary transition-colors"
aria-label="Clear search" title="Random font"
> >
<X className="h-3.5 w-3.5" /> <Shuffle className="w-3.5 h-3.5" />
</button> </button>
)} )}
</div> </div>
</div>
{/* Font List */} {/* ── Filter tabs ───────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto space-y-0.5 pr-1 scrollbar"> <div className="flex glass rounded-lg p-0.5 gap-0.5 mb-3 shrink-0">
{filteredFonts.length === 0 ? ( {FILTERS.map(({ value, icon: Icon, label }) => (
<Empty> <button
<EmptyHeader> key={value}
<EmptyMedia variant="icon"> onClick={() => setFilter(value)}
{filter === 'favorites' ? <Heart /> : (filter === 'recent' ? <Clock /> : <Search />)} className={cn(
</EmptyMedia> 'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
<EmptyTitle>{ filter === value
filter === 'favorites' ? 'bg-primary text-primary-foreground shadow-sm'
? 'No favorite fonts yet' : 'text-muted-foreground hover:text-foreground'
: filter === 'recent' )}
? 'No recent fonts' >
: 'No fonts found' <Icon className="w-3 h-3" />
}</EmptyTitle> {label}
<EmptyDescription> </button>
{ ))}
filter === 'favorites' </div>
? 'Click the heart icon on any font to add it to your favorites'
: filter === 'recent' {/* ── Search ────────────────────────────────────────────── */}
? 'Fonts you use will appear here' <div className="relative mb-3 shrink-0">
: searchQuery <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/40 pointer-events-none" />
? 'Try a different search term' <input
: 'Loading fonts...' type="text"
} placeholder="Search fonts…"
</EmptyDescription> value={searchQuery}
</EmptyHeader> onChange={(e) => setSearchQuery(e.target.value)}
</Empty> className="w-full bg-transparent border border-border/40 rounded-lg pl-8 pr-7 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30"
) : ( />
filteredFonts.map((font) => ( {searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* ── Font list ─────────────────────────────────────────── */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent space-y-0.5 pr-0.5">
{filteredFonts.length === 0 ? (
<div className="py-10 text-center">
<p className="text-xs text-muted-foreground/35 italic">{emptyMessage}</p>
</div>
) : (
filteredFonts.map((font) => {
const isSelected = selectedFont === font.name;
const fav = isFavorite(font.name);
return (
<div <div
key={font.name} key={font.name}
className={cn( className={cn(
'group flex items-center gap-1 px-2 py-1.5 rounded text-xs transition-colors', 'group flex items-center gap-1.5 rounded-lg transition-all cursor-pointer',
'hover:bg-accent hover:text-accent-foreground', 'border-l-2',
selectedFont === font.name && 'bg-accent text-accent-foreground font-medium' isSelected
? 'bg-primary/10 border-primary text-primary'
: 'border-transparent text-foreground/65 hover:bg-primary/8 hover:text-foreground'
)} )}
> >
<button <button
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelectFont(font.name)} onClick={() => onSelectFont(font.name)}
className="flex-1 text-left truncate" className="flex-1 text-left text-xs font-mono truncate px-2 py-1.5"
> >
{font.name} {font.name}
</button> </button>
<button <button
onClick={(e) => handleToggleFavorite(font.name, e)} onClick={(e) => handleToggleFavorite(font.name, e)}
className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" className={cn(
aria-label={isFavorite(font.name) ? 'Remove from favorites' : 'Add to favorites'} 'shrink-0 pr-2 transition-all',
fav ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
)}
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
> >
<Heart <Heart
className={cn( className={cn(
'h-3 w-3 transition-colors', 'w-3 h-3 transition-colors',
isFavorite(font.name) ? 'fill-red-500 text-red-500 !opacity-100' : 'text-muted-foreground/50 hover:text-red-500/50' fav ? 'fill-rose-500 text-rose-500' : 'text-muted-foreground/40 hover:text-rose-400'
)} )}
/> />
</button> </button>
</div> </div>
)) );
)} })
</div> )}
</div>
{/* Stats */} {/* ── Footer ────────────────────────────────────────────── */}
<div className="mt-3 pt-3 border-t text-[10px] text-muted-foreground shrink-0"> <div className="mt-3 pt-2.5 border-t border-border/25 flex items-center justify-between shrink-0">
<span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''} {filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
{filter === 'favorites' && ` · ${favorites.length} favorites`} </span>
{filter === 'recent' && ` · ${recentFonts.length} recent`} {filter === 'favorites' && (
</div> <span className="text-[10px] text-muted-foreground/35">{favorites.length} saved</span>
</CardContent> )}
</Card> {filter === 'recent' && (
<span className="text-[10px] text-muted-foreground/35">{recentFonts.length} recent</span>
)}
</div>
</div>
); );
} }

View File

@@ -2,7 +2,6 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Textarea } from '@/components/ui/textarea';
export interface TextInputProps { export interface TextInputProps {
value: string; value: string;
@@ -14,14 +13,17 @@ export interface TextInputProps {
export function TextInput({ value, onChange, placeholder, className }: TextInputProps) { export function TextInput({ value, onChange, placeholder, className }: TextInputProps) {
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<Textarea <textarea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Type something...'} placeholder={placeholder || 'Type something'}
className="h-32 resize-none" rows={4}
maxLength={100} maxLength={100}
className="w-full bg-transparent resize-none font-mono text-sm outline-none text-foreground placeholder:text-muted-foreground/35 border border-border/40 rounded-lg px-3 py-2.5 focus:border-primary/50 transition-colors"
spellCheck={false}
autoComplete="off"
/> />
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground"> <div className="absolute bottom-3 right-3 text-[10px] text-muted-foreground/35 font-mono pointer-events-none tabular-nums">
{value.length}/100 {value.length}/100
</div> </div>
</div> </div>

View File

@@ -0,0 +1,51 @@
'use client';
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { ExpressionPanel } from './ExpressionPanel';
import { GraphPanel } from './GraphPanel';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'calc' | 'graph';
export default function Calculator() {
const [tab, setTab] = useState<Tab>('calc');
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'calc', label: 'Calculator' }, { value: 'graph', label: 'Graph' }]}
active={tab}
onChange={(v) => setTab(v as Tab)}
/>
{/* Main layout — side-by-side on lg, tabbed on mobile */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Expression panel */}
<div
className={cn(
'lg:col-span-2 overflow-hidden flex flex-col',
tab !== 'calc' && 'hidden lg:flex'
)}
>
<ExpressionPanel />
</div>
{/* Graph panel */}
<div
className={cn(
'lg:col-span-3 overflow-hidden flex flex-col',
tab !== 'graph' && 'hidden lg:flex'
)}
>
<GraphPanel />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,328 @@
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { Plus, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react';
import { useCalculateStore } from '@/lib/calculate/store';
import { evaluateExpression } from '@/lib/calculate/math-engine';
import { cn } from '@/lib/utils';
const QUICK_KEYS = [
// Constants
{ label: 'π', insert: 'pi', group: 'const' },
{ label: 'e', insert: 'e', group: 'const' },
{ label: 'φ', insert: '(1+sqrt(5))/2', group: 'const' },
{ label: '∞', insert: 'Infinity', group: 'const' },
{ label: 'i', insert: 'i', group: 'const' },
// Ops
{ label: '^', insert: '^', group: 'op' },
{ label: '(', insert: '(', group: 'op' },
{ label: ')', insert: ')', group: 'op' },
{ label: '%', insert: ' % ', group: 'op' },
{ label: 'mod', insert: ' mod ', group: 'op' },
// Functions
{ label: '√', insert: 'sqrt(', group: 'fn' },
{ label: '∛', insert: 'cbrt(', group: 'fn' },
{ label: '|x|', insert: 'abs(', group: 'fn' },
{ label: 'n!', insert: '!', group: 'fn' },
{ label: 'sin', insert: 'sin(', group: 'trig' },
{ label: 'cos', insert: 'cos(', group: 'trig' },
{ label: 'tan', insert: 'tan(', group: 'trig' },
{ label: 'asin', insert: 'asin(', group: 'trig' },
{ label: 'acos', insert: 'acos(', group: 'trig' },
{ label: 'atan', insert: 'atan(', group: 'trig' },
{ label: 'sinh', insert: 'sinh(', group: 'trig' },
{ label: 'cosh', insert: 'cosh(', group: 'trig' },
{ label: 'log', insert: 'log10(', group: 'log' },
{ label: 'ln', insert: 'log(', group: 'log' },
{ label: 'log₂', insert: 'log2(', group: 'log' },
{ label: 'exp', insert: 'exp(', group: 'log' },
{ label: 'floor', insert: 'floor(', group: 'round' },
{ label: 'ceil', insert: 'ceil(', group: 'round' },
{ label: 'round', insert: 'round(', group: 'round' },
{ label: 'gcd', insert: 'gcd(', group: 'misc' },
{ label: 'lcm', insert: 'lcm(', group: 'misc' },
{ label: 'nCr', insert: 'combinations(', group: 'misc' },
{ label: 'nPr', insert: 'permutations(', group: 'misc' },
] as const;
export function ExpressionPanel() {
const {
expression, setExpression,
history, addToHistory, clearHistory,
variables, setVariable, removeVariable,
} = useCalculateStore();
const [liveResult, setLiveResult] = useState<{ result: string; error: boolean } | null>(null);
const [newVarName, setNewVarName] = useState('');
const [newVarValue, setNewVarValue] = useState('');
const [showAddVar, setShowAddVar] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Real-time evaluation
useEffect(() => {
if (!expression.trim()) { setLiveResult(null); return; }
const r = evaluateExpression(expression, variables);
setLiveResult(r.result ? { result: r.result, error: r.error } : null);
}, [expression, variables]);
const handleSubmit = useCallback(() => {
if (!expression.trim()) return;
const r = evaluateExpression(expression, variables);
if (!r.result) return;
addToHistory({ expression: expression.trim(), result: r.result, error: r.error });
if (!r.error) {
if (r.assignedName && r.assignedValue) {
setVariable(r.assignedName, r.assignedValue);
}
setExpression('');
}
}, [expression, variables, addToHistory, setExpression, setVariable]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}, [handleSubmit]);
const insertAtCursor = useCallback((text: string) => {
const ta = textareaRef.current;
if (!ta) { setExpression(expression + text); return; }
const start = ta.selectionStart;
const end = ta.selectionEnd;
const next = expression.slice(0, start) + text + expression.slice(end);
setExpression(next);
requestAnimationFrame(() => {
ta.focus();
const pos = start + text.length;
ta.selectionStart = ta.selectionEnd = pos;
});
}, [expression, setExpression]);
const addVar = useCallback(() => {
if (!newVarName.trim() || !newVarValue.trim()) return;
setVariable(newVarName.trim(), newVarValue.trim());
setNewVarName(''); setNewVarValue(''); setShowAddVar(false);
}, [newVarName, newVarValue, setVariable]);
const visibleKeys = showAllKeys ? QUICK_KEYS : QUICK_KEYS.slice(0, 16);
return (
<div className="flex flex-col gap-3 h-full overflow-hidden">
{/* ── Expression input ──────────────────────────────────── */}
<div className="glass rounded-xl p-4 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Expression
</span>
<span className="text-[10px] text-muted-foreground/50">
Enter to evaluate · Shift+Enter for newline
</span>
</div>
<textarea
ref={textareaRef}
value={expression}
onChange={(e) => setExpression(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g. sin(pi/4) * sqrt(2)"
rows={3}
className={cn(
'w-full bg-transparent resize-none font-mono text-sm outline-none',
'text-foreground placeholder:text-muted-foreground/35',
'border border-border/40 rounded-lg px-3 py-2.5',
'focus:border-primary/50 transition-colors'
)}
spellCheck={false}
autoComplete="off"
/>
{/* Result display */}
<div className="mt-3 flex items-baseline gap-2 min-h-[2rem]">
{liveResult && (
<>
<span className="font-mono text-muted-foreground shrink-0">=</span>
<span
className={cn(
'font-mono font-semibold break-all',
liveResult.error
? 'text-sm text-destructive/90'
: 'text-2xl bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent'
)}
>
{liveResult.result}
</span>
</>
)}
</div>
<button
onClick={handleSubmit}
disabled={!expression.trim()}
className={cn(
'mt-2 w-full py-2 rounded-lg text-xs font-medium transition-all',
'bg-primary/90 text-primary-foreground hover:bg-primary',
'disabled:opacity-30 disabled:cursor-not-allowed'
)}
>
Evaluate
</button>
</div>
{/* ── Quick insert keys ─────────────────────────────────── */}
<div className="glass rounded-xl p-3 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Insert
</span>
<button
onClick={() => setShowAllKeys((v) => !v)}
className="flex items-center gap-0.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
{showAllKeys ? (
<><ChevronUp className="w-3 h-3" /> less</>
) : (
<><ChevronDown className="w-3 h-3" /> more</>
)}
</button>
</div>
<div className="flex flex-wrap gap-1">
{visibleKeys.map((k) => (
<button
key={k.label}
onClick={() => insertAtCursor(k.insert)}
className={cn(
'px-2 py-1 text-xs font-mono rounded-md transition-all',
'glass border border-transparent',
'hover:border-primary/30 hover:bg-primary/10 hover:text-primary',
'text-foreground/80'
)}
>
{k.label}
</button>
))}
</div>
</div>
{/* ── Variables ─────────────────────────────────────────── */}
<div className="glass rounded-xl p-3 shrink-0">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Variables
</span>
<button
onClick={() => setShowAddVar((v) => !v)}
className="text-muted-foreground hover:text-primary transition-colors"
title="Add variable"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
{Object.keys(variables).length === 0 && !showAddVar && (
<p className="text-xs text-muted-foreground/40 italic">
Define variables like <span className="font-mono not-italic">x = 5</span> by evaluating assignments
</p>
)}
<div className="space-y-1">
{Object.entries(variables).map(([name, val]) => (
<div key={name} className="flex items-center gap-2 group">
<span
className="font-mono text-sm text-primary cursor-pointer hover:underline"
onClick={() => insertAtCursor(name)}
title="Insert into expression"
>
{name}
</span>
<span className="text-muted-foreground/50 text-xs">=</span>
<span className="font-mono text-sm text-foreground/80 flex-1 truncate">{val}</span>
<button
onClick={() => removeVariable(name)}
className="opacity-0 group-hover:opacity-100 text-muted-foreground/50 hover:text-destructive transition-all"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
{showAddVar && (
<div className="flex items-center gap-1.5 mt-2">
<input
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
placeholder="name"
className="w-16 bg-transparent border border-border/40 rounded px-2 py-1 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
/>
<span className="text-muted-foreground/50 text-xs">=</span>
<input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
placeholder="value"
onKeyDown={(e) => e.key === 'Enter' && addVar()}
className="flex-1 bg-transparent border border-border/40 rounded px-2 py-1 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
/>
<button
onClick={addVar}
className="text-primary hover:text-primary/70 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setShowAddVar(false)}
className="text-muted-foreground/50 hover:text-muted-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* ── History ───────────────────────────────────────────── */}
<div className="glass rounded-xl p-3 flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex items-center justify-between mb-2 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
History
</span>
{history.length > 0 && (
<button
onClick={clearHistory}
className="text-muted-foreground/50 hover:text-destructive transition-colors"
title="Clear history"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{history.length === 0 ? (
<p className="text-xs text-muted-foreground/40 italic">No calculations yet</p>
) : (
<div className="overflow-y-auto flex-1 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
{history.map((entry) => (
<button
key={entry.id}
onClick={() => setExpression(entry.expression)}
className="w-full text-left px-2 py-2 rounded-lg hover:bg-primary/8 group transition-colors"
>
<div className="font-mono text-[11px] text-muted-foreground/70 truncate group-hover:text-muted-foreground transition-colors">
{entry.expression}
</div>
<div className={cn(
'font-mono text-sm font-medium mt-0.5',
entry.error ? 'text-destructive/80' : 'text-foreground/90'
)}>
= {entry.result}
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,370 @@
'use client';
import { useRef, useEffect, useCallback, useState } from 'react';
import type { GraphFunction } from '@/lib/calculate/store';
import { sampleFunction, evaluateAt } from '@/lib/calculate/math-engine';
interface ViewState {
xMin: number;
xMax: number;
yMin: number;
yMax: number;
}
interface Props {
functions: GraphFunction[];
variables: Record<string, string>;
}
const DEFAULT_VIEW: ViewState = { xMin: -10, xMax: 10, yMin: -6, yMax: 6 };
function niceStep(range: number): number {
if (range <= 0) return 1;
const rawStep = range / 8;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
const n = rawStep / mag;
const nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10;
return nice * mag;
}
function fmtLabel(v: number): string {
if (Math.abs(v) < 1e-10) return '0';
const abs = Math.abs(v);
if (abs >= 1e5 || (abs < 0.01 && abs > 0)) return v.toExponential(1);
return parseFloat(v.toPrecision(4)).toString();
}
export default function GraphCanvas({ functions, variables }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewRef = useRef<ViewState>(DEFAULT_VIEW);
const [, tick] = useState(0);
const redraw = useCallback(() => tick((n) => n + 1), []);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const cursorRef = useRef<{ x: number; y: number } | null>(null);
const functionsRef = useRef(functions);
const variablesRef = useRef(variables);
const dragRef = useRef<{ startX: number; startY: number; startView: ViewState } | null>(null);
const rafRef = useRef(0);
useEffect(() => { functionsRef.current = functions; }, [functions]);
useEffect(() => { variablesRef.current = variables; }, [variables]);
useEffect(() => { cursorRef.current = cursor; }, [cursor]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const W = rect.width;
const H = rect.height;
if (!W || !H) return;
if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const v = viewRef.current;
const xRange = v.xMax - v.xMin;
const yRange = v.yMax - v.yMin;
const fns = functionsRef.current;
const vars = variablesRef.current;
const cur = cursorRef.current;
const toP = (mx: number, my: number): [number, number] => [
(mx - v.xMin) / xRange * W,
H - (my - v.yMin) / yRange * H,
];
// ── Background ──────────────────────────────────────────────
ctx.fillStyle = '#08080f';
ctx.fillRect(0, 0, W, H);
const radGrad = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, Math.max(W, H) * 0.7);
radGrad.addColorStop(0, 'rgba(139, 92, 246, 0.05)');
radGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = radGrad;
ctx.fillRect(0, 0, W, H);
// ── Grid ─────────────────────────────────────────────────────
const xStep = niceStep(xRange);
const yStep = niceStep(yRange);
ctx.lineWidth = 1;
for (let x = Math.ceil(v.xMin / xStep) * xStep; x <= v.xMax + xStep * 0.01; x += xStep) {
const [px] = toP(x, 0);
ctx.strokeStyle =
Math.abs(x) < xStep * 0.01 ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.055)';
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
}
for (let y = Math.ceil(v.yMin / yStep) * yStep; y <= v.yMax + yStep * 0.01; y += yStep) {
const [, py] = toP(0, y);
ctx.strokeStyle =
Math.abs(y) < yStep * 0.01 ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.055)';
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
}
// ── Axes ──────────────────────────────────────────────────────
const [ax, ay] = toP(0, 0);
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
if (ay >= 0 && ay <= H) {
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W, ay); ctx.stroke();
}
if (ax >= 0 && ax <= W) {
ctx.beginPath(); ctx.moveTo(ax, 0); ctx.lineTo(ax, H); ctx.stroke();
}
// ── Axis arrow tips ───────────────────────────────────────────
const arrowSize = 5;
ctx.fillStyle = 'rgba(255,255,255,0.25)';
if (ax >= 0 && ax <= W) {
// Y-axis arrow (pointing up)
ctx.beginPath();
ctx.moveTo(ax, 4);
ctx.lineTo(ax - arrowSize, 4 + arrowSize * 1.8);
ctx.lineTo(ax + arrowSize, 4 + arrowSize * 1.8);
ctx.closePath(); ctx.fill();
}
if (ay >= 0 && ay <= H) {
// X-axis arrow (pointing right)
ctx.beginPath();
ctx.moveTo(W - 4, ay);
ctx.lineTo(W - 4 - arrowSize * 1.8, ay - arrowSize);
ctx.lineTo(W - 4 - arrowSize * 1.8, ay + arrowSize);
ctx.closePath(); ctx.fill();
}
// ── Axis labels ───────────────────────────────────────────────
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(161,161,170,0.6)';
const labelAY = Math.min(Math.max(ay + 5, 2), H - 14);
const labelAX = Math.min(Math.max(ax + 5, 2), W - 46);
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let x = Math.ceil(v.xMin / xStep) * xStep; x <= v.xMax; x += xStep) {
if (Math.abs(x) < xStep * 0.01) continue;
const [px] = toP(x, 0);
if (px < 8 || px > W - 8) continue;
ctx.fillText(fmtLabel(x), px, labelAY);
}
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
for (let y = Math.ceil(v.yMin / yStep) * yStep; y <= v.yMax; y += yStep) {
if (Math.abs(y) < yStep * 0.01) continue;
const [, py] = toP(0, y);
if (py < 8 || py > H - 8) continue;
ctx.fillText(fmtLabel(y), labelAX, py);
}
if (ax >= 0 && ax <= W && ay >= 0 && ay <= H) {
ctx.fillStyle = 'rgba(161,161,170,0.35)';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('0', labelAX, labelAY);
}
// ── Function curves ───────────────────────────────────────────
const numPts = Math.round(W * 1.5);
for (const fn of fns) {
if (!fn.visible || !fn.expression.trim()) continue;
const pts = sampleFunction(fn.expression, v.xMin, v.xMax, numPts, vars);
// Three render passes: wide glow → medium glow → crisp line
const passes = [
{ alpha: 0.08, width: 10 },
{ alpha: 0.28, width: 3.5 },
{ alpha: 1.0, width: 1.8 },
];
for (const { alpha, width } of passes) {
ctx.beginPath();
ctx.strokeStyle = fn.color;
ctx.globalAlpha = alpha;
ctx.lineWidth = width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
let penDown = false;
for (const pt of pts) {
if (pt === null) {
if (penDown) { ctx.stroke(); ctx.beginPath(); }
penDown = false;
} else {
const [px, py] = toP(pt.x, pt.y);
if (!penDown) { ctx.moveTo(px, py); penDown = true; }
else ctx.lineTo(px, py);
}
}
if (penDown) ctx.stroke();
ctx.globalAlpha = 1;
}
}
// ── Cursor crosshair + tooltip ────────────────────────────────
if (cur) {
const [cx, cy] = toP(cur.x, cur.y);
ctx.setLineDash([3, 5]);
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(W, cy); ctx.stroke();
ctx.setLineDash([]);
// Crosshair dot
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
// Function values at cursor x
type FnVal = { color: string; y: number; label: string };
const fnVals: FnVal[] = fns
.filter((f) => f.visible && f.expression.trim())
.map((f, i) => {
const y = evaluateAt(f.expression, cur.x, vars);
return isNaN(y) ? null : { color: f.color, y, label: `f${i + 1}(x)` };
})
.filter((v): v is FnVal => v !== null);
const coordLine = `x = ${cur.x.toFixed(3)} y = ${cur.y.toFixed(3)}`;
const lines: { text: string; color: string }[] = [
{ text: coordLine, color: 'rgba(200,200,215,0.85)' },
...fnVals.map((f) => ({
text: `${f.label} = ${f.y.toFixed(4)}`,
color: f.color,
})),
];
const lh = 15;
const pad = 9;
ctx.font = '10px monospace';
const maxW = Math.max(...lines.map((l) => ctx.measureText(l.text).width));
const bw = maxW + pad * 2;
const bh = lines.length * lh + pad * 2;
let bx = cx + 14;
let by = cy - bh / 2;
if (bx + bw > W - 4) bx = cx - bw - 14;
if (by < 4) by = 4;
if (by + bh > H - 4) by = H - bh - 4;
ctx.fillStyle = 'rgba(6, 6, 16, 0.92)';
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(bx, by, bw, bh, 5);
ctx.fill(); ctx.stroke();
lines.forEach((line, i) => {
ctx.fillStyle = line.color;
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText(line.text, bx + pad, by + pad + i * lh);
});
}
}, []);
const scheduleDraw = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(draw);
}, [draw]);
// Resize observer
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const obs = new ResizeObserver(scheduleDraw);
obs.observe(canvas);
scheduleDraw();
return () => obs.disconnect();
}, [scheduleDraw]);
// Redraw whenever reactive state changes
useEffect(() => { scheduleDraw(); }, [functions, variables, cursor, tick, scheduleDraw]);
// Convert mouse event to math coords
const toMath = useCallback((e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
const rect = canvasRef.current!.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width;
const py = (e.clientY - rect.top) / rect.height;
const v = viewRef.current;
return [v.xMin + px * (v.xMax - v.xMin), v.yMax - py * (v.yMax - v.yMin)];
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
dragRef.current = { startX: e.clientX, startY: e.clientY, startView: { ...viewRef.current } };
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const [mx, my] = toMath(e);
setCursor({ x: mx, y: my });
if (dragRef.current) {
const { startX, startY, startView: sv } = dragRef.current;
const rect = canvasRef.current!.getBoundingClientRect();
const dx = (e.clientX - startX) / rect.width * (sv.xMax - sv.xMin);
const dy = (e.clientY - startY) / rect.height * (sv.yMax - sv.yMin);
viewRef.current = {
xMin: sv.xMin - dx, xMax: sv.xMax - dx,
yMin: sv.yMin + dy, yMax: sv.yMax + dy,
};
redraw();
}
}, [toMath, redraw]);
const handleMouseUp = useCallback(() => { dragRef.current = null; }, []);
const handleMouseLeave = useCallback(() => { dragRef.current = null; setCursor(null); }, []);
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault();
const rect = canvasRef.current!.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width;
const py = (e.clientY - rect.top) / rect.height;
const v = viewRef.current;
const mx = v.xMin + px * (v.xMax - v.xMin);
const my = v.yMax - py * (v.yMax - v.yMin);
const factor = e.deltaY > 0 ? 1.12 : 1 / 1.12;
viewRef.current = {
xMin: mx - (mx - v.xMin) * factor,
xMax: mx + (v.xMax - mx) * factor,
yMin: my - (my - v.yMin) * factor,
yMax: my + (v.yMax - my) * factor,
};
redraw();
scheduleDraw();
}, [redraw, scheduleDraw]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.addEventListener('wheel', handleWheel, { passive: false });
return () => canvas.removeEventListener('wheel', handleWheel);
}, [handleWheel]);
const resetView = useCallback(() => {
viewRef.current = DEFAULT_VIEW;
redraw();
scheduleDraw();
}, [redraw, scheduleDraw]);
return (
<div className="relative w-full h-full group">
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ display: 'block', cursor: dragRef.current ? 'grabbing' : 'crosshair' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
/>
<button
onClick={resetView}
className="absolute bottom-3 right-3 px-2.5 py-1 text-xs font-mono text-muted-foreground glass rounded-md opacity-0 group-hover:opacity-100 hover:text-foreground transition-all duration-200"
>
reset view
</button>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { Plus, Eye, EyeOff, Trash2 } from 'lucide-react';
import { useCalculateStore } from '@/lib/calculate/store';
import { cn } from '@/lib/utils';
import GraphCanvas from './GraphCanvas';
export function GraphPanel() {
const {
graphFunctions,
variables,
addGraphFunction,
updateGraphFunction,
removeGraphFunction,
} = useCalculateStore();
return (
<div className="flex flex-col gap-3 h-full min-h-0">
{/* ── Function list ────────────────────────────────────── */}
<div className="glass rounded-xl p-3 shrink-0">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Functions <span className="text-muted-foreground/40 normal-case font-normal"> use x as variable</span>
</span>
<button
onClick={addGraphFunction}
disabled={graphFunctions.length >= 8}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-30"
>
<Plus className="w-3.5 h-3.5" /> Add
</button>
</div>
<div className="space-y-2">
{graphFunctions.map((fn, i) => (
<div key={fn.id} className="flex items-center gap-2">
{/* Color swatch / color picker */}
<div className="relative shrink-0 w-4 h-4">
<div
className="w-4 h-4 rounded-full ring-1 ring-white/15 cursor-pointer"
style={{ background: fn.color }}
/>
<input
type="color"
value={fn.color}
onChange={(e) => updateGraphFunction(fn.id, { color: e.target.value })}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
title="Change color"
/>
</div>
{/* Index label */}
<span
className="text-xs font-mono shrink-0 w-6"
style={{ color: fn.visible ? fn.color : 'rgba(161,161,170,0.4)' }}
>
f{i + 1}
</span>
{/* Expression input */}
<input
value={fn.expression}
onChange={(e) => updateGraphFunction(fn.id, { expression: e.target.value })}
placeholder={i === 0 ? 'sin(x)' : i === 1 ? 'x^2 / 4' : 'f(x)…'}
className={cn(
'flex-1 min-w-0 bg-transparent border border-border/35 rounded px-2 py-1',
'text-sm font-mono outline-none transition-colors',
'placeholder:text-muted-foreground/30',
'focus:border-primary/50',
!fn.visible && 'opacity-40'
)}
/>
{/* Visibility toggle */}
<button
onClick={() => updateGraphFunction(fn.id, { visible: !fn.visible })}
className={cn(
'shrink-0 transition-colors',
fn.visible
? 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground/25 hover:text-muted-foreground'
)}
title={fn.visible ? 'Hide' : 'Show'}
>
{fn.visible ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
</button>
{/* Delete */}
{graphFunctions.length > 1 && (
<button
onClick={() => removeGraphFunction(fn.id)}
className="shrink-0 text-muted-foreground/30 hover:text-destructive transition-colors"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
</div>
{/* ── Canvas ───────────────────────────────────────────── */}
<div className="glass rounded-xl overflow-hidden flex-1 min-h-0">
<GraphCanvas functions={graphFunctions} variables={variables} />
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { ColorInfo as ColorInfoType } from '@/lib/color/api/types'; import { ColorInfo as ColorInfoType } from '@/lib/color/api/types';
import { Button } from '@/components/ui/button';
import { Copy } from 'lucide-react'; import { Copy } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
@@ -12,79 +11,70 @@ interface ColorInfoProps {
} }
export function ColorInfo({ info, className }: ColorInfoProps) { export function ColorInfo({ info, className }: ColorInfoProps) {
const copyToClipboard = (value: string, label: string) => { const copy = (value: string, label: string) => {
navigator.clipboard.writeText(value); navigator.clipboard.writeText(value);
toast.success(`Copied ${label} to clipboard`); toast.success(`Copied ${label}`);
}; };
const formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) => { const formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) =>
if (rgb.a !== undefined && rgb.a < 1) { rgb.a !== undefined && rgb.a < 1
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`; ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`
} : `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
};
const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) => { const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) =>
if (hsl.a !== undefined && hsl.a < 1) { hsl.a !== undefined && hsl.a < 1
return `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})`; ? `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})`
} : `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`;
return `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`;
};
const formatLab = (lab: { l: number; a: number; b: number }) => {
return `lab(${lab.l.toFixed(1)} ${lab.a.toFixed(1)} ${lab.b.toFixed(1)})`;
};
const formatOkLab = (oklab: { l: number; a: number; b: number }) => {
return `oklab(${(oklab.l * 100).toFixed(1)}% ${oklab.a.toFixed(3)} ${oklab.b.toFixed(3)})`;
};
const formats = [ const formats = [
{ label: 'Hex', value: info.hex }, { label: 'HEX', value: info.hex },
{ label: 'RGB', value: formatRgb(info.rgb) }, { label: 'RGB', value: formatRgb(info.rgb) },
{ label: 'HSL', value: formatHsl(info.hsl) }, { label: 'HSL', value: formatHsl(info.hsl) },
{ label: 'Lab', value: formatLab(info.lab) }, { label: 'Lab', value: `lab(${info.lab.l.toFixed(1)} ${info.lab.a.toFixed(1)} ${info.lab.b.toFixed(1)})` },
{ label: 'OkLab', value: formatOkLab(info.oklab) }, { label: 'OkLab', value: `oklab(${(info.oklab.l * 100).toFixed(1)}% ${info.oklab.a.toFixed(3)} ${info.oklab.b.toFixed(3)})` },
]; ];
return ( return (
<div className={cn('space-y-3', className)}> <div className={cn('space-y-3', className)}>
<div className="grid grid-cols-1 gap-1.5"> {/* Format rows */}
{formats.map((format) => ( <div className="space-y-1">
{formats.map((fmt) => (
<div <div
key={format.label} key={fmt.label}
className="flex items-center justify-between px-3 py-2 bg-muted/50 rounded-md group" className="group flex items-center justify-between px-2.5 py-1.5 rounded-lg border border-transparent hover:border-border/30 hover:bg-primary/5 transition-all"
> >
<div className="flex items-baseline gap-2 min-w-0 flex-1"> <div className="flex items-baseline gap-2 min-w-0 flex-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground w-10 shrink-0">{format.label}</span> <span className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-widest w-9 shrink-0">
<span className="font-mono text-xs truncate">{format.value}</span> {fmt.label}
</span>
<span className="font-mono text-xs text-foreground/80 truncate">{fmt.value}</span>
</div> </div>
<Button <button
size="icon-xs" onClick={() => copy(fmt.value, fmt.label)}
variant="ghost" aria-label={`Copy ${fmt.label}`}
onClick={() => copyToClipboard(format.value, format.label)} className="shrink-0 ml-2 p-1 rounded text-muted-foreground/30 hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
aria-label={`Copy ${format.label} value`}
className="opacity-0 group-hover:opacity-100 transition-opacity"
> >
<Copy className="h-3 w-3" /> <Copy className="w-3 h-3" />
</Button> </button>
</div> </div>
))} ))}
</div> </div>
<div className="grid grid-cols-3 gap-3 pt-2 border-t text-xs"> {/* Metadata row */}
<div> <div className="grid grid-cols-3 gap-2 pt-2 border-t border-border/25">
<div className="text-muted-foreground mb-0.5">Brightness</div> {[
<div className="font-medium">{(info.brightness * 100).toFixed(1)}%</div> { label: 'Brightness', value: `${(info.brightness * 100).toFixed(1)}%` },
</div> { label: 'Luminance', value: `${(info.luminance * 100).toFixed(1)}%` },
<div> {
<div className="text-muted-foreground mb-0.5">Luminance</div> label: info.name && typeof info.name === 'string' ? 'Name' : 'Type',
<div className="font-medium">{(info.luminance * 100).toFixed(1)}%</div> value: info.name && typeof info.name === 'string' ? info.name : (info.is_light ? 'Light' : 'Dark'),
</div> },
<div> ].map((m) => (
<div className="text-muted-foreground mb-0.5">{info.name && typeof info.name === 'string' ? 'Name' : 'Type'}</div> <div key={m.label} className="px-2.5 py-2 rounded-lg bg-primary/5 border border-border/20">
<div className="font-medium">{info.name && typeof info.name === 'string' ? info.name : (info.is_light ? 'Light' : 'Dark')}</div> <div className="text-[10px] text-muted-foreground/40 font-mono mb-0.5">{m.label}</div>
</div> <div className="text-xs font-mono font-medium text-foreground/75 truncate">{m.value}</div>
</div>
))}
</div> </div>
</div> </div>
); );

View File

@@ -7,27 +7,31 @@ import { ColorInfo } from '@/components/color/ColorInfo';
import { ManipulationPanel } from '@/components/color/ManipulationPanel'; import { ManipulationPanel } from '@/components/color/ManipulationPanel';
import { PaletteGrid } from '@/components/color/PaletteGrid'; import { PaletteGrid } from '@/components/color/PaletteGrid';
import { ExportMenu } from '@/components/color/ExportMenu'; import { ExportMenu } from '@/components/color/ExportMenu';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries'; import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
import { Loader2, Share2, Palette, Plus, X, Layers } from 'lucide-react'; import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type HarmonyType = 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient';
type MobileTab = 'pick' | 'explore';
const HARMONY_OPTS: { value: HarmonyType; label: string; desc: string }[] = [
{ value: 'monochromatic', label: 'Mono', desc: 'Single hue, varied lightness' },
{ value: 'analogous', label: 'Analogous', desc: 'Adjacent colors ±30°' },
{ value: 'complementary', label: 'Complement', desc: 'Opposite on wheel 180°' },
{ value: 'triadic', label: 'Triadic', desc: 'Three equal 120° steps' },
{ value: 'tetradic', label: 'Tetradic', desc: 'Four equal 90° steps' },
];
const RIGHT_TABS: { value: RightTab; label: string }[] = [
{ value: 'info', label: 'Info' },
{ value: 'adjust', label: 'Adjust' },
{ value: 'harmony', label: 'Harmony' },
{ value: 'gradient', label: 'Gradient' },
];
type HarmonyType =
| 'monochromatic'
| 'analogous'
| 'complementary'
| 'triadic'
| 'tetradic';
function ColorManipulationContent() { function ColorManipulationContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -37,24 +41,23 @@ function ColorManipulationContent() {
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099'; return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
}); });
// Harmony state const [rightTab, setRightTab] = useState<RightTab>('info');
const [mobileTab, setMobileTab] = useState<MobileTab>('pick');
// Harmony
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary'); const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
const [palette, setPalette] = useState<string[]>([]); const [palette, setPalette] = useState<string[]>([]);
const paletteMutation = useGeneratePalette(); const paletteMutation = useGeneratePalette();
// Gradient state // Gradient
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']); const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
const [gradientCount, setGradientCount] = useState(10); const [gradientCount, setGradientCount] = useState(10);
const [gradientResult, setGradientResult] = useState<string[]>([]); const [gradientResult, setGradientResult] = useState<string[]>([]);
const gradientMutation = useGenerateGradient(); const gradientMutation = useGenerateGradient();
const { data, isLoading, isError, error } = useColorInfo({ const { data, isLoading } = useColorInfo({ colors: [color] });
colors: [color],
});
const colorInfo = data?.colors[0]; const colorInfo = data?.colors[0];
// Update URL when color changes
useEffect(() => { useEffect(() => {
const hex = color.replace('#', ''); const hex = color.replace('#', '');
if (hex.length === 6 || hex.length === 3) { if (hex.length === 6 || hex.length === 3) {
@@ -64,301 +67,277 @@ function ColorManipulationContent() {
// Sync first gradient stop with active color // Sync first gradient stop with active color
useEffect(() => { useEffect(() => {
const newStops = [...stops]; setStops((prev) => [color, ...prev.slice(1)]);
newStops[0] = color;
setStops(newStops);
}, [color]); }, [color]);
const handleShare = () => { const handleShare = () => {
const url = `${window.location.origin}/color?color=${color.replace('#', '')}`; navigator.clipboard.writeText(`${window.location.origin}/color?color=${color.replace('#', '')}`);
navigator.clipboard.writeText(url); toast.success('Link copied!');
toast.success('Link copied to clipboard!');
}; };
const generateHarmony = async () => { const generateHarmony = async () => {
try { try {
const result = await paletteMutation.mutateAsync({ const result = await paletteMutation.mutateAsync({ base: color, scheme: harmonyType });
base: color, setPalette([result.palette.primary, ...result.palette.secondary]);
scheme: harmonyType, toast.success(`Generated ${harmonyType} palette`);
}); } catch { toast.error('Failed to generate palette'); }
const colors = [result.palette.primary, ...result.palette.secondary];
setPalette(colors);
toast.success(`Generated ${harmonyType} harmony palette`);
} catch (error) {
toast.error('Failed to generate harmony palette');
console.error(error);
}
}; };
const generateGradient = async () => { const generateGradient = async () => {
try { try {
const result = await gradientMutation.mutateAsync({ const result = await gradientMutation.mutateAsync({ stops, count: gradientCount });
stops,
count: gradientCount,
});
setGradientResult(result.gradient); setGradientResult(result.gradient);
toast.success(`Generated ${result.gradient.length} colors`); toast.success(`Generated ${result.gradient.length} colors`);
} catch (error) { } catch { toast.error('Failed to generate gradient'); }
toast.error('Failed to generate gradient');
}
}; };
const addStop = () => { const updateStop = (i: number, v: string) => {
setStops([...stops, '#000000']); const next = [...stops];
}; next[i] = v;
setStops(next);
const removeStop = (index: number) => { if (i === 0) setColor(v);
if (index === 0) return;
if (stops.length > 2) {
setStops(stops.filter((_, i) => i !== index));
}
};
const updateStop = (index: number, colorValue: string) => {
const newStops = [...stops];
newStops[index] = colorValue;
setStops(newStops);
if (index === 0) setColor(colorValue);
};
const harmonyDescriptions: Record<HarmonyType, string> = {
monochromatic: 'Single color with variations',
analogous: 'Colors adjacent on the color wheel (±30°)',
complementary: 'Colors opposite on the color wheel (180°)',
triadic: 'Three colors evenly spaced on the color wheel (120°)',
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
}; };
return ( return (
<div className="space-y-6"> <div className="flex flex-col gap-4">
{/* Row 1: Workspace */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Main Workspace: Color Picker and Information */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Color Picker</CardTitle>
<Button onClick={handleShare} variant="outline" size="xs">
<Share2 className="h-3 w-3 mr-1" />
Share
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-shrink-0 mx-auto md:mx-0">
<ColorPicker color={color} onChange={setColor} />
</div>
<div className="flex-1 min-w-0"> <MobileTabs
{isLoading && ( tabs={[{ value: 'pick', label: 'Pick' }, { value: 'explore', label: 'Explore' }]}
<div className="flex items-center justify-center py-12"> active={mobileTab}
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> onChange={(v) => setMobileTab(v as MobileTab)}
</div> />
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: Picker + ColorInfo */}
<div
className={cn(
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
mobileTab !== 'pick' && 'hidden lg:flex'
)}
>
{/* Color picker card */}
<div className="glass rounded-xl p-4 shrink-0">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Color
</span>
<button onClick={handleShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share
</button>
</div>
<ColorPicker color={color} onChange={setColor} />
</div>
{/* Color info card */}
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3 shrink-0">
Info
</span>
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-4 w-4 animate-spin text-muted-foreground/40" />
</div>
) : colorInfo ? (
<ColorInfo info={colorInfo} />
) : null}
</div>
</div>
</div>
{/* Right panel: tabbed tools */}
<div
className={cn(
'lg:col-span-3 flex flex-col overflow-hidden',
mobileTab !== 'explore' && 'hidden lg:flex'
)}
>
<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">
{RIGHT_TABS.map(({ value, label }) => (
<button
key={value}
onClick={() => setRightTab(value)}
className={cn(
'flex-1 py-1.5 rounded-md text-xs font-medium transition-all',
rightTab === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)} )}
>
{label}
</button>
))}
</div>
{isError && ( {/* Tab content */}
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm"> <div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<p className="font-medium">Error loading color information</p>
<p className="mt-1">{error?.message || 'Unknown error'}</p>
</div>
)}
{colorInfo && <ColorInfo info={colorInfo} />} {/* ── Info tab ─────────────────────────────── */}
</div> {rightTab === 'info' && (
</div> <div className="space-y-3">
</CardContent> {/* Large color preview */}
</Card>
</div>
{/* Sidebar: Color Manipulation */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle>Adjustments</CardTitle>
</CardHeader>
<CardContent>
<ManipulationPanel color={color} onColorChange={setColor} />
</CardContent>
</Card>
</div>
</div>
{/* Row 2: Harmony Generator */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Harmony Controls */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle>Harmony</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Select
value={harmonyType}
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select harmony" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monochromatic">Monochromatic</SelectItem>
<SelectItem value="analogous">Analogous</SelectItem>
<SelectItem value="complementary">Complementary</SelectItem>
<SelectItem value="triadic">Triadic</SelectItem>
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{harmonyDescriptions[harmonyType]}
</p>
<Button
onClick={generateHarmony}
disabled={paletteMutation.isPending}
className="w-full"
>
{paletteMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Generating...
</>
) : (
'Generate'
)}
</Button>
</CardContent>
</Card>
</div>
{/* Harmony Results */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>
Palette {palette.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({palette.length})</span>}
</CardTitle>
</CardHeader>
<CardContent>
{palette.length > 0 ? (
<div className="space-y-5">
<PaletteGrid colors={palette} onColorClick={setColor} />
<div className="pt-3 border-t">
<ExportMenu colors={palette} />
</div>
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-xs">
<Palette className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p>Generate a harmony palette from the current color</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Row 3: Gradient Generator */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Gradient Controls */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle>Gradient</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-xs">Color Stops</Label>
{stops.map((stop, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="color"
value={stop}
onChange={(e) => updateStop(index, e.target.value)}
className="w-9 h-9 p-1 shrink-0 cursor-pointer"
/>
<Input
type="text"
value={stop}
onChange={(e) => updateStop(index, e.target.value)}
className="font-mono text-xs flex-1"
/>
{index !== 0 && stops.length > 2 && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeStop(index)}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
<Button onClick={addStop} variant="outline" className="w-full">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Stop
</Button>
</div>
<div className="space-y-2">
<Label className="text-xs">Steps</Label>
<Input
type="number"
min={2}
max={100}
value={gradientCount}
onChange={(e) => setGradientCount(parseInt(e.target.value))}
/>
</div>
<Button
onClick={generateGradient}
disabled={gradientMutation.isPending}
className="w-full"
>
{gradientMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Generating...
</>
) : (
'Generate'
)}
</Button>
</CardContent>
</Card>
</div>
{/* Gradient Results */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader>
<CardTitle>
Gradient {gradientResult.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({gradientResult.length})</span>}
</CardTitle>
</CardHeader>
<CardContent>
{gradientResult.length > 0 ? (
<div className="space-y-5">
<div <div
className="h-16 w-full rounded-lg border" className="w-full rounded-xl border border-white/8 transition-colors duration-300"
style={{ style={{ height: '140px', background: color }}
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
}}
/> />
<PaletteGrid colors={gradientResult} onColorClick={setColor} /> {isLoading ? (
<div className="pt-3 border-t"> <div className="flex justify-center py-6">
<ExportMenu colors={gradientResult} /> <Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div> </div>
</div> ) : colorInfo ? (
) : ( <ColorInfo info={colorInfo} />
<div className="py-8 text-center text-muted-foreground text-xs"> ) : null}
<Layers className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p>Add color stops and generate a smooth gradient</p>
</div> </div>
)} )}
</CardContent>
</Card> {/* ── Adjust tab ───────────────────────────── */}
{rightTab === 'adjust' && (
<ManipulationPanel color={color} onColorChange={setColor} />
)}
{/* ── Harmony tab ──────────────────────────── */}
{rightTab === 'harmony' && (
<div className="space-y-4">
{/* Scheme selector */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Scheme
</span>
<div className="flex flex-wrap gap-1.5">
{HARMONY_OPTS.map((opt) => (
<button
key={opt.value}
onClick={() => setHarmonyType(opt.value)}
className={cn(
'px-2.5 py-1 rounded-lg border text-xs font-mono transition-all',
harmonyType === opt.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
)}
>
{opt.label}
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/50 font-mono">
{HARMONY_OPTS.find((o) => o.value === harmonyType)?.desc}
</p>
</div>
<button
onClick={generateHarmony}
disabled={paletteMutation.isPending}
className={cn(actionBtn, 'w-full justify-center py-2')}
>
{paletteMutation.isPending
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating</>
: <><Palette className="w-3 h-3" /> Generate Palette</>
}
</button>
{palette.length > 0 && (
<div className="space-y-4">
<PaletteGrid colors={palette} onColorClick={setColor} />
<div className="border-t border-border/25 pt-4">
<ExportMenu colors={palette} />
</div>
</div>
)}
</div>
)}
{/* ── Gradient tab ─────────────────────────── */}
{rightTab === 'gradient' && (
<div className="space-y-4">
{/* Color stops */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Stops
</span>
{stops.map((stop, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="color"
value={stop}
onChange={(e) => updateStop(i, e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/>
<input
type="text"
value={stop}
onChange={(e) => updateStop(i, e.target.value)}
className="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"
/>
{i !== 0 && stops.length > 2 && (
<button
onClick={() => setStops(stops.filter((_, idx) => idx !== i))}
className="shrink-0 text-muted-foreground/35 hover:text-destructive transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
<button
onClick={() => setStops([...stops, '#000000'])}
className="w-full py-1.5 rounded-lg border border-dashed border-border/30 text-xs text-muted-foreground/40 hover:text-foreground hover:border-primary/30 transition-all flex items-center justify-center gap-1"
>
<Plus className="w-3 h-3" /> Add stop
</button>
</div>
{/* Steps */}
<div className="flex items-center gap-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest shrink-0">
Steps
</span>
<input
type="number"
min={2}
max={100}
value={gradientCount}
onChange={(e) => setGradientCount(parseInt(e.target.value))}
className="w-20 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono text-center outline-none focus:border-primary/50 transition-colors"
/>
</div>
<button
onClick={generateGradient}
disabled={gradientMutation.isPending}
className={cn(actionBtn, 'w-full justify-center py-2')}
>
{gradientMutation.isPending
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating</>
: <><Layers className="w-3 h-3" /> Generate Gradient</>
}
</button>
{gradientResult.length > 0 && (
<div className="space-y-4">
{/* Gradient preview bar */}
<div
className="h-12 w-full rounded-xl border border-white/8"
style={{ background: `linear-gradient(to right, ${gradientResult.join(', ')})` }}
/>
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
<div className="border-t border-border/25 pt-4">
<ExportMenu colors={gradientResult} />
</div>
</div>
)}
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -369,7 +348,7 @@ export function ColorManipulation() {
return ( return (
<Suspense fallback={ <Suspense fallback={
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground/40" />
</div> </div>
}> }>
<ColorManipulationContent /> <ColorManipulationContent />

View File

@@ -1,8 +1,6 @@
'use client'; 'use client';
import { HexColorPicker } from 'react-colorful'; import { HexColorPicker } from 'react-colorful';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { hexToRgb } from '@/lib/color/utils/color'; import { hexToRgb } from '@/lib/color/utils/color';
@@ -13,45 +11,23 @@ interface ColorPickerProps {
} }
export function ColorPicker({ color, onChange, className }: ColorPickerProps) { export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const rgb = hexToRgb(color);
const value = e.target.value; const brightness = rgb ? (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 : 0;
// Allow partial input while typing const textColor = brightness > 128 ? '#000000' : '#ffffff';
onChange(value); const borderColor = brightness > 128 ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.2)';
};
// Determine text color based on background brightness
const getContrastColor = (hex: string) => {
const rgb = hexToRgb(hex);
if (!rgb) return 'inherit';
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
return brightness > 128 ? '#000000' : '#ffffff';
};
const textColor = getContrastColor(color);
return ( return (
<div className={cn('flex flex-col items-center justify-center space-y-3', className)}> <div className={cn('flex flex-col gap-3', className)}>
<div className="w-full max-w-[200px] space-y-3"> <HexColorPicker color={color} onChange={onChange} className="!w-full" />
<HexColorPicker color={color} onChange={onChange} className="!w-full" /> <input
<div className="space-y-1.5"> type="text"
<Label htmlFor="color-input" className="text-xs"> value={color}
Hex Value onChange={(e) => onChange(e.target.value)}
</Label> placeholder="#ff0099"
<Input className="w-full font-mono text-xs rounded-lg px-3 py-2 outline-none transition-colors duration-200 border"
id="color-input" style={{ backgroundColor: color, color: textColor, borderColor }}
type="text" spellCheck={false}
value={color} />
onChange={handleInputChange}
placeholder="#ff0099"
className="font-mono text-xs transition-colors duration-200"
style={{
backgroundColor: color,
color: textColor,
borderColor: textColor === '#000000' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.2)'
}}
/>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -13,54 +13,43 @@ interface ColorSwatchProps {
className?: string; className?: string;
} }
export function ColorSwatch({ export function ColorSwatch({ color, size = 'md', showLabel = true, onClick, className }: ColorSwatchProps) {
color,
size = 'md',
showLabel = true,
onClick,
className,
}: ColorSwatchProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const sizeClasses = { const handleClick = () => {
sm: 'h-12 w-12', if (onClick) { onClick(); return; }
md: 'h-16 w-16',
lg: 'h-24 w-24',
};
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(color); navigator.clipboard.writeText(color);
setCopied(true); setCopied(true);
toast.success(`Copied ${color}`); toast.success(`Copied ${color}`);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 1500);
}; };
return ( return (
<div className={cn('flex flex-col items-center gap-2', className)}> <button
<button onClick={handleClick}
className={cn( title={color}
'relative rounded-lg ring-2 ring-border transition-all duration-200', aria-label={`Color ${color}`}
'hover:scale-110 hover:ring-primary hover:shadow-lg', className={cn(
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 'group relative w-full rounded-lg overflow-hidden border border-white/8 transition-all',
'group active:scale-95', 'hover:scale-[1.04] hover:border-white/20 hover:shadow-lg hover:shadow-black/20',
sizeClasses[size] size === 'sm' && 'h-10',
)} size === 'md' && 'h-14',
style={{ backgroundColor: color }} size === 'lg' && 'h-20',
onClick={onClick || handleCopy} className
aria-label={`Color ${color}`}
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 bg-black/30 rounded-lg backdrop-blur-sm">
{copied ? (
<Check className="h-5 w-5 text-white animate-scale-in" />
) : (
<Copy className="h-5 w-5 text-white" />
)}
</div>
</button>
{showLabel && (
<span className="text-xs font-mono text-muted-foreground">{color}</span>
)} )}
</div> style={{ backgroundColor: color }}
>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/25">
{copied
? <Check className="w-3.5 h-3.5 text-white drop-shadow" />
: <Copy className="w-3.5 h-3.5 text-white drop-shadow" />
}
</div>
{showLabel && (
<div className="absolute bottom-0 inset-x-0 px-1 py-0.5 text-[9px] font-mono text-white/70 bg-black/25 truncate text-center leading-tight">
{color}
</div>
)}
</button>
); );
} }

View File

@@ -1,15 +1,7 @@
'use client'; 'use client';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Download, Copy, Check, Loader2 } from 'lucide-react'; import { Download, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
exportAsCSS, exportAsCSS,
@@ -21,6 +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 { CodeSnippet } from '@/components/ui/code-snippet';
import { cn, actionBtn } from '@/lib/utils';
interface ExportMenuProps { interface ExportMenuProps {
colors: string[]; colors: string[];
@@ -30,162 +24,98 @@ 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 selectCls =
'flex-1 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer';
export function ExportMenu({ colors, className }: ExportMenuProps) { 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() {
if (colorSpace === 'hex') { if (colorSpace === 'hex') { setConvertedColors(colors); return; }
setConvertedColors(colors);
return;
}
setIsConverting(true); setIsConverting(true);
try { try {
const response = await colorAPI.convertFormat({ const response = await colorAPI.convertFormat({ colors, format: colorSpace });
colors,
format: colorSpace,
});
if (response.success) { if (response.success) {
setConvertedColors(response.data.conversions.map(c => c.output)); setConvertedColors(response.data.conversions.map((c) => c.output));
} }
} catch (error) { } catch {
console.error('Failed to convert colors:', error); toast.error('Failed to convert colors');
toast.error('Failed to convert colors to selected space');
} finally { } finally {
setIsConverting(false); setIsConverting(false);
} }
} }
convertColors(); convertColors();
}, [colors, colorSpace]); }, [colors, colorSpace]);
const exportColors: ExportColor[] = convertedColors.map((value) => ({ value })); const exportColors: ExportColor[] = convertedColors.map((value) => ({ value }));
const getExportContent = (): string => { const getContent = (): string => {
switch (format) { switch (format) {
case 'css': case 'css': return exportAsCSS(exportColors);
return exportAsCSS(exportColors); case 'scss': return exportAsSCSS(exportColors);
case 'scss': case 'tailwind': return exportAsTailwind(exportColors);
return exportAsSCSS(exportColors); case 'json': return exportAsJSON(exportColors);
case 'tailwind': case 'javascript': return exportAsJavaScript(exportColors);
return exportAsTailwind(exportColors);
case 'json':
return exportAsJSON(exportColors);
case 'javascript':
return exportAsJavaScript(exportColors);
} }
}; };
const getFileExtension = (): string => { const getExt = () => ({ css: 'css', scss: 'scss', tailwind: 'js', json: 'json', javascript: 'js' }[format]);
switch (format) {
case 'css':
return 'css';
case 'scss':
return 'scss';
case 'tailwind':
return 'js';
case 'json':
return 'json';
case 'javascript':
return 'js';
}
};
const handleCopy = () => {
const content = getExportContent();
navigator.clipboard.writeText(content);
setCopied(true);
toast.success('Copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
const handleDownload = () => { const handleDownload = () => {
const content = getExportContent(); downloadAsFile(getContent(), `palette.${getExt()}`, 'text/plain');
const extension = getFileExtension();
downloadAsFile(content, `palette.${extension}`, 'text/plain');
toast.success('Downloaded!'); toast.success('Downloaded!');
}; };
if (colors.length === 0) { if (colors.length === 0) return null;
return null;
}
return ( return (
<div className={className}> <div className={cn('space-y-3', className)}>
<div className="space-y-3"> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span>
<div className="flex flex-col md:flex-row gap-3">
<Select
value={format}
onValueChange={(value) => setFormat(value as ExportFormat)}
>
<SelectTrigger className="w-full md:flex-1">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="css">CSS Variables</SelectItem>
<SelectItem value="scss">SCSS Variables</SelectItem>
<SelectItem value="tailwind">Tailwind Config</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="javascript">JavaScript Array</SelectItem>
</SelectContent>
</Select>
<Select {/* Selectors */}
value={colorSpace} <div className="flex gap-2">
onValueChange={(value) => setColorSpace(value as ColorSpace)} <select
> value={format}
<SelectTrigger className="w-full md:flex-1"> onChange={(e) => setFormat(e.target.value as ExportFormat)}
<SelectValue placeholder="Space" /> className={selectCls}
</SelectTrigger> >
<SelectContent> <option value="css">CSS Vars</option>
<SelectItem value="hex">Hex</SelectItem> <option value="scss">SCSS</option>
<SelectItem value="rgb">RGB</SelectItem> <option value="tailwind">Tailwind</option>
<SelectItem value="hsl">HSL</SelectItem> <option value="json">JSON</option>
<SelectItem value="lab">Lab</SelectItem> <option value="javascript">JS Array</option>
<SelectItem value="oklab">OkLab</SelectItem> </select>
<SelectItem value="lch">LCH</SelectItem> <select
<SelectItem value="oklch">OkLCH</SelectItem> value={colorSpace}
</SelectContent> onChange={(e) => setColorSpace(e.target.value as ColorSpace)}
</Select> className={selectCls}
</div> >
{['hex', 'rgb', 'hsl', 'lab', 'oklab', 'lch', 'oklch'].map((s) => (
<div className="p-3 bg-muted/50 rounded-lg relative min-h-[80px]"> <option key={s} value={s}>{s}</option>
{isConverting ? ( ))}
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 backdrop-blur-sm rounded-lg z-10"> </select>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : null}
<pre className="text-[11px] overflow-x-auto leading-relaxed">
<code>{getExportContent()}</code>
</pre>
</div>
<div className="flex flex-col md:flex-row gap-3">
<Button onClick={handleCopy} variant="outline" className="w-full md:flex-1" disabled={isConverting}>
{copied ? (
<>
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</>
)}
</Button>
<Button onClick={handleDownload} variant="default" className="w-full md:flex-1" disabled={isConverting}>
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
</Button>
</div>
</div> </div>
{/* Code preview */}
<div className="relative">
{isConverting && (
<div className="absolute inset-0 flex items-center justify-center z-20 rounded-xl bg-black/40">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
<CodeSnippet code={getContent()} />
</div>
{/* Actions */}
<button onClick={handleDownload} disabled={isConverting} className={cn(actionBtn, 'w-full justify-center')}>
<Download className="w-3 h-3" />
Download
</button>
</div> </div>
); );
} }

View File

@@ -2,34 +2,23 @@
import { useState } from 'react'; import { useState } from 'react';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button';
import { import {
useLighten, useLighten,
useDarken, useDarken,
useSaturate, useSaturate,
useDesaturate, useDesaturate,
useRotate, useRotate,
useComplement useComplement,
} 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, actionBtn } from '@/lib/utils';
interface ManipulationPanelProps { interface ManipulationPanelProps {
color: string; color: string;
onColorChange: (color: string) => void; onColorChange: (color: string) => void;
} }
interface ManipulationRow {
label: string;
icon: React.ReactNode;
value: number;
setValue: (v: number) => void;
format: (v: number) => string;
min: number;
max: number;
step: number;
onApply: () => Promise<void>;
}
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) { export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2); const [lightenAmount, setLightenAmount] = useState(0.2);
@@ -53,150 +42,104 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
rotateMutation.isPending || rotateMutation.isPending ||
complementMutation.isPending; complementMutation.isPending;
const handleMutation = async ( const applyMutation = async (
mutationFn: (params: any) => Promise<any>, // eslint-disable-next-line @typescript-eslint/no-explicit-any
mutationFn: (p: any) => Promise<{ colors: { output: string }[] }>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: any, params: any,
successMsg: string, msg: string
errorMsg: string
) => { ) => {
try { try {
const result = await mutationFn(params); const result = await mutationFn(params);
if (result.colors[0]) { if (result.colors[0]) {
onColorChange(result.colors[0].output); onColorChange(result.colors[0].output);
toast.success(successMsg); toast.success(msg);
} }
} catch { } catch {
toast.error(errorMsg); toast.error('Failed to apply');
} }
}; };
const rows: ManipulationRow[] = [ const rows = [
{ {
label: 'Lighten', label: 'Lighten', icon: <Sun className="w-3 h-3" />,
icon: <Sun className="h-3.5 w-3.5" />, value: lightenAmount, setValue: setLightenAmount,
value: lightenAmount, display: `${(lightenAmount * 100).toFixed(0)}%`,
setValue: setLightenAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05, min: 0, max: 1, step: 0.05,
onApply: () => handleMutation( onApply: () => applyMutation(lightenMutation.mutateAsync, { colors: [color], amount: lightenAmount }, `Lightened ${(lightenAmount * 100).toFixed(0)}%`),
lightenMutation.mutateAsync,
{ colors: [color], amount: lightenAmount },
`Lightened by ${(lightenAmount * 100).toFixed(0)}%`,
'Failed to lighten color'
),
}, },
{ {
label: 'Darken', label: 'Darken', icon: <Moon className="w-3 h-3" />,
icon: <Moon className="h-3.5 w-3.5" />, value: darkenAmount, setValue: setDarkenAmount,
value: darkenAmount, display: `${(darkenAmount * 100).toFixed(0)}%`,
setValue: setDarkenAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05, min: 0, max: 1, step: 0.05,
onApply: () => handleMutation( onApply: () => applyMutation(darkenMutation.mutateAsync, { colors: [color], amount: darkenAmount }, `Darkened ${(darkenAmount * 100).toFixed(0)}%`),
darkenMutation.mutateAsync,
{ colors: [color], amount: darkenAmount },
`Darkened by ${(darkenAmount * 100).toFixed(0)}%`,
'Failed to darken color'
),
}, },
{ {
label: 'Saturate', label: 'Saturate', icon: <Droplets className="w-3 h-3" />,
icon: <Droplets className="h-3.5 w-3.5" />, value: saturateAmount, setValue: setSaturateAmount,
value: saturateAmount, display: `${(saturateAmount * 100).toFixed(0)}%`,
setValue: setSaturateAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05, min: 0, max: 1, step: 0.05,
onApply: () => handleMutation( onApply: () => applyMutation(saturateMutation.mutateAsync, { colors: [color], amount: saturateAmount }, `Saturated ${(saturateAmount * 100).toFixed(0)}%`),
saturateMutation.mutateAsync,
{ colors: [color], amount: saturateAmount },
`Saturated by ${(saturateAmount * 100).toFixed(0)}%`,
'Failed to saturate color'
),
}, },
{ {
label: 'Desaturate', label: 'Desaturate', icon: <Droplet className="w-3 h-3" />,
icon: <Droplet className="h-3.5 w-3.5" />, value: desaturateAmount, setValue: setDesaturateAmount,
value: desaturateAmount, display: `${(desaturateAmount * 100).toFixed(0)}%`,
setValue: setDesaturateAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05, min: 0, max: 1, step: 0.05,
onApply: () => handleMutation( onApply: () => applyMutation(desaturateMutation.mutateAsync, { colors: [color], amount: desaturateAmount }, `Desaturated ${(desaturateAmount * 100).toFixed(0)}%`),
desaturateMutation.mutateAsync,
{ colors: [color], amount: desaturateAmount },
`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`,
'Failed to desaturate color'
),
}, },
{ {
label: 'Rotate', label: 'Rotate Hue', icon: <RotateCcw className="w-3 h-3" />,
icon: <RotateCcw className="h-3.5 w-3.5" />, value: rotateAmount, setValue: setRotateAmount,
value: rotateAmount, display: `${rotateAmount}°`,
setValue: setRotateAmount,
format: (v) => `${v}°`,
min: -180, max: 180, step: 5, min: -180, max: 180, step: 5,
onApply: () => handleMutation( onApply: () => applyMutation(rotateMutation.mutateAsync, { colors: [color], amount: rotateAmount }, `Rotated ${rotateAmount}°`),
rotateMutation.mutateAsync,
{ colors: [color], amount: rotateAmount },
`Rotated hue by ${rotateAmount}°`,
'Failed to rotate hue'
),
}, },
]; ];
const handleComplement = async () => {
try {
const result = await complementMutation.mutateAsync([color]);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success('Generated complementary color');
}
} catch {
toast.error('Failed to generate complement');
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{rows.map((row) => ( {rows.map((row) => (
<div key={row.label} className="space-y-2"> <div key={row.label} className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-medium"> <div className="flex items-center gap-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{row.icon} {row.icon}
<span>{row.label}</span> <span>{row.label}</span>
</div> </div>
<span className="text-[10px] text-muted-foreground tabular-nums">{row.format(row.value)}</span> <span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{row.display}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Slider <Slider
min={row.min} min={row.min} max={row.max} step={row.step}
max={row.max}
step={row.step}
value={[row.value]} value={[row.value]}
onValueChange={(vals) => row.setValue(vals[0])} onValueChange={(vals) => row.setValue(vals[0])}
className="flex-1" className="flex-1"
/> />
<Button <button onClick={row.onApply} disabled={isLoading} className={cn(actionBtn, 'shrink-0')}>
onClick={row.onApply}
disabled={isLoading}
variant="outline"
className="shrink-0 w-16"
>
Apply Apply
</Button> </button>
</div> </div>
</div> </div>
))} ))}
<div className="pt-3 border-t"> <div className="pt-3 border-t border-border/25">
<Button <button
onClick={handleComplement} onClick={async () => {
try {
const result = await complementMutation.mutateAsync([color]);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success('Complementary color applied');
}
} catch { toast.error('Failed'); }
}}
disabled={isLoading} disabled={isLoading}
variant="outline" className={cn(actionBtn, 'w-full justify-center py-2')}
className="w-full"
> >
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" /> <ArrowLeftRight className="w-3 h-3" />
Complementary Color Complementary Color
</Button> </button>
</div> </div>
</div> </div>
); );

View File

@@ -19,16 +19,12 @@ export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProp
} }
return ( return (
<div <div className={cn('grid grid-cols-4 sm:grid-cols-5 gap-2', className)}>
className={cn(
'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4',
className
)}
>
{colors.map((color, index) => ( {colors.map((color, index) => (
<ColorSwatch <ColorSwatch
key={`${color}-${index}`} key={`${color}-${index}`}
color={color} color={color}
size="sm"
onClick={onColorClick ? () => onColorClick(color) : undefined} onClick={onColorClick ? () => onColorClick(color) : undefined}
/> />
))} ))}

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" 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,60 +2,43 @@
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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
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';
export interface ConversionPreviewProps { export interface ConversionPreviewProps {
job: ConversionJob; job: ConversionJob;
onDownload?: () => void;
onRetry?: () => void; onRetry?: () => void;
} }
export function ConversionPreview({ job, onDownload, 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);
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState<number | null>(null); const [estimatedRemaining, setEstimatedRemaining] = React.useState<number | null>(null);
// Timer for elapsed time and estimation
React.useEffect(() => { React.useEffect(() => {
if (job.status === 'processing' || job.status === 'loading') { if (job.status === 'processing' || job.status === 'loading') {
const interval = setInterval(() => { const interval = setInterval(() => {
if (job.startTime) { if (job.startTime) {
const elapsed = Date.now() - job.startTime; const elapsed = Date.now() - job.startTime;
setElapsedTime(elapsed); setElapsedTime(elapsed);
// Estimate time remaining based on progress
if (job.progress > 5 && job.progress < 100) { if (job.progress > 5 && job.progress < 100) {
const progressRate = job.progress / elapsed; const rate = job.progress / elapsed;
const remainingProgress = 100 - job.progress; setEstimatedRemaining((100 - job.progress) / rate);
const estimated = remainingProgress / progressRate;
setEstimatedTimeRemaining(estimated);
} }
} }
}, 100); }, 100);
return () => clearInterval(interval); return () => clearInterval(interval);
} else { } else {
setEstimatedTimeRemaining(null); setEstimatedRemaining(null);
} }
}, [job.status, job.startTime, job.progress]); }, [job.status, job.startTime, job.progress]);
// Create preview URL for result
React.useEffect(() => { React.useEffect(() => {
if (job.result && job.status === 'completed') { if (job.result && job.status === 'completed') {
console.log('[Preview] Creating object URL for blob');
const url = URL.createObjectURL(job.result); const url = URL.createObjectURL(job.result);
setPreviewUrl(url); setPreviewUrl(url);
console.log('[Preview] Object URL created:', url); return () => URL.revokeObjectURL(url);
return () => {
console.log('[Preview] Revoking object URL:', url);
URL.revokeObjectURL(url);
};
} else { } else {
setPreviewUrl(null); setPreviewUrl(null);
} }
@@ -63,215 +46,151 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
const handleDownload = () => { const handleDownload = () => {
if (job.result) { if (job.result) {
const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension); downloadBlob(job.result, generateOutputFilename(job.inputFile.name, job.outputFormat.extension));
downloadBlob(job.result, filename);
onDownload?.();
} }
}; };
const renderPreview = () => { const fmt = (ms: number) => {
if (!previewUrl || !job.result) return null; const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const category = job.outputFormat.category; return `${Math.floor(s / 60)}m ${s % 60}s`;
// Log blob details for debugging
console.log('[Preview] Blob details:', {
size: job.result.size,
type: job.result.type,
previewUrl,
outputFormat: job.outputFormat.extension,
});
switch (category) {
case 'image':
return (
<div className="mt-3 rounded-lg overflow-hidden bg-muted/30 flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Converted image preview"
className="max-w-full max-h-64 object-contain"
onError={(e) => {
console.error('[Preview] Image failed to load:', {
src: previewUrl,
blobSize: job.result?.size,
blobType: job.result?.type,
error: e,
});
}}
onLoad={() => {
console.log('[Preview] Image loaded successfully');
}}
/>
</div>
);
case 'video':
return (
<div className="mt-3 rounded-lg overflow-hidden bg-muted/30">
<video src={previewUrl} controls className="w-full max-h-64">
Your browser does not support video playback.
</video>
</div>
);
case 'audio':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 p-4">
<audio src={previewUrl} controls className="w-full">
Your browser does not support audio playback.
</audio>
</div>
);
default:
return null;
}
}; };
const formatTime = (ms: number) => { if (job.status === 'pending') return null;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
const renderStatus = () => { const inputSize = job.inputFile.size;
switch (job.status) { const outputSize = job.result?.size ?? 0;
case 'loading': const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0;
return ( const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension);
<div className="space-y-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Loading converter...</span>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTime(elapsedTime)}</span>
</div>
</div>
);
case 'processing':
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Converting...</span>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">{job.progress}%</span>
</div>
<Progress value={job.progress} className="h-1" />
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" />
<span>{formatTime(elapsedTime)}</span>
</div>
{estimatedTimeRemaining && (
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3" />
<span>~{formatTime(estimatedTimeRemaining)} left</span>
</div>
)}
</div>
</div>
);
case 'completed':
const inputSize = job.inputFile.size;
const outputSize = job.result?.size || 0;
const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium">Complete</span>
</div>
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Input</span>
<span className="font-medium tabular-nums">{formatFileSize(inputSize)}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Output</span>
<div className="flex items-center gap-1.5">
<span className="font-medium tabular-nums">{formatFileSize(outputSize)}</span>
{Math.abs(sizeReduction) > 1 && (
<span className={cn(
"text-[10px] px-1.5 py-0.5 rounded-full",
sizeReduction > 0
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}>
{sizeReduction > 0 ? '-' : '+'}{Math.abs(sizeReduction).toFixed(0)}%
</span>
)}
</div>
</div>
</div>
</div>
);
case 'error':
return (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Conversion failed</span>
</div>
);
default:
return null;
}
};
if (job.status === 'pending') {
return null;
}
return ( return (
<Card className="animate-fade-in"> <div className="glass rounded-xl p-3 border border-border/20 space-y-3">
<CardHeader> {/* Header row */}
<CardTitle className="text-sm">Conversion</CardTitle> <div className="flex items-center justify-between gap-2">
</CardHeader> <div className="flex items-center gap-2 min-w-0">
<CardContent> {(job.status === 'loading' || job.status === 'processing') && (
<div className="space-y-3"> <Loader2 className="w-3 h-3 animate-spin text-primary shrink-0" />
{renderStatus()} )}
{job.status === 'completed' && (
<CheckCircle className="w-3 h-3 text-emerald-400 shrink-0" />
)}
{job.status === 'error' && (
<XCircle className="w-3 h-3 text-rose-400 shrink-0" />
)}
<span className="text-xs font-mono text-foreground/70 truncate">{job.inputFile.name}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground/40 shrink-0">
{job.inputFormat.extension} {job.outputFormat.extension}
</span>
</div>
{/* Loading state */}
{job.status === 'loading' && (
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50 font-mono">
<Clock className="w-3 h-3" />
<span>Loading converter {fmt(elapsedTime)}</span>
</div>
)}
{/* Processing state */}
{job.status === 'processing' && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground/50">
<div className="flex items-center gap-1.5">
<Clock className="w-3 h-3" />
<span>{fmt(elapsedTime)}</span>
{estimatedRemaining && (
<>
<TrendingUp className="w-3 h-3 ml-1" />
<span>~{fmt(estimatedRemaining)} left</span>
</>
)}
</div>
<span className="tabular-nums">{job.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: `${job.progress}%` }}
/>
</div>
</div>
)}
{/* Completed state */}
{job.status === 'completed' && (
<div className="space-y-3">
{/* Size stats */}
<div className="flex items-center gap-3 text-[10px] font-mono">
<span className="text-muted-foreground/40">{formatFileSize(inputSize)}</span>
<span className="text-muted-foreground/25"></span>
<span className="text-foreground/60">{formatFileSize(outputSize)}</span>
{Math.abs(sizeReduction) > 1 && (
<span className={cn(
'px-1.5 py-0.5 rounded font-mono text-[9px]',
sizeReduction > 0 ? 'bg-emerald-500/15 text-emerald-400' : 'bg-white/5 text-muted-foreground/50'
)}>
{sizeReduction > 0 ? '↓' : '↑'}{Math.abs(sizeReduction).toFixed(0)}%
</span>
)}
{job.startTime && job.endTime && (
<span className="text-muted-foreground/25 ml-auto">
{((job.endTime - job.startTime) / 1000).toFixed(1)}s
</span>
)}
</div>
{/* Media preview */}
{previewUrl && (() => {
switch (job.outputFormat.category) {
case 'image':
return (
<div className="rounded-lg overflow-hidden border border-white/5 bg-white/3 flex items-center justify-center p-2 max-h-48">
<img src={previewUrl} alt="Preview" className="max-w-full max-h-44 object-contain rounded" />
</div>
);
case 'video':
return (
<div className="rounded-lg overflow-hidden border border-white/5 bg-black/20">
<video src={previewUrl} controls className="w-full max-h-48">
Video not supported
</video>
</div>
);
case 'audio':
return (
<div className="rounded-lg border border-white/5 bg-white/3 p-3">
<audio src={previewUrl} controls className="w-full h-8" />
</div>
);
default: return null;
}
})()}
{/* Download */}
<button onClick={handleDownload} className={cn(actionBtn, 'w-full justify-center')}>
<Download className="w-3 h-3" />
<span className="truncate min-w-0">{filename}</span>
</button>
</div>
)}
{/* Error state */}
{job.status === 'error' && (
<div className="space-y-2">
{job.error && ( {job.error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-2.5"> <div className="rounded-lg border border-rose-500/20 bg-rose-500/8 p-2.5">
<p className="text-xs text-destructive">{job.error}</p> <p className="text-[10px] font-mono text-rose-400/80">{job.error}</p>
</div> </div>
)} )}
{onRetry && (
{job.status === 'error' && onRetry && ( <button onClick={onRetry} className={cn(actionBtn, 'w-full justify-center')}>
<Button onClick={onRetry} variant="outline" className="w-full"> <RefreshCw className="w-3 h-3" />
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Retry Retry
</Button> </button>
)}
{job.status === 'completed' && renderPreview()}
{job.status === 'completed' && job.result && (
<Button onClick={handleDownload} className="w-full">
<Download className="h-3.5 w-3.5 shrink-0 mr-1.5" />
<span className="truncate min-w-0">
{generateOutputFilename(job.inputFile.name, job.outputFormat.extension)}
</span>
</Button>
)}
{job.status === 'completed' && job.startTime && job.endTime && (
<p className="text-[10px] text-muted-foreground text-center">
{((job.endTime - job.startTime) / 1000).toFixed(1)}s
</p>
)} )}
</div> </div>
</CardContent> )}
</Card> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
import * as React from 'react'; import * as React from 'react';
import { Upload, X, File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film } from 'lucide-react'; import { Upload, X, File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film } from 'lucide-react';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
import type { ConversionFormat } from '@/types/media'; import type { ConversionFormat } from '@/types/media';
export interface FileUploadProps { export interface FileUploadProps {
@@ -17,6 +16,17 @@ export interface FileUploadProps {
inputFormat?: ConversionFormat; inputFormat?: ConversionFormat;
} }
function CategoryIcon({ format, className }: { format?: ConversionFormat; className?: string }) {
const cls = cn('text-primary', className);
if (!format) return <File className={cls} />;
switch (format.category) {
case 'video': return <FileVideo className={cls} />;
case 'audio': return <FileAudio className={cls} />;
case 'image': return <FileImage className={cls} />;
default: return <File className={cls} />;
}
}
export function FileUpload({ export function FileUpload({
onFileSelect, onFileSelect,
onFileRemove, onFileRemove,
@@ -28,322 +38,170 @@ export function FileUpload({
inputFormat, inputFormat,
}: FileUploadProps) { }: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
const [fileMetadata, setFileMetadata] = React.useState<Record<number, any>>({}); const [fileMetadata, setFileMetadata] = React.useState<Record<number, Record<string, string>>>({});
const localFileInputRef = React.useRef<HTMLInputElement>(null); const localRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || localFileInputRef; const fileInputRef = inputRef || localRef;
// Extract metadata for files
React.useEffect(() => { React.useEffect(() => {
const extractMetadata = async () => { const extract = async () => {
if (selectedFiles.length === 0 || !inputFormat) { if (selectedFiles.length === 0 || !inputFormat) { setFileMetadata({}); return; }
setFileMetadata({}); const out: Record<number, Record<string, string>> = {};
return;
}
const metadata: Record<number, any> = {};
for (let i = 0; i < selectedFiles.length; i++) { for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i]; const file = selectedFiles[i];
const baseMetadata = { const base = {
name: file.name, size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` : `${(file.size / (1024 * 1024)).toFixed(1)} MB`,
size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(2)} KB` : `${(file.size / (1024 * 1024)).toFixed(2)} MB`,
type: inputFormat.name, type: inputFormat.name,
}; };
// Extract media-specific metadata
if (inputFormat.category === 'video' && file.type.startsWith('video/')) { if (inputFormat.category === 'video' && file.type.startsWith('video/')) {
try { const video = document.createElement('video');
const video = document.createElement('video'); video.preload = 'metadata';
video.preload = 'metadata'; out[i] = await new Promise((res) => {
video.onloadedmetadata = () => {
const metadataPromise = new Promise<any>((resolve) => { const d = video.duration, m = Math.floor(d / 60), s = Math.floor(d % 60);
video.onloadedmetadata = () => { res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}`, dimensions: `${video.videoWidth}×${video.videoHeight}` });
const duration = video.duration; URL.revokeObjectURL(video.src);
const minutes = Math.floor(duration / 60); };
const seconds = Math.floor(duration % 60); video.onerror = () => { res(base); URL.revokeObjectURL(video.src); };
resolve({
...baseMetadata,
duration: `${minutes}:${seconds.toString().padStart(2, '0')}`,
dimensions: `${video.videoWidth} × ${video.videoHeight}`,
});
URL.revokeObjectURL(video.src);
};
video.onerror = () => {
resolve(baseMetadata);
URL.revokeObjectURL(video.src);
};
});
video.src = URL.createObjectURL(file); video.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise; });
} catch (error) {
metadata[i] = baseMetadata;
}
} else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) { } else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) {
try { const audio = document.createElement('audio');
const audio = document.createElement('audio'); audio.preload = 'metadata';
audio.preload = 'metadata'; out[i] = await new Promise((res) => {
audio.onloadedmetadata = () => {
const metadataPromise = new Promise<any>((resolve) => { const d = audio.duration, m = Math.floor(d / 60), s = Math.floor(d % 60);
audio.onloadedmetadata = () => { res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}` });
const duration = audio.duration; URL.revokeObjectURL(audio.src);
const minutes = Math.floor(duration / 60); };
const seconds = Math.floor(duration % 60); audio.onerror = () => { res(base); URL.revokeObjectURL(audio.src); };
resolve({
...baseMetadata,
duration: `${minutes}:${seconds.toString().padStart(2, '0')}`,
});
URL.revokeObjectURL(audio.src);
};
audio.onerror = () => {
resolve(baseMetadata);
URL.revokeObjectURL(audio.src);
};
});
audio.src = URL.createObjectURL(file); audio.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise; });
} catch (error) {
metadata[i] = baseMetadata;
}
} else if (inputFormat.category === 'image' && file.type.startsWith('image/')) { } else if (inputFormat.category === 'image' && file.type.startsWith('image/')) {
try { const img = new Image();
const img = new Image(); out[i] = await new Promise((res) => {
img.onload = () => { res({ ...base, dimensions: `${img.width}×${img.height}` }); URL.revokeObjectURL(img.src); };
const metadataPromise = new Promise<any>((resolve) => { img.onerror = () => { res(base); URL.revokeObjectURL(img.src); };
img.onload = () => {
resolve({
...baseMetadata,
dimensions: `${img.width} × ${img.height}`,
});
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve(baseMetadata);
URL.revokeObjectURL(img.src);
};
});
img.src = URL.createObjectURL(file); img.src = URL.createObjectURL(file);
metadata[i] = await metadataPromise; });
} catch (error) {
metadata[i] = baseMetadata;
}
} else { } else {
metadata[i] = baseMetadata; out[i] = base;
} }
} }
setFileMetadata(out);
setFileMetadata(metadata);
}; };
extract();
extractMetadata();
}, [selectedFiles, inputFormat]); }, [selectedFiles, inputFormat]);
const getCategoryIcon = () => { const handleFiles = (files: File[]) => {
if (!inputFormat) return <File className="h-5 w-5 text-primary" />; const maxBytes = maxSizeMB * 1024 * 1024;
switch (inputFormat.category) { const valid = files.filter((f) => {
case 'video': if (f.size > maxBytes) { alert(`${f.name} exceeds ${maxSizeMB}MB limit.`); return false; }
return <FileVideo className="h-5 w-5 text-primary" />; return true;
case 'audio': });
return <FileAudio className="h-5 w-5 text-primary" />; if (valid.length > 0) onFileSelect(valid);
case 'image': if (fileInputRef.current) fileInputRef.current.value = '';
return <FileImage className="h-5 w-5 text-primary" />;
default:
return <File className="h-5 w-5 text-primary" />;
}
};
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) handleFiles(Array.from(e.dataTransfer.files));
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleFiles(files);
}
}; };
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => { const triggerInput = () => { if (!disabled) fileInputRef.current?.click(); };
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFiles(files);
}
};
const handleFiles = (files: File[]) => {
// Check file sizes
const maxBytes = maxSizeMB * 1024 * 1024;
const validFiles = files.filter(file => {
if (file.size > maxBytes) {
alert(`${file.name} exceeds ${maxSizeMB}MB limit and will be skipped.`);
return false;
}
return true;
});
if (validFiles.length > 0) {
onFileSelect(validFiles);
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleClick = () => {
if (!disabled) {
fileInputRef.current?.click();
}
};
const handleRemove = (index: number) => (e: React.MouseEvent) => {
e.stopPropagation();
onFileRemove(index);
};
return ( return (
<div className="w-full space-y-3"> <div className="w-full flex flex-col gap-2 flex-1 min-h-0">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
className="hidden" className="hidden"
accept={accept} accept={accept}
onChange={handleFileInput} onChange={(e) => handleFiles(Array.from(e.target.files || []))}
disabled={disabled} disabled={disabled}
/> />
{selectedFiles.length > 0 ? ( {selectedFiles.length === 0 ? (
<div className="space-y-3"> /* ── Drop zone ─────────────────────────────────────── */
{selectedFiles.map((file, index) => { <div
const metadata = fileMetadata[index]; onClick={triggerInput}
return ( onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
<div key={`${file.name}-${index}`} className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm"> onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
<div className="flex items-start gap-3"> onDragOver={(e) => e.preventDefault()}
<div className="p-2 bg-primary/10 rounded-lg shrink-0"> onDrop={handleDrop}
{getCategoryIcon()} className={cn(
'flex-1 flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer',
'text-center select-none',
isDragging
? 'border-primary bg-primary/10 scale-[0.99]'
: 'border-border/35 hover:border-primary/40 hover:bg-primary/5',
disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
)}
>
<div className={cn(
'w-14 h-14 rounded-full flex items-center justify-center mb-4 transition-colors',
isDragging ? 'bg-primary/25' : 'bg-primary/10'
)}>
<Upload className={cn('w-6 h-6 transition-colors', isDragging ? 'text-primary' : 'text-primary/60')} />
</div>
<p className="text-sm font-medium text-foreground/70 mb-1">
{isDragging ? 'Drop to upload' : 'Drop files or click to browse'}
</p>
<p className="text-[10px] text-muted-foreground/35 font-mono">
Video · Audio · Image · Max {maxSizeMB}MB
</p>
</div>
) : (
/* ── File list ─────────────────────────────────────── */
<div className="flex flex-col gap-2 flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent space-y-2 pr-0.5">
{selectedFiles.map((file, idx) => {
const meta = fileMetadata[idx];
return (
<div
key={`${file.name}-${idx}`}
className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3"
>
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<CategoryIcon format={inputFormat} className="w-4 h-4" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground truncate" title={file.name}> <p className="text-xs font-mono text-foreground/80 truncate" title={file.name}>
{file.name} {file.name}
</p> </p>
<Button <button
variant="ghost" onClick={(e) => { e.stopPropagation(); onFileRemove(idx); }}
size="icon-xs"
onClick={handleRemove(index)}
disabled={disabled} disabled={disabled}
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0" 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="h-3.5 w-3.5" /> <X className="w-3 h-3" />
<span className="sr-only">Remove file</span> </button>
</Button>
</div> </div>
{metadata && ( {meta && (
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-muted-foreground"> <div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
{/* File Size */} <span className="flex items-center gap-1"><HardDrive className="w-2.5 h-2.5" />{meta.size}</span>
<div className="flex items-center gap-1"> {meta.duration && <span className="flex items-center gap-1"><Clock className="w-2.5 h-2.5" />{meta.duration}</span>}
<HardDrive className="h-3 w-3" /> {meta.dimensions && <span className="flex items-center gap-1"><Film className="w-2.5 h-2.5" />{meta.dimensions}</span>}
<span>{metadata.size}</span>
</div>
{/* Type */}
<div className="flex items-center gap-1">
<File className="h-3 w-3" />
<span>{metadata.type}</span>
</div>
{/* Duration (for video/audio) */}
{metadata.duration && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{metadata.duration}</span>
</div>
)}
{/* Dimensions */}
{metadata.dimensions && (
<div className="flex items-center gap-1">
{inputFormat?.category === 'video' ? (
<Film className="h-3 w-3" />
) : (
<FileImage className="h-3 w-3" />
)}
<span>{metadata.dimensions}</span>
</div>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> );
); })}
})}
{/* Add more files button */}
<Button
variant="outline"
onClick={handleClick}
disabled={disabled}
className="w-full"
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Add More Files
</Button>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
'hover:border-primary/40 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border/50': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
<Upload className="h-6 w-6 text-primary" />
</div> </div>
<p className="text-sm font-medium text-foreground mb-0.5">
Drop files here or click to browse {/* Add more */}
</p> <button
<p className="text-[10px] text-muted-foreground"> onClick={triggerInput}
Max {maxSizeMB}MB per file disabled={disabled}
</p> className="shrink-0 w-full py-2 rounded-xl border border-dashed border-border/30 text-xs text-muted-foreground/40 hover:text-foreground hover:border-primary/30 transition-all flex items-center justify-center gap-1.5 font-mono"
>
<Upload className="w-3 h-3" />
Add more files
</button>
</div> </div>
)} )}
</div> </div>

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,21 +1,10 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ArrowLeftRight, BarChart3 } from 'lucide-react'; import { ArrowLeftRight, BarChart3, Grid3X3, Copy } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
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 {
getAllMeasures, getAllMeasures,
getUnitsForMeasure, getUnitsForMeasure,
@@ -26,6 +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';
export default function MainConverter() { export default function MainConverter() {
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length'); const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
@@ -33,205 +26,293 @@ export default function MainConverter() {
const [targetUnit, setTargetUnit] = useState<string>('ft'); const [targetUnit, setTargetUnit] = useState<string>('ft');
const [inputValue, setInputValue] = useState<string>('1'); const [inputValue, setInputValue] = useState<string>('1');
const [conversions, setConversions] = useState<ConversionResult[]>([]); const [conversions, setConversions] = useState<ConversionResult[]>([]);
const [showVisualComparison, setShowVisualComparison] = useState(false); const [showChart, setShowChart] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [tab, setTab] = useState<Tab>('category');
const measures = getAllMeasures(); const measures = getAllMeasures();
const units = getUnitsForMeasure(selectedMeasure); const units = getUnitsForMeasure(selectedMeasure);
// Update conversions when input changes
useEffect(() => { useEffect(() => {
const numValue = parseNumberInput(inputValue); const numValue = parseNumberInput(inputValue);
if (numValue !== null && selectedUnit) { if (numValue !== null && selectedUnit) {
const results = convertToAll(numValue, selectedUnit); setConversions(convertToAll(numValue, selectedUnit));
setConversions(results);
} else { } else {
setConversions([]); setConversions([]);
} }
}, [inputValue, selectedUnit]); }, [inputValue, selectedUnit]);
// Update selected unit when measure changes
useEffect(() => { useEffect(() => {
const availableUnits = getUnitsForMeasure(selectedMeasure); const availableUnits = getUnitsForMeasure(selectedMeasure);
if (availableUnits.length > 0) { if (availableUnits.length > 0) {
setSelectedUnit(availableUnits[0]); setSelectedUnit(availableUnits[0]);
setTargetUnit(availableUnits[1] || availableUnits[0]); setTargetUnit(availableUnits[1] ?? availableUnits[0]);
} }
}, [selectedMeasure]); }, [selectedMeasure]);
// Swap units
const handleSwapUnits = useCallback(() => { const handleSwapUnits = useCallback(() => {
const temp = selectedUnit;
setSelectedUnit(targetUnit);
setTargetUnit(temp);
// Convert the value
const numValue = parseNumberInput(inputValue); const numValue = parseNumberInput(inputValue);
if (numValue !== null) { if (numValue !== null) {
const converted = convertUnit(numValue, selectedUnit, targetUnit); setInputValue(convertUnit(numValue, selectedUnit, targetUnit).toString());
setInputValue(converted.toString());
} }
setSelectedUnit(targetUnit);
setTargetUnit(selectedUnit);
}, [selectedUnit, targetUnit, inputValue]); }, [selectedUnit, targetUnit, inputValue]);
// Handle search selection
const handleSearchSelect = useCallback((unit: string, measure: Measure) => { const handleSearchSelect = useCallback((unit: string, measure: Measure) => {
setSelectedMeasure(measure); setSelectedMeasure(measure);
setSelectedUnit(unit); setSelectedUnit(unit);
setTab('convert');
}, []); }, []);
// Handle value change from draggable bars const handleCategorySelect = useCallback((measure: Measure) => {
const handleValueChange = useCallback((value: number, unit: string, dragging: boolean) => { setSelectedMeasure(measure);
setIsDragging(dragging); setTab('convert');
}, []);
// Convert the dragged unit's value back to the currently selected unit const handleValueChange = useCallback(
// This keeps the source unit stable while updating the value (value: number, unit: string, _dragging: boolean) => {
const convertedValue = convertUnit(value, unit, selectedUnit); setInputValue(convertUnit(value, unit, selectedUnit).toString());
setInputValue(convertedValue.toString()); },
// Keep selectedUnit unchanged [selectedUnit]
}, [selectedUnit]); );
const resultValue = (() => {
const n = parseNumberInput(inputValue);
return n !== null ? convertUnit(n, selectedUnit, targetUnit) : null;
})();
return ( return (
<div className="w-full space-y-6"> <div className="flex flex-col gap-4">
{/* Quick Access Row */} <MobileTabs
<Card> tabs={[{ value: 'category', label: 'Category' }, { value: 'convert', label: 'Convert' }]}
<CardContent className="flex flex-col md:flex-row md:items-center gap-3 justify-between"> active={tab}
<div className="flex-1"> onChange={(v) => setTab(v as Tab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: search + categories */}
<div
className={cn(
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
tab !== 'category' && 'hidden lg:flex'
)}
>
{/* Search */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Search
</span>
<SearchUnits onSelectUnit={handleSearchSelect} /> <SearchUnits onSelectUnit={handleSearchSelect} />
</div> </div>
<div className="w-full md:w-56 shrink-0">
<Select
value={selectedMeasure}
onValueChange={(value) => setSelectedMeasure(value as Measure)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Measure" />
</SelectTrigger>
<SelectContent>
{measures.map((measure) => (
<SelectItem key={measure} value={measure}>
{formatMeasureName(measure)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Main Converter Card */} {/* Category list */}
<Card> <div className="glass rounded-xl p-3 flex flex-col flex-1 min-h-0 overflow-hidden">
<CardHeader> <div className="flex items-center justify-between mb-3 shrink-0">
<CardTitle>Convert {formatMeasureName(selectedMeasure)}</CardTitle> <span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
</CardHeader> Categories
<CardContent className="space-y-4"> </span>
<div className="flex flex-col gap-3 md:flex-row md:items-end md:gap-2"> <span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
<div className="flex-1 w-full"> {measures.length}
<Label className="text-xs mb-1.5">Value</Label> </span>
<Input </div>
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent space-y-0.5 pr-0.5">
{measures.map((measure) => {
const isSelected = selectedMeasure === measure;
const unitCount = getUnitsForMeasure(measure).length;
return (
<button
key={measure}
onClick={() => handleCategorySelect(measure)}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all text-left',
isSelected
? 'bg-primary/10 text-primary'
: 'text-foreground/65 hover:bg-primary/8 hover:text-foreground'
)}
>
<span className="flex-1 text-xs font-mono truncate">{formatMeasureName(measure)}</span>
<span
className={cn(
'text-[10px] font-mono tabular-nums shrink-0 px-1.5 py-0.5 rounded',
isSelected
? 'bg-primary/20 text-primary'
: 'bg-muted/40 text-muted-foreground/40'
)}
>
{unitCount}
</span>
</button>
);
})}
</div>
</div>
</div>
{/* Right panel: converter + results */}
<div
className={cn(
'lg:col-span-3 flex flex-col gap-3 overflow-hidden',
tab !== 'convert' && 'hidden lg:flex'
)}
>
{/* Converter card */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
Convert {formatMeasureName(selectedMeasure)}
</span>
{/* Input row */}
<div className="flex flex-col gap-2">
{/* Value input */}
<input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
placeholder="Enter value" placeholder="0"
className={cn("text-lg", "w-full", "max-w-full")} 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"
/> />
</div>
<div className="w-full md:w-36">
<Label className="text-xs mb-1.5">From</Label>
<Select
value={selectedUnit}
onValueChange={(value) => setSelectedUnit(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="From" />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="icon"
onClick={handleSwapUnits}
className="shrink-0 w-full md:w-7"
title="Swap units"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</Button>
<div className="w-full md:w-36">
<Label className="text-xs mb-1.5">To</Label>
<Select
value={targetUnit}
onValueChange={(value) => setTargetUnit(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="To" />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{parseNumberInput(inputValue) !== null && ( {/* Unit selectors + swap */}
<div className="p-3 rounded-lg bg-primary/5 border border-primary/15"> <div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground mb-0.5">Result</div> {/* From unit */}
<div className="text-2xl font-bold text-primary tabular-nums"> <select
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} <span className="text-base font-medium text-muted-foreground">{targetUnit}</span> value={selectedUnit}
onChange={(e) => setSelectedUnit(e.target.value)}
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{units.map((unit) => (
<option key={unit} value={unit}>{unit}</option>
))}
</select>
{/* Swap */}
<button
onClick={handleSwapUnits}
title="Swap units"
className="shrink-0 w-8 h-8 flex items-center justify-center glass rounded-lg border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all"
>
<ArrowLeftRight className="w-3.5 h-3.5" />
</button>
{/* To unit */}
<select
value={targetUnit}
onChange={(e) => setTargetUnit(e.target.value)}
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{units.map((unit) => (
<option key={unit} value={unit}>{unit}</option>
))}
</select>
</div> </div>
</div> </div>
)}
</CardContent>
</Card>
{/* Results */} {/* Result display */}
<Card> {resultValue !== null && (
<CardHeader> <div className="mt-3 px-3 py-2.5 rounded-lg bg-primary/5 border border-primary/15">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-0.5">
<CardTitle>All Conversions</CardTitle> <div className="text-[10px] text-muted-foreground/50 font-mono">Result</div>
<Button <button
variant="outline" onClick={() => {
size="xs" const text = `${formatNumber(resultValue)} ${targetUnit}`;
onClick={() => setShowVisualComparison(!showVisualComparison)} navigator.clipboard.writeText(text);
> toast.success('Copied', { description: text, duration: 2000 });
<BarChart3 className="h-3 w-3 mr-1" /> }}
{showVisualComparison ? 'Grid' : 'Chart'} title="Copy result"
</Button> className="w-5 h-5 flex items-center justify-center rounded text-muted-foreground/40 hover:text-primary transition-colors"
</div> >
</CardHeader> <Copy className="w-3 h-3" />
<CardContent> </button>
{showVisualComparison ? (
<VisualComparison
conversions={conversions}
onValueChange={handleValueChange}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{conversions.map((conversion) => (
<div
key={conversion.unit}
className="p-3 rounded-lg border border-border/50 hover:border-primary/30 transition-colors"
>
<div className="text-xs text-muted-foreground">{conversion.unitInfo.plural}</div>
<div className="text-lg font-bold tabular-nums mt-0.5">{formatNumber(conversion.value)}</div>
<div className="text-xs text-muted-foreground">{conversion.unit}</div>
</div> </div>
))} <div className="flex items-baseline gap-2">
<span className="text-xl font-bold tabular-nums font-mono bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent">
{formatNumber(resultValue)}
</span>
<span className="text-sm text-muted-foreground/60 font-mono">{targetUnit}</span>
</div>
</div>
)}
</div>
{/* All conversions */}
<div className="glass rounded-xl p-3 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
All Conversions
</span>
{/* Grid / Chart toggle */}
<div className="flex glass rounded-lg p-0.5 gap-0.5">
<button
onClick={() => setShowChart(false)}
title="Grid view"
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-all',
!showChart
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Grid3X3 className="w-3 h-3" />
</button>
<button
onClick={() => setShowChart(true)}
title="Chart view"
className={cn(
'flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-all',
showChart
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<BarChart3 className="w-3 h-3" />
</button>
</div>
</div> </div>
)}
</CardContent> <div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
</Card> {showChart ? (
<VisualComparison conversions={conversions} onValueChange={handleValueChange} />
) : (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-2">
{conversions.map((conversion) => {
const isTarget = targetUnit === conversion.unit;
return (
<button
key={conversion.unit}
onClick={() => setTargetUnit(conversion.unit)}
className={cn(
'p-2.5 rounded-lg border text-left transition-all',
isTarget
? 'border-primary/50 bg-primary/10 text-primary'
: 'border-border/30 hover:border-primary/30 hover:bg-primary/6 text-foreground/75'
)}
>
<div className="text-[10px] text-muted-foreground/50 font-mono truncate mb-0.5">
{conversion.unitInfo.plural}
</div>
<div className="text-sm font-bold tabular-nums font-mono leading-none">
{formatNumber(conversion.value)}
</div>
<div className="text-[10px] text-muted-foreground/40 font-mono mt-0.5">
{conversion.unit}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -3,8 +3,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { import {
getAllMeasures, getAllMeasures,
getUnitsForMeasure, getUnitsForMeasure,
@@ -31,30 +29,17 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Build search index
const searchIndex = useRef<Fuse<SearchResult> | null>(null); const searchIndex = useRef<Fuse<SearchResult> | null>(null);
useEffect(() => { useEffect(() => {
// Build comprehensive search data
const allData: SearchResult[] = []; const allData: SearchResult[] = [];
const measures = getAllMeasures(); const measures = getAllMeasures();
for (const measure of measures) { for (const measure of measures) {
const units = getUnitsForMeasure(measure); for (const unit of getUnitsForMeasure(measure)) {
for (const unit of units) {
const unitInfo = getUnitInfo(unit); const unitInfo = getUnitInfo(unit);
if (unitInfo) { if (unitInfo) allData.push({ unitInfo, measure });
allData.push({
unitInfo,
measure,
});
}
} }
} }
// Initialize Fuse.js for fuzzy search
searchIndex.current = new Fuse(allData, { searchIndex.current = new Fuse(allData, {
keys: [ keys: [
{ name: 'unitInfo.abbr', weight: 2 }, { name: 'unitInfo.abbr', weight: 2 },
@@ -67,30 +52,22 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp
}); });
}, []); }, []);
// Perform search
useEffect(() => { useEffect(() => {
if (!query.trim() || !searchIndex.current) { if (!query.trim() || !searchIndex.current) {
setResults([]); setResults([]);
setIsOpen(false); setIsOpen(false);
return; return;
} }
setResults(searchIndex.current.search(query).map((r) => r.item).slice(0, 10));
const searchResults = searchIndex.current.search(query);
setResults(searchResults.map(r => r.item).slice(0, 10));
setIsOpen(true); setIsOpen(true);
}, [query]); }, [query]);
// Handle click outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if ( if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
} }
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
@@ -102,67 +79,60 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp
inputRef.current?.blur(); inputRef.current?.blur();
}; };
const clearSearch = () => {
setQuery('');
setIsOpen(false);
};
return ( return (
<div ref={containerRef} className={cn("relative w-full", className)}> <div ref={containerRef} className={cn('relative w-full', className)}>
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/40 pointer-events-none" />
<Input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
placeholder="Search units..." placeholder="Search all units"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onFocus={() => query && setIsOpen(true)} onFocus={() => query && setIsOpen(true)}
className="pl-10 pr-10" className="w-full bg-transparent border border-border/40 rounded-lg pl-8 pr-7 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors placeholder:text-muted-foreground/30"
/> />
{query && ( {query && (
<Button <button
variant="ghost" onClick={() => { setQuery(''); setIsOpen(false); }}
size="icon" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={clearSearch}
> >
<X className="h-4 w-4" /> <X className="w-3 h-3" />
</Button> </button>
)} )}
</div> </div>
{/* Results dropdown */}
{isOpen && results.length > 0 && ( {isOpen && results.length > 0 && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg max-h-80 overflow-y-auto scrollbar"> <div className="absolute z-50 w-full mt-1.5 bg-popover border border-border/60 rounded-xl shadow-xl max-h-72 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
{results.map((result, index) => ( {results.map((result, index) => (
<button <button
key={`${result.measure}-${result.unitInfo.abbr}`} key={`${result.measure}-${result.unitInfo.abbr}`}
onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)} onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)}
className={cn( className={cn(
'w-full px-4 py-3 text-left hover:bg-accent transition-colors', 'w-full px-3 py-2.5 text-left hover:bg-primary/8 hover:text-foreground transition-colors',
'flex items-center justify-between gap-4', 'flex items-center justify-between gap-3',
index !== 0 && 'border-t' index !== 0 && 'border-t border-border/20'
)} )}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate"> <div className="text-xs font-medium font-mono truncate">{result.unitInfo.plural}</div>
{result.unitInfo.plural} <div className="text-[10px] text-muted-foreground/50 flex items-center gap-1.5 mt-0.5">
</div> <span className="font-mono">{result.unitInfo.abbr}</span>
<div className="text-sm text-muted-foreground flex items-center gap-2"> <span>·</span>
<span className="truncate">{result.unitInfo.abbr}</span> <span>{formatMeasureName(result.measure)}</span>
<span></span>
<span className="truncate">{formatMeasureName(result.measure)}</span>
</div> </div>
</div> </div>
<span className="text-[10px] text-muted-foreground/30 font-mono shrink-0">
{result.measure}
</span>
</button> </button>
))} ))}
</div> </div>
)} )}
{isOpen && query && results.length === 0 && ( {isOpen && query && results.length === 0 && (
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg p-4 text-center text-muted-foreground"> <div className="absolute z-50 w-full mt-1.5 bg-popover border border-border/60 rounded-xl p-4 text-center">
No units found for &quot;{query}&quot; <p className="text-xs text-muted-foreground/40 font-mono italic">No units found for &quot;{query}&quot;</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -9,10 +9,7 @@ interface VisualComparisonProps {
onValueChange?: (value: number, unit: string, dragging: boolean) => void; onValueChange?: (value: number, unit: string, dragging: boolean) => void;
} }
export default function VisualComparison({ export default function VisualComparison({ conversions, onValueChange }: VisualComparisonProps) {
conversions,
onValueChange,
}: VisualComparisonProps) {
const [draggingUnit, setDraggingUnit] = useState<string | null>(null); const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null); const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
const dragStartX = useRef<number>(0); const dragStartX = useRef<number>(0);
@@ -20,197 +17,130 @@ export default function VisualComparison({
const activeBarRef = useRef<HTMLDivElement | null>(null); const activeBarRef = useRef<HTMLDivElement | null>(null);
const lastUpdateTime = useRef<number>(0); const lastUpdateTime = useRef<number>(0);
const baseConversionsRef = useRef<ConversionResult[]>([]); const baseConversionsRef = useRef<ConversionResult[]>([]);
// Calculate percentages for visual bars using logarithmic scale
const withPercentages = useMemo(() => { const withPercentages = useMemo(() => {
if (conversions.length === 0) return []; if (conversions.length === 0) return [];
// Use base conversions for scale if we're dragging (keeps scale stable)
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions; const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const values = scaleSource.map((c) => Math.abs(c.value));
// Get all values from the SCALE SOURCE (not current conversions)
const values = scaleSource.map(c => Math.abs(c.value));
const maxValue = Math.max(...values); const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0)); const minValue = Math.min(...values.filter((v) => v > 0));
if (maxValue === 0 || !isFinite(maxValue)) { if (maxValue === 0 || !isFinite(maxValue)) {
return conversions.map(c => ({ ...c, percentage: 0 })); return conversions.map((c) => ({ ...c, percentage: 0 }));
} }
// Use logarithmic scale for better visualization return conversions.map((c) => {
return conversions.map(c => {
const absValue = Math.abs(c.value); const absValue = Math.abs(c.value);
if (absValue === 0 || !isFinite(absValue)) return { ...c, percentage: 2 };
if (absValue === 0 || !isFinite(absValue)) {
return { ...c, percentage: 2 }; // Show minimal bar
}
// Logarithmic scale
const logValue = Math.log10(absValue); const logValue = Math.log10(absValue);
const logMax = Math.log10(maxValue); const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6; // 6 orders of magnitude range const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
const logRange = logMax - logMin; const logRange = logMax - logMin;
const percentage =
let percentage: number; logRange === 0
if (logRange === 0) { ? 100
percentage = 100; : Math.max(3, Math.min(100, ((logValue - logMin) / logRange) * 100));
} else { return { ...c, percentage };
percentage = ((logValue - logMin) / logRange) * 100;
// Ensure bars are visible - minimum 3%, maximum 100%
percentage = Math.max(3, Math.min(100, percentage));
}
return {
...c,
percentage,
};
}); });
}, [conversions]); }, [conversions]);
// Calculate value from percentage (reverse logarithmic scale) const calculateValueFromPercentage = useCallback(
const calculateValueFromPercentage = useCallback(( (percentage: number, minValue: number, maxValue: number): number => {
percentage: number, const logMax = Math.log10(maxValue);
minValue: number, const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
maxValue: number return Math.pow(10, logMin + (percentage / 100) * (logMax - logMin));
): number => { },
const logMax = Math.log10(maxValue); []
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6; );
const logRange = logMax - logMin;
// Convert percentage back to log value const handleMouseDown = useCallback(
const logValue = logMin + (percentage / 100) * logRange; (e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
// Convert log value back to actual value if (!onValueChange) return;
return Math.pow(10, logValue); e.preventDefault();
}, []); setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = e.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
baseConversionsRef.current = [...conversions];
},
[onValueChange, conversions]
);
// Mouse drag handlers const handleMouseMove = useCallback(
const handleMouseDown = useCallback((e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => { (e: MouseEvent) => {
if (!onValueChange) return; if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
const now = Date.now();
e.preventDefault(); if (now - lastUpdateTime.current < 16) return;
setDraggingUnit(unit); lastUpdateTime.current = now;
setDraggedPercentage(currentPercentage); const deltaPercentage = ((e.clientX - dragStartX.current) / activeBarRef.current.offsetWidth) * 100;
dragStartX.current = e.clientX; const newPercentage = Math.max(3, Math.min(100, dragStartWidth.current + deltaPercentage));
dragStartWidth.current = currentPercentage; setDraggedPercentage(newPercentage);
activeBarRef.current = barElement; const base = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Save the current conversions as reference const vals = base.map((c) => Math.abs(c.value));
baseConversionsRef.current = [...conversions]; const newValue = calculateValueFromPercentage(newPercentage, Math.min(...vals.filter((v) => v > 0)), Math.max(...vals));
}, [onValueChange, conversions]); onValueChange(newValue, draggingUnit, true);
},
const handleMouseMove = useCallback((e: MouseEvent) => { [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]
if (!draggingUnit || !activeBarRef.current || !onValueChange) return; );
// Throttle updates to every 16ms (~60fps)
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = e.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
// Calculate min/max values for the scale from BASE conversions
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
// Calculate new value from percentage
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
if (draggingUnit && onValueChange) { if (draggingUnit && onValueChange) {
// Find the current value for the dragged unit const conversion = conversions.find((c) => c.unit === draggingUnit);
const conversion = conversions.find(c => c.unit === draggingUnit); if (conversion) onValueChange(conversion.value, draggingUnit, false);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
} }
setDraggingUnit(null); setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null; activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]); }, [draggingUnit, conversions, onValueChange]);
// Touch drag handlers const handleTouchStart = useCallback(
const handleTouchStart = useCallback((e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => { (e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return; if (!onValueChange) return;
const touch = e.touches[0];
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = touch.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
baseConversionsRef.current = [...conversions];
},
[onValueChange, conversions]
);
const touch = e.touches[0]; const handleTouchMove = useCallback(
setDraggingUnit(unit); (e: TouchEvent) => {
setDraggedPercentage(currentPercentage); if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
dragStartX.current = touch.clientX; const now = Date.now();
dragStartWidth.current = currentPercentage; if (now - lastUpdateTime.current < 16) return;
activeBarRef.current = barElement; lastUpdateTime.current = now;
// Save the current conversions as reference e.preventDefault();
baseConversionsRef.current = [...conversions]; const touch = e.touches[0];
}, [onValueChange, conversions]); const deltaPercentage = ((touch.clientX - dragStartX.current) / activeBarRef.current.offsetWidth) * 100;
const newPercentage = Math.max(3, Math.min(100, dragStartWidth.current + deltaPercentage));
const handleTouchMove = useCallback((e: TouchEvent) => { setDraggedPercentage(newPercentage);
if (!draggingUnit || !activeBarRef.current || !onValueChange) return; const base = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const vals = base.map((c) => Math.abs(c.value));
// Throttle updates to every 16ms (~60fps) const newValue = calculateValueFromPercentage(newPercentage, Math.min(...vals.filter((v) => v > 0)), Math.max(...vals));
const now = Date.now(); onValueChange(newValue, draggingUnit, true);
if (now - lastUpdateTime.current < 16) return; },
lastUpdateTime.current = now; [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]
);
e.preventDefault(); // Prevent scrolling while dragging
const touch = e.touches[0];
const barWidth = activeBarRef.current.offsetWidth;
const deltaX = touch.clientX - dragStartX.current;
const deltaPercentage = (deltaX / barWidth) * 100;
let newPercentage = dragStartWidth.current + deltaPercentage;
newPercentage = Math.max(3, Math.min(100, newPercentage));
// Update visual percentage immediately
setDraggedPercentage(newPercentage);
// Use the base conversions (from when drag started) for scale calculation
const baseConversions = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const values = baseConversions.map(c => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter(v => v > 0));
const newValue = calculateValueFromPercentage(newPercentage, minValue, maxValue);
onValueChange(newValue, draggingUnit, true); // true = currently dragging
}, [draggingUnit, conversions, onValueChange, calculateValueFromPercentage]);
const handleTouchEnd = useCallback(() => { const handleTouchEnd = useCallback(() => {
if (draggingUnit && onValueChange) { if (draggingUnit && onValueChange) {
// Find the current value for the dragged unit const conversion = conversions.find((c) => c.unit === draggingUnit);
const conversion = conversions.find(c => c.unit === draggingUnit); if (conversion) onValueChange(conversion.value, draggingUnit, false);
if (conversion) {
onValueChange(conversion.value, draggingUnit, false); // false = drag ended
}
} }
setDraggingUnit(null); setDraggingUnit(null);
// Don't clear draggedPercentage yet - let it clear when conversions update
activeBarRef.current = null; activeBarRef.current = null;
// baseConversionsRef cleared after conversions update
}, [draggingUnit, conversions, onValueChange]); }, [draggingUnit, conversions, onValueChange]);
// Add/remove global event listeners for drag
useEffect(() => { useEffect(() => {
if (draggingUnit) { if (draggingUnit) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd); document.addEventListener('touchend', handleTouchEnd);
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
@@ -220,10 +150,8 @@ export default function VisualComparison({
} }
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]); }, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
// Clear drag state when conversions update after drag ends
useEffect(() => { useEffect(() => {
if (!draggingUnit && draggedPercentage !== null) { if (!draggingUnit && draggedPercentage !== null) {
// Drag has ended, conversions have updated, now clear visual state
setDraggedPercentage(null); setDraggedPercentage(null);
baseConversionsRef.current = []; baseConversionsRef.current = [];
} }
@@ -231,75 +159,54 @@ export default function VisualComparison({
if (conversions.length === 0) { if (conversions.length === 0) {
return ( return (
<div className="text-center py-8 text-muted-foreground"> <div className="py-10 text-center">
Enter a value to see conversions <p className="text-xs text-muted-foreground/35 font-mono italic">Enter a value to see conversions</p>
</div> </div>
); );
} }
return ( return (
<div className="space-y-3"> <div className="space-y-2.5">
{withPercentages.map(item => { {withPercentages.map((item) => {
const isDragging = draggingUnit === item.unit; const isDragging = draggingUnit === item.unit;
const isDraggable = !!onValueChange; const isDraggable = !!onValueChange;
// Use draggedPercentage if this bar is being dragged
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage; const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
return ( return (
<div key={item.unit} className="space-y-1.5"> <div key={item.unit} className="space-y-1">
<div className="flex items-baseline justify-between gap-4"> <div className="flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-foreground min-w-0 flex-shrink"> <span className="text-[10px] text-muted-foreground/60 font-mono truncate">{item.unitInfo.plural}</span>
{item.unitInfo.plural} <span className="text-xs font-bold tabular-nums font-mono shrink-0 text-foreground/85">
</span>
<span className="text-lg font-bold tabular-nums flex-shrink-0">
{formatNumber(item.value)} {formatNumber(item.value)}
<span className="text-sm font-normal text-muted-foreground ml-1"> <span className="text-[10px] font-normal text-muted-foreground/50 ml-1">{item.unit}</span>
{item.unit}
</span>
</span> </span>
</div> </div>
{/* Progress bar */}
<div <div
className={cn( className={cn(
"w-full h-8 bg-muted rounded-lg overflow-hidden border border-border relative", 'w-full h-5 rounded-md overflow-hidden relative',
"transition-all duration-200", 'bg-primary/6 border border-border/25',
isDraggable && "cursor-grab active:cursor-grabbing", isDraggable && 'cursor-grab active:cursor-grabbing',
isDragging && "ring-2 ring-ring ring-offset-2 ring-offset-background scale-105" isDragging && 'ring-1 ring-primary/40'
)} )}
onMouseDown={(e) => { onMouseDown={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) { if (isDraggable && e.currentTarget instanceof HTMLDivElement)
handleMouseDown(e, item.unit, item.percentage, e.currentTarget); handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
}
}} }}
onTouchStart={(e) => { onTouchStart={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement) { if (isDraggable && e.currentTarget instanceof HTMLDivElement)
handleTouchStart(e, item.unit, item.percentage, e.currentTarget); handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
}
}} }}
> >
{/* Colored fill */}
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 bg-primary", 'absolute inset-y-0 left-0 rounded-sm bg-primary/65',
draggingUnit ? "transition-none" : "transition-all duration-500 ease-out" draggingUnit ? 'transition-none' : 'transition-all duration-500 ease-out'
)} )}
style={{ style={{ width: `${displayPercentage}%` }}
width: `${displayPercentage}%`,
}}
/> />
{/* Percentage label overlay */}
<div className="absolute inset-0 flex items-center px-3 text-xs font-bold pointer-events-none">
<span className="text-foreground drop-shadow-sm">
{Math.round(displayPercentage)}%
</span>
</div>
{/* Drag hint on hover */}
{isDraggable && !isDragging && ( {isDraggable && !isDragging && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-background/10 backdrop-blur-[1px]"> <div className="absolute inset-0 flex items-center justify-end px-2 opacity-0 hover:opacity-100 transition-opacity">
<span className="text-xs font-semibold text-foreground drop-shadow-md"> <span className="text-[9px] font-mono text-muted-foreground/40">drag</span>
Drag to adjust
</span>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,157 @@
import { create, all, type EvalFunction } from 'mathjs';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const math = create(all, { number: 'number', precision: 14 } as any);
function buildScope(variables: Record<string, string>): Record<string, unknown> {
const scope: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(variables)) {
if (!expr.trim()) continue;
try {
scope[name] = math.evaluate(expr);
} catch {
// skip invalid variables
}
}
return scope;
}
export interface EvalResult {
result: string;
error: boolean;
assignedName?: string;
assignedValue?: string;
}
export function evaluateExpression(
expression: string,
variables: Record<string, string> = {}
): EvalResult {
const trimmed = expression.trim();
if (!trimmed) return { result: '', error: false };
try {
const scope = buildScope(variables);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = math.evaluate(trimmed, scope as any);
const formatted = formatValue(raw);
// Detect assignment: "name = expr" or "name(args) = expr"
const assignMatch = trimmed.match(/^([a-zA-Z_]\w*)\s*(?:\([^)]*\))?\s*=/);
if (assignMatch) {
return {
result: formatted,
error: false,
assignedName: assignMatch[1],
assignedValue: formatted,
};
}
return { result: formatted, error: false };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { result: msg.replace(/^Error: /, ''), error: true };
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return 'null';
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') {
if (!isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
if (value === 0) return '0';
const abs = Math.abs(value);
if (abs >= 1e13 || (abs < 1e-7 && abs > 0)) {
return value.toExponential(6).replace(/\.?0+(e)/, '$1');
}
return parseFloat(value.toPrecision(12)).toString();
}
try {
return math.format(value as never, { precision: 10 });
} catch {
return String(value);
}
}
// Compilation cache for fast repeated graph evaluation
const compileCache = new Map<string, EvalFunction>();
function getCompiled(expr: string): EvalFunction | null {
if (!compileCache.has(expr)) {
try {
compileCache.set(expr, math.compile(expr) as EvalFunction);
} catch {
return null;
}
if (compileCache.size > 200) {
compileCache.delete(compileCache.keys().next().value!);
}
}
return compileCache.get(expr) ?? null;
}
export function evaluateAt(
expression: string,
x: number,
variables: Record<string, string>
): number {
const compiled = getCompiled(expression);
if (!compiled) return NaN;
try {
const scope = buildScope(variables);
const result = compiled.evaluate({ ...scope, x });
return typeof result === 'number' && isFinite(result) ? result : NaN;
} catch {
return NaN;
}
}
export interface GraphPoint {
x: number;
y: number;
}
export function sampleFunction(
expression: string,
xMin: number,
xMax: number,
numPoints: number,
variables: Record<string, string> = {}
): Array<GraphPoint | null> {
if (!expression.trim()) return [];
const compiled = getCompiled(expression);
if (!compiled) return [];
const scope = buildScope(variables);
const points: Array<GraphPoint | null> = [];
const step = (xMax - xMin) / numPoints;
let prevY: number | null = null;
const jumpThreshold = Math.abs(xMax - xMin) * 4;
for (let i = 0; i <= numPoints; i++) {
const x = xMin + i * step;
let y: number;
try {
const r = compiled.evaluate({ ...scope, x });
y = typeof r === 'number' ? r : NaN;
} catch {
y = NaN;
}
if (!isFinite(y) || isNaN(y)) {
if (points.length > 0 && points[points.length - 1] !== null) {
points.push(null);
}
prevY = null;
} else {
if (prevY !== null && Math.abs(y - prevY) > jumpThreshold) {
points.push(null);
}
points.push({ x, y });
prevY = y;
}
}
return points;
}

107
lib/calculate/store.ts Normal file
View File

@@ -0,0 +1,107 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const FUNCTION_COLORS = [
'#f472b6',
'#60a5fa',
'#4ade80',
'#fb923c',
'#a78bfa',
'#22d3ee',
'#fbbf24',
'#f87171',
];
export interface HistoryEntry {
id: string;
expression: string;
result: string;
error: boolean;
}
export interface GraphFunction {
id: string;
expression: string;
color: string;
visible: boolean;
}
interface CalculateStore {
expression: string;
history: HistoryEntry[];
variables: Record<string, string>;
graphFunctions: GraphFunction[];
setExpression: (expr: string) => void;
addToHistory: (entry: Omit<HistoryEntry, 'id'>) => void;
clearHistory: () => void;
setVariable: (name: string, value: string) => void;
removeVariable: (name: string) => void;
addGraphFunction: () => void;
updateGraphFunction: (
id: string,
updates: Partial<Pick<GraphFunction, 'expression' | 'color' | 'visible'>>
) => void;
removeGraphFunction: (id: string) => void;
}
const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
export const useCalculateStore = create<CalculateStore>()(
persist(
(set) => ({
expression: '',
history: [],
variables: {},
graphFunctions: [
{ id: 'init-1', expression: 'sin(x)', color: FUNCTION_COLORS[0], visible: true },
{ id: 'init-2', expression: 'cos(x)', color: FUNCTION_COLORS[1], visible: true },
],
setExpression: (expression) => set({ expression }),
addToHistory: (entry) =>
set((state) => ({
history: [{ ...entry, id: uid() }, ...state.history].slice(0, 50),
})),
clearHistory: () => set({ history: [] }),
setVariable: (name, value) =>
set((state) => ({ variables: { ...state.variables, [name]: value } })),
removeVariable: (name) =>
set((state) => {
const v = { ...state.variables };
delete v[name];
return { variables: v };
}),
addGraphFunction: () =>
set((state) => {
const used = new Set(state.graphFunctions.map((f) => f.color));
const color =
FUNCTION_COLORS.find((c) => !used.has(c)) ??
FUNCTION_COLORS[state.graphFunctions.length % FUNCTION_COLORS.length];
return {
graphFunctions: [
...state.graphFunctions,
{ id: uid(), expression: '', color, visible: true },
],
};
}),
updateGraphFunction: (id, updates) =>
set((state) => ({
graphFunctions: state.graphFunctions.map((f) =>
f.id === id ? { ...f, ...updates } : f
),
})),
removeGraphFunction: (id) =>
set((state) => ({
graphFunctions: state.graphFunctions.filter((f) => f.id !== id),
})),
}),
{ name: 'kit-calculate-v1' }
)
);

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 } 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") */
@@ -27,9 +27,9 @@ export const tools: Tool[] = [
href: '/color', href: '/color',
description: 'Interactive color manipulation and analysis tool.', description: 'Interactive color manipulation and analysis tool.',
summary: summary:
'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.', 'Modern color manipulation toolkit with palette generation and format conversion. Supports hex, RGB, HSL, Lab, and more.',
icon: ColorIcon, icon: ColorIcon,
badges: ['Open Source', 'WCAG', 'Free'], badges: ['Color', 'Palette', 'Format'],
}, },
{ {
shortTitle: 'Units', shortTitle: 'Units',
@@ -40,7 +40,7 @@ export const tools: Tool[] = [
summary: summary:
'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.', 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.',
icon: UnitsIcon, icon: UnitsIcon,
badges: ['Open Source', 'Real-time', 'Free'], badges: ['187 Units', '23 Categories', 'Real-time'],
}, },
{ {
shortTitle: 'ASCII', shortTitle: 'ASCII',
@@ -51,7 +51,7 @@ export const tools: Tool[] = [
summary: summary:
'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.', 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
icon: ASCIIIcon, icon: ASCIIIcon,
badges: ['Open Source', 'ASCII Art', 'Free'], badges: ['373 Fonts', 'ASCII Art', 'Terminal'],
}, },
{ {
shortTitle: 'Media', shortTitle: 'Media',
@@ -60,9 +60,9 @@ export const tools: Tool[] = [
href: '/media', href: '/media',
description: 'Browser-based media conversion for video, audio, and images.', description: 'Browser-based media conversion for video, audio, and images.',
summary: summary:
'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.', 'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads.',
icon: MediaIcon, icon: MediaIcon,
badges: ['Open Source', 'Converter', 'Free'], badges: ['WebAssembly', 'Privacy-first', 'Converter'],
}, },
{ {
shortTitle: 'Favicon', shortTitle: 'Favicon',
@@ -71,9 +71,9 @@ export const tools: Tool[] = [
href: '/favicon', href: '/favicon',
description: 'Create a complete set of icons for your website.', description: 'Create a complete set of icons for your website.',
summary: summary:
'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.', 'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code.',
icon: FaviconIcon, icon: FaviconIcon,
badges: ['Open Source', 'Generator', 'Free'], badges: ['PWA', 'Multi-size', 'Generator'],
}, },
{ {
shortTitle: 'QR Code', shortTitle: 'QR Code',
@@ -84,7 +84,7 @@ export const tools: Tool[] = [
summary: summary:
'Generate QR codes with live preview, customizable colors, error correction levels, and export as PNG or SVG. All processing happens locally in your browser.', 'Generate QR codes with live preview, customizable colors, error correction levels, and export as PNG or SVG. All processing happens locally in your browser.',
icon: QRCodeIcon, icon: QRCodeIcon,
badges: ['Open Source', 'Generator', 'Free'], badges: ['PNG & SVG', 'Customizable', 'Generator'],
}, },
{ {
shortTitle: 'Animate', shortTitle: 'Animate',
@@ -93,9 +93,42 @@ export const tools: Tool[] = [
href: '/animate', href: '/animate',
description: 'Visual editor for CSS keyframe animations with live preview.', description: 'Visual editor for CSS keyframe animations with live preview.',
summary: summary:
'Build and export CSS @keyframe animations visually. Configure timing, easing, transforms, and more — with a live preview and 20+ built-in presets. Export to plain CSS or Tailwind v4.', 'Build and export CSS @keyframe animations visually. Configure timing, easing, transforms, and more — with a live preview and 20+ built-in presets.',
icon: AnimateIcon, icon: AnimateIcon,
badges: ['Open Source', 'CSS', 'Free'], badges: ['CSS', 'Tailwind v4', '20+ Presets'],
},
{
shortTitle: 'Random',
title: 'Random Generator',
navTitle: 'Random Generator',
href: '/random',
description: 'Generate secure passwords, UUIDs, API keys and tokens.',
summary:
'Cryptographically secure random generator. Create passwords, UUIDs, API keys, SHA hashes, and secure tokens — all using the browser Web Crypto API, nothing leaves your machine.',
icon: RandomIcon,
badges: ['Web Crypto', 'Passwords', 'UUID', 'Hashes'],
},
{
shortTitle: 'Cron',
title: 'Cron Editor',
navTitle: 'Cron Editor',
href: '/cron',
description: 'Visual editor for cron expressions with live preview.',
summary:
'Build and validate cron expressions with an intuitive visual field editor. Get a human-readable description and preview the next upcoming scheduled runs.',
icon: CronIcon,
badges: ['Cron', 'Scheduler', 'Visual'],
},
{
shortTitle: 'Calculate',
title: 'Calculator',
navTitle: 'Calculator',
href: '/calculate',
description: 'Advanced expression evaluator with function graphing.',
summary:
'Powerful mathematical calculator powered by Math.js. Evaluate complex expressions, define variables, and plot functions on an interactive graph.',
icon: CalculateIcon,
badges: ['Math.js', 'Graphing', 'Interactive'],
}, },
]; ];

View File

@@ -4,3 +4,4 @@ export * from './urlSharing';
export * from './animations'; export * from './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,11 +25,11 @@
"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",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"mathjs": "^15.1.1",
"next": "^16.1.6", "next": "^16.1.6",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",

101
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
@@ -56,6 +53,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.575.0 specifier: ^0.575.0
version: 0.575.0(react@19.2.4) version: 0.575.0(react@19.2.4)
mathjs:
specifier: ^15.1.1
version: 15.1.1
next: next:
specifier: ^16.1.6 specifier: ^16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -257,6 +257,10 @@ packages:
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0-0 '@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6': '@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -1938,6 +1942,9 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'} engines: {node: '>=20'}
complex.js@2.4.3:
resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -2035,6 +2042,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dedent@1.7.1: dedent@1.7.1:
resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==}
peerDependencies: peerDependencies:
@@ -2173,6 +2183,9 @@ packages:
escape-html@1.0.3: escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-latex@1.2.0:
resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
escape-string-regexp@4.0.0: escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2416,19 +2429,8 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
framer-motion@12.34.3: fraction.js@5.3.4:
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
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==}
@@ -2806,6 +2808,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@@ -3051,6 +3056,11 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
mathjs@15.1.1:
resolution: {integrity: sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==}
engines: {node: '>= 18'}
hasBin: true
media-typer@1.1.0: media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3100,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==}
@@ -3558,6 +3562,9 @@ packages:
scheduler@0.27.0: scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
semver@6.3.1: semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
@@ -3773,6 +3780,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -3853,6 +3863,10 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typed-function@4.2.2:
resolution: {integrity: sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==}
engines: {node: '>= 18'}
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -4218,6 +4232,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/runtime@7.28.6': {}
'@babel/template@7.28.6': '@babel/template@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
@@ -5911,6 +5927,8 @@ snapshots:
commander@14.0.3: {} commander@14.0.3: {}
complex.js@2.4.3: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
content-disposition@1.0.1: {} content-disposition@1.0.1: {}
@@ -5988,6 +6006,8 @@ snapshots:
decamelize@1.2.0: {} decamelize@1.2.0: {}
decimal.js@10.6.0: {}
dedent@1.7.1: {} dedent@1.7.1: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
@@ -6172,6 +6192,8 @@ snapshots:
escape-html@1.0.3: {} escape-html@1.0.3: {}
escape-latex@1.2.0: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-next@15.1.7(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): eslint-config-next@15.1.7(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3):
@@ -6538,14 +6560,7 @@ snapshots:
forwarded@0.2.0: {} forwarded@0.2.0: {}
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): fraction.js@5.3.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: {}
@@ -6883,6 +6898,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
set-function-name: 2.0.2 set-function-name: 2.0.2
javascript-natural-sort@0.7.1: {}
jiti@2.6.1: {} jiti@2.6.1: {}
jose@6.1.3: {} jose@6.1.3: {}
@@ -7124,6 +7141,18 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mathjs@15.1.1:
dependencies:
'@babel/runtime': 7.28.6
complex.js: 2.4.3
decimal.js: 10.6.0
escape-latex: 1.2.0
fraction.js: 5.3.4
javascript-natural-sort: 0.7.1
seedrandom: 3.0.5
tiny-emitter: 2.1.0
typed-function: 4.2.2
media-typer@1.1.0: {} media-typer@1.1.0: {}
merge-descriptors@2.0.0: {} merge-descriptors@2.0.0: {}
@@ -7161,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):
@@ -7704,6 +7727,8 @@ snapshots:
scheduler@0.27.0: {} scheduler@0.27.0: {}
seedrandom@3.0.5: {}
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.4: {} semver@7.7.4: {}
@@ -8015,6 +8040,8 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
tiny-emitter@2.1.0: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinyexec@1.0.2: {} tinyexec@1.0.2: {}
@@ -8113,6 +8140,8 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typed-function@4.2.2: {}
typescript@5.9.3: {} typescript@5.9.3: {}
unbox-primitive@1.1.0: unbox-primitive@1.1.0: