Compare commits

...

172 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
36c02cea55 polish: unit labels in parentheses, export panel styling, remove unused gradient utilities
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:38:48 +01:00
0f5e67a007 fix: remove duplicate className attribute in AnimationSettings cubic-bezier labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:35:57 +01:00
d0e8ae322f style: move units into label text in KeyframeProperties SliderRow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:33:03 +01:00
0e95b7e543 fix: track animation ended state and wire preview controls correctly
- Replace boolean paused with AnimState ('playing'|'paused'|'ended')
- Use onAnimationEnd to detect when finite animations finish
- Play re-enables after end and restarts the animation (replay)
- Pause only active while playing; Restart always available
- Config changes auto-restart preview so edits are instantly visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:17:20 +01:00
27c7372a31 style: match ToggleGroup style to QR preview (outline variant, h-6 compact items)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:13:47 +01:00
1a517c4655 style: replace speed Select with ToggleGroup in AnimationPreview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:12:04 +01:00
f4ee557e26 fix: preset thumbnails no longer conflict with main preview animation
- Inject only @keyframes (not .animated class rule) per preset thumbnail
  so the main preview's .animated rule cannot override them
- Drive thumbnail animation entirely via inline style properties
- Remove isActive/currentName — presets should never appear selected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:08:22 +01:00
eeef3283c8 feat: add CSS Animation Editor tool
Comprehensive visual editor for CSS @keyframe animations:
- AnimationSettings: name, duration, delay, easing (incl. cubic-bezier), iteration, direction, fill-mode
- KeyframeTimeline: drag-to-reposition keyframe markers, click-track to add, delete selected
- KeyframeProperties: per-keyframe transform (translate/rotate/scale/skew), opacity, background-color, border-radius, blur, brightness via sliders
- AnimationPreview: live preview on box/circle/text element with play/pause/restart and speed control (0.25×–2×)
- PresetLibrary: 22 presets across Entrance/Exit/Attention/Special categories with animated thumbnails
- ExportPanel: plain CSS and Tailwind v4 @utility formats with copy and download

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 14:17:04 +01:00
4a0aa85859 fix: popover color 2026-02-28 13:46:36 +01:00
6a586b936a style: replace emoji heart with primary Heart icon and polish Valknar link
- Swap 💜 for a filled Lucide Heart icon in text-primary in both Footer and AppSidebar
- Style Valknar link with animated underline (decoration-primary on hover)
- Add sidebar footer with copyright, Heart, Valknar link, and GitFork source link
- Add author field to package.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 12:20:28 +01:00
0d731e56da fix: sidebar bg opacity 2026-02-28 12:01:37 +01:00
c9c7d22766 seo: add meta description (tool.summary) to all tool pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 10:34:42 +01:00
bc9e30c918 style: constrain description width in AppPage; add description to sidebar items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 10:01:33 +01:00
28747a6c8f refactor: extract ColorManipulation component and pass icon/summary to AppPage
- Rename ColorPage → ColorManipulation (no AppPage wrapper inside)
- Move AppPage + title/description/icon to color/page.tsx, consistent with other tools
- AppPage now accepts icon prop directly; removes internal usePathname lookup and 'use client'
- All tool pages pass tool.summary as description and tool.icon as icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 09:57:06 +01:00
82649f6674 fix: remove sidebar tool description 2026-02-28 09:09:16 +01:00
f917891a31 feat: add QR code generator tool
Add a sixth tool with live SVG preview, customizable foreground/background
colors, error correction level, margin control, and export as PNG (256–2048px)
or SVG. URL params enable shareable state. All processing runs client-side
via the qrcode package.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 00:58:57 +01:00
695ba434e2 feat: add comment wrapping to ASCII art tool
Add comment style selector (shadcn Select) to wrap generated ASCII art
with language-appropriate comment syntax (// # -- ; /* */ <!-- --> """).
Refactor preview controls to use shadcn ToggleGroup, Tooltip, and Badge.
Alignment is disabled when a comment style is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:11:25 +01:00
a400f694fe refactor: externalize tool definitions and polish app shell
- Create lib/tools.tsx as single source of truth for all tool metadata
  (title, shortTitle, navTitle, description, summary, icon, etc.)
- Update AppSidebar to render nav from centralized tools list with
  descriptions, remove collapse footer button
- Update AppHeader with sidebar collapse toggle, tool short title,
  and app logo; remove breadcrumbs
- Update AppPage to auto-resolve tool icon from pathname
- Update ToolsGrid/ToolCard to use shared tools data, remove per-card
  gradients for uniform styling
- Add per-tool HTML title via metadata exports (title template in root
  layout)
- Style landing page and 404 headings with primary theme color
- Add Toolbox icon to hero CTA, GitFork icon link in footer
- Remove footer from error page and "View on Dev" buttons
- Extract ColorPage client component for RSC metadata compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:46:54 +01:00
5a0d1863ec chore: cleanup 2026-02-27 16:34:27 +01:00
83586c8bbb fix: export menu buttons size 2026-02-27 13:53:08 +01:00
bd08951717 fix: popover color 2026-02-27 13:49:25 +01:00
ee7e5ec06c refactor: streamline, refine and polish 2026-02-27 12:51:28 +01:00
efe3c81576 chore: cleanup 2026-02-27 08:47:44 +01:00
782923f2e0 feat: refactor theme, add tailwind-scrollbar, and improve UI components
- Removed manual theme switching logic and ThemeProvider
- Installed and configured tailwind-scrollbar plugin
- Updated FileConverter and ConversionOptions to use shadcn Input
- Refactored FontSelector to use shadcn Tabs
- Simplified global styles and adjusted glassmorphic effects
2026-02-26 22:22:32 +01:00
a3ef948600 docs: update README and GEMINI with favicon app and PWA info 2026-02-26 18:39:15 +01:00
283855d7a3 fix: icon.png size 2026-02-26 18:24:11 +01:00
c8ff0e5dae fix(media): use processed inputFile for SVG conversion 2026-02-26 18:20:02 +01:00
8a9ff3582f fix(media): handle SVG inputs using browser Canvas pre-conversion for ImageMagick WASM 2026-02-26 18:15:41 +01:00
f20cedffd5 feat: convert app to PWA with offline support and service worker 2026-02-26 18:01:33 +01:00
1d72f34b65 style: streamline media app upload component with favicon app styling 2026-02-26 17:52:41 +01:00
1f1b138089 feat: add Favicon Generator app with ImageMagick WASM support 2026-02-26 17:48:16 +01:00
d99c88df0e fix(media): truncate long filename in download button 2026-02-26 17:12:27 +01:00
0db8ea8773 refactor: remove favorite and copy features from units converter 2026-02-26 12:40:03 +01:00
e1406f427e refactor: rename figlet app to ascii and update all references 2026-02-26 12:31:10 +01:00
484423f299 refactor: rename pastel app to color and update all references 2026-02-26 12:19:22 +01:00
061ea1d806 feat: unify pastel application into single playground and remove standalone pages 2026-02-26 12:07:21 +01:00
225a9ad7fb fix: upload card 2026-02-25 21:44:34 +01:00
311d80bd41 fix: figlet text input card 2026-02-25 21:32:05 +01:00
84bc70b442 fix: ffmpeg import 2026-02-25 20:51:45 +01:00
3a1e0153ac Update postinstall script for FFmpeg 0.12.10
Update the postinstall script to copy from the correct FFmpeg core version path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:40:06 +01:00
40214d9748 Upgrade FFmpeg to latest version (0.12.10)
Updated @ffmpeg/core from 0.12.6 to 0.12.10 and updated CDN URL references.
The newer version may have better Turbopack compatibility.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:39:18 +01:00
87f1384175 Fix FFmpeg WASM loading: use CDN instead of local files
The 'Cannot find module as expression is too dynamic' error occurs at runtime
when FFmpeg tries to dynamically load the core module. Using CDN URLs bypasses
this bundler issue entirely since absolute URLs don't require bundler analysis.

Switched to jsdelivr CDN for FFmpeg core and WASM files - this is a proven
approach used by many projects.

Fixes: wasmLoader.ts runtime error on media conversion

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:36:45 +01:00
6d4426037c Fix FFmpeg import: use direct import like ImageMagick
Import FFmpeg at module level instead of dynamic import. The build now
compiles cleanly without Turbopack bundler warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:29:55 +01:00
3a8b409d1d Fix FFmpeg WASM bundler error: use runtime import instead of static analysis
The @ffmpeg/ffmpeg package has internal dynamic imports that Turbopack
cannot statically analyze, but they work fine at runtime. This change
moves the import to the loadFFmpeg function where it's needed, allowing
Turbopack to skip static analysis and let the bundler resolve it at runtime.

Fixes: Cannot find module as expression is too dynamic error

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:27:53 +01:00
b5812c97b4 fix: handle FFmpeg dynamic import more gracefully
- lazy-load FFmpeg class to avoid static analysis issues
- add proper null checks for FFmpeg instance
- use initFFmpeg() helper to manage async initialization
- fixes bundler 'too dynamic' error with static-friendly approach

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:24:24 +01:00
45a48abc91 fix: resolve FFmpeg WASM loader dynamic import error
- moved FFmpeg and initializeImageMagick imports to top level (static)
- removed dynamic imports that caused bundler analysis issues
- simplified ImageMagick initialization
- fixes: 'Cannot find module as expression is too dynamic' error

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 20:22:04 +01:00
dd71130977 fix: conversion options styling 2026-02-25 20:20:04 +01:00
b560dcbc8e refactor: streamline media converter UI and layout
- consolidated file upload and metadata display in single card
- replaced complex FormatSelector with simple shadcn Select component
- inlined all conversion options without toggle display
- restructured layout to 2-column grid matching pastel app pattern:
  - left column: upload and conversion options
  - right column: conversion results
- removed unused components (FileInfo, FormatSelector, ConversionOptionsPanel)
- cleaned up imports and simplified component hierarchy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-25 19:59:22 +01:00
56cdb1ae4a fix: remove media conversion presets 2026-02-25 19:20:22 +01:00
4668d771c1 style: remove emojis from media app format presets 2026-02-25 18:34:39 +01:00
4898ad942b style: remove emojis from media app quality presets 2026-02-25 18:30:23 +01:00
317a80dbad refactor: remove category colors from units app 2026-02-25 18:24:15 +01:00
f28a2d1eab refactor: replace html range input with shadcn slider in batch operations 2026-02-25 18:18:03 +01:00
7eeb8399b3 refactor: externalize AppPage component and streamline all tool pages 2026-02-25 18:04:32 +01:00
71c22e465e refactor: replace html textarea with shadcn Textarea component 2026-02-25 16:20:25 +01:00
40e0b0e375 refactor: streamline TextInput classes with shadcn input 2026-02-25 16:13:10 +01:00
880bce157b style: improve light theme border and input visibility 2026-02-25 16:09:29 +01:00
2fb2eaa54c refactor: use Card, CardTitle and CardContent in Figlet and Units apps 2026-02-25 16:00:10 +01:00
4ccf316184 refactor: use shadcn Card component in pastel app 2026-02-25 13:35:29 +01:00
57ba63aa32 fix: update nginx fallback to properly serve custom 404 page 2026-02-25 11:49:39 +01:00
0732c9c5e2 feat: add custom 404 page and update nginx error handling 2026-02-25 11:46:37 +01:00
9bee255647 style: rename Pastel (Color) to Pastel in sidebar 2026-02-25 11:23:03 +01:00
da7f60cf04 docs: update README with Media app information and project structure 2026-02-25 11:22:38 +01:00
5612176996 refactor: externalize app icons and update stats count 2026-02-25 11:14:24 +01:00
77e0114e96 refactor: load WASM from local assets via postinstall script 2026-02-25 10:52:59 +01:00
08e1cac3a0 feat: streamline WASM loading with local priority and cleanup UI 2026-02-25 10:44:49 +01:00
84cf6ecab0 feat: remove keyboard shortcuts from media app 2026-02-25 10:15:28 +01:00
fbc8cdeebe feat: add media converter app and fix compilation errors 2026-02-25 10:07:25 +01:00
1da6168f37 fix: sidebar logo 2026-02-24 19:19:34 +01:00
5f46ba8c74 fix: breadcrumb root 2026-02-24 19:14:58 +01:00
a604789285 fix: figlet cards padding 2026-02-24 19:05:22 +01:00
5d6ace4e46 fix: old styling with new shadcn components 2026-02-24 16:58:17 +01:00
9c6b184f7e refactor: update UI component usage to match latest shadcn APIs 2026-02-24 16:20:35 +01:00
bf4729fa4d feat: integrate icon.png as favicon and remove legacy icon.svg 2026-02-24 15:27:16 +01:00
d61add82cd feat: implement sort by hue for named colors using pastel-wasm 2026-02-24 10:45:00 +01:00
d65a7c6c30 feat: remove statistics from Distinct Colors generator 2026-02-24 09:45:09 +01:00
f779d4aa9d fix: remove color_spaces from CapabilitiesData to match implementation 2026-02-23 18:28:30 +01:00
3061260eec feat: remove colorspace option from pastel gradients (not supported by API) 2026-02-23 18:22:12 +01:00
ab930a3279 fix: oklab string format and debounced color history 2026-02-23 17:52:06 +01:00
2d59f3aaca docs: update README and repository structure 2026-02-23 17:43:38 +01:00
4e7fc24582 chore: update icon and units description 2026-02-23 17:41:03 +01:00
906b0e081b feat: create app/icon.png from app/icon.svg 2026-02-23 17:33:12 +01:00
95b270810b feat: use NEXT_PUBLIC_SITE_URL and remove redundant preconnect 2026-02-23 17:21:46 +01:00
b7d427023e feat: add Umami tracking support 2026-02-23 17:09:44 +01:00
4108ffc23f fix: app shell header logo display 2026-02-23 14:04:13 +01:00
43faed224f Feat: Refine UI and consolidate components
- Updated the heading in the Pastel page from 'Color Playground' to 'Pastel'.
- Adjusted the logo size in the Hero component for better visual balance.
- Removed duplicate/unused Footer components from pastel layout and ui directories.
2026-02-23 14:00:09 +01:00
5ab1387165 style: update Logo and Kit title styling in App Shell 2026-02-23 13:33:17 +01:00
facb7e5161 style: remove outer margins and scale elements in app/icon.svg 2026-02-23 13:21:59 +01:00
90b045f349 style(figlet): update default text and remove search keyboard hint 2026-02-23 09:46:35 +01:00
6fbcdd3674 docs: generate comprehensive new README 2026-02-23 09:28:57 +01:00
8ce12c4c70 style(pastel): unify card title sizes with figlet 2026-02-23 09:25:51 +01:00
fd2ada4438 refactor(pastel): remove keyboard shortcuts and useKeyboard hook 2026-02-23 08:41:32 +01:00
2160b9aaa0 style: unify keyboard shortcut hints across tools using Pastel style
- Add shortcut hints below tool descriptions in Figlet and Units pages
- Revert experimental button-based shortcut layout in Units
- Remove redundant shortcut hints from Units footer
- Ensure consistent kbd tag styling across the application
2026-02-23 08:27:26 +01:00
7806bcbede refactor: flatten Pastel routes by removing intermediate palettes and accessibility paths
- Move sub-routes from /pastel/palettes/* and /pastel/accessibility/* to direct children of /pastel
- Update AppSidebar navigation links
- Update Pastel Navbar and Footer links
- Update Tailwind source directives in pastel/globals.css
- Remove intermediate page files
2026-02-23 08:18:44 +01:00
3a100f8fde feat: move units search and category select to a row below description
- Relocate SearchUnits and Category Select into a dedicated row at the top of MainConverter
- Remove them from the converter card header for a cleaner layout
- Update vertical spacing for better visual hierarchy
2026-02-23 08:09:51 +01:00
93bbc2ef22 fix: improve robustness of search focus keyboard shortcut
- Update FontSelector to prevent '/' shortcut from triggering when another input is focused
- Ensure consistent behavior for '/' focus across Figlet and Units tools
2026-02-23 08:05:33 +01:00
81fa370ec9 style: update unit category colors to match Tailwind palette
- Harmonize category colors with standard Tailwind CSS 500 shades
- Use Rose, Violet, Emerald, Amber, Blue, and Indigo for grouping related units
- Synchronize hex values in units.ts with OKLCH variables in globals.css
2026-02-23 08:03:55 +01:00
d767f9714c style: improve icon alignment in figlet font selector tabs
- Switch from inline-block to flex items-center justify-center for filter tab buttons
- Adjust icon spacing for better visual balance
2026-02-23 08:01:48 +01:00
59ad5143eb style: unify button row styling in figlet font preview
- Update Copy, Share, PNG, and TXT button icons to h-3 w-3 with mr-2
- Matches the styling of the Random font button for a consistent UI
2026-02-23 08:00:34 +01:00
d9315ecf7d style: streamline inputs and selects with more visible borders
- Increase --border opacity in both light and dark modes
- Harmonize Input and Select components with consistent rounded-lg corners
- Replace native selects in MainConverter with styled Select component
2026-02-23 07:56:16 +01:00
9a95e97150 feat: align units converter header into a single row
- Move unit search into the CardHeader
- Align title, search, and category selector into one row using flexbox
- Update SearchUnits component to allow custom class names and remove hardcoded max-width
2026-02-23 07:51:39 +01:00
dbdd28d552 feat: move unit search input inside the converter card
- Relocate SearchUnits from a standalone position to inside the main converter card
- Add a subtle border-b separator for visual clarity within the card content
2026-02-23 07:50:03 +01:00
30f88c6f9d feat: replace category grid with select dropdown in units converter
- Consolidate category selection into the main converter card
- Use a space-saving Select component for category switching
- Add category color indicator to the select dropdown
2026-02-23 07:48:06 +01:00
e7cc825c54 feat: remove recent conversions functionality from units
- Delete ConversionHistory component
- Remove history-related logic and state from MainConverter
- Clean up history imports and types in CommandPalette and storage utilities
- Remove history storage functions from lib/units/storage.ts
2026-02-23 07:45:15 +01:00
d1c95254b0 fix: update nginx for proper Next.js deep linking support
- Prioritize .html in try_files to correctly resolve static export routes
2026-02-23 02:36:54 +01:00
d2dcd2ca9f fix: resolve linting errors and improve ESLint configuration
- Downgrade ESLint to v9 to avoid circular structure errors in v10 config validation
- Downgrade eslint-config-next to v15 for stability
- Configure eslint.config.mjs with FlatCompat and appropriate ignores (.next, out)
- Escape entities in ColorBlindPage and SearchUnits to fix react/no-unescaped-entities
- Use useMemo for debounced function in FigletConverter to fix react-hooks/exhaustive-deps
2026-02-23 02:31:49 +01:00
a9d0fd8443 refactor: streamline toast system and harmonize UI across tools
- Migrate all toast notifications to sonner and remove custom ToastProvider
- Align Card and TextInput styling across Figlet and Pastel (rounded-lg, border-based)
- Fix build error by removing non-existent export in lib/units/index.ts
- Clean up unused Figlet components and constants
2026-02-23 02:04:46 +01:00
09838a203c refactor: consolidate utilities, clean up components, and improve theme persistence
- Consolidate common utilities (cn, format, time) into lib/utils
- Remove redundant utility files from pastel and units directories
- Clean up unused components (Separator, KeyboardShortcutsHelp)
- Relocate CommandPalette to components/units/ui/
- Force dark mode on landing page and improve theme persistence logic
- Add FOUC prevention script to RootLayout
- Fix sidebar height constraint in AppShell
2026-02-23 00:40:45 +01:00
2000623c67 feat: implement Figlet, Pastel, and Unit tools with a unified layout
- Add Figlet text converter with font selection and history
- Add Pastel color palette generator and manipulation suite
- Add comprehensive Units converter with category-based logic
- Introduce AppShell with Sidebar and Header for navigation
- Modernize theme system with CSS variables and new animations
- Update project configuration and dependencies
2026-02-22 21:35:53 +01:00
583 changed files with 346258 additions and 1368 deletions

7
.gitignore vendored
View File

@@ -31,6 +31,13 @@ yarn-error.log*
# vercel
.vercel
# wasm binaries
/public/wasm/*
# typescript
*.tsbuildinfo
next-env.d.ts
# agents
.github
.claude

11
.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

77
CLAUDE.md Normal file
View File

@@ -0,0 +1,77 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Kit UI is a static-export toolkit (Next.js 16, React 19, TypeScript strict) with five browser-based tools: Color, Units, ASCII, Media, and Favicon. All heavy processing runs client-side via WebAssembly. Deployed at kit.pivoine.art.
## Commands
```bash
pnpm dev # Dev server with Turbopack (localhost:3000)
pnpm build # Static export to /out
pnpm lint # ESLint (next/core-web-vitals)
pnpm postinstall # Copies WASM binaries to public/wasm/ (runs automatically on install)
```
There are no test suites. Use `pnpm build` to verify changes compile correctly.
## Architecture
### Routing (App Router)
```
app/
├── page.tsx # Landing page (dark mode forced)
├── (app)/layout.tsx # Wraps all tools with Providers + AppShell
├── (app)/color/ # Color manipulation (@valknarthing/pastel-wasm)
├── (app)/units/ # Unit converter (187+ units, 23 categories)
├── (app)/ascii/ # ASCII art (373 figlet fonts)
├── (app)/media/ # File converter (FFmpeg + ImageMagick WASM)
└── (app)/favicon/ # Favicon/PWA asset generator
```
### Code Organization
Each tool follows a mirrored structure across three directories:
- **`app/(app)/{tool}/page.tsx`** — Route entry point
- **`components/{tool}/`** — UI components for the tool
- **`lib/{tool}/`** — Business logic, WASM wrappers, stores, and utilities
- **`types/`** — Shared TypeScript definitions
Shared UI primitives live in `components/ui/` (shadcn/ui, customized with glassmorphic styling). Layout shell in `components/layout/`.
### State Management (three layers)
1. **URL params** — Primary for shareable state (`useSearchParams` / `useRouter().push`)
2. **React Query** — Async WASM computations with caching
3. **Zustand** — Per-tool client stores in `lib/{tool}/`
4. **localStorage** — Persistence for theme, favorites, history
### WASM Integration
WASM modules (FFmpeg, ImageMagick, pastel-wasm) are lazy-loaded on first use. Binaries live in `public/wasm/` and are copied there by the postinstall script — don't move them manually. WASM logic is browser-only; do not attempt to run it in Node.
## Conventions
- **`'use client'`** only where needed (WASM, browser APIs, interactive state). Default to RSC.
- **Styling**: Tailwind CSS 4 with CSS-first config in `app/globals.css`. Use `cn()` from `lib/utils/cn.ts` for conditional classes. Use `@utility glass` for glassmorphic effects and gradient utilities (`gradient-purple-blue`, etc.).
- **Icons**: Lucide React exclusively.
- **Imports**: Use `@/` path alias (resolves to project root).
- **Components**: shadcn/ui from `components/ui/` as building blocks.
- **Logic/UI separation**: Business logic in `lib/`, UI in `components/`. Keep them separate.
## Deployment
Static export (`output: 'export'` in next.config.ts) served by Nginx via Docker. No Node.js runtime in production.
```bash
docker build -t kit-ui .
docker run -p 80:80 kit-ui
```
## PWA
Service worker at `public/sw.js` pre-caches core assets and WASM binaries. Manifest generated from `app/manifest.ts` (force-static). Cache version: `kit-ui-v1`.

View File

@@ -3,6 +3,17 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Add build arguments for Umami and Site URL
ARG UMAMI_SCRIPT
ARG UMAMI_ID
ARG NEXT_PUBLIC_SITE_URL
# Set environment variables for the build process
ENV UMAMI_SCRIPT=$UMAMI_SCRIPT
ENV UMAMI_ID=$UMAMI_ID
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NODE_ENV=production
# Copy package files
COPY package.json pnpm-lock.yaml* ./

71
GEMINI.md Normal file
View File

@@ -0,0 +1,71 @@
# GEMINI.md - Kit UI Context
This file provides foundational context and instructions for Gemini CLI when working in the `kit-ui` workspace.
## 🚀 Project Overview
**Kit UI** is a high-performance, aesthetically pleasing toolkit built with **Next.js 16**, **React 19**, and **Tailwind CSS 4**. It provides five core specialized applications:
1. **Color**: Advanced color theory, manipulation, and accessibility suite powered by `@valknarthing/pastel-wasm`.
2. **Units**: Smart unit converter supporting 187+ units across 23 categories, including a custom `tempo` measure.
3. **ASCII**: ASCII Art generator with 373 fonts and multi-format export.
4. **Media**: Browser-based file converter using **FFmpeg** and **ImageMagick** via WebAssembly.
5. **Favicon**: Asset generator for web icons, PWA manifests, and HTML snippet headers.
**PWA Capabilities**: The toolkit is a fully functional Progressive Web App with service worker integration for offline usage of WASM-based tools.
## 🛠️ Tech Stack & Architecture
- **Framework**: Next.js 16 (App Router, Static Export).
- **Library**: React 19.
- **Styling**: Tailwind CSS 4 (CSS-first configuration in `app/globals.css`).
- **Animations**: Framer Motion 12.
- **State Management**: Zustand & React Query.
- **Performance**: Heavy logic (Color, Media) is offloaded to **WebAssembly (WASM)**.
- **UI Components**: shadcn/ui (customized for a glassmorphic aesthetic).
## 📁 Project Structure
```bash
.
├── app/ # Next.js App Router
│ ├── (app)/ # Core Tool Pages (color, units, ascii, media, favicon)
│ ├── manifest.ts # PWA manifest generation
│ └── globals.css # Tailwind 4 configuration & global styles
├── components/ # UI Components
│ ├── [tool]/ # Tool-specific components (e.g., components/color/, components/favicon/)
│ ├── layout/ # AppShell, Sidebar, Header
│ └── ui/ # Base Atomic Components (shadcn)
├── lib/ # Business Logic
│ ├── [tool]/ # Tool-specific logic & WASM wrappers
│ └── utils/ # General utilities (cn, format, etc.)
├── public/ # Static assets
│ ├── sw.js # Service worker for PWA offline support
│ ├── wasm/ # WASM binaries (ffmpeg, imagemagick)
│ └── fonts/ # ASCII fonts (.flf)
└── types/ # TypeScript definitions
```
## ⚙️ Development Workflows
### Key Commands
- **Development**: `pnpm dev` (Uses Next.js Turbopack).
- **Build**: `pnpm build` (Generates a static export in `/out`).
- **Lint**: `pnpm lint`.
- **WASM Setup**: `pnpm postinstall` (Automatically copies WASM binaries to `public/wasm/`).
### Coding Standards
1. **Tailwind 4**: Use the new CSS-first approach. Avoid `tailwind.config.js`. Define theme variables and utilities in `app/globals.css`.
2. **Glassmorphism**: Use the `@utility glass` for translucent components.
3. **WASM Orchestration**: Heavy processing should stay in `lib/[tool]/` and utilize WASM where possible. Refer to `lib/media/wasm/wasmLoader.ts` for pattern-loading FFmpeg/ImageMagick.
4. **Client-Side Only**: Since this is a static export toolkit that relies on browser APIs (WASM, File API), ensure components using these are marked with `'use client'`.
5. **Icons**: Exclusively use `lucide-react`.
## 🧠 Strategic Instructions for Gemini
- **Surgical Updates**: When modifying tools, ensure the logic remains in `lib/` and the UI in `components/`.
- **WASM Handling**: Do not attempt to run WASM-dependent logic in the terminal/Node environment unless specifically configured. These tools are designed for the browser.
- **Styling**: Adhere to the `glass` and gradient utilities (`gradient-purple-blue`, etc.) defined in `app/globals.css`.
- **Component Consistency**: Use shadcn components from `components/ui/` as the building blocks for new features.

314
README.md
View File

@@ -1,191 +1,175 @@
# Kit Landing Page
# 🛠️ Kit UI
A stylish, animated landing page for [kit.pivoine.art](https://kit.pivoine.art) - your creative toolkit.
[![Production](https://img.shields.io/badge/Production-kit.pivoine.art-blue?style=for-the-badge)](https://kit.pivoine.art)
[![Next.js](https://img.shields.io/badge/Next.js-16-black?style=for-the-badge&logo=next.js)](https://nextjs.org)
[![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)
## Features
**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.
-**Animated UI** - Smooth animations with Framer Motion
- 🎨 **Modern Design** - Glassmorphism effects, gradients, and animated backgrounds
- 📱 **Responsive** - Mobile-first design that works on all devices
-**Fast** - Static export with Next.js 16 and Turbopack for optimal performance
- 🎯 **SEO Optimized** - Proper meta tags and semantic HTML
- 🚀 **Production Ready** - Docker support with Nginx
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.
## Tech Stack
---
- **Next.js 16** - React framework with App Router and Turbopack
- **React 19** - Latest React with modern features
- **Tailwind CSS 4** - Utility-first CSS with CSS-first configuration
- **Framer Motion** - Professional animation library
- **TypeScript 5** - Type-safe development
- **ESLint 9** - Latest linting with flat config
- **pnpm** - Fast, efficient package manager
## 🚀 The Toolkit
## Getting Started
Kit UI currently ships **8 tools**:
### 🎨 [Color](./app/(app)/color) — Color Manipulation
Modern color manipulation toolkit with palette generation and format conversion.
- **Color Playground**: Interactive HSL/RGB/HEX manipulation with real-time analysis.
- **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.
- **WASM Powered**: Utilizes `@valknarthing/pastel-wasm` for high-performance color calculations.
### 📐 [Units](./app/(app)/units) — Units Converter
Smart unit converter with 187 units across 23 categories.
- **187+ Units**: Length, Mass, Temperature, Force, Digital Storage, and more.
- **Real-time Bidirectional Conversion**: Instant results as you type with fuzzy search.
- **Favorites & History**: Save your most-used conversions for instant access.
### ✍️ [ASCII](./app/(app)/ascii) — ASCII Art Generator
Create stunning text banners, terminal art, and retro designs.
- **373 Fonts**: From classic `Standard` to complex 3D and cursive styles.
- **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.
### 🎬 [Media](./app/(app)/media) — Media Converter
Privacy-first, local-only media conversion powered by WebAssembly.
- **Video & Audio**: Transcode between MP4, WebM, MP3, WAV, and more using FFmpeg.
- **Image Processing**: Convert and resize PNG, JPG, WebP, and SVG via ImageMagick.
- **Zero Server Uploads**: All processing happens locally in your browser.
### 🌐 [Favicon](./app/(app)/favicon) — Favicon Generator
Complete favicon and PWA asset generation for modern web presence.
- **Complete Icon Set**: Standard favicons, Apple Touch icons, and Android Chrome icons.
- **PWA Manifest**: Automatically generates a standards-compliant `site.webmanifest`.
- **HTML Snippets**: Copy-paste ready `<head>` tags for easy integration.
### 🔲 [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.
---
## ✨ Core Features
- 🎭 **Glassmorphic UI**: A modern, translucent aesthetic with smooth Framer Motion transitions.
-**Turbopack Optimized**: Built for speed using the latest Next.js 16 Turbopack engine.
- 📱 **Full PWA Support**: Installable application with offline usage capabilities and service worker integration.
-**Accessibility First**: Integrated tools to ensure your designs meet global standards.
- 📱 **Responsive & Fluid**: Tailored experience across mobile, tablet, and desktop.
- 🛠️ **Developer Friendly**: Keyboard shortcuts, command palettes, and URL-state sharing.
- 🐳 **Production Ready**: Full Docker & Nginx integration for seamless deployment.
---
## 💻 Tech Stack
- **Framework**: [Next.js 16](https://nextjs.org) (App Router, Static Export)
- **Library**: [React 19](https://react.dev)
- **Styling**: [Tailwind CSS 4](https://tailwindcss.com) (CSS-first configuration)
- **Animations**: [Framer Motion](https://www.framer.com/motion/)
- **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)
- **Type Safety**: [TypeScript 5](https://www.typescriptlang.org)
---
## 🏗️ Project Structure
```bash
.
├── app/ # Next.js App Router (Pages & Layouts)
│ ├── (app)/ # Tool pages (color, units, ascii, media, favicon, qrcode, animate, calculate)
│ ├── manifest.ts # PWA manifest generation
│ └── api/ # Backend API routes
├── components/ # Reusable UI & Logic Components
│ ├── color/ # Color-specific components
│ ├── units/ # Converter-specific components
│ ├── ascii/ # ASCII-specific components
│ ├── media/ # Media conversion 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.)
├── lib/ # Business Logic & Utilities
│ ├── color/ # WASM wrappers & Color logic
│ ├── units/ # Conversion algorithms
│ ├── ascii/ # Font loading & ASCII generation
│ ├── media/ # FFmpeg & ImageMagick WASM orchestration
│ ├── 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
├── Dockerfile # Multi-stage Docker build
└── nginx.conf # Production Nginx configuration
```
---
## 🛠️ Development & Deployment
### Prerequisites
- **Node.js**: 20.x or higher
- **pnpm**: 9.x or higher
- Node.js 20+
- pnpm (via corepack)
### Development
### Local Development
```bash
# Install dependencies
# Clone and install
pnpm install
# Run development server
# Start development server with Turbopack
pnpm dev
```
# Build for production
### Production Build
```bash
# Build for static export
pnpm build
# Preview production build locally
pnpm start
# The output will be in the /out directory
```
Visit [http://localhost:3000](http://localhost:3000) to see the site.
## Docker Deployment
### Using Pre-built Image from GHCR
The Docker image is automatically built and published to GitHub Container Registry on every push to main:
### Docker Deployment
```bash
# Pull and run the latest image
docker pull ghcr.io/valknarness/kit-ui:latest
docker run -p 80:80 ghcr.io/valknarness/kit-ui:latest
# Build locally
docker build -t kit-ui .
# Run with Nginx
docker run -p 80:80 kit-ui
```
### Build Locally
---
Or build and run locally:
## 📈 Performance & Optimization
```bash
# Build the image
docker build -t kit-landing .
- **Static Site Generation (SSG)**: Entire toolkit is exported as static HTML/JS for sub-second load times.
- **Client-Side WASM**: Complex processing (FFmpeg, ImageMagick, Color) is offloaded to WebAssembly for native-level performance without server latency.
- **CSS-First Configuration**: Leveraging Tailwind 4's native CSS variables for zero-runtime styling overhead.
- **Automatic CI/CD**: GitHub Actions pipeline for multi-architecture Docker builds.
# Run the container
docker run -p 80:80 kit-landing
```
---
### Docker Compose
## 📝 License
For production deployment, see `/home/valknar/Projects/docker-compose/kit/compose.yaml`.
### Available Tags
- `latest` - Latest build from main branch
- `main` - Main branch builds
- `v*` - Semantic version tags (e.g., `v1.0.0`)
- `<branch>-<sha>` - Branch-specific builds with commit SHA
## Project Structure
```
.
├── app/
│ ├── layout.tsx # Root layout with metadata
│ ├── page.tsx # Home page
│ └── globals.css # Global styles and utilities
├── components/
│ ├── AnimatedBackground.tsx # Animated gradient background
│ ├── Hero.tsx # Hero section with logo
│ ├── Logo.tsx # Animated SVG logo
│ ├── ToolCard.tsx # Tool card component
│ ├── ToolsGrid.tsx # Grid of available tools
│ └── Footer.tsx # Footer component
├── public/ # Static assets
├── Dockerfile # Multi-stage Docker build
├── nginx.conf # Nginx configuration
└── next.config.ts # Next.js configuration
```
## Customization
### Adding New Tools
Edit `components/ToolsGrid.tsx` and add a new tool object to the `tools` array:
```typescript
{
title: 'Tool Name',
description: 'Tool description',
url: 'https://tool.kit.pivoine.art',
gradient: 'gradient-purple-blue', // or 'gradient-cyan-purple'
icon: (
// Your SVG icon here
),
}
```
### Styling
Tailwind CSS 4 uses a new CSS-first configuration approach:
- **Theme customization**: Edit the `@theme` block in `app/globals.css`
- **Custom utilities**: Add `@utility` blocks in `app/globals.css`
- **Animations**: Define keyframes directly in the `@theme` block
- **Colors & fonts**: Configure via CSS custom properties in `@theme`
## Available Tools
- **Pastel** - Modern color manipulation toolkit with palette generation and accessibility testing
- **Units** - Smart unit converter with 187 units across 23 categories (length, mass, temperature, etc.)
- **Figlet** - ASCII art text generator with 373 fonts (text banners, terminal art, retro designs)
## CI/CD Pipeline
The project uses GitHub Actions for automated Docker image builds:
### Workflow Features
-**Automated builds** on push to main and tags
-**Multi-architecture support** (linux/amd64, linux/arm64)
-**GitHub Container Registry** (GHCR) publishing
-**Build caching** for faster builds
-**Artifact attestation** for supply chain security
-**Semantic versioning** support
### Triggering Builds
```bash
# Automatic build on push to main
git push origin main
# Create a versioned release
git tag v1.0.0
git push origin v1.0.0
# Manual trigger via GitHub Actions UI
# Go to Actions → Build and Push Docker Image → Run workflow
```
### Using the Published Image
```bash
# Latest from main branch
docker pull ghcr.io/valknarness/kit-ui:latest
# Specific version
docker pull ghcr.io/valknarness/kit-ui:v1.0.0
# Specific commit
docker pull ghcr.io/valknarness/kit-ui:main-abc1234
```
## Performance
- Static export for fast loading
- Optimized images and assets
- Gzip compression via Nginx
- Proper caching headers
## License
Private project - All rights reserved.
## Author
Created for [pivoine.art](https://pivoine.art)
© 2026 [pivoine.art](https://pivoine.art). All rights reserved.

View File

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

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

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

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

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

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

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

View File

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

17
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,17 @@
import AnimatedBackground from '@/components/AnimatedBackground';
import { AppShell } from '@/components/layout/AppShell';
import { Providers } from '@/components/providers/Providers';
export default function AppLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<Providers>
<AppShell>
{children}
</AppShell>
</Providers>
);
}

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

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

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

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

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

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

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

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

29
app/api/fonts/route.ts Normal file
View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export const dynamic = 'force-static';
export async function GET() {
try {
const fontsDir = path.join(process.cwd(), 'public/fonts/ascii-fonts');
const files = fs.readdirSync(fontsDir);
const fonts = files
.filter(file => file.endsWith('.flf'))
.map(file => {
const name = file.replace('.flf', '');
return {
name,
fileName: file,
path: `/fonts/ascii-fonts/${file}`,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json(fonts);
} catch (error) {
console.error('Error reading fonts directory:', error);
return NextResponse.json({ error: 'Failed to load fonts' }, { status: 500 });
}
}

View File

@@ -1,12 +1,32 @@
@import "tailwindcss";
@plugin "tailwind-scrollbar";
@source "../components/*.{js,ts,jsx,tsx}";
@source "../components/ui/*.{js,ts,jsx,tsx}";
@source "*.{js,ts,jsx,tsx}";
@custom-variant hover (&:hover);
@theme {
--color-background: #0a0a0f;
--color-foreground: #ffffff;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@@ -15,6 +35,10 @@
--animate-gradient: gradient 8s linear infinite;
--animate-float: float 3s ease-in-out infinite;
--animate-glow: glow 2s ease-in-out infinite alternate;
--animate-fade-in: fadeIn 0.3s ease-in-out;
--animate-slide-up: slideUp 0.4s ease-out;
--animate-slide-down: slideDown 0.4s ease-out;
--animate-scale-in: scaleIn 0.2s ease-out;
@keyframes gradient {
0%, 100% {
@@ -40,20 +64,93 @@
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6), 0 0 40px rgba(139, 92, 246, 0.3);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
}
@keyframes logoStamp {
0% { opacity: 0; transform: scale(2) rotate(15deg); }
38% { opacity: 1; transform: scale(0.82) rotate(-5deg); }
58% { transform: scale(1.14) rotate(3deg); }
74% { transform: scale(0.94) rotate(-1deg); }
88% { transform: scale(1.04) rotate(0.3deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes pathFlicker {
0% { opacity: 0; }
28%, 30% { opacity: 0; }
31%, 33% { opacity: 1; }
34%, 40% { opacity: 0; }
41%, 44% { opacity: 1; }
45%, 49% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 1; }
}
:root {
/* CORPORATE DARK THEME (The Standard) */
--background: #0a0a0f;
--foreground: #ffffff;
--card: rgba(255, 255, 255, 0.03);
--card-foreground: #ffffff;
--popover: #363665;
--popover-foreground: #ffffff;
--primary: #8b5cf6;
--primary-foreground: #ffffff;
--secondary: rgba(255, 255, 255, 0.05);
--secondary-foreground: #ffffff;
--muted: rgba(255, 255, 255, 0.05);
--muted-foreground: #a1a1aa;
--accent: rgba(255, 255, 255, 0.08);
--accent-foreground: #ffffff;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: rgba(255, 255, 255, 0.15);
--input: rgba(255, 255, 255, 0.05);
--ring: rgba(139, 92, 246, 0.5);
--radius: 1rem;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent hover:scrollbar-thumb-primary/40;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Fix native select dropdown styling */
select option {
@apply bg-popover text-popover-foreground;
}
}
html {
scroll-behavior: smooth;
}
body {
color: var(--color-foreground);
background: var(--color-background);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
@@ -73,24 +170,8 @@ body {
}
@utility glass {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
@utility gradient-purple-blue {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
@utility gradient-cyan-purple {
background: linear-gradient(135deg, #2dd4bf 0%, #8b5cf6 100%);
}
@utility gradient-indigo-purple {
background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
}
@utility gradient-yellow-amber {
background: linear-gradient(135deg, #eab308 0%, #f59e0b 100%);
}
background: var(--card);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--border);
}

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -1,55 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="64" height="64" rx="12" fill="url(#bg)"/>
<!-- Wrench (Lucide) - vertical -->
<g transform="translate(32, 32) rotate(0) scale(2.4) translate(-12, -12)">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
stroke="url(#wrench)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</g>
<!-- Brush (Lucide) - horizontal flipped -->
<g transform="translate(32, 31) rotate(90) scale(2.4) translate(-12, -12)">
<path
d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"
stroke="url(#brush)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
<path
d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
stroke="url(#brush)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
vector-effect="non-scaling-stroke"
/>
</g>
<!-- Gradients -->
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1a1a2e"/>
<stop offset="100%" stop-color="#0f0f1a"/>
</linearGradient>
<linearGradient id="wrench" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#667eea"/>
<stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
<linearGradient id="brush" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#ec4899"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,35 +1,20 @@
import type { Metadata } from 'next';
import './globals.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export const metadata: Metadata = {
title: 'Kit - Your Creative Toolkit',
description: 'A curated collection of creative and utility tools for developers and creators. Features file conversion, image editing, and color manipulation.',
keywords: ['tools', 'utilities', 'file converter', 'image editor', 'color palette', 'creative toolkit', 'convert', 'paint', 'pastel', 'open source'],
authors: [{ name: 'pivoine.art' }],
creator: 'pivoine.art',
publisher: 'pivoine.art',
metadataBase: new URL('https://kit.pivoine.art'),
openGraph: {
title: 'Kit - Your Creative Toolkit',
description: 'A curated collection of creative and utility tools for developers and creators. Privacy-first, open source, and free to use.',
url: 'https://kit.pivoine.art',
siteName: 'Kit',
locale: 'en_US',
type: 'website',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Kit - Your Creative Toolkit',
},
],
title: {
default: 'Kit - Your Creative Toolkit',
template: '%s | Kit',
},
twitter: {
card: 'summary_large_image',
title: 'Kit - Your Creative Toolkit',
description: 'A curated collection of creative and utility tools for developers and creators.',
images: ['/og-image.png'],
description: 'A curated collection of creative and utility tools for developers and creators. Features file conversion, image editing, and color manipulation.',
keywords: ['tools', 'utilities', 'file converter', 'image editor', 'color palette', 'creative toolkit', 'convert', 'paint', 'color', 'open source'],
metadataBase: new URL(siteUrl),
icons: {
icon: '/icon.png',
shortcut: '/icon.png',
apple: '/icon.png',
},
robots: {
index: true,
@@ -49,11 +34,23 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const umamiScript = process.env.UMAMI_SCRIPT;
const umamiId = process.env.UMAMI_ID;
const isProd = process.env.NODE_ENV === 'production';
return (
<html lang="en">
<html lang="en" className="scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://kit.pivoine.art" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Kit" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#8b5cf6" />
{isProd && umamiScript && umamiId && (
<script defer src={umamiScript} data-website-id={umamiId}></script>
)}
</head>
<body className="antialiased">
{children}

29
app/manifest.ts Normal file
View File

@@ -0,0 +1,29 @@
import { MetadataRoute } from 'next';
export const dynamic = 'force-static';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Kit - Creative Toolkit',
short_name: 'Kit',
description: 'A curated collection of creative and utility tools for developers and creators.',
start_url: '/',
display: 'standalone',
background_color: '#0a0a0f',
theme_color: '#8b5cf6',
icons: [
{
src: '/icon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icon.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
};
}

59
app/not-found.tsx Normal file
View File

@@ -0,0 +1,59 @@
import Link from 'next/link';
import AnimatedBackground from '@/components/AnimatedBackground';
import Logo from '@/components/Logo';
import { ArrowLeft } from 'lucide-react';
export default function NotFound() {
return (
<main className="relative min-h-screen dark text-foreground flex flex-col">
<AnimatedBackground />
<div className="flex-1 flex flex-col items-center justify-center px-6 py-20 relative z-10 text-center">
{/* Logo */}
<Logo size={52} />
{/* 404 */}
<div
className="mt-10"
style={{ animation: 'slideUp 0.5s ease-out 0.15s both' }}
>
<span className="text-[80px] md:text-[120px] font-bold font-mono leading-none tabular-nums block bg-gradient-to-b from-foreground to-foreground/25 bg-clip-text text-transparent">
404
</span>
</div>
{/* Divider */}
<div
className="mt-6 w-12 h-px bg-gradient-to-r from-transparent via-primary/50 to-transparent"
style={{ animation: 'fadeIn 0.5s ease-out 0.3s both' }}
/>
{/* Message */}
<div
className="mt-6 space-y-2"
style={{ animation: 'slideUp 0.5s ease-out 0.35s both' }}
>
<p className="text-sm font-medium text-foreground/70">Page not found</p>
<p className="text-[11px] text-muted-foreground/45 font-mono max-w-xs mx-auto leading-relaxed">
The tool or page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
</div>
{/* CTA */}
<div
className="mt-8"
style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
>
<Link
href="/"
className="inline-flex items-center gap-2 px-5 py-2.5 glass rounded-xl border border-white/[0.06] hover:border-primary/40 hover:bg-primary/[0.07] text-sm font-medium text-foreground/60 hover:text-foreground transition-all duration-200"
>
<ArrowLeft className="w-3.5 h-3.5 text-primary" />
Back to Home
</Link>
</div>
</div>
</main>
);
}

View File

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

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -2,7 +2,7 @@
export default function AnimatedBackground() {
return (
<div className="fixed inset-0 -z-10 overflow-hidden">
<div className="fixed inset-0 -z-10 overflow-hidden bg-background transition-colors duration-500">
{/* Animated gradient background */}
<div
className="absolute inset-0 opacity-50"
@@ -13,7 +13,7 @@ export default function AnimatedBackground() {
}}
/>
{/* Grid pattern overlay */}
{/* Signature Grid pattern overlay - Original landing page specification */}
<div
className="absolute inset-0 opacity-10"
style={{
@@ -26,9 +26,9 @@ export default function AnimatedBackground() {
/>
{/* Floating orbs */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" />
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '2s' }} />
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '4s' }} />
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-normal filter blur-3xl opacity-20 animate-float" />
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-normal filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '2s' }} />
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-normal filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '4s' }} />
</div>
);
}

105
components/AppIcons.tsx Normal file
View File

@@ -0,0 +1,105 @@
import * as React from 'react';
export const ColorIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
<circle cx="6.5" cy="11.5" r="1" fill="currentColor" />
<circle cx="9.5" cy="7.5" r="1" fill="currentColor" />
<circle cx="14.5" cy="7.5" r="1" fill="currentColor" />
<circle cx="17.5" cy="11.5" r="1" fill="currentColor" />
</svg>
);
export const UnitsIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
export const ASCIIIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.5 13h6" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m2 16 4.5-9 4.5 9" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 16V7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m14 11 4-4 4 4" />
</svg>
);
export const MediaIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
export const FaviconIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
export const AnimateIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 3c-1.2 2.4-1.2 4.8 0 7.2" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 21c1.2-2.4 1.2-4.8 0-7.2" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 12c2.4 1.2 4.8 1.2 7.2 0" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 12c-2.4-1.2-4.8-1.2-7.2 0" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} strokeDasharray="2 2"
d="M5.6 5.6c1.8 1.8 3.4 2.6 4.8 2.4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} strokeDasharray="2 2"
d="M18.4 18.4c-1.8-1.8-3.4-2.6-4.8-2.4" />
</svg>
);
export const QRCodeIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1" strokeWidth={2} />
<rect x="14" y="3" width="7" height="7" rx="1" strokeWidth={2} />
<rect x="3" y="14" width="7" height="7" rx="1" strokeWidth={2} />
<rect x="14" y="14" width="3" height="3" strokeWidth={2} />
<rect x="18" y="18" width="3" height="3" strokeWidth={2} />
<line x1="14" y1="18" x2="17" y2="18" strokeWidth={2} />
<line x1="18" y1="14" x2="18" y2="17" strokeWidth={2} />
</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-white/10 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,52 +1,35 @@
'use client';
import { motion } from 'framer-motion';
import { GitFork, Heart } from 'lucide-react';
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="relative py-12 px-4">
<div className="max-w-6xl mx-auto border-t border-gray-600 pt-12">
<motion.div
className="flex flex-col md:flex-row items-center justify-between gap-6"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
{/* Brand Section */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-purple-400">
<span className="text-base font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">Kit</span>
<span className="text-base text-gray-600"></span>
<span className="text-base text-purple-400">Open Source</span>
</div>
{/* Copyright - centered */}
<div className="text-center">
<p className="text-base text-gray-500">
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4.
</p>
</div>
{/* Dev Link */}
<footer className="relative py-10 px-6">
<div className="max-w-5xl mx-auto border-t border-white/[0.06] pt-8">
<div className="flex items-center justify-between">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground/35 font-mono">
<span>© {currentYear} Kit</span>
<Heart className="w-2.5 h-2.5 text-primary/60 shrink-0 animate-pulse" fill="currentColor" />
<a
href="https://pivoine.art"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground/60 transition-colors duration-200"
>
Valknar
</a>
</p>
<a
href="https://dev.pivoine.art/valknar/kit-ui"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-3 px-4 py-2 rounded-full border border-gray-700 hover:border-purple-400 transition-all duration-300"
title="View source"
className="flex items-center gap-1.5 text-xs text-muted-foreground/30 font-mono hover:text-primary transition-colors duration-200"
>
<svg className="w-5 h-5 text-gray-400 group-hover:text-purple-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
</svg>
<span className="text-base text-gray-300 group-hover:text-purple-400 transition-colors font-medium">
View on Dev
</span>
<GitFork className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Source</span>
</a>
</motion.div>
</div>
</div>
</footer>
);

View File

@@ -1,110 +1,74 @@
'use client';
import { motion } from 'framer-motion';
import { ArrowDown } from 'lucide-react';
import Logo from './Logo';
export default function Hero() {
const scrollToTools = () => {
document.getElementById('tools')?.scrollIntoView({ behavior: 'smooth' });
};
return (
<section className="relative min-h-screen flex flex-col items-center justify-center px-4 py-20">
<div className="max-w-6xl mx-auto text-center">
<section className="relative min-h-screen flex flex-col items-center justify-center px-6 py-24">
<div className="flex flex-col items-center text-center max-w-2xl mx-auto">
{/* Logo */}
<motion.div
className="mb-8 flex justify-center"
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Logo size={160} />
</motion.div>
<Logo size={72} />
{/* Main heading */}
<motion.h1
className="text-6xl md:text-8xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
{/* Badge */}
<div
className="mt-8 flex items-center gap-2 px-3 py-1.5 glass rounded-full border border-white/[0.06]"
style={{ animation: 'slideUp 0.5s ease-out 0.2s both' }}
>
Kit
</motion.h1>
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
<span className="text-[10px] font-mono text-muted-foreground/55 tracking-widest uppercase">
Browser-first
</span>
</div>
{/* Subtitle */}
<motion.p
className="text-xl md:text-2xl text-gray-300 mb-4 max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
{/* Title */}
<h1
className="mt-6 font-bold tracking-tight leading-none"
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
>
Your Creative Toolkit
</motion.p>
<span className="text-6xl md:text-8xl text-foreground">Kit</span>
<span className="text-6xl md:text-8xl text-primary">.</span>
</h1>
{/* Description */}
<motion.p
className="text-base md:text-lg text-gray-400 mb-12 max-w-xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }}
<p
className="mt-6 text-sm text-muted-foreground/55 max-w-xs leading-relaxed"
style={{ animation: 'slideUp 0.5s ease-out 0.4s both' }}
>
A curated collection of creative and utility tools for developers and creators.
Simple, powerful, and always at your fingertips.
</motion.p>
A curated collection of browser-based tools for developers and creators.
Everything runs locally no data leaves your machine.
</p>
{/* CTA Buttons */}
<motion.div
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-16"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8 }}
{/* CTA */}
<div
className="mt-8"
style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
>
<motion.a
href="#tools"
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
<button
onClick={scrollToTools}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl border border-primary/30 bg-primary/[0.07] hover:border-primary/55 hover:bg-primary/[0.13] text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200"
>
<span className="relative z-10">Explore Tools</span>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-purple-600 to-cyan-600"
initial={{ x: '100%' }}
whileHover={{ x: 0 }}
transition={{ duration: 0.3 }}
/>
</motion.a>
<motion.a
href="https://dev.pivoine.art/valknar/kit-ui"
target="_blank"
rel="noopener noreferrer"
className="group px-8 py-4 rounded-full border-2 border-gray-600 text-gray-300 font-semibold hover:border-purple-400 hover:text-purple-400 transition-all duration-300 inline-flex items-center gap-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
</svg>
View on Dev
</motion.a>
</motion.div>
Explore Tools
<ArrowDown className="w-3.5 h-3.5 text-primary" />
</button>
</div>
{/* Scroll indicator */}
<motion.a
href="#tools"
className="flex flex-col items-center gap-2 cursor-pointer group"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 1 }}
<button
onClick={scrollToTools}
className="mt-24 flex flex-col items-center gap-2 group"
style={{ animation: 'fadeIn 0.5s ease-out 0.9s both' }}
>
<span className="text-base text-gray-500 group-hover:text-gray-400 transition-colors">Scroll to explore</span>
<motion.div
className="w-6 h-10 border-2 border-gray-600 group-hover:border-purple-400 rounded-full p-1 transition-colors"
animate={{ y: [0, 10, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
</motion.div>
</motion.a>
<div className="w-px h-8 bg-gradient-to-b from-transparent via-primary/30 to-primary/60 group-hover:via-primary/50 group-hover:to-primary transition-colors duration-300" />
<span className="text-[9px] font-mono text-muted-foreground/25 uppercase tracking-widest group-hover:text-muted-foreground/50 transition-colors">
Scroll
</span>
</button>
</div>
</section>
);

View File

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

View File

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

View File

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

View File

@@ -1,88 +1,36 @@
'use client';
import { motion } from 'framer-motion';
import ToolCard from './ToolCard';
const tools = [
{
title: 'Pastel',
description: 'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
url: 'https://pastel.kit.pivoine.art',
gradient: 'gradient-indigo-purple',
accentColor: '#a855f7',
badges: ['Open Source', 'WCAG', 'Free'],
icon: (
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
<circle cx="6.5" cy="11.5" r="1" fill="currentColor" />
<circle cx="9.5" cy="7.5" r="1" fill="currentColor" />
<circle cx="14.5" cy="7.5" r="1" fill="currentColor" />
<circle cx="17.5" cy="11.5" r="1" fill="currentColor" />
</svg>
),
},
{
title: 'Units',
description: 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search and conversion history.',
url: 'https://units.kit.pivoine.art',
gradient: 'gradient-cyan-purple',
accentColor: '#2dd4bf',
badges: ['Open Source', 'Real-time', 'Free'],
icon: (
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
),
},
{
title: 'Figlet',
description: 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
url: 'https://figlet.kit.pivoine.art',
gradient: 'gradient-yellow-amber',
accentColor: '#eab308',
badges: ['Open Source', 'ASCII Art', 'Free'],
icon: (
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.5 13h6" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m2 16 4.5-9 4.5 9" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 16V7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m14 11 4-4 4 4" />
</svg>
),
},
];
import { tools } from '@/lib/tools';
export default function ToolsGrid() {
return (
<section id="tools" className="relative py-20 px-4">
<div className="max-w-6xl mx-auto">
<section id="tools" className="relative py-16 px-6">
<div className="max-w-5xl mx-auto">
{/* Section heading */}
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
<div
className="mb-10"
style={{ animation: 'fadeIn 0.5s ease-out both' }}
>
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
Available Tools
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
Available{' '}
<span className="bg-gradient-to-r from-primary via-violet-400 to-pink-400 bg-clip-text text-transparent">
Tools
</span>
</h2>
<p className="text-gray-400 text-lg max-w-2xl mx-auto">
Explore our collection of carefully crafted tools designed to boost your productivity and creativity.
<p className="text-sm text-muted-foreground/40 mt-2">
{tools.length} tools &mdash; everything runs in your browser, no data leaves your machine
</p>
</motion.div>
</div>
{/* Tools grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{tools.map((tool, index) => (
<ToolCard
key={tool.title}
key={tool.href}
title={tool.title}
description={tool.description}
description={tool.summary}
icon={tool.icon}
url={tool.url}
gradient={tool.gradient}
accentColor={tool.accentColor}
url={tool.href}
badges={tool.badges}
index={index}
/>

View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useCallback } from 'react';
import { AnimationSettings } from './AnimationSettings';
import { AnimationPreview } from './AnimationPreview';
import { KeyframeTimeline } from './KeyframeTimeline';
import { KeyframeProperties } from './KeyframeProperties';
import { PresetLibrary } from './PresetLibrary';
import { ExportPanel } from './ExportPanel';
import { DEFAULT_CONFIG, newKeyframe } from '@/lib/animate/defaults';
import { cn } from '@/lib/utils/cn';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import type { AnimationConfig, KeyframeProperties as KFProps, PreviewElement } from '@/types/animate';
type MobileTab = 'edit' | 'preview';
type RightTab = 'keyframes' | 'export' | 'presets';
export function AnimationEditor() {
const [config, setConfig] = useState<AnimationConfig>(DEFAULT_CONFIG);
const [selectedId, setSelectedId] = useState<string | null>(
DEFAULT_CONFIG.keyframes[DEFAULT_CONFIG.keyframes.length - 1].id
);
const [previewElement, setPreviewElement] = useState<PreviewElement>('box');
const [mobileTab, setMobileTab] = useState<MobileTab>('edit');
const [rightTab, setRightTab] = useState<RightTab>('export');
const selectedKeyframe = config.keyframes.find((k) => k.id === selectedId) ?? null;
const updateKeyframeProps = useCallback((id: string, props: KFProps) => {
setConfig((c) => ({
...c,
keyframes: c.keyframes.map((k) => k.id === id ? { ...k, properties: props } : k),
}));
}, []);
const addKeyframe = useCallback((offset: number) => {
const kf = newKeyframe(offset);
setConfig((c) => ({ ...c, keyframes: [...c.keyframes, kf] }));
setSelectedId(kf.id);
}, []);
const deleteKeyframe = useCallback((id: string) => {
setConfig((c) => {
if (c.keyframes.length <= 2) return c;
return { ...c, keyframes: c.keyframes.filter((k) => k.id !== id) };
});
setSelectedId((prev) => {
if (prev !== id) return prev;
const remaining = config.keyframes.filter((k) => k.id !== id);
return remaining[remaining.length - 1]?.id ?? null;
});
}, [config.keyframes]);
const moveKeyframe = useCallback((id: string, newOffset: number) => {
const clamped = Math.min(100, Math.max(0, Math.round(newOffset)));
setConfig((c) => ({
...c,
keyframes: c.keyframes.map((k) => k.id === id ? { ...k, offset: clamped } : k),
}));
}, []);
const loadPreset = useCallback((presetConfig: AnimationConfig) => {
setConfig(presetConfig);
setSelectedId(presetConfig.keyframes[presetConfig.keyframes.length - 1].id);
}, []);
const timelineProps = {
keyframes: config.keyframes,
selectedId,
onSelect: setSelectedId,
onAdd: addKeyframe,
onDelete: deleteKeyframe,
onMove: moveKeyframe,
};
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'edit', label: 'Edit' }, { value: 'preview', label: 'Preview' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: Settings + Properties */}
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'edit' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
<AnimationSettings config={config} onChange={setConfig} />
<div className="border-t border-border/25" />
<KeyframeTimeline {...timelineProps} embedded />
<KeyframeProperties keyframe={selectedKeyframe} onChange={updateKeyframeProps} />
</div>
</div>
</div>
{/* 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>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Play, Pause, RotateCcw, Square, Circle, Type } from 'lucide-react';
import { cn, iconBtn } from '@/lib/utils';
import { buildCSS } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, PreviewElement } from '@/types/animate';
interface Props {
config: AnimationConfig;
element: PreviewElement;
onElementChange: (e: PreviewElement) => void;
}
type AnimState = 'playing' | 'paused' | 'ended';
const SPEEDS: { label: string; value: string }[] = [
{ label: '0.25×', value: '0.25' },
{ label: '0.5×', value: '0.5' },
{ label: '1×', value: '1' },
{ label: '2×', value: '2' },
];
const ELEMENTS: { value: PreviewElement; icon: React.ReactNode; title: string }[] = [
{ value: 'box', icon: <Square className="w-3 h-3" />, title: 'Box' },
{ value: 'circle', icon: <Circle className="w-3 h-3" />, title: 'Circle' },
{ value: 'text', icon: <Type className="w-3 h-3" />, title: 'Text' },
];
const previewBtn = cn(iconBtn, 'w-7 h-7');
const pillCls = (active: boolean) =>
cn(
'px-2 py-0.5 rounded text-[10px] font-mono transition-all',
active ? 'text-primary bg-primary/10' : 'text-muted-foreground/50 hover:text-muted-foreground'
);
export function AnimationPreview({ config, element, onElementChange }: Props) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const [restartKey, setRestartKey] = useState(0);
const [animState, setAnimState] = useState<AnimState>('playing');
const [speed, setSpeed] = useState('1');
useEffect(() => {
if (!styleRef.current) {
styleRef.current = document.createElement('style');
styleRef.current.id = 'kit-animate-preview';
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildCSS(config);
setAnimState('playing');
setRestartKey((k) => k + 1);
}, [config]);
useEffect(() => {
return () => { styleRef.current?.remove(); };
}, []);
const restart = () => { setAnimState('playing'); setRestartKey((k) => k + 1); };
const scaledDuration = Math.round(config.duration / Number(speed));
const isInfinite = config.iterationCount === 'infinite';
return (
<div className="glass rounded-xl p-4 shrink-0 flex flex-col gap-3">
{/* Header: speed pills */}
<div className="flex items-center justify-between shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Preview</span>
<div className="flex items-center glass rounded-md border border-border/30 px-1 gap-0.5">
{SPEEDS.map((s) => (
<button key={s.value} onClick={() => setSpeed(s.value)} className={pillCls(speed === s.value)}>
{s.label}
</button>
))}
</div>
</div>
{/* Canvas */}
<div
className="h-44 rounded-xl flex items-center justify-center relative overflow-hidden"
style={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
backgroundImage: [
'linear-gradient(135deg, rgba(255,255,255,0.02) 0%, rgba(139,92,246,0.04) 100%)',
'linear-gradient(var(--border) 1px, transparent 1px)',
'linear-gradient(90deg, var(--border) 1px, transparent 1px)',
].join(', '),
backgroundSize: 'auto, 32px 32px, 32px 32px',
}}
>
<div
key={restartKey}
className="animated relative z-10"
style={{
animationDuration: `${scaledDuration}ms`,
animationPlayState: animState === 'paused' ? 'paused' : 'running',
}}
onAnimationEnd={() => !isInfinite && setAnimState('ended')}
>
{element === 'box' && (
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-lg shadow-purple-500/30" />
)}
{element === 'circle' && (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-cyan-400 to-violet-500 shadow-lg shadow-cyan-500/30" />
)}
{element === 'text' && (
<span className="text-3xl font-bold bg-gradient-to-r from-violet-400 via-pink-400 to-cyan-400 bg-clip-text text-transparent select-none">
Hello
</span>
)}
</div>
</div>
{/* Controls: element selector + playback */}
<div className="flex items-center justify-between shrink-0">
{/* Element picker */}
<div className="flex items-center glass rounded-md border border-border/30 p-0.5 gap-0.5">
{ELEMENTS.map(({ value, icon, title }) => (
<button
key={value}
onClick={() => onElementChange(value)}
title={title}
className={cn(
'w-7 h-7 flex items-center justify-center rounded transition-all',
element === value
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{icon}
</button>
))}
</div>
{/* Playback */}
<div className="flex items-center gap-1">
<button
onClick={() => animState === 'ended' ? restart() : setAnimState('playing')}
disabled={animState === 'playing'}
title={animState === 'ended' ? 'Replay' : 'Play'}
className={previewBtn}
>
<Play className="w-3 h-3" />
</button>
<button
onClick={() => setAnimState('paused')}
disabled={animState !== 'playing'}
title="Pause"
className={previewBtn}
>
<Pause className="w-3 h-3" />
</button>
<button onClick={restart} title="Restart" className={previewBtn}>
<RotateCcw className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { Infinity } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { AnimationConfig } from '@/types/animate';
interface Props {
config: AnimationConfig;
onChange: (config: AnimationConfig) => void;
}
const EASINGS = [
{ value: 'linear', label: 'Linear' },
{ value: 'ease', label: 'Ease' },
{ value: 'ease-in', label: 'Ease In' },
{ value: 'ease-out', label: 'Ease Out' },
{ value: 'ease-in-out', label: 'Ease In Out' },
{ value: 'cubic-bezier', label: 'Cubic Bézier' },
{ value: 'steps(4, end)', label: 'Steps (4)' },
{ value: 'steps(8, end)', label: 'Steps (8)' },
];
const DIRECTIONS: { value: AnimationConfig['direction']; label: string }[] = [
{ value: 'normal', label: 'Normal' },
{ value: 'reverse', label: 'Reverse' },
{ value: 'alternate', label: 'Alt' },
{ value: 'alternate-reverse', label: 'Alt-Rev' },
];
const FILL_MODES: { value: AnimationConfig['fillMode']; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: 'forwards', label: 'Fwd' },
{ value: 'backwards', label: 'Bwd' },
{ value: 'both', label: 'Both' },
];
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
const pillCls = (active: boolean) =>
cn(
'flex-1 py-1.5 rounded-lg border text-[10px] font-mono transition-all',
active
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
);
export function AnimationSettings({ config, onChange }: Props) {
const set = <K extends keyof AnimationConfig>(key: K, value: AnimationConfig[K]) =>
onChange({ ...config, [key]: value });
const isInfinite = config.iterationCount === 'infinite';
const isCubic = config.easing.startsWith('cubic-bezier');
const cubicValues = (() => {
const m = config.easing.match(/cubic-bezier\(([^)]+)\)/);
if (!m) return [0.25, 0.1, 0.25, 1.0];
return m[1].split(',').map(Number);
})();
const setCubic = (index: number, val: number) => {
const v = [...cubicValues];
v[index] = val;
set('easing', `cubic-bezier(${v.join(',')})`);
};
const easingSelectValue = isCubic ? 'cubic-bezier' : config.easing;
return (
<div className="space-y-4">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
Settings
</span>
{/* Name */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Name</label>
<input
type="text"
value={config.name}
onChange={(e) => {
const val = e.target.value.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-_]/g, '');
set('name', val || 'myAnimation');
}}
className={inputCls}
/>
</div>
{/* Duration + Delay */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Duration (ms)</label>
<input
type="number"
min={50}
max={10000}
step={50}
value={config.duration}
onChange={(e) => set('duration', Math.max(50, Number(e.target.value)))}
className={inputCls}
/>
</div>
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Delay (ms)</label>
<input
type="number"
min={0}
max={5000}
step={50}
value={config.delay}
onChange={(e) => set('delay', Math.max(0, Number(e.target.value)))}
className={inputCls}
/>
</div>
</div>
{/* Easing */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Easing</label>
<select
value={easingSelectValue}
onChange={(e) => {
const v = e.target.value;
set('easing', v === 'cubic-bezier' ? 'cubic-bezier(0.25,0.1,0.25,1)' : v);
}}
className="w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{EASINGS.map((e) => (
<option key={e.value} value={e.value}>
{e.label}
</option>
))}
</select>
</div>
{/* Cubic-bezier inputs */}
{isCubic && (
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">
cubic-bezier(P1x, P1y, P2x, P2y)
</label>
<div className="grid grid-cols-4 gap-1.5">
{(['P1x', 'P1y', 'P2x', 'P2y'] as const).map((label, i) => (
<div key={label}>
<label className="text-[9px] text-muted-foreground/40 font-mono block mb-1">{label}</label>
<input
type="number"
min={i % 2 === 0 ? 0 : -1}
max={i % 2 === 0 ? 1 : 2}
step={0.01}
value={cubicValues[i] ?? 0}
onChange={(e) => setCubic(i, Number(e.target.value))}
className="w-full bg-transparent border border-border/40 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 text-center"
/>
</div>
))}
</div>
</div>
)}
{/* Iterations */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Iterations</label>
<div className="flex gap-1.5">
<input
type="number"
min={1}
max={999}
value={isInfinite ? '' : (config.iterationCount as number)}
disabled={isInfinite}
onChange={(e) => set('iterationCount', Math.max(1, Number(e.target.value)))}
placeholder="1"
className={cn(inputCls, 'flex-1', isInfinite && 'opacity-30')}
/>
<button
onClick={() => set('iterationCount', isInfinite ? 1 : 'infinite')}
title="Toggle infinite"
className={cn(
'w-9 h-9 flex items-center justify-center rounded-lg border text-xs transition-all shrink-0',
isInfinite
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/40 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
<Infinity className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Direction */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Direction</label>
<div className="flex gap-1">
{DIRECTIONS.map(({ value, label }) => (
<button key={value} onClick={() => set('direction', value)} className={pillCls(config.direction === value)}>
{label}
</button>
))}
</div>
</div>
{/* Fill Mode */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Fill Mode</label>
<div className="flex gap-1">
{FILL_MODES.map(({ value, label }) => (
<button key={value} onClick={() => set('fillMode', value)} className={pillCls(config.fillMode === value)}>
{label}
</button>
))}
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,134 @@
'use client';
import { Slider } from '@/components/ui/slider';
import { ColorInput } from '@/components/ui/color-input';
import { MousePointerClick } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
import { DEFAULT_TRANSFORM } from '@/lib/animate/defaults';
interface Props {
keyframe: Keyframe | null;
onChange: (id: string, props: KeyframeProperties) => void;
}
interface SliderRowProps {
label: string;
unit?: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
}
function SliderRow({ label, unit, value, min, max, step = 1, onChange }: SliderRowProps) {
return (
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">
{label}{unit && <span className="opacity-50"> ({unit})</span>}
</label>
<Slider min={min} max={max} step={step} value={[value]} onValueChange={([v]) => onChange(v)} />
</div>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
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>
);
}
export function KeyframeProperties({ keyframe, onChange }: Props) {
if (!keyframe) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center gap-3">
<MousePointerClick className="w-7 h-7 text-muted-foreground/20" />
<p className="text-[10px] text-muted-foreground/40 font-mono leading-relaxed max-w-[180px]">
Select a keyframe on the timeline to edit its properties
</p>
</div>
);
}
const props = keyframe.properties;
const t: TransformValue = { ...DEFAULT_TRANSFORM, ...props.transform };
const setTransform = (key: keyof TransformValue, value: number) => {
onChange(keyframe.id, { ...props, transform: { ...t, [key]: value } });
};
const setProp = <K extends keyof KeyframeProperties>(key: K, value: KeyframeProperties[K]) => {
onChange(keyframe.id, { ...props, [key]: value });
};
const hasBg = props.backgroundColor && props.backgroundColor !== 'none';
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Properties
</span>
<span className="text-[9px] text-primary/60 font-mono bg-primary/10 px-1.5 py-0.5 rounded">
{keyframe.offset}%
</span>
</div>
{/* Transform */}
<div className="space-y-3">
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Transform</p>
<SliderRow label="Translate X" unit="px" value={t.translateX} min={-500} max={500} onChange={(v) => setTransform('translateX', v)} />
<SliderRow label="Translate Y" unit="px" value={t.translateY} min={-500} max={500} onChange={(v) => setTransform('translateY', v)} />
<SliderRow label="Rotate" unit="°" value={t.rotate} min={-360} max={360} onChange={(v) => setTransform('rotate', v)} />
<SliderRow label="Scale X" value={t.scaleX} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleX', v)} />
<SliderRow label="Scale Y" value={t.scaleY} min={0} max={3} step={0.01} onChange={(v) => setTransform('scaleY', v)} />
<SliderRow label="Skew X" unit="°" value={t.skewX} min={-90} max={90} onChange={(v) => setTransform('skewX', v)} />
<SliderRow label="Skew Y" unit="°" value={t.skewY} min={-90} max={90} onChange={(v) => setTransform('skewY', v)} />
</div>
{/* Visual */}
<div className="space-y-3">
<p className="text-[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)} />
{/* Background color */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">Background Color</label>
<button
onClick={() => setProp('backgroundColor', hasBg ? 'none' : '#8b5cf6')}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
hasBg
? 'border-primary/40 text-primary bg-primary/10'
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
{hasBg ? 'On' : 'Off'}
</button>
</div>
<ColorInput
value={hasBg ? props.backgroundColor! : '#8b5cf6'}
onChange={(v) => setProp('backgroundColor', v)}
disabled={!hasBg}
/>
</div>
<SliderRow label="Border Radius" unit="px" value={props.borderRadius ?? 0} min={0} max={200} onChange={(v) => setProp('borderRadius', v)} />
</div>
{/* Filters */}
<div className="space-y-3">
<p className="text-[9px] font-semibold uppercase tracking-wider text-muted-foreground/50">Filter</p>
<SliderRow label="Blur" unit="px" value={props.blur ?? 0} min={0} max={50} onChange={(v) => setProp('blur', v)} />
<SliderRow label="Brightness" value={props.brightness ?? 1} min={0} max={3} step={0.01} onChange={(v) => setProp('brightness', v)} />
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,83 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils/cn';
import { PRESETS, PRESET_CATEGORIES } from '@/lib/animate/presets';
import { buildKeyframesOnly } from '@/lib/animate/cssBuilder';
import type { AnimationConfig, AnimationPreset, PresetCategory } from '@/types/animate';
interface Props {
onSelect: (config: AnimationConfig) => void;
}
function PresetCard({ preset, onSelect }: { preset: AnimationPreset; onSelect: () => void }) {
const styleRef = useRef<HTMLStyleElement | null>(null);
const animName = `preview-${preset.id}`;
const thumbDuration = Math.min(preset.config.duration, 1200);
useEffect(() => {
const renamedConfig = { ...preset.config, name: animName };
if (!styleRef.current) {
styleRef.current = document.createElement('style');
document.head.appendChild(styleRef.current);
}
styleRef.current.textContent = buildKeyframesOnly(renamedConfig);
return () => { styleRef.current?.remove(); styleRef.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<button
onClick={onSelect}
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"
>
<div className="w-full h-12 flex items-center justify-center rounded-lg bg-white/3 overflow-hidden">
<div
className="w-7 h-7 rounded-md bg-gradient-to-br from-violet-500 to-purple-600"
style={{
animationName: animName,
animationDuration: `${thumbDuration}ms`,
animationTimingFunction: preset.config.easing,
animationIterationCount: 'infinite',
animationDirection: 'alternate',
animationFillMode: 'both',
}}
/>
</div>
<span className="text-[10px] font-mono text-center leading-tight text-foreground/60 group-hover:text-foreground/80 transition-colors">
{preset.name}
</span>
</button>
);
}
export function PresetLibrary({ onSelect }: Props) {
const [category, setCategory] = useState<PresetCategory>(PRESET_CATEGORIES[0]);
return (
<div className="space-y-3 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Presets</span>
<div className="flex glass rounded-lg p-0.5 gap-0.5">
{PRESET_CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={cn(
'px-2 py-1 rounded-md text-[10px] font-mono transition-all',
category === cat ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
)}
>
{cat}
</button>
))}
</div>
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
{PRESETS.filter((p) => p.category === category).map((preset) => (
<PresetCard key={preset.id} preset={preset} onSelect={() => onSelect(preset.config)} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import * as React from 'react';
import { TextInput } from './TextInput';
import { FontPreview } from './FontPreview';
import { FontSelector } from './FontSelector';
import { textToAscii } from '@/lib/ascii/asciiService';
import { getFontList } from '@/lib/ascii/fontLoader';
import { debounce } from '@/lib/utils/debounce';
import { addRecentFont } from '@/lib/storage/favorites';
import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing';
import { toast } from 'sonner';
import type { ASCIIFont } from '@/types/ascii';
import { cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'editor' | 'preview';
export function ASCIIConverter() {
const [text, setText] = React.useState('ASCII');
const [selectedFont, setSelectedFont] = React.useState('Standard');
const [asciiArt, setAsciiArt] = React.useState('');
const [fonts, setFonts] = React.useState<ASCIIFont[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [tab, setTab] = React.useState<Tab>('editor');
const commentedTextRef = React.useRef('');
React.useEffect(() => {
getFontList().then(setFonts);
const urlState = decodeFromUrl();
if (urlState) {
if (urlState.text) setText(urlState.text);
if (urlState.font) setSelectedFont(urlState.font);
}
}, []);
const generateAsciiArt = React.useMemo(
() =>
debounce(async (inputText: string, fontName: string) => {
if (!inputText) {
setAsciiArt('');
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const result = await textToAscii(inputText, fontName);
setAsciiArt(result);
} catch {
setAsciiArt('Error generating ASCII art. Please try a different font.');
} finally {
setIsLoading(false);
}
}, 300),
[]
);
React.useEffect(() => {
generateAsciiArt(text, selectedFont);
if (selectedFont) addRecentFont(selectedFont);
updateUrl(text, selectedFont);
}, [text, selectedFont, generateAsciiArt]);
const handleCopy = async () => {
if (!asciiArt) return;
try {
await navigator.clipboard.writeText(commentedTextRef.current || asciiArt);
toast.success('Copied to clipboard!');
} catch {
toast.error('Failed to copy');
}
};
const handleDownload = () => {
if (!asciiArt) return;
const blob = new Blob([commentedTextRef.current || asciiArt], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ascii-${selectedFont}-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleShare = async () => {
try {
await navigator.clipboard.writeText(getShareableUrl(text, selectedFont));
toast.success('Shareable URL copied!');
} catch {
toast.error('Failed to copy URL');
}
};
const handleRandomFont = () => {
if (!fonts.length) return;
const font = fonts[Math.floor(Math.random() * fonts.length)];
setSelectedFont(font.name);
toast.info(`Font: ${font.name}`);
};
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
active={tab}
onChange={(v) => setTab(v as Tab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 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
value={text}
onChange={setText}
placeholder="Type your text here…"
/>
</div>
{/* Font selector — fills remaining height */}
<div className="flex-1 min-h-0 overflow-hidden">
<FontSelector
fonts={fonts}
selectedFont={selectedFont}
onSelectFont={setSelectedFont}
onRandomFont={handleRandomFont}
className="h-full"
/>
</div>
</div>
{/* Right panel: preview */}
<div
className={cn(
'lg:col-span-3 flex flex-col overflow-hidden',
tab !== 'preview' && 'hidden lg:flex'
)}
>
<FontPreview
text={asciiArt}
font={selectedFont}
isLoading={isLoading}
onCopy={handleCopy}
onDownload={handleDownload}
onShare={handleShare}
onCommentedTextChange={React.useCallback(
(t: string) => { commentedTextRef.current = t; },
[]
)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import * as React from 'react';
import { toPng } from 'html-to-image';
import {
Copy,
Download,
Share2,
Image as ImageIcon,
AlignLeft,
AlignCenter,
AlignRight,
MessageSquareCode,
Type,
} from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import { toast } from 'sonner';
export type CommentStyle = 'none' | '//' | '#' | '--' | ';' | '/* */' | '<!-- -->' | '"""';
const COMMENT_STYLES: { value: CommentStyle; label: string }[] = [
{ value: 'none', label: 'None' },
{ value: '//', label: '// C / JS / Go' },
{ value: '#', label: '# Python / Shell' },
{ value: '--', label: '-- SQL / Lua' },
{ value: ';', label: '; Lisp / ASM' },
{ value: '/* */', label: '/* Block */' },
{ value: '<!-- -->', label: '<!-- HTML -->' },
{ value: '"""', label: '""" Docstring' },
];
function applyCommentStyle(text: string, style: CommentStyle): string {
if (style === 'none' || !text) return text;
const lines = text.split('\n');
switch (style) {
case '//':
case '#':
case '--':
case ';':
return lines.map((l) => `${style} ${l}`).join('\n');
case '/* */':
return ['/*', ...lines.map((l) => ` * ${l}`), ' */'].join('\n');
case '<!-- -->':
return ['<!--', ...lines, '-->'].join('\n');
case '"""':
return ['"""', ...lines, '"""'].join('\n');
}
}
export interface FontPreviewProps {
text: string;
font?: string;
isLoading?: boolean;
onCopy?: () => void;
onDownload?: () => void;
onShare?: () => void;
onCommentedTextChange?: (commentedText: string) => void;
className?: string;
}
type TextAlign = 'left' | 'center' | 'right';
type FontSize = 'xs' | 'sm' | 'base';
const ALIGN_OPTS: { value: TextAlign; icon: React.ElementType; label: string }[] = [
{ 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 [fontSize, setFontSize] = React.useState<FontSize>('sm');
const [commentStyle, setCommentStyle] = React.useState<CommentStyle>('none');
const commentedText = React.useMemo(
() => applyCommentStyle(text, commentStyle),
[text, commentStyle]
);
const lineCount = commentedText ? commentedText.split('\n').length : 0;
const charCount = commentedText ? commentedText.length : 0;
React.useEffect(() => {
onCommentedTextChange?.(commentedText);
}, [commentedText, onCommentedTextChange]);
const handleExportPNG = async () => {
if (!terminalRef.current || !text) return;
try {
const dataUrl = await toPng(terminalRef.current, {
backgroundColor: '#06060e',
pixelRatio: 2,
});
const link = document.createElement('a');
link.download = `ascii-${font || 'export'}-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported as PNG!');
} catch {
toast.error('Failed to export PNG');
}
};
return (
<div className={cn('glass rounded-xl p-4 flex flex-col gap-3 flex-1 min-h-0 overflow-hidden', className)}>
{/* ── 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">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Preview
</span>
{font && (
<span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
{font}
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{onCopy && (
<button onClick={onCopy} className={cardBtn}>
<Copy className="w-3 h-3" /> Copy
</button>
)}
{onShare && (
<button onClick={onShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share
</button>
)}
<button onClick={handleExportPNG} className={cardBtn}>
<ImageIcon className="w-3 h-3" /> PNG
</button>
{onDownload && (
<button onClick={onDownload} className={cardBtn}>
<Download className="w-3 h-3" /> TXT
</button>
)}
</div>
</div>
{/* ── Controls: alignment · size · comment style ─────────── */}
<div className="flex items-center gap-2 shrink-0 flex-wrap">
{/* Alignment */}
<div className="flex items-center gap-0.5">
{ALIGN_OPTS.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTextAlign(value)}
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"
>
{COMMENT_STYLES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{/* Stats */}
{!isLoading && text && (
<span className="ml-auto text-[10px] text-muted-foreground/30 font-mono tabular-nums">
{lineCount}L · {charCount}C
</span>
)}
</div>
{/* ── Terminal window ────────────────────────────────────── */}
<div
ref={terminalRef}
className="flex-1 min-h-0 flex flex-col rounded-xl overflow-hidden border border-white/5"
style={{ background: '#06060e' }}
>
{/* Terminal chrome */}
<div className="flex items-center gap-1.5 px-3.5 py-2 border-b border-white/5 shrink-0">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/55" />
<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" />
{font && (
<span className="ml-2 text-[10px] font-mono text-white/20 tracking-wider select-none">
{font}
</span>
)}
</div>
{/* Content */}
<div
className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-white/8 scrollbar-track-transparent"
style={{ textAlign: commentStyle === 'none' ? textAlign : 'left' }}
>
{isLoading ? (
<div className="space-y-2 animate-pulse">
{[0.7, 1, 0.85, 0.55, 1, 0.9, 0.75].map((w, i) => (
<div
key={i}
className="h-3.5 rounded-sm bg-white/5"
style={{ width: `${w * 100}%` }}
/>
))}
</div>
) : text ? (
<pre
className={cn(
'font-mono whitespace-pre text-white/85 leading-snug',
fontSize === 'xs' && 'text-[9px]',
fontSize === 'sm' && 'text-[11px] sm:text-xs',
fontSize === 'base' && 'text-xs sm:text-sm'
)}
>
{commentedText}
</pre>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-center">
<Type className="w-6 h-6 text-white/10" />
<p className="text-xs text-white/20 font-mono">
Start typing to see your ASCII art
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
'use client';
import * as React from 'react';
import Fuse from 'fuse.js';
import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { ASCIIFont } from '@/types/ascii';
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
export interface FontSelectorProps {
fonts: ASCIIFont[];
selectedFont: string;
onSelectFont: (fontName: string) => void;
onRandomFont?: () => void;
className?: string;
}
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({
fonts,
selectedFont,
onSelectFont,
onRandomFont,
className,
}: FontSelectorProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [filter, setFilter] = React.useState<FilterType>('all');
const [favorites, setFavorites] = React.useState<string[]>([]);
const [recentFonts, setRecentFonts] = React.useState<string[]>([]);
const selectedRef = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
setFavorites(getFavorites());
setRecentFonts(getRecentFonts());
}, []);
// Keep selected item in view when font changes externally (e.g. random)
React.useEffect(() => {
selectedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [selectedFont]);
const fuse = React.useMemo(
() => new Fuse(fonts, { keys: ['name', 'fileName'], threshold: 0.3, includeScore: true }),
[fonts]
);
const filteredFonts = React.useMemo(() => {
let base = fonts;
if (filter === 'favorites') {
base = fonts.filter((f) => favorites.includes(f.name));
} else if (filter === 'recent') {
base = [...fonts.filter((f) => recentFonts.includes(f.name))].sort(
(a, b) => recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name)
);
}
if (!searchQuery) return base;
const hits = fuse.search(searchQuery).map((r) => r.item);
return filter === 'all' ? hits : hits.filter((f) => base.includes(f));
}, [fonts, searchQuery, fuse, filter, favorites, recentFonts]);
const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => {
e.stopPropagation();
toggleFavorite(fontName);
setFavorites(getFavorites());
};
const emptyMessage =
filter === 'favorites'
? 'No favorites yet — click ♥ to save'
: filter === 'recent'
? 'No recent fonts'
: searchQuery
? 'No fonts match your search'
: 'Loading fonts…';
return (
<div className={cn('glass rounded-xl p-3 flex flex-col min-h-0 overflow-hidden', className)}>
{/* ── Header ────────────────────────────────────────────── */}
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Fonts
</span>
<div className="flex items-center gap-2.5">
<span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
{fonts.length}
</span>
{onRandomFont && (
<button
onClick={onRandomFont}
className="text-muted-foreground/50 hover:text-primary transition-colors"
title="Random font"
>
<Shuffle className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* ── Filter tabs ───────────────────────────────────────── */}
<div className="flex glass rounded-lg p-0.5 gap-0.5 mb-3 shrink-0">
{FILTERS.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setFilter(value)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
filter === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
{/* ── Search ────────────────────────────────────────────── */}
<div className="relative mb-3 shrink-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/40 pointer-events-none" />
<input
type="text"
placeholder="Search fonts…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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"
/>
{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
key={font.name}
className={cn(
'group flex items-center gap-1.5 rounded-lg transition-all cursor-pointer',
'border-l-2',
isSelected
? 'bg-primary/10 border-primary text-primary'
: 'border-transparent text-foreground/65 hover:bg-primary/8 hover:text-foreground'
)}
>
<button
ref={isSelected ? selectedRef : undefined}
onClick={() => onSelectFont(font.name)}
className="flex-1 text-left text-xs font-mono truncate px-2 py-1.5"
>
{font.name}
</button>
<button
onClick={(e) => handleToggleFavorite(font.name, e)}
className={cn(
'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
className={cn(
'w-3 h-3 transition-colors',
fav ? 'fill-rose-500 text-rose-500' : 'text-muted-foreground/40 hover:text-rose-400'
)}
/>
</button>
</div>
);
})
)}
</div>
{/* ── Footer ────────────────────────────────────────────── */}
<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' : ''}
</span>
{filter === 'favorites' && (
<span className="text-[10px] text-muted-foreground/35">{favorites.length} saved</span>
)}
{filter === 'recent' && (
<span className="text-[10px] text-muted-foreground/35">{recentFonts.length} recent</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function TextInput({ value, onChange, placeholder, className }: TextInputProps) {
return (
<div className={cn('relative', className)}>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Type something…'}
rows={4}
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-3 right-3 text-[10px] text-muted-foreground/35 font-mono pointer-events-none tabular-nums">
{value.length}/100
</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

@@ -0,0 +1,38 @@
'use client';
import { cn } from '@/lib/utils/cn';
interface ColorDisplayProps {
color: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showBorder?: boolean;
}
export function ColorDisplay({
color,
size = 'lg',
className,
showBorder = true,
}: ColorDisplayProps) {
const sizeClasses = {
sm: 'h-16 w-16',
md: 'h-32 w-32',
lg: 'h-48 w-48',
xl: 'h-64 w-64',
};
return (
<div
className={cn(
'rounded-lg transition-all',
showBorder && 'ring-2 ring-border',
sizeClasses[size],
className
)}
style={{ backgroundColor: color }}
role="img"
aria-label={`Color swatch: ${color}`}
/>
);
}

View File

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

View File

@@ -0,0 +1,357 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { ColorPicker } from '@/components/color/ColorPicker';
import { ColorInfo } from '@/components/color/ColorInfo';
import { ManipulationPanel } from '@/components/color/ManipulationPanel';
import { PaletteGrid } from '@/components/color/PaletteGrid';
import { ExportMenu } from '@/components/color/ExportMenu';
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react';
import { toast } from 'sonner';
import { cn, 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' },
];
function ColorManipulationContent() {
const searchParams = useSearchParams();
const router = useRouter();
const [color, setColor] = useState(() => {
const urlColor = searchParams.get('color');
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
});
const [rightTab, setRightTab] = useState<RightTab>('info');
const [mobileTab, setMobileTab] = useState<MobileTab>('pick');
// Harmony
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
const [palette, setPalette] = useState<string[]>([]);
const paletteMutation = useGeneratePalette();
// Gradient
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
const [gradientCount, setGradientCount] = useState(10);
const [gradientResult, setGradientResult] = useState<string[]>([]);
const gradientMutation = useGenerateGradient();
const { data, isLoading } = useColorInfo({ colors: [color] });
const colorInfo = data?.colors[0];
useEffect(() => {
const hex = color.replace('#', '');
if (hex.length === 6 || hex.length === 3) {
router.push(`/color?color=${hex}`, { scroll: false });
}
}, [color, router]);
// Sync first gradient stop with active color
useEffect(() => {
setStops((prev) => [color, ...prev.slice(1)]);
}, [color]);
const handleShare = () => {
navigator.clipboard.writeText(`${window.location.origin}/color?color=${color.replace('#', '')}`);
toast.success('Link copied!');
};
const generateHarmony = async () => {
try {
const result = await paletteMutation.mutateAsync({ base: color, scheme: harmonyType });
setPalette([result.palette.primary, ...result.palette.secondary]);
toast.success(`Generated ${harmonyType} palette`);
} catch { toast.error('Failed to generate palette'); }
};
const generateGradient = async () => {
try {
const result = await gradientMutation.mutateAsync({ stops, count: gradientCount });
setGradientResult(result.gradient);
toast.success(`Generated ${result.gradient.length} colors`);
} catch { toast.error('Failed to generate gradient'); }
};
const updateStop = (i: number, v: string) => {
const next = [...stops];
next[i] = v;
setStops(next);
if (i === 0) setColor(v);
};
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'pick', label: 'Pick' }, { value: 'explore', label: 'Explore' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 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>
{/* Tab content */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
{/* ── Info tab ─────────────────────────────── */}
{rightTab === 'info' && (
<div className="space-y-3">
{/* Large color preview */}
<div
className="w-full rounded-xl border border-white/8 transition-colors duration-300"
style={{ height: '140px', background: color }}
/>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
) : colorInfo ? (
<ColorInfo info={colorInfo} />
) : null}
</div>
)}
{/* ── 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>
);
}
export function ColorManipulation() {
return (
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/40" />
</div>
}>
<ColorManipulationContent />
</Suspense>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { HexColorPicker } from 'react-colorful';
import { cn } from '@/lib/utils/cn';
import { hexToRgb } from '@/lib/color/utils/color';
interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
className?: string;
}
export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
const rgb = hexToRgb(color);
const brightness = rgb ? (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 : 0;
const textColor = brightness > 128 ? '#000000' : '#ffffff';
const borderColor = brightness > 128 ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.2)';
return (
<div className={cn('flex flex-col gap-3', className)}>
<HexColorPicker color={color} onChange={onChange} className="!w-full" />
<input
type="text"
value={color}
onChange={(e) => onChange(e.target.value)}
placeholder="#ff0099"
className="w-full font-mono text-xs rounded-lg px-3 py-2 outline-none transition-colors duration-200 border"
style={{ backgroundColor: color, color: textColor, borderColor }}
spellCheck={false}
/>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { cn } from '@/lib/utils/cn';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
interface ColorSwatchProps {
color: string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
onClick?: () => void;
className?: string;
}
export function ColorSwatch({ color, size = 'md', showLabel = true, onClick, className }: ColorSwatchProps) {
const [copied, setCopied] = useState(false);
const handleClick = () => {
if (onClick) { onClick(); return; }
navigator.clipboard.writeText(color);
setCopied(true);
toast.success(`Copied ${color}`);
setTimeout(() => setCopied(false), 1500);
};
return (
<button
onClick={handleClick}
title={color}
aria-label={`Color ${color}`}
className={cn(
'group relative w-full rounded-lg overflow-hidden border border-white/8 transition-all',
'hover:scale-[1.04] hover:border-white/20 hover:shadow-lg hover:shadow-black/20',
size === 'sm' && 'h-10',
size === 'md' && 'h-14',
size === 'lg' && 'h-20',
className
)}
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

@@ -0,0 +1,121 @@
'use client';
import { useState, useEffect } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
exportAsCSS,
exportAsSCSS,
exportAsTailwind,
exportAsJSON,
exportAsJavaScript,
downloadAsFile,
type ExportColor,
} from '@/lib/color/utils/export';
import { colorAPI } from '@/lib/color/api/client';
import { CodeSnippet } from '@/components/ui/code-snippet';
import { cn, actionBtn } from '@/lib/utils';
interface ExportMenuProps {
colors: string[];
className?: string;
}
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
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) {
const [format, setFormat] = useState<ExportFormat>('css');
const [colorSpace, setColorSpace] = useState<ColorSpace>('hex');
const [convertedColors, setConvertedColors] = useState<string[]>(colors);
const [isConverting, setIsConverting] = useState(false);
useEffect(() => {
async function convertColors() {
if (colorSpace === 'hex') { setConvertedColors(colors); return; }
setIsConverting(true);
try {
const response = await colorAPI.convertFormat({ colors, format: colorSpace });
if (response.success) {
setConvertedColors(response.data.conversions.map((c) => c.output));
}
} catch {
toast.error('Failed to convert colors');
} finally {
setIsConverting(false);
}
}
convertColors();
}, [colors, colorSpace]);
const exportColors: ExportColor[] = convertedColors.map((value) => ({ value }));
const getContent = (): string => {
switch (format) {
case 'css': return exportAsCSS(exportColors);
case 'scss': return exportAsSCSS(exportColors);
case 'tailwind': return exportAsTailwind(exportColors);
case 'json': return exportAsJSON(exportColors);
case 'javascript': return exportAsJavaScript(exportColors);
}
};
const getExt = () => ({ css: 'css', scss: 'scss', tailwind: 'js', json: 'json', javascript: 'js' }[format]);
const handleDownload = () => {
downloadAsFile(getContent(), `palette.${getExt()}`, 'text/plain');
toast.success('Downloaded!');
};
if (colors.length === 0) return null;
return (
<div className={cn('space-y-3', className)}>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Export</span>
{/* Selectors */}
<div className="flex gap-2">
<select
value={format}
onChange={(e) => setFormat(e.target.value as ExportFormat)}
className={selectCls}
>
<option value="css">CSS Vars</option>
<option value="scss">SCSS</option>
<option value="tailwind">Tailwind</option>
<option value="json">JSON</option>
<option value="javascript">JS Array</option>
</select>
<select
value={colorSpace}
onChange={(e) => setColorSpace(e.target.value as ColorSpace)}
className={selectCls}
>
{['hex', 'rgb', 'hsl', 'lab', 'oklab', 'lch', 'oklch'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Code preview */}
<div className="relative">
{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>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { Slider } from '@/components/ui/slider';
import {
useLighten,
useDarken,
useSaturate,
useDesaturate,
useRotate,
useComplement,
} from '@/lib/color/api/queries';
import { toast } from 'sonner';
import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react';
import { cn, actionBtn } from '@/lib/utils';
interface ManipulationPanelProps {
color: string;
onColorChange: (color: string) => void;
}
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2);
const [darkenAmount, setDarkenAmount] = useState(0.2);
const [saturateAmount, setSaturateAmount] = useState(0.2);
const [desaturateAmount, setDesaturateAmount] = useState(0.2);
const [rotateAmount, setRotateAmount] = useState(30);
const lightenMutation = useLighten();
const darkenMutation = useDarken();
const saturateMutation = useSaturate();
const desaturateMutation = useDesaturate();
const rotateMutation = useRotate();
const complementMutation = useComplement();
const isLoading =
lightenMutation.isPending ||
darkenMutation.isPending ||
saturateMutation.isPending ||
desaturateMutation.isPending ||
rotateMutation.isPending ||
complementMutation.isPending;
const applyMutation = async (
// 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,
msg: string
) => {
try {
const result = await mutationFn(params);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(msg);
}
} catch {
toast.error('Failed to apply');
}
};
const rows = [
{
label: 'Lighten', icon: <Sun className="w-3 h-3" />,
value: lightenAmount, setValue: setLightenAmount,
display: `${(lightenAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(lightenMutation.mutateAsync, { colors: [color], amount: lightenAmount }, `Lightened ${(lightenAmount * 100).toFixed(0)}%`),
},
{
label: 'Darken', icon: <Moon className="w-3 h-3" />,
value: darkenAmount, setValue: setDarkenAmount,
display: `${(darkenAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(darkenMutation.mutateAsync, { colors: [color], amount: darkenAmount }, `Darkened ${(darkenAmount * 100).toFixed(0)}%`),
},
{
label: 'Saturate', icon: <Droplets className="w-3 h-3" />,
value: saturateAmount, setValue: setSaturateAmount,
display: `${(saturateAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(saturateMutation.mutateAsync, { colors: [color], amount: saturateAmount }, `Saturated ${(saturateAmount * 100).toFixed(0)}%`),
},
{
label: 'Desaturate', icon: <Droplet className="w-3 h-3" />,
value: desaturateAmount, setValue: setDesaturateAmount,
display: `${(desaturateAmount * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => applyMutation(desaturateMutation.mutateAsync, { colors: [color], amount: desaturateAmount }, `Desaturated ${(desaturateAmount * 100).toFixed(0)}%`),
},
{
label: 'Rotate Hue', icon: <RotateCcw className="w-3 h-3" />,
value: rotateAmount, setValue: setRotateAmount,
display: `${rotateAmount}°`,
min: -180, max: 180, step: 5,
onApply: () => applyMutation(rotateMutation.mutateAsync, { colors: [color], amount: rotateAmount }, `Rotated ${rotateAmount}°`),
},
];
return (
<div className="space-y-4">
{rows.map((row) => (
<div key={row.label} className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{row.icon}
<span>{row.label}</span>
</div>
<span className="text-[10px] text-muted-foreground/40 font-mono tabular-nums">{row.display}</span>
</div>
<div className="flex items-center gap-2">
<Slider
min={row.min} max={row.max} step={row.step}
value={[row.value]}
onValueChange={(vals) => row.setValue(vals[0])}
className="flex-1"
/>
<button onClick={row.onApply} disabled={isLoading} className={cn(actionBtn, 'shrink-0')}>
Apply
</button>
</div>
</div>
))}
<div className="pt-3 border-t border-border/25">
<button
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}
className={cn(actionBtn, 'w-full justify-center py-2')}
>
<ArrowLeftRight className="w-3 h-3" />
Complementary Color
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import { ColorSwatch } from './ColorSwatch';
import { cn } from '@/lib/utils/cn';
interface PaletteGridProps {
colors: string[];
onColorClick?: (color: string) => void;
className?: string;
}
export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProps) {
if (colors.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
No colors in palette yet
</div>
);
}
return (
<div className={cn('grid grid-cols-4 sm:grid-cols-5 gap-2', className)}>
{colors.map((color, index) => (
<ColorSwatch
key={`${color}-${index}`}
color={color}
size="sm"
onClick={onColorClick ? () => onColorClick(color) : undefined}
/>
))}
</div>
);
}

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

@@ -0,0 +1,126 @@
'use client';
import * as React from 'react';
import { Upload, X, FileImage, HardDrive, Film } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
export interface FaviconFileUploadProps {
onFileSelect: (file: File) => void;
onFileRemove: () => void;
selectedFile?: File | null;
disabled?: boolean;
}
export function FaviconFileUpload({
onFileSelect,
onFileRemove,
selectedFile,
disabled = false,
}: FaviconFileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const [dimensions, setDimensions] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (selectedFile) {
const img = new Image();
img.onload = () => {
setDimensions(`${img.width}×${img.height}`);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(selectedFile);
} else {
setDimensions(null);
}
}, [selectedFile]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
};
return (
<div className="w-full">
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*"
onChange={handleFileInput}
disabled={disabled}
/>
{selectedFile ? (
<div className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<FileImage className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-mono text-foreground/80 truncate" title={selectedFile.name}>
{selectedFile.name}
</p>
<button
onClick={onFileRemove}
disabled={disabled}
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
<span className="flex items-center gap-1">
<HardDrive className="w-2.5 h-2.5" />
{selectedFile.size < 1024 * 1024
? `${(selectedFile.size / 1024).toFixed(1)} KB`
: `${(selectedFile.size / (1024 * 1024)).toFixed(1)} MB`}
</span>
{dimensions && (
<span className="flex items-center gap-1">
<Film className="w-2.5 h-2.5" />{dimensions}
</span>
)}
</div>
</div>
</div>
) : (
<div
onClick={() => !disabled && fileInputRef.current?.click()}
onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
className={cn(
'flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer text-center select-none py-8',
isDragging
? 'border-primary bg-primary/10 scale-[0.99]'
: 'border-border/35 hover:border-primary/40 hover:bg-primary/5',
disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
)}
>
<div className={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 icon here or click to browse'}
</p>
<p className="text-[10px] text-muted-foreground/35 font-mono">
PNG · SVG · 512×512 recommended
</p>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface AppPageProps {
children: React.ReactNode;
className?: string;
}
export function AppPage({ children, className }: AppPageProps) {
return (
<div className={cn('overflow-y-auto', className)}>
<div className="max-w-7xl mx-auto px-6 lg:px-8 animate-fade-in py-6 lg:py-8">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import { AppSidebar } from './AppSidebar';
import { AppHeader } from './AppHeader';
import AnimatedBackground from '@/components/AnimatedBackground';
import { SidebarProvider } from './SidebarProvider';
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<SidebarProvider>
<div className="flex h-screen overflow-hidden bg-transparent text-foreground relative">
<AnimatedBackground />
<AppSidebar />
<div className="flex-1 flex flex-col min-w-0 relative z-10">
<AppHeader />
<main className="flex-1 overflow-y-auto scrollbar">
{children}
</main>
</div>
</div>
</SidebarProvider>
);
}

View File

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

View File

@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
interface SidebarContextType {
isOpen: boolean;
isCollapsed: boolean;
toggle: () => void;
toggleCollapse: () => void;
close: () => void;
}
const SidebarContext = React.createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = React.useState(false);
const [isCollapsed, setIsCollapsed] = React.useState(false);
const toggle = React.useCallback(() => setIsOpen((prev) => !prev), []);
const toggleCollapse = React.useCallback(() => setIsCollapsed((prev) => !prev), []);
const close = React.useCallback(() => setIsOpen(false), []);
return (
<SidebarContext.Provider value={{ isOpen, isCollapsed, toggle, toggleCollapse, close }}>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
}

View File

@@ -0,0 +1,196 @@
'use client';
import * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react';
import { cn, actionBtn } from '@/lib/utils';
import { downloadBlob, formatFileSize, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob } from '@/types/media';
export interface ConversionPreviewProps {
job: ConversionJob;
onRetry?: () => void;
}
export function ConversionPreview({ job, onRetry }: ConversionPreviewProps) {
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [elapsedTime, setElapsedTime] = React.useState(0);
const [estimatedRemaining, setEstimatedRemaining] = React.useState<number | null>(null);
React.useEffect(() => {
if (job.status === 'processing' || job.status === 'loading') {
const interval = setInterval(() => {
if (job.startTime) {
const elapsed = Date.now() - job.startTime;
setElapsedTime(elapsed);
if (job.progress > 5 && job.progress < 100) {
const rate = job.progress / elapsed;
setEstimatedRemaining((100 - job.progress) / rate);
}
}
}, 100);
return () => clearInterval(interval);
} else {
setEstimatedRemaining(null);
}
}, [job.status, job.startTime, job.progress]);
React.useEffect(() => {
if (job.result && job.status === 'completed') {
const url = URL.createObjectURL(job.result);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
} else {
setPreviewUrl(null);
}
}, [job.result, job.status]);
const handleDownload = () => {
if (job.result) {
downloadBlob(job.result, generateOutputFilename(job.inputFile.name, job.outputFormat.extension));
}
};
const fmt = (ms: number) => {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
};
if (job.status === 'pending') return null;
const inputSize = job.inputFile.size;
const outputSize = job.result?.size ?? 0;
const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0;
const filename = generateOutputFilename(job.inputFile.name, job.outputFormat.extension);
return (
<div className="glass rounded-xl p-3 border border-border/20 space-y-3">
{/* Header row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{(job.status === 'loading' || job.status === 'processing') && (
<Loader2 className="w-3 h-3 animate-spin text-primary shrink-0" />
)}
{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 && (
<div className="rounded-lg border border-rose-500/20 bg-rose-500/8 p-2.5">
<p className="text-[10px] font-mono text-rose-400/80">{job.error}</p>
</div>
)}
{onRetry && (
<button onClick={onRetry} className={cn(actionBtn, 'w-full justify-center')}>
<RefreshCw className="w-3 h-3" />
Retry
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,494 @@
'use client';
import * as React from 'react';
import { SliderRow } from '@/components/ui/slider-row';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import { FileUpload } from './FileUpload';
import { ConversionPreview } from './ConversionPreview';
import { toast } from 'sonner';
import {
getFormatByExtension,
getFormatByMimeType,
getCompatibleFormats,
} from '@/lib/media/utils/formatMappings';
import { convertWithFFmpeg } from '@/lib/media/converters/ffmpegService';
import { convertWithImageMagick } from '@/lib/media/converters/imagemagickService';
import { addToHistory } from '@/lib/media/storage/history';
import { downloadBlobsAsZip, generateOutputFilename } from '@/lib/media/utils/fileUtils';
import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/media';
import { ShieldCheck, Download, RotateCcw, Loader2 } from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
type MobileTab = 'upload' | 'convert';
const selectCls =
'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer disabled:opacity-40';
export function FileConverter() {
const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]);
const [inputFormat, setInputFormat] = React.useState<ConversionFormat | undefined>();
const [outputFormat, setOutputFormat] = React.useState<ConversionFormat | undefined>();
const [compatibleFormats, setCompatibleFormats] = React.useState<ConversionFormat[]>([]);
const [conversionJobs, setConversionJobs] = React.useState<ConversionJob[]>([]);
const [conversionOptions, setConversionOptions] = React.useState<ConversionOptions>({});
const [mobileTab, setMobileTab] = React.useState<MobileTab>('upload');
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Detect format when files change
React.useEffect(() => {
if (selectedFiles.length === 0) {
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJobs([]);
return;
}
const first = selectedFiles[0];
const ext = first.name.split('.').pop()?.toLowerCase();
const fmt = (ext ? getFormatByExtension(ext) : undefined) ?? getFormatByMimeType(first.type);
if (fmt) {
setInputFormat(fmt);
const compat = getCompatibleFormats(fmt);
setCompatibleFormats(compat);
if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]);
toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`);
setMobileTab('convert');
} else {
toast.error('Could not detect file format');
setInputFormat(undefined);
setCompatibleFormats([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedFiles]);
const runConversion = async (
jobIndex: number,
jobs: ConversionJob[],
outFmt: ConversionFormat
) => {
const job = jobs[jobIndex];
const updateJob = (patch: Partial<ConversionJob>) =>
setConversionJobs((prev) => prev.map((j, i) => (i === jobIndex ? { ...j, ...patch } : j)));
try {
updateJob({ status: 'loading' });
updateJob({ status: 'processing', progress: 10 });
const onProgress = (progress: number) => updateJob({ progress });
const result =
outFmt.converter === 'ffmpeg'
? await convertWithFFmpeg(job.inputFile, outFmt.extension, conversionOptions, onProgress)
: await convertWithImageMagick(job.inputFile, outFmt.extension, conversionOptions, onProgress);
if (result.success && result.blob) {
updateJob({ status: 'completed', progress: 100, result: result.blob, endTime: Date.now() });
addToHistory({
inputFileName: job.inputFile.name,
inputFormat: job.inputFormat.name,
outputFormat: outFmt.name,
outputFileName: generateOutputFilename(job.inputFile.name, outFmt.extension),
fileSize: result.blob.size,
result: result.blob,
});
return true;
} else {
updateJob({ status: 'error', error: result.error || 'Unknown error', endTime: Date.now() });
return false;
}
} catch (err) {
updateJob({ status: 'error', error: err instanceof Error ? err.message : 'Unknown error', endTime: Date.now() });
return false;
}
};
const handleConvert = async () => {
if (!selectedFiles.length || !inputFormat || !outputFormat) {
toast.error('Please select files and an output format');
return;
}
const jobs: ConversionJob[] = selectedFiles.map((file) => ({
id: Math.random().toString(36).slice(2, 9),
inputFile: file,
inputFormat: inputFormat!,
outputFormat,
options: conversionOptions,
status: 'pending',
progress: 0,
startTime: Date.now(),
}));
setConversionJobs(jobs);
let ok = 0;
for (let i = 0; i < jobs.length; i++) {
const success = await runConversion(i, jobs, outputFormat);
if (success) ok++;
}
if (ok === jobs.length) toast.success(`All ${jobs.length} file${jobs.length > 1 ? 's' : ''} converted!`);
else if (ok > 0) toast.info(`${ok}/${jobs.length} files converted`);
else toast.error('All conversions failed');
};
const handleRetry = async (jobId: string) => {
const idx = conversionJobs.findIndex((j) => j.id === jobId);
if (idx === -1 || !outputFormat) return;
setConversionJobs((prev) =>
prev.map((j, i) =>
i === idx ? { ...j, status: 'loading', progress: 0, error: undefined, startTime: Date.now() } : j
)
);
const success = await runConversion(idx, conversionJobs, outputFormat);
if (success) toast.success('Conversion completed!');
else toast.error('Retry failed');
};
const handleReset = () => {
setSelectedFiles([]);
setInputFormat(undefined);
setOutputFormat(undefined);
setCompatibleFormats([]);
setConversionJobs([]);
setConversionOptions({});
setMobileTab('upload');
};
const handleDownloadAll = async () => {
if (!outputFormat) return;
const done = conversionJobs.filter((j) => j.status === 'completed' && j.result);
if (!done.length) { toast.error('No completed files'); return; }
if (done.length === 1) {
const url = URL.createObjectURL(done[0].result!);
const a = document.createElement('a');
a.href = url;
a.download = generateOutputFilename(done[0].inputFile.name, outputFormat.extension);
a.click();
URL.revokeObjectURL(url);
return;
}
await downloadBlobsAsZip(
done.map((j) => ({ blob: j.result!, filename: generateOutputFilename(j.inputFile.name, outputFormat.extension) })),
'converted-files.zip'
);
toast.success(`Downloaded ${done.length} files as ZIP`);
};
const isConverting = conversionJobs.some((j) => j.status === 'loading' || j.status === 'processing');
const completedCount = conversionJobs.filter((j) => j.status === 'completed').length;
const setOpt = (patch: Partial<ConversionOptions>) =>
setConversionOptions((prev) => ({ ...prev, ...patch }));
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'upload', label: 'Upload' }, { value: 'convert', label: 'Convert' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: upload zone */}
<div
className={cn(
'lg:col-span-2 flex flex-col overflow-hidden',
mobileTab !== 'upload' && 'hidden lg:flex'
)}
>
<div className="glass rounded-xl p-4 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">
Upload
</span>
<span className="flex items-center gap-1 text-[10px] text-emerald-400/60 font-mono">
<ShieldCheck className="w-3 h-3" />
Zero uploads
</span>
</div>
<FileUpload
onFileSelect={(files) => setSelectedFiles((prev) => [...prev, ...files])}
onFileRemove={(i) => setSelectedFiles((prev) => prev.filter((_, idx) => idx !== i))}
selectedFiles={selectedFiles}
disabled={isConverting}
inputRef={fileInputRef}
inputFormat={inputFormat}
/>
</div>
</div>
{/* Right: options + results */}
<div
className={cn(
'lg:col-span-3 flex flex-col gap-3 overflow-hidden',
mobileTab !== 'convert' && 'hidden lg:flex'
)}
>
{inputFormat && compatibleFormats.length > 0 ? (
<div className="glass rounded-xl p-4 shrink-0">
{/* Detected format */}
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Output Format
</span>
{inputFormat && (
<span className="px-2 py-0.5 rounded-md bg-primary/10 text-primary text-[10px] font-mono border border-primary/20">
{inputFormat.name}
</span>
)}
</div>
{/* Format pill grid */}
<div className="flex flex-wrap gap-1.5 mb-4">
{compatibleFormats.map((fmt) => (
<button
key={fmt.id}
onClick={() => setOutputFormat(fmt)}
disabled={isConverting}
className={cn(
'px-2.5 py-1 rounded-lg border text-xs font-mono transition-all',
outputFormat?.id === fmt.id
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground',
isConverting && 'opacity-40 cursor-not-allowed'
)}
>
.{fmt.extension}
</button>
))}
</div>
{outputFormat && (
<>
<div className="border-t border-border/25 pt-3 space-y-3">
{/* Video options */}
{outputFormat.category === 'video' && (
<>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Video Codec</span>
<select
value={conversionOptions.videoCodec || 'default'}
onChange={(e) => setOpt({ videoCodec: e.target.value === 'default' ? undefined : e.target.value })}
disabled={isConverting}
className={selectCls}
>
<option value="default">Auto (Recommended)</option>
<option value="libx264">H.264</option>
<option value="libx265">H.265</option>
<option value="libvpx">VP8 (WebM)</option>
<option value="libvpx-vp9">VP9 (WebM)</option>
</select>
</div>
<SliderRow
label="Video Bitrate"
display={conversionOptions.videoBitrate || '2M'}
value={parseFloat(conversionOptions.videoBitrate?.replace('M', '') || '2')}
min={0.5} max={10} step={0.5}
onChange={(v) => setOpt({ videoBitrate: `${v}M` })}
disabled={isConverting}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Resolution</span>
<select
value={conversionOptions.videoResolution || 'original'}
onChange={(e) => setOpt({ videoResolution: e.target.value === 'original' ? undefined : e.target.value })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="1920x-1">1080p</option>
<option value="1280x-1">720p</option>
<option value="854x-1">480p</option>
<option value="640x-1">360p</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">FPS</span>
<select
value={conversionOptions.videoFps?.toString() || 'original'}
onChange={(e) => setOpt({ videoFps: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="60">60 fps</option>
<option value="30">30 fps</option>
<option value="24">24 fps</option>
<option value="15">15 fps</option>
</select>
</div>
</div>
<SliderRow
label="Audio Bitrate"
display={conversionOptions.audioBitrate || '128k'}
value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '128')}
min={64} max={320} step={32}
onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
disabled={isConverting}
/>
</>
)}
{/* Audio options */}
{outputFormat.category === 'audio' && (
<>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Codec</span>
<select
value={conversionOptions.audioCodec || 'default'}
onChange={(e) => setOpt({ audioCodec: e.target.value === 'default' ? undefined : e.target.value })}
disabled={isConverting}
className={selectCls}
>
<option value="default">Auto</option>
<option value="libmp3lame">MP3 (LAME)</option>
<option value="aac">AAC</option>
<option value="libvorbis">Vorbis</option>
<option value="libopus">Opus</option>
<option value="flac">FLAC</option>
</select>
</div>
<SliderRow
label="Bitrate"
display={conversionOptions.audioBitrate || '192k'}
value={parseInt(conversionOptions.audioBitrate?.replace('k', '') || '192')}
min={64} max={320} step={32}
onChange={(v) => setOpt({ audioBitrate: `${v}k` })}
disabled={isConverting}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Sample Rate</span>
<select
value={conversionOptions.audioSampleRate?.toString() || 'original'}
onChange={(e) => setOpt({ audioSampleRate: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="48000">48 kHz</option>
<option value="44100">44.1 kHz</option>
<option value="22050">22 kHz</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Channels</span>
<select
value={conversionOptions.audioChannels?.toString() || 'original'}
onChange={(e) => setOpt({ audioChannels: e.target.value === 'original' ? undefined : parseInt(e.target.value) })}
disabled={isConverting}
className={selectCls}
>
<option value="original">Original</option>
<option value="2">Stereo</option>
<option value="1">Mono</option>
</select>
</div>
</div>
</>
)}
{/* Image options */}
{outputFormat.category === 'image' && (
<>
<SliderRow
label="Quality"
display={`${conversionOptions.imageQuality ?? 85}%`}
value={conversionOptions.imageQuality ?? 85}
min={1} max={100} step={1}
onChange={(v) => setOpt({ imageQuality: v })}
disabled={isConverting}
/>
<div className="grid grid-cols-2 gap-2">
{(['imageWidth', 'imageHeight'] as const).map((key) => (
<div key={key} className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{key === 'imageWidth' ? 'Width (px)' : 'Height (px)'}
</span>
<input
type="number"
value={conversionOptions[key] ?? ''}
onChange={(e) => setOpt({ [key]: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="Original"
disabled={isConverting}
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 placeholder:text-muted-foreground/30 disabled:opacity-40"
/>
</div>
))}
</div>
</>
)}
</div>
{/* Action buttons */}
<div className="flex gap-2 mt-4 pt-3 border-t border-border/25">
<button
onClick={handleConvert}
disabled={!selectedFiles.length || !outputFormat || isConverting}
className={cn(actionBtn, 'flex-1 justify-center py-2')}
>
{isConverting
? <><Loader2 className="w-3 h-3 animate-spin" />Converting</>
: `Convert ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}`
}
</button>
<button onClick={handleReset} className={actionBtn} title="Reset">
<RotateCcw className="w-3 h-3" />
</button>
</div>
</>
)}
</div>
) : (
/* No files yet — right panel placeholder */
<div className="glass rounded-xl p-4 flex flex-col items-center justify-center flex-1 min-h-0 text-center">
<div className="w-12 h-12 rounded-full bg-primary/8 flex items-center justify-center mb-3">
<ShieldCheck className="w-5 h-5 text-primary/30" />
</div>
<p className="text-xs text-muted-foreground/30 font-mono">
Upload files to see conversion options
</p>
</div>
)}
{/* Results panel */}
{conversionJobs.length > 0 && (
<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">
Results
</span>
{completedCount > 0 && (
<button onClick={handleDownloadAll} className={cardBtn}>
<Download className="w-3 h-3" />
{completedCount > 1 ? `Download all (${completedCount}) as ZIP` : 'Download'}
</button>
)}
</div>
<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">
{conversionJobs.map((job) => (
<ConversionPreview
key={job.id}
job={job}
onRetry={() => handleRetry(job.id)}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import * as React from 'react';
import { Upload, X, File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import type { ConversionFormat } from '@/types/media';
export interface FileUploadProps {
onFileSelect: (files: File[]) => void;
onFileRemove: (index: number) => void;
selectedFiles?: File[];
accept?: string;
maxSizeMB?: number;
disabled?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
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({
onFileSelect,
onFileRemove,
selectedFiles = [],
accept,
maxSizeMB = 500,
disabled = false,
inputRef,
inputFormat,
}: FileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const [fileMetadata, setFileMetadata] = React.useState<Record<number, Record<string, string>>>({});
const localRef = React.useRef<HTMLInputElement>(null);
const fileInputRef = inputRef || localRef;
React.useEffect(() => {
const extract = async () => {
if (selectedFiles.length === 0 || !inputFormat) { setFileMetadata({}); return; }
const out: Record<number, Record<string, string>> = {};
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const base = {
size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(1)} KB` : `${(file.size / (1024 * 1024)).toFixed(1)} MB`,
type: inputFormat.name,
};
if (inputFormat.category === 'video' && file.type.startsWith('video/')) {
const video = document.createElement('video');
video.preload = 'metadata';
out[i] = await new Promise((res) => {
video.onloadedmetadata = () => {
const d = video.duration, m = Math.floor(d / 60), s = Math.floor(d % 60);
res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}`, dimensions: `${video.videoWidth}×${video.videoHeight}` });
URL.revokeObjectURL(video.src);
};
video.onerror = () => { res(base); URL.revokeObjectURL(video.src); };
video.src = URL.createObjectURL(file);
});
} else if (inputFormat.category === 'audio' && file.type.startsWith('audio/')) {
const audio = document.createElement('audio');
audio.preload = 'metadata';
out[i] = await new Promise((res) => {
audio.onloadedmetadata = () => {
const d = audio.duration, m = Math.floor(d / 60), s = Math.floor(d % 60);
res({ ...base, duration: `${m}:${s.toString().padStart(2, '0')}` });
URL.revokeObjectURL(audio.src);
};
audio.onerror = () => { res(base); URL.revokeObjectURL(audio.src); };
audio.src = URL.createObjectURL(file);
});
} else if (inputFormat.category === 'image' && file.type.startsWith('image/')) {
const img = new Image();
out[i] = await new Promise((res) => {
img.onload = () => { res({ ...base, dimensions: `${img.width}×${img.height}` }); URL.revokeObjectURL(img.src); };
img.onerror = () => { res(base); URL.revokeObjectURL(img.src); };
img.src = URL.createObjectURL(file);
});
} else {
out[i] = base;
}
}
setFileMetadata(out);
};
extract();
}, [selectedFiles, inputFormat]);
const handleFiles = (files: File[]) => {
const maxBytes = maxSizeMB * 1024 * 1024;
const valid = files.filter((f) => {
if (f.size > maxBytes) { alert(`${f.name} exceeds ${maxSizeMB}MB limit.`); return false; }
return true;
});
if (valid.length > 0) onFileSelect(valid);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!disabled) handleFiles(Array.from(e.dataTransfer.files));
};
const triggerInput = () => { if (!disabled) fileInputRef.current?.click(); };
return (
<div className="w-full flex flex-col gap-2 flex-1 min-h-0">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
accept={accept}
onChange={(e) => handleFiles(Array.from(e.target.files || []))}
disabled={disabled}
/>
{selectedFiles.length === 0 ? (
/* ── Drop zone ─────────────────────────────────────── */
<div
onClick={triggerInput}
onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
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 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={file.name}>
{file.name}
</p>
<button
onClick={(e) => { e.stopPropagation(); onFileRemove(idx); }}
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>
{meta && (
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
<span className="flex items-center gap-1"><HardDrive className="w-2.5 h-2.5" />{meta.size}</span>
{meta.duration && <span className="flex items-center gap-1"><Clock className="w-2.5 h-2.5" />{meta.duration}</span>}
{meta.dimensions && <span className="flex items-center gap-1"><Film className="w-2.5 h-2.5" />{meta.dimensions}</span>}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Add more */}
<button
onClick={triggerInput}
disabled={disabled}
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>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
import { useState } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import { SWRegistration } from './SWRegistration';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SWRegistration />
{children}
</TooltipProvider>
<Toaster
theme="dark"
position="bottom-right"
toastOptions={{
classNames: {
toast:
'!bg-[#13131f] !border !border-white/8 !text-white/85 !rounded-xl !shadow-2xl !font-sans',
title: '!text-sm !font-medium !text-white/85',
description: '!text-xs !text-white/45',
icon: '!mt-px',
success: '!border-primary/25',
error: '!border-red-500/25',
warning: '!border-amber-400/25',
info: '!border-blue-400/25',
},
}}
/>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useEffect } from 'react';
export function SWRegistration() {
useEffect(() => {
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
});
}
}, []);
return null;
}

View File

@@ -0,0 +1,152 @@
'use client';
import * as React from 'react';
import { QRInput } from './QRInput';
import { QRPreview } from './QRPreview';
import { QROptions } from './QROptions';
import { generateSvg, generateDataUrl } from '@/lib/qrcode/qrcodeService';
import { decodeQRFromUrl, updateQRUrl, getQRShareableUrl } from '@/lib/qrcode/urlSharing';
import { downloadBlob } from '@/lib/media/utils/fileUtils';
import { debounce } from '@/lib/utils/debounce';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import type { ErrorCorrectionLevel, ExportSize } from '@/types/qrcode';
type MobileTab = 'configure' | 'preview';
export function QRCodeGenerator() {
const [text, setText] = React.useState('https://kit.pivoine.art');
const [errorCorrection, setErrorCorrection] = React.useState<ErrorCorrectionLevel>('M');
const [foregroundColor, setForegroundColor] = React.useState('#000000');
const [backgroundColor, setBackgroundColor] = React.useState('#ffffff');
const [margin, setMargin] = React.useState(4);
const [exportSize, setExportSize] = React.useState<ExportSize>(512);
const [svgString, setSvgString] = React.useState('');
const [isGenerating, setIsGenerating] = React.useState(false);
const [mobileTab, setMobileTab] = React.useState<MobileTab>('configure');
// Load state from URL on mount
React.useEffect(() => {
const urlState = decodeQRFromUrl();
if (urlState) {
if (urlState.text !== undefined) setText(urlState.text);
if (urlState.errorCorrection) setErrorCorrection(urlState.errorCorrection);
if (urlState.foregroundColor) setForegroundColor(urlState.foregroundColor);
if (urlState.backgroundColor) setBackgroundColor(urlState.backgroundColor);
if (urlState.margin !== undefined) setMargin(urlState.margin);
}
}, []);
// Debounced generation
const generate = React.useMemo(
() =>
debounce(async (t: string, ec: ErrorCorrectionLevel, fg: string, bg: string, m: number) => {
if (!t) { setSvgString(''); setIsGenerating(false); return; }
setIsGenerating(true);
try {
const svg = await generateSvg(t, ec, fg, bg, m);
setSvgString(svg);
} catch (error) {
console.error('QR generation error:', error);
setSvgString('');
toast.error('Failed to generate QR code. Text may be too long.');
} finally {
setIsGenerating(false);
}
}, 200),
[],
);
React.useEffect(() => {
generate(text, errorCorrection, foregroundColor, backgroundColor, margin);
updateQRUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
}, [text, errorCorrection, foregroundColor, backgroundColor, margin, generate]);
const handleDownloadPng = async () => {
if (!text) return;
try {
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
const res = await fetch(dataUrl);
const blob = await res.blob();
downloadBlob(blob, `qrcode-${Date.now()}.png`);
} catch { toast.error('Failed to export PNG'); }
};
const handleDownloadSvg = () => {
if (!svgString) return;
const blob = new Blob([svgString], { type: 'image/svg+xml' });
downloadBlob(blob, `qrcode-${Date.now()}.svg`);
};
const handleCopyImage = async () => {
if (!text) return;
try {
const dataUrl = await generateDataUrl(text, errorCorrection, foregroundColor, backgroundColor, margin, exportSize);
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
toast.success('Image copied to clipboard!');
} catch { toast.error('Failed to copy image'); }
};
const handleShare = async () => {
const shareUrl = getQRShareableUrl(text, errorCorrection, foregroundColor, backgroundColor, margin);
try {
await navigator.clipboard.writeText(shareUrl);
toast.success('Shareable URL copied!');
} catch { toast.error('Failed to copy URL'); }
};
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'configure', label: 'Configure' }, { value: 'preview', label: 'Preview' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left: Input + Options */}
<div className={cn('lg:col-span-2 flex flex-col overflow-hidden', mobileTab !== 'configure' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5 space-y-5">
<QRInput value={text} onChange={setText} />
<div className="border-t border-border/25" />
<QROptions
errorCorrection={errorCorrection}
foregroundColor={foregroundColor}
backgroundColor={backgroundColor}
margin={margin}
onErrorCorrectionChange={setErrorCorrection}
onForegroundColorChange={setForegroundColor}
onBackgroundColorChange={setBackgroundColor}
onMarginChange={setMargin}
/>
</div>
</div>
</div>
{/* Right: Preview */}
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'preview' && 'hidden lg:flex')}>
<QRPreview
svgString={svgString}
isGenerating={isGenerating}
exportSize={exportSize}
onExportSizeChange={setExportSize}
onCopyImage={handleCopyImage}
onShare={handleShare}
onDownloadPng={handleDownloadPng}
onDownloadSvg={handleDownloadSvg}
/>
</div>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,113 @@
'use client';
import { SliderRow } from '@/components/ui/slider-row';
import { ColorInput } from '@/components/ui/color-input';
import { cn } from '@/lib/utils/cn';
import type { ErrorCorrectionLevel } from '@/types/qrcode';
interface QROptionsProps {
errorCorrection: ErrorCorrectionLevel;
foregroundColor: string;
backgroundColor: string;
margin: number;
onErrorCorrectionChange: (ec: ErrorCorrectionLevel) => void;
onForegroundColorChange: (color: string) => void;
onBackgroundColorChange: (color: string) => void;
onMarginChange: (margin: number) => void;
}
const EC_OPTIONS: { value: ErrorCorrectionLevel; label: string; desc: string }[] = [
{ value: 'L', label: 'L', desc: '7%' },
{ value: 'M', label: 'M', desc: '15%' },
{ value: 'Q', label: 'Q', desc: '25%' },
{ value: 'H', label: 'H', desc: '30%' },
];
export function QROptions({
errorCorrection,
foregroundColor,
backgroundColor,
margin,
onErrorCorrectionChange,
onForegroundColorChange,
onBackgroundColorChange,
onMarginChange,
}: QROptionsProps) {
const isTransparent = backgroundColor === '#00000000';
return (
<div className="space-y-5">
{/* Error Correction */}
<div>
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-2">
Error Correction
</span>
<div className="flex gap-1.5">
{EC_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onErrorCorrectionChange(opt.value)}
className={cn(
'flex-1 flex flex-col items-center py-2 rounded-lg border text-xs font-mono transition-all',
errorCorrection === opt.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
)}
>
<span className="font-semibold">{opt.label}</span>
<span className="text-[9px] opacity-50 mt-0.5">{opt.desc}</span>
</button>
))}
</div>
</div>
{/* Colors */}
<div className="space-y-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block">
Colors
</span>
{/* Foreground */}
<div>
<label className="text-[9px] text-muted-foreground/50 font-mono block mb-1.5">Foreground</label>
<ColorInput value={foregroundColor} onChange={onForegroundColorChange} />
</div>
{/* Background */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[9px] text-muted-foreground/50 font-mono">Background</label>
<button
onClick={() => onBackgroundColorChange(isTransparent ? '#ffffff' : '#00000000')}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded border transition-all',
isTransparent
? 'border-primary/40 text-primary bg-primary/10'
: 'border-border/30 text-muted-foreground/50 hover:border-primary/30 hover:text-primary'
)}
>
Transparent
</button>
</div>
<ColorInput
value={isTransparent ? '#ffffff' : backgroundColor}
onChange={onBackgroundColorChange}
disabled={isTransparent}
/>
</div>
</div>
{/* Margin */}
<SliderRow
label="Margin"
display={String(margin)}
value={margin}
min={0}
max={8}
step={1}
onChange={onMarginChange}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { Copy, Share2, Image as ImageIcon, FileCode, QrCode } from 'lucide-react';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import type { ExportSize } from '@/types/qrcode';
interface QRPreviewProps {
svgString: string;
isGenerating: boolean;
exportSize: ExportSize;
onExportSizeChange: (size: ExportSize) => void;
onCopyImage: () => void;
onShare: () => void;
onDownloadPng: () => 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({
svgString,
isGenerating,
exportSize,
onExportSizeChange,
onCopyImage,
onShare,
onDownloadPng,
onDownloadSvg,
}: QRPreviewProps) {
return (
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Action bar */}
<div className="flex items-center gap-1.5 mb-4 shrink-0 flex-wrap">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mr-auto">
Preview
</span>
<button onClick={onCopyImage} disabled={!svgString} className={cardBtn}>
<Copy className="w-3 h-3" />Copy
</button>
<button onClick={onShare} disabled={!svgString} className={cardBtn}>
<Share2 className="w-3 h-3" />Share
</button>
{/* PNG + inline size selector */}
<div className="flex items-center glass rounded-md border border-border/30">
<button
onClick={onDownloadPng}
disabled={!svgString}
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-xs text-muted-foreground hover:text-primary transition-all disabled:opacity-40 disabled:cursor-not-allowed border-r border-border/20"
>
<ImageIcon className="w-3 h-3" />PNG
</button>
<div className="flex items-center px-1 gap-0.5">
{EXPORT_SIZES.map(({ value, label }) => (
<button
key={value}
onClick={() => onExportSizeChange(value)}
className={cn(
'text-[9px] font-mono px-1.5 py-0.5 rounded transition-all',
exportSize === value
? 'text-primary bg-primary/10'
: 'text-muted-foreground/40 hover:text-muted-foreground'
)}
>
{label}
</button>
))}
</div>
</div>
<button onClick={onDownloadSvg} disabled={!svgString} className={cardBtn}>
<FileCode className="w-3 h-3" />SVG
</button>
</div>
{/* QR canvas */}
<div
className="flex-1 min-h-0 rounded-xl flex items-center justify-center"
style={{
backgroundImage: 'repeating-conic-gradient(rgba(255,255,255,0.025) 0% 25%, transparent 0% 50%)',
backgroundSize: '16px 16px',
}}
>
{isGenerating ? (
<div className="w-56 h-56 rounded-xl bg-white/5 animate-pulse" />
) : svgString ? (
<div
className="w-full max-w-sm aspect-square [&>svg]:w-full [&>svg]:h-full p-6"
dangerouslySetInnerHTML={{ __html: svgString }}
/>
) : (
<div className="flex flex-col items-center gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<QrCode className="w-6 h-6 text-primary/40" />
</div>
<div>
<p className="text-sm font-medium text-foreground/40">No QR code yet</p>
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Enter text or a URL to generate</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,436 @@
'use client';
import { useState, useCallback } from 'react';
import { RefreshCw, Copy, Check, Clock } from 'lucide-react';
import { toast } from 'sonner';
import { cn, actionBtn } from '@/lib/utils';
import { SliderRow } from '@/components/ui/slider-row';
import { MobileTabs } from '@/components/ui/mobile-tabs';
import {
generatePassword, passwordEntropy,
generateUUID,
generateApiKey,
generateHash,
generateToken,
type PasswordOpts,
type ApiKeyOpts,
type HashOpts,
type TokenOpts,
} from '@/lib/random/generators';
type GeneratorType = 'password' | 'uuid' | 'apikey' | 'hash' | 'token';
type MobileTab = 'configure' | 'output';
const GENERATOR_TABS: { value: GeneratorType; label: string }[] = [
{ value: 'password', label: 'Password' },
{ value: 'uuid', label: 'UUID' },
{ value: 'apikey', label: 'API Key' },
{ value: 'hash', label: 'Hash' },
{ value: 'token', label: 'Token' },
];
const selectCls =
'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer';
const strengthLabel = (bits: number) => {
if (bits < 40) return { label: 'Weak', color: 'bg-red-500' };
if (bits < 60) return { label: 'Fair', color: 'bg-amber-400' };
if (bits < 80) return { label: 'Good', color: 'bg-yellow-400' };
if (bits < 100) return { label: 'Strong', color: 'bg-emerald-400' };
return { label: 'Very Strong', color: 'bg-primary' };
};
export function RandomGenerator() {
const [type, setType] = useState<GeneratorType>('password');
const [mobileTab, setMobileTab] = useState<MobileTab>('configure');
const [output, setOutput] = useState('');
const [copied, setCopied] = useState(false);
const [generating, setGenerating] = useState(false);
const [history, setHistory] = useState<string[]>([]);
// Options per type
const [pwOpts, setPwOpts] = useState<PasswordOpts>({
length: 24, uppercase: true, lowercase: true, numbers: true, symbols: true,
});
const [apiOpts, setApiOpts] = useState<ApiKeyOpts>({
length: 32, format: 'hex', prefix: '',
});
const [hashOpts, setHashOpts] = useState<HashOpts>({
algorithm: 'SHA-256', input: '',
});
const [tokenOpts, setTokenOpts] = useState<TokenOpts>({
bytes: 32, format: 'hex',
});
const pushHistory = (val: string) =>
setHistory((h) => [val, ...h].slice(0, 8));
const generate = useCallback(async () => {
setGenerating(true);
try {
let result = '';
switch (type) {
case 'password': result = generatePassword(pwOpts); break;
case 'uuid': result = generateUUID(); break;
case 'apikey': result = generateApiKey(apiOpts); break;
case 'hash': result = await generateHash(hashOpts); break;
case 'token': result = generateToken(tokenOpts); break;
}
setOutput(result);
pushHistory(result);
setMobileTab('output');
} catch {
toast.error('Generation failed');
} finally {
setGenerating(false);
}
}, [type, pwOpts, apiOpts, hashOpts, tokenOpts]);
const copy = (val = output) => {
if (!val) return;
navigator.clipboard.writeText(val);
setCopied(true);
toast.success('Copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
const entropy = type === 'password' ? passwordEntropy(pwOpts) : null;
const strength = entropy !== null ? strengthLabel(entropy) : null;
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'configure', label: 'Configure' }, { value: 'output', label: 'Output' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* ── Left: type selector + options ───────────────────── */}
<div className={cn(
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
mobileTab !== 'configure' && 'hidden lg:flex'
)}>
{/* Type selector */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
Generator
</span>
<div className="flex flex-col gap-1">
{GENERATOR_TABS.map(({ value, label }) => (
<button
key={value}
onClick={() => { setType(value); setOutput(''); }}
className={cn(
'w-full text-left px-3 py-2 rounded-lg text-xs font-mono transition-all',
type === value
? 'bg-primary/15 border border-primary/30 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.03] border border-transparent'
)}
>
{label}
</button>
))}
</div>
</div>
{/* Options */}
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-4 shrink-0">
Options
</span>
{/* ── Password ── */}
{type === 'password' && (
<div className="space-y-4">
<SliderRow
label="Length"
display={`${pwOpts.length} chars`}
value={pwOpts.length}
min={4} max={128}
onChange={(v) => setPwOpts((o) => ({ ...o, length: v }))}
/>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Character sets
</span>
<div className="grid grid-cols-2 gap-2">
{([
{ key: 'uppercase', label: 'AZ', hint: 'Uppercase' },
{ key: 'lowercase', label: 'az', hint: 'Lowercase' },
{ key: 'numbers', label: '09', hint: 'Numbers' },
{ key: 'symbols', label: '!@#', hint: 'Symbols' },
] as const).map(({ key, label, hint }) => (
<label
key={key}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-all select-none',
pwOpts[key]
? 'bg-primary/10 border-primary/30 text-primary'
: 'border-border/30 text-muted-foreground/50 hover:border-border/50 hover:text-muted-foreground'
)}
title={hint}
>
<input
type="checkbox"
checked={pwOpts[key]}
onChange={(e) => setPwOpts((o) => ({ ...o, [key]: e.target.checked }))}
className="sr-only"
/>
<span className="text-xs font-mono">{label}</span>
</label>
))}
</div>
</div>
{strength && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Strength
</span>
<span className="text-[10px] font-mono text-muted-foreground/40">
{entropy} bits
</span>
</div>
<div className="h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', strength.color)}
style={{ width: `${Math.min(100, (entropy! / 128) * 100)}%` }}
/>
</div>
<span className={cn('text-[10px] font-mono', strength.color.replace('bg-', 'text-'))}>
{strength.label}
</span>
</div>
)}
</div>
)}
{/* ── UUID ── */}
{type === 'uuid' && (
<div className="space-y-3">
<div className="px-3 py-2.5 rounded-lg bg-white/[0.02] border border-border/20">
<p className="text-xs text-muted-foreground/60 leading-relaxed">
Generates a cryptographically random UUID v4 using the browser&apos;s built-in{' '}
<code className="text-primary/70 text-[10px]">crypto.randomUUID()</code>.
</p>
</div>
<p className="text-[10px] font-mono text-muted-foreground/30">
Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
</p>
</div>
)}
{/* ── API Key ── */}
{type === 'apikey' && (
<div className="space-y-4">
<SliderRow
label="Length"
display={`${apiOpts.length} chars`}
value={apiOpts.length}
min={8} max={64}
onChange={(v) => setApiOpts((o) => ({ ...o, length: v }))}
/>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Encoding
</span>
<select
value={apiOpts.format}
onChange={(e) => setApiOpts((o) => ({ ...o, format: e.target.value as ApiKeyOpts['format'] }))}
className={selectCls}
>
<option value="hex">Hex (0-9, a-f)</option>
<option value="base62">Base62 (alphanumeric)</option>
<option value="base64url">Base64url</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Prefix <span className="normal-case font-normal text-muted-foreground/40">(optional)</span>
</span>
<input
type="text"
value={apiOpts.prefix}
onChange={(e) => setApiOpts((o) => ({ ...o, prefix: e.target.value }))}
placeholder="sk, pk, api..."
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/25"
/>
</div>
</div>
)}
{/* ── Hash ── */}
{type === 'hash' && (
<div className="space-y-4">
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Algorithm
</span>
<select
value={hashOpts.algorithm}
onChange={(e) => setHashOpts((o) => ({ ...o, algorithm: e.target.value as HashOpts['algorithm'] }))}
className={selectCls}
>
<option value="SHA-1">SHA-1 (160 bit)</option>
<option value="SHA-256">SHA-256 (256 bit)</option>
<option value="SHA-512">SHA-512 (512 bit)</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Input <span className="normal-case font-normal text-muted-foreground/40">(empty = random)</span>
</span>
<textarea
value={hashOpts.input}
onChange={(e) => setHashOpts((o) => ({ ...o, input: e.target.value }))}
placeholder="Text to hash, or leave empty for random data..."
rows={4}
className="w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/25 resize-none"
/>
</div>
</div>
)}
{/* ── Token ── */}
{type === 'token' && (
<div className="space-y-4">
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Byte length
</span>
<div className="grid grid-cols-4 gap-1.5">
{[16, 32, 48, 64].map((b) => (
<button
key={b}
onClick={() => setTokenOpts((o) => ({ ...o, bytes: b }))}
className={cn(
'py-1.5 rounded-lg text-xs font-mono border transition-all',
tokenOpts.bytes === b
? 'bg-primary/15 border-primary/30 text-primary'
: 'border-border/30 text-muted-foreground/50 hover:border-border/50 hover:text-muted-foreground'
)}
>
{b}
</button>
))}
</div>
<p className="text-[10px] font-mono text-muted-foreground/30">
{tokenOpts.bytes * 8} bits of entropy
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Encoding
</span>
<select
value={tokenOpts.format}
onChange={(e) => setTokenOpts((o) => ({ ...o, format: e.target.value as TokenOpts['format'] }))}
className={selectCls}
>
<option value="hex">Hex</option>
<option value="base64url">Base64url</option>
</select>
</div>
</div>
)}
</div>
</div>
{/* ── Right: output + history ──────────────────────────── */}
<div className={cn(
'lg:col-span-3 flex flex-col gap-3 overflow-hidden',
mobileTab !== 'output' && 'hidden lg:flex'
)}>
{/* Output display */}
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Output
</span>
{output && (
<span className="text-[9px] font-mono text-muted-foreground/30 tabular-nums">
{output.length} chars
</span>
)}
</div>
{/* Value box */}
<div
className="relative flex-1 min-h-0 rounded-xl overflow-hidden border border-white/[0.06]"
style={{ background: '#06060e' }}
>
{output ? (
<div className="absolute inset-0 p-5 overflow-auto scrollbar-thin scrollbar-thumb-white/10">
<p className="font-mono text-sm text-white/80 break-all leading-relaxed select-all">
{output}
</p>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xs font-mono text-white/15 italic">
Press Generate to create a value
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 mt-3 shrink-0">
<button
onClick={generate}
disabled={generating}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-primary/30 bg-primary/[0.08] hover:border-primary/55 hover:bg-primary/[0.15] text-xs font-medium text-primary transition-all duration-200 disabled:opacity-50"
>
<RefreshCw className={cn('w-3.5 h-3.5', generating && 'animate-spin')} />
Generate
</button>
<button
onClick={() => copy()}
disabled={!output}
className={actionBtn}
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
{/* History */}
{history.length > 0 && (
<div className="glass rounded-xl p-4 shrink-0">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-3 h-3 text-muted-foreground/40" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Recent
</span>
</div>
<div className="space-y-1">
{history.map((item, i) => (
<div
key={i}
className="group flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.02] transition-colors"
>
<span className="text-[10px] font-mono text-white/30 group-hover:text-white/50 transition-colors truncate flex-1">
{item}
</span>
<button
onClick={() => copy(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground/40 hover:text-primary"
>
<Copy className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

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

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

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

63
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

57
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils/index"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,318 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { ArrowLeftRight, BarChart3, Grid3X3, Copy } from 'lucide-react';
import { toast } from 'sonner';
import SearchUnits from './SearchUnits';
import VisualComparison from './VisualComparison';
import {
getAllMeasures,
getUnitsForMeasure,
convertToAll,
convertUnit,
formatMeasureName,
type Measure,
type ConversionResult,
} from '@/lib/units/units';
import { parseNumberInput, formatNumber, cn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type Tab = 'category' | 'convert';
export default function MainConverter() {
const [selectedMeasure, setSelectedMeasure] = useState<Measure>('length');
const [selectedUnit, setSelectedUnit] = useState<string>('m');
const [targetUnit, setTargetUnit] = useState<string>('ft');
const [inputValue, setInputValue] = useState<string>('1');
const [conversions, setConversions] = useState<ConversionResult[]>([]);
const [showChart, setShowChart] = useState(false);
const [tab, setTab] = useState<Tab>('category');
const measures = getAllMeasures();
const units = getUnitsForMeasure(selectedMeasure);
useEffect(() => {
const numValue = parseNumberInput(inputValue);
if (numValue !== null && selectedUnit) {
setConversions(convertToAll(numValue, selectedUnit));
} else {
setConversions([]);
}
}, [inputValue, selectedUnit]);
useEffect(() => {
const availableUnits = getUnitsForMeasure(selectedMeasure);
if (availableUnits.length > 0) {
setSelectedUnit(availableUnits[0]);
setTargetUnit(availableUnits[1] ?? availableUnits[0]);
}
}, [selectedMeasure]);
const handleSwapUnits = useCallback(() => {
const numValue = parseNumberInput(inputValue);
if (numValue !== null) {
setInputValue(convertUnit(numValue, selectedUnit, targetUnit).toString());
}
setSelectedUnit(targetUnit);
setTargetUnit(selectedUnit);
}, [selectedUnit, targetUnit, inputValue]);
const handleSearchSelect = useCallback((unit: string, measure: Measure) => {
setSelectedMeasure(measure);
setSelectedUnit(unit);
setTab('convert');
}, []);
const handleCategorySelect = useCallback((measure: Measure) => {
setSelectedMeasure(measure);
setTab('convert');
}, []);
const handleValueChange = useCallback(
(value: number, unit: string, _dragging: boolean) => {
setInputValue(convertUnit(value, unit, selectedUnit).toString());
},
[selectedUnit]
);
const resultValue = (() => {
const n = parseNumberInput(inputValue);
return n !== null ? convertUnit(n, selectedUnit, targetUnit) : null;
})();
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'category', label: 'Category' }, { value: 'convert', label: 'Convert' }]}
active={tab}
onChange={(v) => setTab(v as Tab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 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} />
</div>
{/* Category list */}
<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">
Categories
</span>
<span className="text-[10px] text-muted-foreground/35 font-mono tabular-nums">
{measures.length}
</span>
</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"
inputMode="decimal"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="0"
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"
/>
{/* Unit selectors + swap */}
<div className="flex items-center gap-2">
{/* From unit */}
<select
value={selectedUnit}
onChange={(e) => setSelectedUnit(e.target.value)}
className="flex-1 min-w-0 bg-transparent border border-border/40 rounded-lg px-2.5 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer"
>
{units.map((unit) => (
<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>
{/* Result display */}
{resultValue !== null && (
<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 mb-0.5">
<div className="text-[10px] text-muted-foreground/50 font-mono">Result</div>
<button
onClick={() => {
const text = `${formatNumber(resultValue)} ${targetUnit}`;
navigator.clipboard.writeText(text);
toast.success('Copied', { description: text, duration: 2000 });
}}
title="Copy result"
className="w-5 h-5 flex items-center justify-center rounded text-muted-foreground/40 hover:text-primary transition-colors"
>
<Copy className="w-3 h-3" />
</button>
</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold tabular-nums font-mono bg-gradient-to-r from-primary to-pink-400 bg-clip-text text-transparent">
{formatNumber(resultValue)}
</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 className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
{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>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { Search, X } from 'lucide-react';
import Fuse from 'fuse.js';
import {
getAllMeasures,
getUnitsForMeasure,
getUnitInfo,
formatMeasureName,
type Measure,
type UnitInfo,
} from '@/lib/units/units';
import { cn } from '@/lib/utils';
interface SearchResult {
unitInfo: UnitInfo;
measure: Measure;
}
interface SearchUnitsProps {
onSelectUnit: (unit: string, measure: Measure) => void;
className?: string;
}
export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const searchIndex = useRef<Fuse<SearchResult> | null>(null);
useEffect(() => {
const allData: SearchResult[] = [];
const measures = getAllMeasures();
for (const measure of measures) {
for (const unit of getUnitsForMeasure(measure)) {
const unitInfo = getUnitInfo(unit);
if (unitInfo) allData.push({ unitInfo, measure });
}
}
searchIndex.current = new Fuse(allData, {
keys: [
{ name: 'unitInfo.abbr', weight: 2 },
{ name: 'unitInfo.singular', weight: 1.5 },
{ name: 'unitInfo.plural', weight: 1.5 },
{ name: 'measure', weight: 1 },
],
threshold: 0.3,
includeScore: true,
});
}, []);
useEffect(() => {
if (!query.trim() || !searchIndex.current) {
setResults([]);
setIsOpen(false);
return;
}
setResults(searchIndex.current.search(query).map((r) => r.item).slice(0, 10));
setIsOpen(true);
}, [query]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectUnit = (unit: string, measure: Measure) => {
onSelectUnit(unit, measure);
setQuery('');
setIsOpen(false);
inputRef.current?.blur();
};
return (
<div ref={containerRef} className={cn('relative w-full', className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground/40 pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder="Search all units…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => query && setIsOpen(true)}
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 && (
<button
onClick={() => { setQuery(''); setIsOpen(false); }}
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>
{isOpen && results.length > 0 && (
<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) => (
<button
key={`${result.measure}-${result.unitInfo.abbr}`}
onClick={() => handleSelectUnit(result.unitInfo.abbr, result.measure)}
className={cn(
'w-full px-3 py-2.5 text-left hover:bg-primary/8 hover:text-foreground transition-colors',
'flex items-center justify-between gap-3',
index !== 0 && 'border-t border-border/20'
)}
>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium font-mono truncate">{result.unitInfo.plural}</div>
<div className="text-[10px] text-muted-foreground/50 flex items-center gap-1.5 mt-0.5">
<span className="font-mono">{result.unitInfo.abbr}</span>
<span>·</span>
<span>{formatMeasureName(result.measure)}</span>
</div>
</div>
<span className="text-[10px] text-muted-foreground/30 font-mono shrink-0">
{result.measure}
</span>
</button>
))}
</div>
)}
{isOpen && query && results.length === 0 && (
<div className="absolute z-50 w-full mt-1.5 bg-popover border border-border/60 rounded-xl p-4 text-center">
<p className="text-xs text-muted-foreground/40 font-mono italic">No units found for &quot;{query}&quot;</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
import { type ConversionResult } from '@/lib/units/units';
import { formatNumber, cn } from '@/lib/utils';
interface VisualComparisonProps {
conversions: ConversionResult[];
onValueChange?: (value: number, unit: string, dragging: boolean) => void;
}
export default function VisualComparison({ conversions, onValueChange }: VisualComparisonProps) {
const [draggingUnit, setDraggingUnit] = useState<string | null>(null);
const [draggedPercentage, setDraggedPercentage] = useState<number | null>(null);
const dragStartX = useRef<number>(0);
const dragStartWidth = useRef<number>(0);
const activeBarRef = useRef<HTMLDivElement | null>(null);
const lastUpdateTime = useRef<number>(0);
const baseConversionsRef = useRef<ConversionResult[]>([]);
const withPercentages = useMemo(() => {
if (conversions.length === 0) return [];
const scaleSource = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const values = scaleSource.map((c) => Math.abs(c.value));
const maxValue = Math.max(...values);
const minValue = Math.min(...values.filter((v) => v > 0));
if (maxValue === 0 || !isFinite(maxValue)) {
return conversions.map((c) => ({ ...c, percentage: 0 }));
}
return conversions.map((c) => {
const absValue = Math.abs(c.value);
if (absValue === 0 || !isFinite(absValue)) return { ...c, percentage: 2 };
const logValue = Math.log10(absValue);
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
const logRange = logMax - logMin;
const percentage =
logRange === 0
? 100
: Math.max(3, Math.min(100, ((logValue - logMin) / logRange) * 100));
return { ...c, percentage };
});
}, [conversions]);
const calculateValueFromPercentage = useCallback(
(percentage: number, minValue: number, maxValue: number): number => {
const logMax = Math.log10(maxValue);
const logMin = minValue > 0 ? Math.log10(minValue) : logMax - 6;
return Math.pow(10, logMin + (percentage / 100) * (logMax - logMin));
},
[]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
if (!onValueChange) return;
e.preventDefault();
setDraggingUnit(unit);
setDraggedPercentage(currentPercentage);
dragStartX.current = e.clientX;
dragStartWidth.current = currentPercentage;
activeBarRef.current = barElement;
baseConversionsRef.current = [...conversions];
},
[onValueChange, conversions]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
const deltaPercentage = ((e.clientX - dragStartX.current) / activeBarRef.current.offsetWidth) * 100;
const newPercentage = Math.max(3, Math.min(100, dragStartWidth.current + deltaPercentage));
setDraggedPercentage(newPercentage);
const base = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const vals = base.map((c) => Math.abs(c.value));
const newValue = calculateValueFromPercentage(newPercentage, Math.min(...vals.filter((v) => v > 0)), Math.max(...vals));
onValueChange(newValue, draggingUnit, true);
},
[draggingUnit, conversions, onValueChange, calculateValueFromPercentage]
);
const handleMouseUp = useCallback(() => {
if (draggingUnit && onValueChange) {
const conversion = conversions.find((c) => c.unit === draggingUnit);
if (conversion) onValueChange(conversion.value, draggingUnit, false);
}
setDraggingUnit(null);
activeBarRef.current = null;
}, [draggingUnit, conversions, onValueChange]);
const handleTouchStart = useCallback(
(e: React.TouchEvent, unit: string, currentPercentage: number, barElement: HTMLDivElement) => {
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 handleTouchMove = useCallback(
(e: TouchEvent) => {
if (!draggingUnit || !activeBarRef.current || !onValueChange) return;
const now = Date.now();
if (now - lastUpdateTime.current < 16) return;
lastUpdateTime.current = now;
e.preventDefault();
const touch = e.touches[0];
const deltaPercentage = ((touch.clientX - dragStartX.current) / activeBarRef.current.offsetWidth) * 100;
const newPercentage = Math.max(3, Math.min(100, dragStartWidth.current + deltaPercentage));
setDraggedPercentage(newPercentage);
const base = baseConversionsRef.current.length > 0 ? baseConversionsRef.current : conversions;
const vals = base.map((c) => Math.abs(c.value));
const newValue = calculateValueFromPercentage(newPercentage, Math.min(...vals.filter((v) => v > 0)), Math.max(...vals));
onValueChange(newValue, draggingUnit, true);
},
[draggingUnit, conversions, onValueChange, calculateValueFromPercentage]
);
const handleTouchEnd = useCallback(() => {
if (draggingUnit && onValueChange) {
const conversion = conversions.find((c) => c.unit === draggingUnit);
if (conversion) onValueChange(conversion.value, draggingUnit, false);
}
setDraggingUnit(null);
activeBarRef.current = null;
}, [draggingUnit, conversions, onValueChange]);
useEffect(() => {
if (draggingUnit) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [draggingUnit, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
useEffect(() => {
if (!draggingUnit && draggedPercentage !== null) {
setDraggedPercentage(null);
baseConversionsRef.current = [];
}
}, [conversions, draggingUnit, draggedPercentage]);
if (conversions.length === 0) {
return (
<div className="py-10 text-center">
<p className="text-xs text-muted-foreground/35 font-mono italic">Enter a value to see conversions</p>
</div>
);
}
return (
<div className="space-y-2.5">
{withPercentages.map((item) => {
const isDragging = draggingUnit === item.unit;
const isDraggable = !!onValueChange;
const displayPercentage = isDragging && draggedPercentage !== null ? draggedPercentage : item.percentage;
return (
<div key={item.unit} className="space-y-1">
<div className="flex items-baseline justify-between gap-3">
<span className="text-[10px] text-muted-foreground/60 font-mono truncate">{item.unitInfo.plural}</span>
<span className="text-xs font-bold tabular-nums font-mono shrink-0 text-foreground/85">
{formatNumber(item.value)}
<span className="text-[10px] font-normal text-muted-foreground/50 ml-1">{item.unit}</span>
</span>
</div>
<div
className={cn(
'w-full h-5 rounded-md overflow-hidden relative',
'bg-primary/6 border border-border/25',
isDraggable && 'cursor-grab active:cursor-grabbing',
isDragging && 'ring-1 ring-primary/40'
)}
onMouseDown={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement)
handleMouseDown(e, item.unit, item.percentage, e.currentTarget);
}}
onTouchStart={(e) => {
if (isDraggable && e.currentTarget instanceof HTMLDivElement)
handleTouchStart(e, item.unit, item.percentage, e.currentTarget);
}}
>
<div
className={cn(
'absolute inset-y-0 left-0 rounded-sm bg-primary/65',
draggingUnit ? 'transition-none' : 'transition-all duration-500 ease-out'
)}
style={{ width: `${displayPercentage}%` }}
/>
{isDraggable && !isDragging && (
<div className="absolute inset-0 flex items-center justify-end px-2 opacity-0 hover:opacity-100 transition-opacity">
<span className="text-[9px] font-mono text-muted-foreground/40">drag</span>
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -10,6 +10,9 @@ const compat = new FlatCompat({
});
const eslintConfig = [
{
ignores: [".next/**", "out/**", "node_modules/**"],
},
...compat.extends("next/core-web-vitals"),
];

30
icon.svg Normal file
View File

@@ -0,0 +1,30 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Wrench (Lucide) - vertical -->
<g transform="translate(32, 32) rotate(0) scale(3.15) translate(-12.5, -11.5)">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
stroke="url(#wrench)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"
vector-effect="non-scaling-stroke" />
</g>
<!-- Brush (Lucide) - horizontal flipped -->
<g transform="translate(32, 30) rotate(90) scale(3.025) translate(-11.25, -11)">
<path d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" stroke="url(#brush)"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"
vector-effect="non-scaling-stroke" />
<path d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" stroke="url(#brush)" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" fill="none" vector-effect="non-scaling-stroke" />
</g>
<!-- Gradients -->
<defs>
<linearGradient id="wrench" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#667eea" />
<stop offset="100%" stop-color="#a855f7" />
</linearGradient>
<linearGradient id="brush" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f59e0b" />
<stop offset="100%" stop-color="#ec4899" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

132
lib/animate/cssBuilder.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { AnimationConfig, Keyframe, KeyframeProperties, TransformValue } from '@/types/animate';
import { DEFAULT_TRANSFORM } from './defaults';
function isIdentityTransform(t: TransformValue): boolean {
return (
t.translateX === 0 &&
t.translateY === 0 &&
t.rotate === 0 &&
t.scaleX === 1 &&
t.scaleY === 1 &&
t.skewX === 0 &&
t.skewY === 0
);
}
export function buildTransform(t: TransformValue): string {
if (isIdentityTransform(t)) return '';
const parts: string[] = [];
if (t.translateX !== 0 || t.translateY !== 0)
parts.push(`translate(${t.translateX}px, ${t.translateY}px)`);
if (t.rotate !== 0) parts.push(`rotate(${t.rotate}deg)`);
if (t.scaleX !== 1 || t.scaleY !== 1) {
parts.push(t.scaleX === t.scaleY ? `scale(${t.scaleX})` : `scale(${t.scaleX}, ${t.scaleY})`);
}
if (t.skewX !== 0 || t.skewY !== 0)
parts.push(`skew(${t.skewX}deg, ${t.skewY}deg)`);
return parts.join(' ');
}
function buildProperties(props: KeyframeProperties): string[] {
const lines: string[] = [];
if (props.transform) {
const t = { ...DEFAULT_TRANSFORM, ...props.transform };
const val = buildTransform(t);
lines.push(`transform: ${val || 'none'}`);
}
if (props.opacity !== undefined) lines.push(`opacity: ${props.opacity}`);
if (props.backgroundColor && props.backgroundColor !== 'none')
lines.push(`background-color: ${props.backgroundColor}`);
if (props.borderRadius !== undefined && props.borderRadius !== 0)
lines.push(`border-radius: ${props.borderRadius}px`);
const filterParts: string[] = [];
if (props.blur !== undefined && props.blur !== 0) filterParts.push(`blur(${props.blur}px)`);
if (props.brightness !== undefined && props.brightness !== 1)
filterParts.push(`brightness(${props.brightness})`);
if (filterParts.length) lines.push(`filter: ${filterParts.join(' ')}`);
return lines;
}
function buildIterationCount(count: number | 'infinite'): string {
return count === 'infinite' ? 'infinite' : String(count);
}
export function buildAnimationShorthand(config: AnimationConfig): string {
const iter = buildIterationCount(config.iterationCount);
const delay = config.delay ? ` ${config.delay}ms` : '';
return `${config.name} ${config.duration}ms ${config.easing}${delay} ${iter} ${config.direction} ${config.fillMode}`;
}
export function buildKeyframesOnly(config: AnimationConfig): string {
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
let out = `@keyframes ${config.name} {\n`;
for (const kf of sorted) {
const lines = buildProperties(kf.properties);
if (lines.length === 0) {
out += ` ${kf.offset}% { }\n`;
} else {
out += ` ${kf.offset}% {\n`;
for (const line of lines) out += ` ${line};\n`;
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
out += ` }\n`;
}
}
out += `}\n`;
return out;
}
export function buildCSS(config: AnimationConfig): string {
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
let out = `@keyframes ${config.name} {\n`;
for (const kf of sorted) {
const lines = buildProperties(kf.properties);
if (lines.length === 0) {
out += ` ${kf.offset}% { }\n`;
} else {
out += ` ${kf.offset}% {\n`;
for (const line of lines) out += ` ${line};\n`;
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
out += ` }\n`;
}
}
out += `}\n\n`;
out += `.animated {\n`;
out += ` animation: ${buildAnimationShorthand(config)};\n`;
out += `}\n\n`;
out += `/* Usage: add class="animated" to your element */`;
return out;
}
export function buildTailwindCSS(config: AnimationConfig): string {
const sorted = [...config.keyframes].sort((a, b) => a.offset - b.offset);
let out = `/* In your globals.css */\n\n`;
out += `@keyframes ${config.name} {\n`;
for (const kf of sorted) {
const lines = buildProperties(kf.properties);
if (lines.length === 0) {
out += ` ${kf.offset}% { }\n`;
} else {
out += ` ${kf.offset}% {\n`;
for (const line of lines) out += ` ${line};\n`;
if (kf.easing) out += ` animation-timing-function: ${kf.easing};\n`;
out += ` }\n`;
}
}
out += `}\n\n`;
out += `@utility animate-${config.name} {\n`;
out += ` animation: ${buildAnimationShorthand(config)};\n`;
out += `}\n\n`;
out += `/* Usage: className="animate-${config.name}" */`;
return out;
}

47
lib/animate/defaults.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { AnimationConfig, Keyframe, TransformValue } from '@/types/animate';
export const DEFAULT_TRANSFORM: TransformValue = {
translateX: 0,
translateY: 0,
rotate: 0,
scaleX: 1,
scaleY: 1,
skewX: 0,
skewY: 0,
};
export function newKeyframe(offset: number): Keyframe {
return {
id: crypto.randomUUID(),
offset,
properties: {},
};
}
export const DEFAULT_CONFIG: AnimationConfig = {
name: 'fadeInUp',
duration: 600,
delay: 0,
easing: 'ease-out',
iterationCount: 1,
direction: 'normal',
fillMode: 'forwards',
keyframes: [
{
id: crypto.randomUUID(),
offset: 0,
properties: {
opacity: 0,
transform: { ...DEFAULT_TRANSFORM, translateY: 20 },
},
},
{
id: crypto.randomUUID(),
offset: 100,
properties: {
opacity: 1,
transform: { ...DEFAULT_TRANSFORM },
},
},
],
};

257
lib/animate/presets.ts Normal file
View File

@@ -0,0 +1,257 @@
import type { AnimationPreset, AnimationConfig } from '@/types/animate';
import { DEFAULT_TRANSFORM } from './defaults';
function preset(
id: string,
name: string,
category: AnimationPreset['category'],
config: Omit<AnimationConfig, 'name'>,
): AnimationPreset {
return { id, name, category, config: { ...config, name: id } };
}
const T = DEFAULT_TRANSFORM;
export const PRESETS: AnimationPreset[] = [
// ─── Entrance ────────────────────────────────────────────────────────────────
preset('fadeIn', 'Fade In', 'Entrance', {
duration: 500, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0 } },
{ id: 'b', offset: 100, properties: { opacity: 1 } },
],
}),
preset('fadeInUp', 'Fade In Up', 'Entrance', {
duration: 600, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateY: 30 } } },
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
preset('fadeInDown', 'Fade In Down', 'Entrance', {
duration: 600, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateY: -30 } } },
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
preset('fadeInLeft', 'Fade In Left', 'Entrance', {
duration: 600, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateX: -40 } } },
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
preset('fadeInRight', 'Fade In Right', 'Entrance', {
duration: 600, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, translateX: 40 } } },
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
preset('zoomIn', 'Zoom In', 'Entrance', {
duration: 400, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, scaleX: 0.5, scaleY: 0.5 } } },
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
preset('bounceIn', 'Bounce In', 'Entrance', {
duration: 750, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, scaleX: 0.3, scaleY: 0.3 } } },
{ id: 'b', offset: 50, properties: { opacity: 1, transform: { ...T, scaleX: 1.1, scaleY: 1.1 } } },
{ id: 'c', offset: 75, properties: { transform: { ...T, scaleX: 0.9, scaleY: 0.9 } } },
{ id: 'd', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
preset('slideInLeft', 'Slide In Left', 'Entrance', {
duration: 500, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T, translateX: -100 } } },
{ id: 'b', offset: 100, properties: { transform: { ...T } } },
],
}),
preset('rotateIn', 'Rotate In', 'Entrance', {
duration: 600, delay: 0, easing: 'ease-out',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 0, transform: { ...T, rotate: -180, scaleX: 0.6, scaleY: 0.6 } } },
{ id: 'b', offset: 100, properties: { opacity: 1, transform: { ...T } } },
],
}),
// ─── Exit ─────────────────────────────────────────────────────────────────────
preset('fadeOut', 'Fade Out', 'Exit', {
duration: 500, delay: 0, easing: 'ease-in',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 1 } },
{ id: 'b', offset: 100, properties: { opacity: 0 } },
],
}),
preset('fadeOutDown', 'Fade Out Down', 'Exit', {
duration: 600, delay: 0, easing: 'ease-in',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 1, transform: { ...T } } },
{ id: 'b', offset: 100, properties: { opacity: 0, transform: { ...T, translateY: 30 } } },
],
}),
preset('zoomOut', 'Zoom Out', 'Exit', {
duration: 400, delay: 0, easing: 'ease-in',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 1, transform: { ...T } } },
{ id: 'b', offset: 100, properties: { opacity: 0, transform: { ...T, scaleX: 0.4, scaleY: 0.4 } } },
],
}),
preset('slideOutRight', 'Slide Out Right', 'Exit', {
duration: 500, delay: 0, easing: 'ease-in',
iterationCount: 1, direction: 'normal', fillMode: 'forwards',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
{ id: 'b', offset: 100, properties: { transform: { ...T, translateX: 100 } } },
],
}),
// ─── Attention ────────────────────────────────────────────────────────────────
preset('pulse', 'Pulse', 'Attention', {
duration: 1000, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'alternate', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
{ id: 'b', offset: 100, properties: { transform: { ...T, scaleX: 1.08, scaleY: 1.08 } } },
],
}),
preset('shake', 'Shake', 'Attention', {
duration: 600, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
{ id: 'b', offset: 20, properties: { transform: { ...T, translateX: -8 } } },
{ id: 'c', offset: 40, properties: { transform: { ...T, translateX: 8 } } },
{ id: 'd', offset: 60, properties: { transform: { ...T, translateX: -6 } } },
{ id: 'e', offset: 80, properties: { transform: { ...T, translateX: 6 } } },
{ id: 'f', offset: 100, properties: { transform: { ...T } } },
],
}),
preset('wobble', 'Wobble', 'Attention', {
duration: 800, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
{ id: 'b', offset: 20, properties: { transform: { ...T, translateX: -10, rotate: -5 } } },
{ id: 'c', offset: 50, properties: { transform: { ...T, translateX: 8, rotate: 4 } } },
{ id: 'd', offset: 80, properties: { transform: { ...T, translateX: -5, rotate: -3 } } },
{ id: 'e', offset: 100, properties: { transform: { ...T } } },
],
}),
preset('swing', 'Swing', 'Attention', {
duration: 1000, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
{ id: 'b', offset: 25, properties: { transform: { ...T, rotate: 15 } } },
{ id: 'c', offset: 50, properties: { transform: { ...T, rotate: -12 } } },
{ id: 'd', offset: 75, properties: { transform: { ...T, rotate: 8 } } },
{ id: 'e', offset: 100, properties: { transform: { ...T } } },
],
}),
preset('flash', 'Flash', 'Attention', {
duration: 800, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { opacity: 1 } },
{ id: 'b', offset: 25, properties: { opacity: 0 } },
{ id: 'c', offset: 50, properties: { opacity: 1 } },
{ id: 'd', offset: 75, properties: { opacity: 0 } },
{ id: 'e', offset: 100, properties: { opacity: 1 } },
],
}),
// ─── Special ──────────────────────────────────────────────────────────────────
preset('spin', 'Spin', 'Special', {
duration: 1000, delay: 0, easing: 'linear',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T, rotate: 0 } } },
{ id: 'b', offset: 100, properties: { transform: { ...T, rotate: 360 } } },
],
}),
preset('ping', 'Ping', 'Special', {
duration: 1200, delay: 0, easing: 'cubic-bezier(0, 0, 0.2, 1)',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T }, opacity: 1 } },
{ id: 'b', offset: 75, properties: { transform: { ...T, scaleX: 2, scaleY: 2 }, opacity: 0 } },
{ id: 'c', offset: 100, properties: { transform: { ...T, scaleX: 2, scaleY: 2 }, opacity: 0 } },
],
}),
preset('wave', 'Wave', 'Special', {
duration: 1500, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T, rotate: 0 } } },
{ id: 'b', offset: 15, properties: { transform: { ...T, rotate: 14 } } },
{ id: 'c', offset: 30, properties: { transform: { ...T, rotate: -8 } } },
{ id: 'd', offset: 40, properties: { transform: { ...T, rotate: 14 } } },
{ id: 'e', offset: 50, properties: { transform: { ...T, rotate: -4 } } },
{ id: 'f', offset: 60, properties: { transform: { ...T, rotate: 10 } } },
{ id: 'g', offset: 100, properties: { transform: { ...T, rotate: 0 } } },
],
}),
preset('heartbeat', 'Heartbeat', 'Special', {
duration: 1300, delay: 0, easing: 'ease-in-out',
iterationCount: 'infinite', direction: 'normal', fillMode: 'none',
keyframes: [
{ id: 'a', offset: 0, properties: { transform: { ...T } } },
{ id: 'b', offset: 14, properties: { transform: { ...T, scaleX: 1.3, scaleY: 1.3 } } },
{ id: 'c', offset: 28, properties: { transform: { ...T } } },
{ id: 'd', offset: 42, properties: { transform: { ...T, scaleX: 1.3, scaleY: 1.3 } } },
{ id: 'e', offset: 70, properties: { transform: { ...T } } },
{ id: 'f', offset: 100, properties: { transform: { ...T } } },
],
}),
];
export const PRESET_CATEGORIES: AnimationPreset['category'][] = [
'Entrance',
'Exit',
'Attention',
'Special',
];
export function getPresetsByCategory(category: AnimationPreset['category']): AnimationPreset[] {
return PRESETS.filter((p) => p.category === category);
}

80
lib/ascii/asciiService.ts Normal file
View File

@@ -0,0 +1,80 @@
'use client';
import figlet from 'figlet';
import type { ASCIIOptions } from '@/types/ascii';
import { loadFont } from './fontLoader';
/**
* Convert text to ASCII art using figlet
*/
export async function textToAscii(
text: string,
fontName: string = 'Standard',
options: ASCIIOptions = {}
): Promise<string> {
if (!text) {
return '';
}
try {
// Load the font
const fontData = await loadFont(fontName);
if (!fontData) {
throw new Error(`Font ${fontName} could not be loaded`);
}
// Parse and load the font into figlet
figlet.parseFont(fontName, fontData);
// Generate ASCII art
return new Promise((resolve, reject) => {
figlet.text(
text,
{
font: fontName,
horizontalLayout: options.horizontalLayout || 'default',
verticalLayout: options.verticalLayout || 'default',
width: options.width,
whitespaceBreak: options.whitespaceBreak ?? true,
},
(err, result) => {
if (err) {
reject(err);
} else {
resolve(result || '');
}
}
);
});
} catch (error) {
console.error('Error generating ASCII art:', error);
throw error;
}
}
/**
* Generate ASCII art synchronously (requires font to be pre-loaded)
*/
export function textToAsciiSync(
text: string,
fontName: string = 'Standard',
options: ASCIIOptions = {}
): string {
if (!text) {
return '';
}
try {
return figlet.textSync(text, {
font: fontName as any,
horizontalLayout: options.horizontalLayout || 'default',
verticalLayout: options.verticalLayout || 'default',
width: options.width,
whitespaceBreak: options.whitespaceBreak ?? true,
});
} catch (error) {
console.error('Error generating ASCII art (sync):', error);
return '';
}
}

61
lib/ascii/fontLoader.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { ASCIIFont } from '@/types/ascii';
// Cache for loaded fonts
const fontCache = new Map<string, string>();
/**
* Get list of all available ascii fonts
*/
export async function getFontList(): Promise<ASCIIFont[]> {
try {
const response = await fetch('/api/fonts');
if (!response.ok) {
throw new Error('Failed to fetch font list');
}
const fonts: ASCIIFont[] = await response.json();
return fonts;
} catch (error) {
console.error('Error fetching font list:', error);
return [];
}
}
/**
* Load a specific font file content
*/
export async function loadFont(fontName: string): Promise<string | null> {
// Check cache first
if (fontCache.has(fontName)) {
return fontCache.get(fontName)!;
}
try {
const response = await fetch(`/fonts/ascii-fonts/${fontName}.flf`);
if (!response.ok) {
throw new Error(`Failed to load font: ${fontName}`);
}
const fontData = await response.text();
// Cache the font
fontCache.set(fontName, fontData);
return fontData;
} catch (error) {
console.error(`Error loading font ${fontName}:`, error);
return null;
}
}
/**
* Preload a font into cache
*/
export async function preloadFont(fontName: string): Promise<void> {
await loadFont(fontName);
}
/**
* Clear font cache
*/
export function clearFontCache(): void {
fontCache.clear();
}

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

175
lib/color/api/client.ts Normal file
View File

@@ -0,0 +1,175 @@
import type {
ApiResponse,
ColorInfoRequest,
ColorInfoData,
ConvertFormatRequest,
ConvertFormatData,
ColorManipulationRequest,
ColorManipulationData,
RandomColorsRequest,
RandomColorsData,
GradientRequest,
GradientData,
HealthData,
CapabilitiesData,
PaletteGenerateRequest,
PaletteGenerateData,
} from './types';
import { colorWASM } from './wasm-client';
export class ColorAPIClient {
private baseURL: string;
constructor(baseURL?: string) {
// Use the Next.js API proxy route for runtime configuration
// This allows changing the backend API URL without rebuilding
this.baseURL = baseURL || '/api/color';
}
private async request<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
// Endpoint already includes /api/v1 prefix on backend,
// but our proxy route expects paths after /api/v1/
const url = `${this.baseURL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: data.error || {
code: 'INTERNAL_ERROR',
message: 'An unknown error occurred',
},
};
}
return data;
} catch (error) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network request failed',
},
};
}
}
// Color Information
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
return this.request<ColorInfoData>('/colors/info', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Format Conversion
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
return this.request<ConvertFormatData>('/colors/convert', {
method: 'POST',
body: JSON.stringify(request),
});
}
// Color Manipulation
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/lighten', {
method: 'POST',
body: JSON.stringify(request),
});
}
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/darken', {
method: 'POST',
body: JSON.stringify(request),
});
}
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/saturate', {
method: 'POST',
body: JSON.stringify(request),
});
}
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/desaturate', {
method: 'POST',
body: JSON.stringify(request),
});
}
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/rotate', {
method: 'POST',
body: JSON.stringify(request),
});
}
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/complement', {
method: 'POST',
body: JSON.stringify({ colors }),
});
}
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request<ColorManipulationData>('/colors/grayscale', {
method: 'POST',
body: JSON.stringify({ colors }),
});
}
// Color Generation
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
return this.request<RandomColorsData>('/colors/random', {
method: 'POST',
body: JSON.stringify(request),
});
}
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
return this.request<GradientData>('/colors/gradient', {
method: 'POST',
body: JSON.stringify(request),
});
}
// System
async getHealth(): Promise<ApiResponse<HealthData>> {
return this.request<HealthData>('/health', {
method: 'GET',
});
}
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
return this.request<CapabilitiesData>('/capabilities', {
method: 'GET',
});
}
// Palette Generation
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
return this.request<PaletteGenerateData>('/palettes/generate', {
method: 'POST',
body: JSON.stringify(request),
});
}
}
// Export singleton instance
// Now using WASM client for zero-latency, offline-first color operations
export const colorAPI = colorWASM;

177
lib/color/api/queries.ts Normal file
View File

@@ -0,0 +1,177 @@
'use client';
import { useQuery, useMutation, UseQueryOptions } from '@tanstack/react-query';
import { colorAPI } from './client';
import {
ColorInfoRequest,
ColorInfoData,
ConvertFormatRequest,
ConvertFormatData,
ColorManipulationRequest,
ColorManipulationData,
RandomColorsRequest,
RandomColorsData,
GradientRequest,
GradientData,
PaletteGenerateRequest,
PaletteGenerateData,
HealthData,
} from './types';
// Color Information
export const useColorInfo = (
request: ColorInfoRequest,
options?: Omit<UseQueryOptions<ColorInfoData>, 'queryKey' | 'queryFn'>
) => {
return useQuery({
queryKey: ['colorInfo', request.colors],
queryFn: async () => {
const response = await colorAPI.getColorInfo(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
enabled: request.colors.length > 0 && request.colors.every((c) => c.length > 0),
...options,
});
};
// Format Conversion
export const useConvertFormat = () => {
return useMutation({
mutationFn: async (request: ConvertFormatRequest) => {
const response = await colorAPI.convertFormat(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Color Manipulation
export const useLighten = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await colorAPI.lighten(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useDarken = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await colorAPI.darken(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useSaturate = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await colorAPI.saturate(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useDesaturate = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await colorAPI.desaturate(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useRotate = () => {
return useMutation({
mutationFn: async (request: ColorManipulationRequest) => {
const response = await colorAPI.rotate(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useComplement = () => {
return useMutation({
mutationFn: async (colors: string[]) => {
const response = await colorAPI.complement(colors);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Color Generation
export const useGenerateRandom = () => {
return useMutation({
mutationFn: async (request: RandomColorsRequest) => {
const response = await colorAPI.generateRandom(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
export const useGenerateGradient = () => {
return useMutation({
mutationFn: async (request: GradientRequest) => {
const response = await colorAPI.generateGradient(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};
// Health Check
export const useHealth = () => {
return useQuery({
queryKey: ['health'],
queryFn: async () => {
const response = await colorAPI.getHealth();
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
refetchInterval: 60000, // Check every minute
});
};
// Palette Generation
export const useGeneratePalette = () => {
return useMutation({
mutationFn: async (request: PaletteGenerateRequest) => {
const response = await colorAPI.generatePalette(request);
if (!response.success) {
throw new Error(response.error.message);
}
return response.data;
},
});
};

169
lib/color/api/types.ts Normal file
View File

@@ -0,0 +1,169 @@
// API Response Types
export interface SuccessResponse<T> {
success: true;
data: T;
}
export interface ErrorResponse {
success: false;
error: {
code: string;
message: string;
details?: string;
};
}
export type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;
// Color Component Types
export interface RGBColor {
r: number;
g: number;
b: number;
a?: number;
}
export interface HSLColor {
h: number;
s: number;
l: number;
a?: number;
}
export interface HSVColor {
h: number;
s: number;
v: number;
}
export interface LabColor {
l: number;
a: number;
b: number;
}
export interface OkLabColor {
l: number;
a: number;
b: number;
}
export interface LCHColor {
l: number;
c: number;
h: number;
}
export interface OkLCHColor {
l: number;
c: number;
h: number;
}
export interface CMYKColor {
c: number;
m: number;
y: number;
k: number;
}
// Color Information
export interface ColorInfo {
input: string;
hex: string;
rgb: RGBColor;
hsl: HSLColor;
hsv: HSVColor;
lab: LabColor;
oklab: OkLabColor;
lch: LCHColor;
oklch: OkLCHColor;
cmyk: CMYKColor;
gray?: number;
brightness: number;
luminance: number;
is_light: boolean;
name?: string;
distance_to_named?: number;
}
// Request/Response Types for Each Endpoint
export interface ColorInfoRequest {
colors: string[];
}
export interface ColorInfoData {
colors: ColorInfo[];
}
export interface ConvertFormatRequest {
colors: string[];
format: 'hex' | 'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch' | 'cmyk' | 'gray';
}
export interface ConvertFormatData {
conversions: Array<{
input: string;
output: string;
}>;
}
export interface ColorManipulationRequest {
colors: string[];
amount: number;
}
export interface ColorManipulationData {
operation?: string;
amount?: number;
colors: Array<{
input: string;
output: string;
}>;
}
export interface RandomColorsRequest {
count: number;
strategy?: 'vivid' | 'rgb' | 'gray' | 'lch';
}
export interface RandomColorsData {
colors: string[];
}
export interface GradientRequest {
stops: string[];
count: number;
}
export interface GradientData {
stops: string[];
count: number;
gradient: string[];
}
export interface HealthData {
status: string;
version: string;
}
export interface CapabilitiesData {
endpoints: string[];
formats: string[];
distance_metrics: string[];
colorblindness_types: string[];
}
export interface PaletteGenerateRequest {
base: string;
scheme: 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
}
export interface PaletteGenerateData {
base: string;
scheme: string;
palette: {
primary: string;
secondary: string[];
};
}

View File

@@ -0,0 +1,350 @@
import {
init,
parse_color,
lighten_color,
darken_color,
saturate_color,
desaturate_color,
rotate_hue,
complement_color,
generate_random_colors,
generate_gradient,
generate_palette,
version,
} from '@valknarthing/pastel-wasm';
import type {
ApiResponse,
ColorInfoRequest,
ColorInfoData,
ConvertFormatRequest,
ConvertFormatData,
ColorManipulationRequest,
ColorManipulationData,
RandomColorsRequest,
RandomColorsData,
GradientRequest,
GradientData,
HealthData,
CapabilitiesData,
PaletteGenerateRequest,
PaletteGenerateData,
} from './types';
// Initialize WASM module
let wasmInitialized = false;
async function ensureWasmInit() {
if (!wasmInitialized) {
init(); // Initialize panic hook
wasmInitialized = true;
}
}
/**
* WASM-based Color client
* Provides the same interface as ColorAPIClient but uses WebAssembly
* Zero network latency, works offline!
*/
export class ColorWASMClient {
constructor() {
// Initialize WASM eagerly
ensureWasmInit().catch(console.error);
}
private async request<T>(fn: () => T): Promise<ApiResponse<T>> {
try {
await ensureWasmInit();
const data = fn();
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
error: {
code: 'WASM_ERROR',
message: error instanceof Error ? error.message : 'Unknown error',
},
};
}
}
// Color Information
async getColorInfo(request: ColorInfoRequest): Promise<ApiResponse<ColorInfoData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => {
const info = parse_color(colorStr) as any;
return {
input: info.input,
hex: info.hex,
rgb: {
r: info.rgb[0],
g: info.rgb[1],
b: info.rgb[2],
},
hsl: {
h: info.hsl[0],
s: info.hsl[1],
l: info.hsl[2],
},
hsv: {
h: info.hsv[0],
s: info.hsv[1],
v: info.hsv[2],
},
lab: {
l: info.lab[0],
a: info.lab[1],
b: info.lab[2],
},
oklab: {
l: info.oklab ? info.oklab[0] : info.lab[0] / 100.0,
a: info.oklab ? info.oklab[1] : info.lab[1] / 100.0,
b: info.oklab ? info.oklab[2] : info.lab[2] / 100.0,
},
lch: {
l: info.lch[0],
c: info.lch[1],
h: info.lch[2],
},
oklch: {
l: info.oklch ? info.oklch[0] : info.lch[0] / 100.0,
c: info.oklch ? info.oklch[1] : info.lch[1] / 100.0,
h: info.oklch ? info.oklch[2] : info.lch[2],
},
cmyk: {
c: 0,
m: 0,
y: 0,
k: 0,
},
brightness: info.brightness,
luminance: info.luminance,
is_light: info.is_light,
};
});
return { colors };
});
}
// Format Conversion
async convertFormat(request: ConvertFormatRequest): Promise<ApiResponse<ConvertFormatData>> {
return this.request(() => {
const conversions = request.colors.map((colorStr) => {
const parsed = parse_color(colorStr) as any;
let output: string;
switch (request.format) {
case 'hex':
output = parsed.hex;
break;
case 'rgb':
output = `rgb(${parsed.rgb[0]}, ${parsed.rgb[1]}, ${parsed.rgb[2]})`;
break;
case 'hsl':
output = `hsl(${parsed.hsl[0].toFixed(1)}, ${(parsed.hsl[1] * 100).toFixed(1)}%, ${(parsed.hsl[2] * 100).toFixed(1)}%)`;
break;
case 'hsv':
output = `hsv(${parsed.hsv[0].toFixed(1)}, ${(parsed.hsv[1] * 100).toFixed(1)}%, ${(parsed.hsv[2] * 100).toFixed(1)}%)`;
break;
case 'lab':
output = `lab(${parsed.lab[0].toFixed(2)}, ${parsed.lab[1].toFixed(2)}, ${parsed.lab[2].toFixed(2)})`;
break;
case 'lch':
output = `lch(${parsed.lch[0].toFixed(2)}, ${parsed.lch[1].toFixed(2)}, ${parsed.lch[2].toFixed(2)})`;
break;
case 'oklab': {
const l = parsed.oklab ? parsed.oklab[0] : parsed.lab[0] / 100.0;
const a = parsed.oklab ? parsed.oklab[1] : parsed.lab[1] / 100.0;
const b = parsed.oklab ? parsed.oklab[2] : parsed.lab[2] / 100.0;
output = `oklab(${(l * 100).toFixed(1)}% ${a.toFixed(3)} ${b.toFixed(3)})`;
break;
}
case 'oklch': {
const l = parsed.oklch ? parsed.oklch[0] : parsed.lch[0] / 100.0;
const c = parsed.oklch ? parsed.oklch[1] : parsed.lch[1] / 100.0;
const h = parsed.oklch ? parsed.oklch[2] : parsed.lch[2];
output = `oklch(${(l * 100).toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(2)})`;
break;
}
default:
output = parsed.hex;
}
return {
input: colorStr,
output,
};
});
return { conversions };
});
}
// Color Manipulation
async lighten(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: lighten_color(colorStr, request.amount),
}));
return { operation: 'lighten', amount: request.amount, colors };
});
}
async darken(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: darken_color(colorStr, request.amount),
}));
return { operation: 'darken', amount: request.amount, colors };
});
}
async saturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: saturate_color(colorStr, request.amount),
}));
return { operation: 'saturate', amount: request.amount, colors };
});
}
async desaturate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: desaturate_color(colorStr, request.amount),
}));
return { operation: 'desaturate', amount: request.amount, colors };
});
}
async rotate(request: ColorManipulationRequest): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const colors = request.colors.map((colorStr) => ({
input: colorStr,
output: rotate_hue(colorStr, request.amount),
}));
return { operation: 'rotate', amount: request.amount, colors };
});
}
async complement(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const results = colors.map((colorStr) => ({
input: colorStr,
output: complement_color(colorStr),
}));
return { operation: 'complement', colors: results };
});
}
async grayscale(colors: string[]): Promise<ApiResponse<ColorManipulationData>> {
return this.request(() => {
const results = colors.map((colorStr) => ({
input: colorStr,
output: desaturate_color(colorStr, 1.0),
}));
return { operation: 'grayscale', colors: results };
});
}
// Color Generation
async generateRandom(request: RandomColorsRequest): Promise<ApiResponse<RandomColorsData>> {
return this.request(() => {
const vivid = request.strategy === 'vivid' || request.strategy === 'lch';
const colors = generate_random_colors(request.count, vivid);
return { colors };
});
}
async generateGradient(request: GradientRequest): Promise<ApiResponse<GradientData>> {
return this.request(() => {
if (request.stops.length < 2) {
throw new Error('At least 2 color stops are required');
}
// For 2 stops, use the WASM gradient function
if (request.stops.length === 2) {
const gradient = generate_gradient(request.stops[0], request.stops[1], request.count);
return {
stops: request.stops,
count: request.count,
gradient,
};
}
// For multiple stops, interpolate segments
const segments = request.stops.length - 1;
const colorsPerSegment = Math.floor(request.count / segments);
const gradient: string[] = [];
for (let i = 0; i < segments; i++) {
const segmentColors = generate_gradient(
request.stops[i],
request.stops[i + 1],
i === segments - 1 ? request.count - gradient.length : colorsPerSegment
);
gradient.push(...segmentColors.slice(0, -1)); // Avoid duplicates
}
gradient.push(request.stops[request.stops.length - 1]);
return {
stops: request.stops,
count: request.count,
gradient,
};
});
}
// System
async getHealth(): Promise<ApiResponse<HealthData>> {
return this.request(() => ({
status: 'healthy',
version: version(),
}));
}
async getCapabilities(): Promise<ApiResponse<CapabilitiesData>> {
return this.request(() => ({
endpoints: [
'colors/info',
'colors/convert',
'colors/lighten',
'colors/darken',
'colors/saturate',
'colors/desaturate',
'colors/rotate',
'colors/complement',
'colors/grayscale',
'colors/random',
'colors/gradient',
'colors/names',
],
formats: ['hex', 'rgb', 'hsl', 'hsv', 'lab', 'lch'],
distance_metrics: ['cie76', 'ciede2000'],
colorblindness_types: ['protanopia', 'deuteranopia', 'tritanopia'],
}));
}
// Palette Generation
async generatePalette(request: PaletteGenerateRequest): Promise<ApiResponse<PaletteGenerateData>> {
return this.request(() => {
const colors = generate_palette(request.base, request.scheme);
return {
base: request.base,
scheme: request.scheme,
palette: {
primary: colors[0],
secondary: colors.slice(1),
},
};
});
}
}
// Export singleton instance
export const colorWASM = new ColorWASMClient();

Some files were not shown because too many files have changed in this diff Show More